diff --git a/ckanext/privatedatasets/fanstatic/privatedatasets_autocomplete.js b/ckanext/privatedatasets/fanstatic/privatedatasets_autocomplete.js new file mode 100644 index 0000000..62eb87e --- /dev/null +++ b/ckanext/privatedatasets/fanstatic/privatedatasets_autocomplete.js @@ -0,0 +1,289 @@ +/* An auto-complete module for select and input elements that can pull in + * a list of terms from an API endpoint (provided using data-module-source). + * + * source - A url pointing to an API autocomplete endpoint. + * interval - The interval between requests in milliseconds (default: 300). + * items - The max number of items to display (default: 10) + * tags - Boolean attribute if true will create a tag input. + * key - A string of the key you want to be the form value to end up on + * from the ajax returned results + * label - A string of the label you want to appear within the dropdown for + * returned results + * tokensep - A string that contains characters which will be interpreted + * as separators for tags when typed or pasted (default ","). + * Examples + * + * // + * + */ +this.ckan.module('privatedatasets_autocomplete', function (jQuery) { + return { + /* Options for the module */ + options: { + tags: false, + key: false, + label: false, + items: 10, + source: null, + tokensep: ',', + interval: 300, + dropdownClass: '', + containerClass: '' + }, + + /* Sets up the module, binding methods, creating elements etc. Called + * internally by ckan.module.initialize(); + * + * Returns nothing. + */ + initialize: function () { + jQuery.proxyAll(this, /_on/, /format/); + this.setupAutoComplete(); + }, + + /* Sets up the auto complete plugin. + * + * Returns nothing. + */ + setupAutoComplete: function () { + var settings = { + width: 'resolve', + formatResult: this.formatResult, + formatNoMatches: this.formatNoMatches, + formatInputTooShort: this.formatInputTooShort, + dropdownCssClass: this.options.dropdownClass, + containerCssClass: this.options.containerClass, + tokenSeparators: this.options.tokensep.split('') + }; + + // Different keys are required depending on whether the select is + // tags or generic completion. + if (!this.el.is('select')) { + if (this.options.tags) { + settings.tags = this._onQuery; + } else { + settings.query = this._onQuery; + settings.createSearchChoice = this.formatTerm; + } + settings.initSelection = this.formatInitialValue; + } + else { + if (/MSIE (\d+\.\d+);/.test(navigator.userAgent)) { + var ieversion=new Number(RegExp.$1); + if (ieversion<=7) {return} + } + } + + var select2 = this.el.select2(settings).data('select2'); + + if (this.options.tags && select2 && select2.search) { + // find the "fake" input created by select2 and add the keypress event. + // This is not part of the plugins API and so may break at any time. + select2.search.on('keydown', this._onKeydown); + } + + // This prevents Internet Explorer from causing a window.onbeforeunload + // even from firing unnecessarily + $('.select2-choice', select2.container).on('click', function() { + return false; + }); + + this._select2 = select2; + }, + + /* Looks up the completions for the current search term and passes them + * into the provided callback function. + * + * The results are formatted for use in the select2 autocomplete plugin. + * + * string - The term to search for. + * fn - A callback function. + * + * Examples + * + * module.getCompletions('cake', function (results) { + * results === {results: []} + * }); + * + * Returns a jqXHR promise. + */ + getCompletions: function (string, fn) { + var parts = this.options.source.split('?'); + var end = parts.pop(); + var source = parts.join('?') + encodeURIComponent(string) + end; + var client = this.sandbox.client; + var options = { + format: function(data) { + var completion_options = jQuery.extend(options, {objects: true}); + return { + results: client.parseCompletions(data, completion_options) + } + }, + key: this.options.key, + label: this.options.label + }; + + return client.getCompletions(source, options, fn); + }, + + /* Looks up the completions for the provided text but also provides a few + * optimisations. If there is no search term it will automatically set + * an empty array. Ajax requests will also be debounced to ensure that + * the server is not overloaded. + * + * string - The term to search for. + * fn - A callback function. + * + * Returns nothing. + */ + lookup: function (string, fn) { + var module = this; + + // Cache the last searched term otherwise we'll end up searching for + // old data. + this._lastTerm = string; + + // Kills previous timeout + clearTimeout(this._debounced); + + // OK, wipe the dropdown before we start ajaxing the completions + fn({results:[]}); + + if (string) { + // Set a timer to prevent the search lookup occurring too often. + this._debounced = setTimeout(function () { + var term = module._lastTerm; + + // Cancel the previous request if it hasn't yet completed. + if (module._last && typeof module._last.abort == 'function') { + module._last.abort(); + } + + module._last = module.getCompletions(term, fn); + + }, this.options.interval); + + // This forces the ajax throbber to appear, because we've called the + // callback already and that hides the throbber + $('.select2-search input', this._select2.dropdown).addClass('select2-active'); + } + }, + + /* Formatter for the select2 plugin that returns a string for use in the + * results list with the current term emboldened. + * + * state - The current object that is being rendered. + * container - The element the content will be added to (added in 3.0) + * query - The query object (added in select2 3.0). + * + * + * Returns a text string. + */ + formatResult: function (state, container, query) { + var term = this._lastTerm || null; // same as query.term + + if (container) { + // Append the select id to the element for styling. + container.attr('data-value', state.id); + } + + // if the current string is the actual query, just format that + if (state.text === term && state.id === term){ + var ret = state.text; + } + // if we're formatting a suggestion, concatenate the full name and + // username of the suggestion + else { + var ret = state.text + " (" + state.id + ")"; + } + + return ret.split(term).join(term && term.bold()); + }, + + /* Formatter for the select2 plugin that returns a string used when + * the filter has no matches. + * + * Returns a text string. + */ + formatNoMatches: function (term) { + return !term ? this._('Start typing…') : this._('No matches found'); + }, + + /* Formatter used by the select2 plugin that returns a string when the + * input is too short. + * + * Returns a string. + */ + formatInputTooShort: function (term, min) { + return this.ngettext( + 'Input is too short, must be at least one character', + 'Input is too short, must be at least %(num)d characters', + min + ); + }, + + /* Takes a string and converts it into an object used by the select2 plugin. + * + * term - The term to convert. + * + * Returns an object for use in select2. + */ + formatTerm: function (term) { + term = jQuery.trim(term || ''); + + // Need to replace comma with a unicode character to trick the plugin + // as it won't split this into multiple items. + return {id: term.replace(/,/g, '\u002C'), text: term}; + }, + + /* Callback function that parses the initial field value. + * + * element - The initialized input element wrapped in jQuery. + * callback - A callback to run once the formatting is complete. + * + * Returns a term object or an array depending on the type. + */ + formatInitialValue: function (element, callback) { + var value = jQuery.trim(element.val() || ''); + var formatted; + + if (this.options.tags) { + formatted = jQuery.map(value.split(","), this.formatTerm); + } else { + formatted = this.formatTerm(value); + } + + // Select2 v3.0 supports a callback for async calls. + if (typeof callback === 'function') { + callback(formatted); + } + + return formatted; + }, + + /* Callback triggered when the select2 plugin needs to make a request. + * + * Returns nothing. + */ + _onQuery: function (options) { + if (options) { + this.lookup(options.term, options.callback); + } + }, + + /* Called when a key is pressed. If the key is a comma we block it and + * then simulate pressing return. + * + * Returns nothing. + */ + _onKeydown: function (event) { + if (typeof event.key !== 'undefined' ? event.key === ',' : event.which === 188) { + event.preventDefault(); + setTimeout(function () { + var e = jQuery.Event("keydown", { which: 13 }); + jQuery(event.target).trigger(e); + }, 10); + } + } + }; +}); diff --git a/ckanext/privatedatasets/templates_2.8/package/snippets/package_basic_fields.html b/ckanext/privatedatasets/templates_2.8/package/snippets/package_basic_fields.html index b240f46..d34b172 100644 --- a/ckanext/privatedatasets/templates_2.8/package/snippets/package_basic_fields.html +++ b/ckanext/privatedatasets/templates_2.8/package/snippets/package_basic_fields.html @@ -51,7 +51,7 @@ {% trans %} - Private datasets can only be accessed by certain users, while public datasets can be accessed by anyone. + Private datasets can only be accessed by certain users, while public datasets can be accessed by anyone. {% endtrans %} @@ -82,10 +82,12 @@ {% endif %} - {% set users_attrs = {'data-module': 'autocomplete', 'data-module-tags': '', 'data-module-source': '/api/2/util/user/autocomplete?q=?'} %} + {% resource 'privatedatasets/privatedatasets_autocomplete.js' %} + + {% set users_attrs = {'data-module': 'privatedatasets_autocomplete', 'data-module-tags': '', 'data-module-label': 'fullname', 'data-module-source': '/api/2/util/user/autocomplete?q=?'} %} {{ form.input('allowed_users_str', label=_('Allowed Users'), id='field-allowed_users_str', placeholder=_('Allowed Users'), value=h.get_allowed_users_str(data.allowed_users), error=errors.custom_text, classes=['control-full'], attrs=users_attrs) }} - + {% if editing and h.show_acquire_url_on_edit() or not editing and h.show_acquire_url_on_create() %} {{ form.input('acquire_url', label=_('Acquire URL'), id='field-acquire_url', placeholder=_('http://example.com/acquire/'), value=data.acquire_url, error=errors.custom_text, classes=['control-medium']) }} {% else %}