MDL-35680 user selector: more usable when queries are slow.
[moodle.git] / user / selector / module.js
CommitLineData
456e4852
SH
1/**
2 * JavaScript for the user selectors.
3 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
4 * @package userselector
5 */
6
7// Define the core_user namespace if it has not already been defined
8M.core_user = M.core_user || {};
9// Define a user selectors array for against the cure_user namespace
10M.core_user.user_selectors = [];
11/**
12 * Retrieves an instantiated user selector or null if there isn't one by the requested name
13 * @param {string} name The name of the selector to retrieve
14 * @return bool
15 */
16M.core_user.get_user_selector = function (name) {
17 return this.user_selectors[name] || null;
ca3b1fd9 18};
456e4852
SH
19
20/**
21 * Initialise a new user selector.
ca3b1fd9 22 *
456e4852
SH
23 * @param {YUI} Y The YUI3 instance
24 * @param {string} name the control name/id.
25 * @param {string} hash the hash that identifies this selector in the user's session.
26 * @param {array} extrafields extra fields we are displaying for each user in addition to fullname.
27 * @param {string} lastsearch The last search that took place
28 */
29M.core_user.init_user_selector = function (Y, name, hash, extrafields, lastsearch) {
30 // Creates a new user_selector object
31 var user_selector = {
32 /** This id/name used for this control in the HTML. */
33 name : name,
34 /** Array of fields to display for each user, in addition to fullname. */
35 extrafields: extrafields,
36 /** Number of seconds to delay before submitting a query request */
37 querydelay : 0.5,
38 /** The input element that contains the search term. */
39 searchfield : Y.one('#'+name + '_searchtext'),
40 /** The clear button. */
41 clearbutton : null,
42 /** The select element that contains the list of users. */
43 listbox : Y.one('#'+name),
44 /** Used to hold the timeout id of the timeout that waits before doing a search. */
45 timeoutid : null,
28c93484
TH
46 /** Stores any in-progress remote requests. */
47 iotransactions : {},
456e4852
SH
48 /** The last string that we searched for, so we can avoid unnecessary repeat searches. */
49 lastsearch : lastsearch,
50 /** Whether any options where selected last time we checked. Used by
51 * handle_selection_change to track when this status changes. */
52 selectionempty : true,
53 /**
54 * Initialises the user selector object
55 * @constructor
56 */
57 init : function() {
58 // Hide the search button and replace it with a label.
59 var searchbutton = Y.one('#'+this.name + '_searchbutton');
60 this.searchfield.insert(Y.Node.create('<label for="'+this.name + '_searchtext">'+searchbutton.get('value')+'</label>'), this.searchfield);
61 searchbutton.remove();
62
63 // Hook up the event handler for when the search text changes.
64 this.searchfield.on('keyup', this.handle_keyup, this);
65
66 // Hook up the event handler for when the selection changes.
67 this.listbox.on('keyup', this.handle_selection_change, this);
68 this.listbox.on('click', this.handle_selection_change, this);
69 this.listbox.on('change', this.handle_selection_change, this);
70
71 // And when the search any substring preference changes. Do an immediate re-search.
72 Y.one('#userselector_searchanywhereid').on('click', this.handle_searchanywhere_change, this);
73
74 // Define our custom event.
75 //this.createEvent('selectionchanged');
76 this.selectionempty = this.is_selection_empty();
77
78 // Replace the Clear submit button with a clone that is not a submit button.
79 var clearbtn = Y.one('#'+this.name + '_clearbutton');
80 this.clearbutton = Y.Node.create('<input type="button" value="'+clearbtn.get('value')+'" />');
81 clearbtn.replace(Y.Node.getDOMNode(this.clearbutton));
acff4866 82 this.clearbutton.set('id', this.name+"_clearbutton");
456e4852 83 this.clearbutton.on('click', this.handle_clear, this);
acff4866 84 this.clearbutton.set('disabled', (this.get_search_text() == ''));
456e4852
SH
85
86 this.send_query(false);
87 },
88 /**
89 * Key up hander for the search text box.
90 * @param {Y.Event} e the keyup event.
91 */
92 handle_keyup : function(e) {
93 // Trigger an ajax search after a delay.
94 this.cancel_timeout();
9bf68157 95 this.timeoutid = Y.later(this.querydelay*1000, e, function(obj){obj.send_query(false)}, this);
456e4852
SH
96
97 // Enable or diable the clear button.
98 this.clearbutton.set('disabled', (this.get_search_text() == ''));
99
100 // If enter was pressed, prevent a form submission from happening.
101 if (e.keyCode == 13) {
102 e.halt();
103 }
104 },
105 /**
106 * Handles when the selection has changed. If the selection has changed from
107 * empty to not-empty, or vice versa, then fire the event handlers.
108 */
109 handle_selection_change : function() {
110 var isselectionempty = this.is_selection_empty();
111 if (isselectionempty !== this.selectionempty) {
112 this.fire('user_selector:selectionchanged', isselectionempty);
113 }
114 this.selectionempty = isselectionempty;
115 },
116 /**
117 * Trigger a re-search when the 'search any substring' option is changed.
118 */
119 handle_searchanywhere_change : function() {
120 if (this.lastsearch != '' && this.get_search_text() != '') {
121 this.send_query(true);
122 }
123 },
124 /**
125 * Click handler for the clear button..
126 */
127 handle_clear : function() {
128 this.searchfield.set('value', '');
129 this.clearbutton.set('disabled',true);
130 this.send_query(false);
131 },
132 /**
133 * Fires off the ajax search request.
134 */
135 send_query : function(forceresearch) {
136 // Cancel any pending timeout.
137 this.cancel_timeout();
138
139 var value = this.get_search_text();
140 this.searchfield.set('class', '');
141 if (this.lastsearch == value && !forceresearch) {
142 return;
143 }
144
28c93484
TH
145 // Try to cancel existing transactions.
146 Y.Object.each(this.iotransactions, function(trans) {
147 trans.abort();
148 });
149
150 var iotrans = Y.io(M.cfg.wwwroot + '/user/selector/search.php', {
456e4852
SH
151 method: 'POST',
152 data: 'selectorid='+hash+'&sesskey='+M.cfg.sesskey+'&search='+value + '&userselector_searchanywhere=' + this.get_option('searchanywhere'),
153 on: {
154 success:this.handle_response,
155 failure:this.handle_failure
156 },
157 context:this
158 });
28c93484 159 this.iotransactions[iotrans.id] = iotrans;
456e4852
SH
160
161 this.lastsearch = value;
162 this.listbox.setStyle('background','url(' + M.util.image_url('i/loading', 'moodle') + ') no-repeat center center');
163 },
164 /**
165 * Handle what happens when we get some data back from the search.
166 * @param {int} requestid not used.
167 * @param {object} response the list of users that was returned.
168 */
169 handle_response : function(requestid, response) {
170 try {
28c93484
TH
171 delete this.iotransactions[requestid];
172 if (!Y.Object.isEmpty(this.iotransactions)) {
173 // More searches pending. Wait until they are all done.
174 return;
175 }
456e4852
SH
176 this.listbox.setStyle('background','');
177 var data = Y.JSON.parse(response.responseText);
178 this.output_options(data);
179 } catch (e) {
28c93484 180 this.handle_failure(requestid);
456e4852
SH
181 }
182 },
183 /**
184 * Handles what happens when the ajax request fails.
185 */
28c93484
TH
186 handle_failure : function(requestid) {
187 delete this.iotransactions[requestid];
188 if (!Y.Object.isEmpty(this.iotransactions)) {
189 // More searches pending. Wait until they are all done.
190 return;
191 }
456e4852
SH
192 this.listbox.setStyle('background','');
193 this.searchfield.addClass('error');
194
195 // If we are in developer debug mode, output a link to help debug the failure.
196 if (M.cfg.developerdebug) {
197 this.searchfield.insert(Y.Node.create('<a href="'+M.cfg.wwwroot +'/user/selector/search.php?selectorid='+hash+'&sesskey='+M.cfg.sesskey+'&search='+this.get_search_text()+'&debug=1">Ajax call failed. Click here to try the search call directly.</a>'));
198 }
199 },
200 /**
201 * This method should do the same sort of thing as the PHP method
202 * user_selector_base::output_options.
203 * @param {object} data the list of users to populate the list box with.
204 */
205 output_options : function(data) {
206 // Clear out the existing options, keeping any ones that are already selected.
207 var selectedusers = {};
208 this.listbox.all('optgroup').each(function(optgroup){
209 optgroup.all('option').each(function(option){
210 if (option.get('selected')) {
211 selectedusers[option.get('value')] = {
212 id : option.get('value'),
213 name : option.get('innerText') || option.get('textContent'),
214 disabled: option.get('disabled')
215 }
216 }
217 option.remove();
218 }, this);
219 optgroup.remove();
220 }, this);
221
222 // Output each optgroup.
223 var count = 0;
224 for (var groupname in data.results) {
225 this.output_group(groupname, data.results[groupname], selectedusers, true);
226 count++;
227 }
228 if (!count) {
229 var searchstr = (this.lastsearch != '')?this.insert_search_into_str(M.str.moodle.nomatchingusers, this.lastsearch):M.str.moodle.none;
230 this.output_group(searchstr, {}, selectedusers, true)
231 }
232
233 // If there were previously selected users who do not match the search, show them too.
234 if (this.get_option('preserveselected') && selectedusers) {
235 this.output_group(this.insert_search_into_str(M.str.moodle.previouslyselectedusers, this.lastsearch), selectedusers, true, false);
236 }
237 this.handle_selection_change();
238 },
239 /**
240 * This method should do the same sort of thing as the PHP method
241 * user_selector_base::output_optgroup.
242 *
243 * @param {string} groupname the label for this optgroup.v
244 * @param {object} users the users to put in this optgroup.
245 * @param {boolean|object} selectedusers if true, select the users in this group.
246 * @param {boolean} processsingle
247 */
248 output_group : function(groupname, users, selectedusers, processsingle) {
249 var optgroup = Y.Node.create('<optgroup></optgroup>');
250 var count = 0;
251 for (var userid in users) {
252 var user = users[userid];
253 var option = Y.Node.create('<option value="'+userid+'">'+user.name+'</option>');
254 if (user.disabled) {
255 option.set('disabled', true);
256 } else if (selectedusers===true || selectedusers[userid]) {
257 option.set('selected', true);
258 } else {
259 option.set('selected', false);
260 }
261 optgroup.append(option);
262 count++;
263 }
264
265 if (count > 0) {
266 optgroup.set('label', groupname+' ('+count+')');
267 if (processsingle && count===1 && this.get_option('autoselectunique') && option.get('disabled')) {
268 option.set('selected', true);
269 }
270 } else {
271 optgroup.append(Y.Node.create('<option disabled="disabled">\u00A0</option>'));
272 }
273 this.listbox.append(optgroup);
274 },
275 /**
276 * Replace
277 * @param {string} str
278 * @param {string} search The search term
279 * @return string
280 */
281 insert_search_into_str : function(str, search) {
282 return str.replace("%%SEARCHTERM%%", search);
283 },
284 /**
285 * Gets the search text
286 * @return String the value to search for, with leading and trailing whitespace trimmed.
287 */
288 get_search_text : function() {
289 return this.searchfield.get('value').toString().replace(/^ +| +$/, '');
290 },
291 /**
292 * Returns true if the selection is empty (nothing is selected)
293 * @return Boolean check all the options and return whether any are selected.
294 */
295 is_selection_empty : function() {
296 var selection = false;
297 this.listbox.all('option').each(function(){
298 if (this.get('selected')) {
299 selection = true;
300 }
301 });
302 return !(selection);
303 },
304 /**
305 * Cancel the search delay timeout, if there is one.
306 */
307 cancel_timeout : function() {
308 if (this.timeoutid) {
309 clearTimeout(this.timeoutid);
310 this.timeoutid = null;
311 }
312 },
313 /**
314 * @param {string} name The name of the option to retrieve
315 * @return the value of one of the option checkboxes.
316 */
317 get_option : function(name) {
318 var checkbox = Y.one('#userselector_' + name + 'id');
319 if (checkbox) {
320 return (checkbox.get('checked'));
321 } else {
322 return false;
323 }
324 }
ca3b1fd9 325 };
456e4852
SH
326 // Augment the user selector with the EventTarget class so that we can use
327 // custom events
328 Y.augment(user_selector, Y.EventTarget, null, null, {});
329 // Initialise the user selector
330 user_selector.init();
331 // Store the user selector so that it can be retrieved
332 this.user_selectors[name] = user_selector;
333 // Return the user selector
334 return user_selector;
ca3b1fd9 335};
456e4852
SH
336
337/**
338 * Initialise a class that updates the user's preferences when they change one of
339 * the options checkboxes.
340 * @constructor
341 * @param {YUI} Y
342 * @return Tracker object
343 */
344M.core_user.init_user_selector_options_tracker = function(Y) {
345 // Create a user selector options tracker
346 var user_selector_options_tracker = {
347 /**
348 * Initlises the option tracker and gets everything going.
349 * @constructor
350 */
351 init : function() {
352 var settings = [
353 'userselector_preserveselected',
354 'userselector_autoselectunique',
355 'userselector_searchanywhere'
356 ];
357 for (var s in settings) {
358 var setting = settings[s];
359 Y.one('#'+setting+'id').on('click', this.set_user_preference, this, setting);
360 }
361 },
362 /**
363 * Sets a user preference for the options tracker
364 * @param {Y.Event|null} e
365 * @param {string} name The name of the preference to set
366 */
367 set_user_preference : function(e, name) {
368 M.util.set_user_preference(name, Y.one('#'+name+'id').get('checked'));
369 }
ca3b1fd9 370 };
456e4852
SH
371 // Initialise the options tracker
372 user_selector_options_tracker.init();
373 // Return it just incase it is ever wanted
374 return user_selector_options_tracker;
ca3b1fd9 375};