Skip to content

Commit

Permalink
Tag autocompletion, better search hits
Browse files Browse the repository at this point in the history
  • Loading branch information
friflaj committed Sep 25, 2010
1 parent bd81de4 commit d49eb98
Show file tree
Hide file tree
Showing 3 changed files with 298 additions and 26 deletions.
267 changes: 267 additions & 0 deletions assets/javascripts/tag.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
/*
@author: remy sharp / http://remysharp.com
@url: http://remysharp.com/2007/12/28/jquery-tag-suggestion/
@usage: setGlobalTags(['javascript', 'jquery', 'java', 'json']); // applied tags to be used for all implementations
$('input.tags').tagSuggest(options);
The selector is the element that the user enters their tag list
@params:
matchClass - class applied to the suggestions, defaults to 'tagMatches'
tagContainer - the type of element uses to contain the suggestions, defaults to 'span'
tagWrap - the type of element the suggestions a wrapped in, defaults to 'span'
sort - boolean to force the sorted order of suggestions, defaults to false
url - optional url to get suggestions if setGlobalTags isn't used. Must return array of suggested tags
tags - optional array of tags specific to this instance of element matches
delay - optional sets the delay between keyup and the request - can help throttle ajax requests, defaults to zero delay
separator - optional separator string, defaults to ' ' (Brian J. Cardiff)
@license: Creative Commons License - ShareAlike http://creativecommons.org/licenses/by-sa/3.0/
@version: 1.4
@changes: fixed filtering to ajax hits
*/

(function ($) {
var globalTags = [];

// creates a public function within our private code.
// tags can either be an array of strings OR
// array of objects containing a 'tag' attribute
window.setGlobalTags = function(tags /* array */) {
globalTags = getTags(tags);
};

function getTags(tags) {
var tag, i, goodTags = [];
for (i = 0; i < tags.length; i++) {
tag = tags[i];
if (typeof tags[i] == 'object') {
tag = tags[i].tag;
}
goodTags.push(tag.toLowerCase());
}

return goodTags;
}

$.fn.tagSuggest = function (options) {
var defaults = {
'matchClass' : 'tagMatches',
'tagContainer' : 'span',
'tagWrap' : 'span',
'sort' : true,
'tags' : null,
'url' : null,
'delay' : 0,
'separator' : ' '
};

var i, tag, userTags = [], settings = $.extend({}, defaults, options);

if (settings.tags) {
userTags = getTags(settings.tags);
} else {
userTags = globalTags;
}

return this.each(function () {
var tagsElm = $(this);
var elm = this;
var matches, fromTab = false;
var suggestionsShow = false;
var workingTags = [];
var currentTag = {"position": 0, tag: ""};
var tagMatches = document.createElement(settings.tagContainer);

function showSuggestionsDelayed(el, key) {
if (settings.delay) {
if (elm.timer) clearTimeout(elm.timer);
elm.timer = setTimeout(function () {
showSuggestions(el, key);
}, settings.delay);
} else {
showSuggestions(el, key);
}
}

function showSuggestions(el, key) {
workingTags = el.value.split(settings.separator);
matches = [];
var i, html = '', chosenTags = {}, tagSelected = false;

// we're looking to complete the tag on currentTag.position (to start with)
currentTag = { position: currentTags.length-1, tag: '' };

for (i = 0; i < currentTags.length && i < workingTags.length; i++) {
if (!tagSelected &&
currentTags[i].toLowerCase() != workingTags[i].toLowerCase()) {
currentTag = { position: i, tag: workingTags[i].toLowerCase() };
tagSelected = true;
}
// lookup for filtering out chosen tags
chosenTags[currentTags[i].toLowerCase()] = true;
}

if (currentTag.tag) {
// collect potential tags
if (settings.url) {
$.ajax({
'url' : settings.url,
'dataType' : 'json',
'data' : { 'tag' : currentTag.tag },
'async' : false, // wait until this is ajax hit is complete before continue
'success' : function (m) {
matches = m;
}
});
} else {
for (i = 0; i < userTags.length; i++) {
if (userTags[i].indexOf(currentTag.tag) === 0) {
matches.push(userTags[i]);
}
}
}

matches = $.grep(matches, function (v, i) {
return !chosenTags[v.toLowerCase()];
});

if (settings.sort) {
matches = matches.sort();
}

for (i = 0; i < matches.length; i++) {
html += '<' + settings.tagWrap + ' class="_tag_suggestion">' + matches[i] + '</' + settings.tagWrap + '>';
}

tagMatches.html(html);
suggestionsShow = !!(matches.length);
} else {
hideSuggestions();
}
}

function hideSuggestions() {
tagMatches.empty();
matches = [];
suggestionsShow = false;
}

function setSelection() {
var v = tagsElm.val();

// tweak for hintted elements
// http://remysharp.com/2007/01/25/jquery-tutorial-text-box-hints/
if (v == tagsElm.attr('title') && tagsElm.is('.hint')) v = '';

currentTags = v.split(settings.separator);
hideSuggestions();
}

function chooseTag(tag) {
var i, index;
for (i = 0; i < currentTags.length; i++) {
if (currentTags[i].toLowerCase() != workingTags[i].toLowerCase()) {
index = i;
break;
}
}

if (index == workingTags.length - 1) tag = tag + settings.separator;

workingTags[i] = tag;

tagsElm.val(workingTags.join(settings.separator));
tagsElm.blur().focus();
setSelection();
}

function handleKeys(ev) {
fromTab = false;
var type = ev.type;
var resetSelection = false;

switch (ev.keyCode) {
case 37: // ignore cases (arrow keys)
case 38:
case 39:
case 40: {
hideSuggestions();
return true;
}
case 224:
case 17:
case 16:
case 18: {
return true;
}

case 8: {
// delete - hide selections if we're empty
if (this.value == '') {
hideSuggestions();
setSelection();
return true;
} else {
type = 'keyup'; // allow drop through
resetSelection = true;
showSuggestionsDelayed(this);
}
break;
}

case 9: // return and tab
case 13: {
if (suggestionsShow) {
// complete
chooseTag(matches[0]);

fromTab = true;
return false;
} else {
return true;
}
}
case 27: {
hideSuggestions();
setSelection();
return true;
}
case 32: {
setSelection();
return true;
}
}

if (type == 'keyup') {
switch (ev.charCode) {
case 9:
case 13: {
return true;
}
}

if (resetSelection) {
setSelection();
}
showSuggestionsDelayed(this, ev.charCode);
}
}

tagsElm.after(tagMatches).keypress(handleKeys).keyup(handleKeys).blur(function () {
if (fromTab == true || suggestionsShow) { // tweak to support tab selection for Opera & IE
fromTab = false;
tagsElm.focus();
}
});

// replace with jQuery version
tagMatches = $(tagMatches).click(function (ev) {
if (ev.target.nodeName == settings.tagWrap.toUpperCase() && $(ev.target).is('._tag_suggestion')) {
chooseTag(ev.target.innerHTML);
}
}).addClass(settings.matchClass);

// initialise
setSelection();
});
};
})(jQuery);
16 changes: 0 additions & 16 deletions assets/stylesheets/tagging.css

This file was deleted.

41 changes: 31 additions & 10 deletions lib/tagging_hooks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def view_issues_form_details_bottom(context={ })

tag_context = issue.project.identifier.gsub('-', '_')

tags = issue.tag_list_on(tag_context).sort.join(' ')
tags = issue.tag_list_on(tag_context).sort.collect{|tag| tag.gsub(/^#/, '')}.join(' ')

return '<p>' + context[:form].text_field(:tags, :value => tags) + '</p>'
end
Expand All @@ -39,7 +39,7 @@ def controller_issues_edit_after_save(context = {})
issue = context[:issue]
tags = context[:params]['issue']['tags'].to_s

tags = tags.split(/[\s,]+/).join(', ')
tags = tags.split(/[\s,]+/).collect{|tag| "##{tag}"}.join(', ')
tag_context = issue.project.identifier.gsub('-', '_')

issue.set_tag_list_on(tag_context, tags)
Expand Down Expand Up @@ -71,19 +71,23 @@ def view_layouts_base_content(context = {})
end

if request.parameters['action'] == 'edit'
tags = page.tag_list_on(tag_context).sort.join(' ')
tags = "<p
id='tagging_wiki_edit_field'><label>#{l(:field_tags)}</label><br /><input id='wikipage_tags' name='wikipage_tags' size='120' type='text' value='#{h(tags)}'/></p>"
tags = page.tag_list_on(tag_context).sort.collect{|tag| tag.gsub(/^#/, '')}.join(' ')
tags = "<p id='tagging_wiki_edit_block'><label>#{l(:field_tags)}</label><br /><input id='wikipage_tags' name='wikipage_tags' size='120' type='text' value='#{h(tags)}'/></p>"

ac = ActsAsTaggableOn::Tag.find(:all,
:conditions => ["id in (select tag_id from taggings
where taggable_type in ('WikiPage', 'Issue') and context = ?)", tag_context]).collect {|tag| tag.name}
ac = ac.collect{|tag| "'#{escape_javascript(tag.gsub(/^#/, ''))}'"}.join(', ')

# we add marker data at the end of the body because
# otherwise the save method doesn't get called. This is
# nuts, people.
tags += javascript_include_tag 'jquery-1.4.2.min.js', :plugin => 'redmine_tagging'
tags += javascript_include_tag 'tag.js', :plugin => 'redmine_tagging'

tags += <<-generatedscript
<script type="text/javascript">
var $j = jQuery.noConflict();
$j(document).ready(function() {
$j('#tagging_wiki_edit_field').insertAfter($j("#content_text").parent().parent());
$j('#tagging_wiki_edit_block').insertAfter($j("#content_text").parent().parent());
$j('#wikipage_tags').tagSuggest({ tags: [#{ac}] });
});
</script>
generatedscript
Expand All @@ -97,13 +101,30 @@ def controller_wiki_edit_after_save(context = {})

project = context[:page].wiki.project

tags = context[:params]['wikipage_tags'].to_s.split(/[\s,]+/).join(', ')
tags = context[:params]['wikipage_tags'].to_s.split(/[\s,]+/).collect{|tag| "##{tag}"}.join(', ')
tag_context = project.identifier.gsub('-', '_')

context[:page].set_tag_list_on(tag_context, tags)
context[:page].save
end

def view_layouts_base_html_head(context = {})
return "
<style>
span.tagMatches {
margin-left: 10px;
}
span.tagMatches span {
padding: 2px;
margin-right: 4px;
background-color: #0000AB;
color: #fff;
cursor: pointer;
}
</style>"
end

end
end
end

0 comments on commit d49eb98

Please sign in to comment.