Ezio Melotti created an autocomplete tool for the bugs.python.org tracker. The original diff/checkin is at: https://github.com/psf/bpo-tracker-cpython/commit/505e97ff30ab5c91813321e9bffe0c4900045fdb
Because we have had issues with link targets disappearing, below is a git format patch of the checkin. Remember to use raw mode if you are cutting/pasting to preserve spacing in the python code.
Some things to note:
this was done before roundup had a RestInterface.
- uses jquery and jqueryui
- two new templates (user.experts.html, user.devs.html) that simply return the json
- generated by new extensions (in extensions/jnosy.py).
- javascript client side requests these templates to get the raw json data. It tries
- to cache in html5 storage if avalable for 1 day. The javascript reformats the data into a form better suited for the jquery ui widget.
From 505e97ff30ab5c91813321e9bffe0c4900045fdb Mon Sep 17 00:00:00 2001 From: "ezio.melotti" <devnull@localhost> Date: Sat, 13 Aug 2011 07:50:30 +0000 Subject: [PATCH] #417: add an autocomplete for the nosy list. --- extensions/jnosy.py | 84 ++++++++++++++++++++ html/issue.item.html | 4 +- html/issue.item.js | 170 +++++++++++++++++++++++++++++++++++++++++ html/style.css | 13 ++++ html/user.devs.html | 4 + html/user.experts.html | 5 ++ 6 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 extensions/jnosy.py create mode 100644 html/user.devs.html create mode 100644 html/user.experts.html diff --git a/extensions/jnosy.py b/extensions/jnosy.py new file mode 100644 index 0000000..22168d4 --- /dev/null +++ b/extensions/jnosy.py @@ -0,0 +1,84 @@ +""" +This module provides two helper functions used by the Javascript autocomplete +of the nosy list: + 1) a simple state machine to parse the tables of the + experts index and turn them in a JSON object; + 2) a function to get the list of developers as a JSON object; +""" + +import urllib +try: + import json +except ImportError: + import simplejson as json + +url = 'http://hg.python.org/devguide/raw-file/default/experts.rst' + +# possible states +no_table = 0 # not parsing a table +table_header = 1 # parsing the header +table_content = 2 # parsing the content +table_end = 3 # reached the end of the table + +def experts_as_json(): + """ + Parse the tables of the experts index and turn them into a JSON object. + """ + data = {} + table_state = no_table + + try: + page = urllib.urlopen(url) + except Exception: + # if something goes wrong just return an empty JSON object + return '{}' + + for line in page: + columns = [column.strip() for column in line.split(' ', 1)] + # all the tables have 2 columns (some entries might not have experts, + # so we just skip them) + if len(columns) != 2: + continue + first, second = columns + # check if we found a table separator + if set(first) == set(second) == set('='): + table_state += 1 + if table_state == table_end: + table_state = no_table + continue + if table_state == table_header: + # create a dict for the category (e.g. 'Modules', 'Interest areas') + category = first + data[category] = {} + if table_state == table_content: + # add to the category dict the entries for that category + # (e.g.module names) and the list of experts + # if the entry is empty the names belong to the previous entry + entry = first or entry + names = (name.strip(' *') for name in second.split(',')) + names = ','.join(name for name in names if '(inactive)' not in name) + if not first: + data[category][entry] += names + else: + data[category][entry] = names + return json.dumps(data, separators=(',',':')) + + +def devs_as_json(cls): + """ + Generate a JSON object that contains the username and realname of all + the committers. + """ + users = [] + for user in cls.filter(None, {'iscommitter': 1}): + username = user.username.plain() + realname = user.realname.plain(unchecked=1) + if not realname: + continue + users.append([username, realname]) + return json.dumps(users, separators=(',',':')) + + +def init(instance): + instance.registerUtil('experts_as_json', experts_as_json) + instance.registerUtil('devs_as_json', devs_as_json) diff --git a/html/issue.item.html b/html/issue.item.html index 9372f9c..cc83c0a 100644 --- a/html/issue.item.html +++ b/html/issue.item.html @@ -11,8 +11,10 @@ </title> <metal:slot fill-slot="more-javascript"> -<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.6.1/jquery.min.js"></script> +<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.6.2/jquery.min.js"></script> +<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jqueryui/1.8.15/jquery-ui.js"></script> <script type="text/javascript" src="@@file/issue.item.js"></script> +<link rel="stylesheet" type="text/css" href="http://ajax.googleapis.com/ajax/libs/jqueryui/1.8/themes/smoothness/jquery-ui.css" /> </metal:slot> <tal:block metal:fill-slot="body_title"> diff --git a/html/issue.item.js b/html/issue.item.js index b4d5702..7055067 100644 --- a/html/issue.item.js +++ b/html/issue.item.js @@ -62,3 +62,173 @@ $(document).ready(function() { } }); }) + + +$(document).ready(function() { + /* Add an autocomplete to the nosy list that searches the term in two lists: + 1) the list of developers (both the user and the real name); + 2) the list of experts in the devguide; + See also the "categories" and "multiple values" examples at + http://jqueryui.com/demos/autocomplete/. */ + + if ($("input[name=nosy]").length == 0) { + // if we can't find the nosy <input>, the user can't edit the nosy + // so there's no need to load the autocomplete + return; + } + + // create a custom widget to group the entries in categories + $.widget("custom.catcomplete", $.ui.autocomplete, { + _renderMenu: function(ul, items) { + var self = this, current_category = ""; + // loop through the items, adding a <li> when a new category is + // found, and then render the item in the <ul> + $.each(items, function(index, item) { + if (item.category != current_category) { + ul.append("<li class='ui-autocomplete-category'>" + item.category + "</li>"); + current_category = item.category; + } + self._renderItem(ul, item); + }); + } + }); + + function split(val) { + return val.split(/\s*,\s*/); + } + function extract_last(term) { + return split(term).pop(); + } + function unix_time() { + return Math.floor(new Date().getTime() / 1000); + } + function is_expired(time_str) { + // check if the cached file is older than 1 day + return ((unix_time() - parseInt(time_str)) > 24*60*60); + } + + // this will be called once we have retrieved the data + function add_autocomplete(data) { + $("input[name=nosy]") + // don't navigate away from the field on tab when selecting an item + .bind("keydown", function(event) { + if (event.keyCode === $.ui.keyCode.TAB && + $(this).data("autocomplete").menu.active) { + event.preventDefault(); + } + }) + .catcomplete({ + minLength: 2, // this doesn't seem to work + delay: 0, + source: function(request, response) { + // delegate back to autocomplete, but extract the last term + response($.ui.autocomplete.filter( + data, extract_last(request.term))); + }, + focus: function() { + // prevent value inserted on focus + return false; + }, + select: function(event, ui) { + var usernames = split(this.value); + // remove the current input + usernames.pop(); + // add the selected item + $.each(split(ui.item.value), function(i, username) { + // check if any of the usernames are already there + if ($.inArray(username, usernames) == -1) + usernames.push(username); + }); + // add placeholder to get the comma at the end + usernames.push(""); + this.value = usernames.join(",") ; + return false; + } + }); + } + + + // check if we have HTML5 storage available + try { + var supports_html5_storage = !!localStorage.getItem; + } catch(e) { + var supports_html5_storage = false; + } + + // this object receives the entries for the devs and experts and + // when it has both it calls add_autocomplete + var data = { + devs: null, + experts: null, + add: function(data, type) { + // type is either 'devs' or 'experts' + this[type] = data; + if (this.devs && this.experts) + add_autocomplete(this.devs.concat(this.experts)) + } + }; + + /* Note: instead of using a nested structure like: + {"Platform": {"plat1": "name1,name2", "plat2": "name3,name4", ...}, + "Module": {"mod1": "name1,name2", "mod2": "name3,name4", ...}, + ...} + (i.e. the same format sent by the server), we have to change it and + repeat the category for each entry, because the autocomplete wants a + flat structure like: + [{label: "plat1: name1,name2", value: "name1,name2", category: "Platform"}, + {label: "plat2: name3,name4", value: "name3,name4", category: "Platform"}, + {label: "mod1: name1,name2", value: "name1,name2", category: "Module"}, + {label: "mod2: name3,name4", value: "name3,name4", category: "Module"}, + ...]. + Passing a nested structure to ui.autocomplete.filter() and attempt + further parsing in _renderMenu doesn't seem to work. + */ + function get_json(file, callback) { + // Get the JSON from either the HTML5 storage or the server. + // file is either 'devs' or 'experts', + // the callback is called once the json is retrieved + var json; + if (supports_html5_storage && + ((json = localStorage[file]) != null) && + !is_expired(localStorage[file+'time'])) { + // if we have HTML5 storage and already cached the JSON, use it + callback(JSON.parse(json), file); + } + else { + // if we don't have HTML5 storage or the cache is empty, request + // the JSON to the server + $.getJSON('user?@template='+file, function(rawdata) { + var objects = []; // array of objs with label, value, category + if (file == 'devs') { + // save devs as 'Name Surname (user.name)' + $.each(rawdata, function(index, names) { + objects.push({label: names[1] + ' (' + names[0] + ')', + value: names[0], category: 'Developer'}); + }); + } + else { + // save experts as e.g. 'modname: user1,user2' + $.each(rawdata, function(category, entries) { + $.each(entries, function(entry, names) { + objects.push({label: entry + ': ' + names, + value: names, category: category}); + }); + }); + } + // cache the objects if we have HTML5 storage + if (supports_html5_storage) { + localStorage[file] = JSON.stringify(objects); + localStorage[file+'time'] = unix_time(); + } + callback(objects, file); + }); + } + } + + // request the JSON. This will get it from the HTML5 storage if it's there + // or request it to the server if it's not, The JSON will be passed to the + // data object, that will wait to get both the files before calling the + // add_autocomplete function. + get_json('experts', data.add); + get_json('devs', data.add); +}); diff --git a/html/style.css b/html/style.css index 5c196d5..4d710cc 100644 --- a/html/style.css +++ b/html/style.css @@ -513,3 +513,16 @@ textarea:focus, select:focus { .calendar_display .today { background-color: #afafaf; } + +.ui-autocomplete-category { + font-weight: bold; + padding: 0 .2em; + line-height: 1.2; +} + +.ui-autocomplete { + font-size: 75% !important; + max-height: 25em; + max-width: 20em; + overflow: auto; +} diff --git a/html/user.devs.html b/html/user.devs.html new file mode 100644 index 0000000..0e4137a --- /dev/null +++ b/html/user.devs.html @@ -0,0 +1,4 @@ +<tal:block tal:condition="context/is_view_ok" + content="python:utils.devs_as_json(context)"> + [["username1","Real Name1"],["username2", "Real Name2"],...] +</tal:block> diff --git a/html/user.experts.html b/html/user.experts.html new file mode 100644 index 0000000..3bd70c8 --- /dev/null +++ b/html/user.experts.html @@ -0,0 +1,5 @@ +<tal:block content="python:utils.experts_as_json()"> +{"Platform":{"platname":"name1,name2",...}, + "Module":{"modname":"name1,name2",...}, + ...} +</tal:block> -- 2.17.1