Commit 65730c22 by Brittany Cheng

Merge branch 'master' of github.com:dementrock/mitx into discussion

parents f6a3842f 3ad309c2
/**
* @fileOverview jquery-autocomplete, the jQuery Autocompleter
* @author <a href="mailto:dylan@dyve.net">Dylan Verheul</a>
* @requires jQuery 1.6+
*
* Copyright 2005-2012, Dylan Verheul
*
* Use under either MIT, GPL or Apache 2.0. See LICENSE.txt
*
* Project home: https://github.com/dyve/jquery-autocomplete
*/
(function($) {
"use strict";
/**
* jQuery autocomplete plugin
* @param {object|string} options
* @returns (object} jQuery object
*/
$.fn.autocomplete = function(options) {
var url;
if (arguments.length > 1) {
url = options;
options = arguments[1];
options.url = url;
} else if (typeof options === 'string') {
url = options;
options = { url: url };
}
var opts = $.extend({}, $.fn.autocomplete.defaults, options);
return this.each(function() {
var $this = $(this);
$this.data('autocompleter', new $.Autocompleter(
$this,
$.meta ? $.extend({}, opts, $this.data()) : opts
));
});
};
/**
* Store default options
* @type {object}
*/
$.fn.autocomplete.defaults = {
inputClass: 'acInput',
loadingClass: 'acLoading',
resultsClass: 'acResults',
selectClass: 'acSelect',
queryParamName: 'q',
extraParams: {},
remoteDataType: false,
lineSeparator: '\n',
cellSeparator: '|',
minChars: 2,
maxItemsToShow: 10,
delay: 400,
useCache: true,
maxCacheLength: 10,
matchSubset: true,
matchCase: false,
matchInside: true,
mustMatch: false,
selectFirst: false,
selectOnly: false,
showResult: null,
preventDefaultReturn: true,
preventDefaultTab: false,
autoFill: false,
filterResults: true,
sortResults: true,
sortFunction: null,
onItemSelect: null,
onNoMatch: null,
onFinish: null,
matchStringConverter: null,
beforeUseConverter: null,
autoWidth: 'min-width',
useDelimiter: false,
delimiterChar: ',',
delimiterKeyCode: 188,
processData: null,
onError: null
};
/**
* Sanitize result
* @param {Object} result
* @returns {Object} object with members value (String) and data (Object)
* @private
*/
var sanitizeResult = function(result) {
var value, data;
var type = typeof result;
if (type === 'string') {
value = result;
data = {};
} else if ($.isArray(result)) {
value = result[0];
data = result.slice(1);
} else if (type === 'object') {
value = result.value;
data = result.data;
}
value = String(value);
if (typeof data !== 'object') {
data = {};
}
return {
value: value,
data: data
};
};
/**
* Sanitize integer
* @param {mixed} value
* @param {Object} options
* @returns {Number} integer
* @private
*/
var sanitizeInteger = function(value, stdValue, options) {
var num = parseInt(value, 10);
options = options || {};
if (isNaN(num) || (options.min && num < options.min)) {
num = stdValue;
}
return num;
};
/**
* Create partial url for a name/value pair
*/
var makeUrlParam = function(name, value) {
return [name, encodeURIComponent(value)].join('=');
};
/**
* Build an url
* @param {string} url Base url
* @param {object} [params] Dictionary of parameters
*/
var makeUrl = function(url, params) {
var urlAppend = [];
$.each(params, function(index, value) {
urlAppend.push(makeUrlParam(index, value));
});
if (urlAppend.length) {
url += url.indexOf('?') === -1 ? '?' : '&';
url += urlAppend.join('&');
}
return url;
};
/**
* Default sort filter
* @param {object} a
* @param {object} b
* @param {boolean} matchCase
* @returns {number}
*/
var sortValueAlpha = function(a, b, matchCase) {
a = String(a.value);
b = String(b.value);
if (!matchCase) {
a = a.toLowerCase();
b = b.toLowerCase();
}
if (a > b) {
return 1;
}
if (a < b) {
return -1;
}
return 0;
};
/**
* Parse data received in text format
* @param {string} text Plain text input
* @param {string} lineSeparator String that separates lines
* @param {string} cellSeparator String that separates cells
* @returns {array} Array of autocomplete data objects
*/
var plainTextParser = function(text, lineSeparator, cellSeparator) {
var results = [];
var i, j, data, line, value, lines;
// Be nice, fix linebreaks before splitting on lineSeparator
lines = String(text).replace('\r\n', '\n').split(lineSeparator);
for (i = 0; i < lines.length; i++) {
line = lines[i].split(cellSeparator);
data = [];
for (j = 0; j < line.length; j++) {
data.push(decodeURIComponent(line[j]));
}
value = data.shift();
results.push({ value: value, data: data });
}
return results;
};
/**
* Autocompleter class
* @param {object} $elem jQuery object with one input tag
* @param {object} options Settings
* @constructor
*/
$.Autocompleter = function($elem, options) {
/**
* Assert parameters
*/
if (!$elem || !($elem instanceof $) || $elem.length !== 1 || $elem.get(0).tagName.toUpperCase() !== 'INPUT') {
throw new Error('Invalid parameter for jquery.Autocompleter, jQuery object with one element with INPUT tag expected.');
}
/**
* @constant Link to this instance
* @type object
* @private
*/
var self = this;
/**
* @property {object} Options for this instance
* @public
*/
this.options = options;
/**
* @property object Cached data for this instance
* @private
*/
this.cacheData_ = {};
/**
* @property {number} Number of cached data items
* @private
*/
this.cacheLength_ = 0;
/**
* @property {string} Class name to mark selected item
* @private
*/
this.selectClass_ = 'jquery-autocomplete-selected-item';
/**
* @property {number} Handler to activation timeout
* @private
*/
this.keyTimeout_ = null;
/**
* @property {number} Handler to finish timeout
* @private
*/
this.finishTimeout_ = null;
/**
* @property {number} Last key pressed in the input field (store for behavior)
* @private
*/
this.lastKeyPressed_ = null;
/**
* @property {string} Last value processed by the autocompleter
* @private
*/
this.lastProcessedValue_ = null;
/**
* @property {string} Last value selected by the user
* @private
*/
this.lastSelectedValue_ = null;
/**
* @property {boolean} Is this autocompleter active (showing results)?
* @see showResults
* @private
*/
this.active_ = false;
/**
* @property {boolean} Is this autocompleter allowed to finish on blur?
* @private
*/
this.finishOnBlur_ = true;
/**
* Sanitize options
*/
this.options.minChars = sanitizeInteger(this.options.minChars, $.fn.autocomplete.defaults.minChars, { min: 1 });
this.options.maxItemsToShow = sanitizeInteger(this.options.maxItemsToShow, $.fn.autocomplete.defaults.maxItemsToShow, { min: 0 });
this.options.maxCacheLength = sanitizeInteger(this.options.maxCacheLength, $.fn.autocomplete.defaults.maxCacheLength, { min: 1 });
this.options.delay = sanitizeInteger(this.options.delay, $.fn.autocomplete.defaults.delay, { min: 0 });
/**
* Init DOM elements repository
*/
this.dom = {};
/**
* Store the input element we're attached to in the repository
*/
this.dom.$elem = $elem;
/**
* Switch off the native autocomplete and add the input class
*/
this.dom.$elem.attr('autocomplete', 'off').addClass(this.options.inputClass);
/**
* Create DOM element to hold results, and force absolute position
*/
this.dom.$results = $('<div></div>').hide().addClass(this.options.resultsClass).css({
position: 'absolute'
});
$('body').append(this.dom.$results);
/**
* Attach keyboard monitoring to $elem
*/
$elem.keydown(function(e) {
self.lastKeyPressed_ = e.keyCode;
switch(self.lastKeyPressed_) {
case self.options.delimiterKeyCode: // comma = 188
if (self.options.useDelimiter && self.active_) {
self.selectCurrent();
}
break;
// ignore navigational & special keys
case 35: // end
case 36: // home
case 16: // shift
case 17: // ctrl
case 18: // alt
case 37: // left
case 39: // right
break;
case 38: // up
e.preventDefault();
if (self.active_) {
self.focusPrev();
} else {
self.activate();
}
return false;
case 40: // down
e.preventDefault();
if (self.active_) {
self.focusNext();
} else {
self.activate();
}
return false;
case 9: // tab
if (self.active_) {
self.selectCurrent();
if (self.options.preventDefaultTab) {
e.preventDefault();
return false;
}
}
break;
case 13: // return
if (self.active_) {
self.selectCurrent();
if (self.options.preventDefaultReturn) {
e.preventDefault();
return false;
}
}
break;
case 27: // escape
if (self.active_) {
e.preventDefault();
self.deactivate(true);
return false;
}
break;
default:
self.activate();
}
});
/**
* Finish on blur event
* Use a timeout because instant blur gives race conditions
*/
$elem.blur(function() {
if (self.finishOnBlur_) {
self.finishTimeout_ = setTimeout(function() { self.deactivate(true); }, 200);
}
});
};
/**
* Position output DOM elements
* @private
*/
$.Autocompleter.prototype.position = function() {
var offset = this.dom.$elem.offset();
this.dom.$results.css({
top: offset.top + this.dom.$elem.outerHeight(),
left: offset.left
});
};
/**
* Read from cache
* @private
*/
$.Autocompleter.prototype.cacheRead = function(filter) {
var filterLength, searchLength, search, maxPos, pos;
if (this.options.useCache) {
filter = String(filter);
filterLength = filter.length;
if (this.options.matchSubset) {
searchLength = 1;
} else {
searchLength = filterLength;
}
while (searchLength <= filterLength) {
if (this.options.matchInside) {
maxPos = filterLength - searchLength;
} else {
maxPos = 0;
}
pos = 0;
while (pos <= maxPos) {
search = filter.substr(0, searchLength);
if (this.cacheData_[search] !== undefined) {
return this.cacheData_[search];
}
pos++;
}
searchLength++;
}
}
return false;
};
/**
* Write to cache
* @private
*/
$.Autocompleter.prototype.cacheWrite = function(filter, data) {
if (this.options.useCache) {
if (this.cacheLength_ >= this.options.maxCacheLength) {
this.cacheFlush();
}
filter = String(filter);
if (this.cacheData_[filter] !== undefined) {
this.cacheLength_++;
}
this.cacheData_[filter] = data;
return this.cacheData_[filter];
}
return false;
};
/**
* Flush cache
* @public
*/
$.Autocompleter.prototype.cacheFlush = function() {
this.cacheData_ = {};
this.cacheLength_ = 0;
};
/**
* Call hook
* Note that all called hooks are passed the autocompleter object
* @param {string} hook
* @param data
* @returns Result of called hook, false if hook is undefined
*/
$.Autocompleter.prototype.callHook = function(hook, data) {
var f = this.options[hook];
if (f && $.isFunction(f)) {
return f(data, this);
}
return false;
};
/**
* Set timeout to activate autocompleter
*/
$.Autocompleter.prototype.activate = function() {
var self = this;
if (this.keyTimeout_) {
clearTimeout(this.keyTimeout_);
}
this.keyTimeout_ = setTimeout(function() {
self.activateNow();
}, this.options.delay);
};
/**
* Activate autocompleter immediately
*/
$.Autocompleter.prototype.activateNow = function() {
var value = this.beforeUseConverter(this.dom.$elem.val());
if (value !== this.lastProcessedValue_ && value !== this.lastSelectedValue_) {
this.fetchData(value);
}
};
/**
* Get autocomplete data for a given value
* @param {string} value Value to base autocompletion on
* @private
*/
$.Autocompleter.prototype.fetchData = function(value) {
var self = this;
var processResults = function(results, filter) {
if (self.options.processData) {
results = self.options.processData(results);
}
self.showResults(self.filterResults(results, filter), filter);
};
this.lastProcessedValue_ = value;
if (value.length < this.options.minChars) {
processResults([], value);
} else if (this.options.data) {
processResults(this.options.data, value);
} else {
this.fetchRemoteData(value, function(remoteData) {
console.log(remoteData);
processResults(remoteData, value);
});
}
};
/**
* Get remote autocomplete data for a given value
* @param {string} filter The filter to base remote data on
* @param {function} callback The function to call after data retrieval
* @private
*/
$.Autocompleter.prototype.fetchRemoteData = function(filter, callback) {
var data = this.cacheRead(filter);
if (data) {
callback(data);
} else {
var self = this;
var dataType = self.options.remoteDataType === 'json' ? 'json' : 'text';
var ajaxCallback = function(data) {
var parsed = false;
if (data !== false) {
parsed = self.parseRemoteData(data);
self.cacheWrite(filter, parsed);
}
self.dom.$elem.removeClass(self.options.loadingClass);
callback(parsed);
};
this.dom.$elem.addClass(this.options.loadingClass);
$.ajax({
url: this.makeUrl(filter),
success: ajaxCallback,
error: function(jqXHR, textStatus, errorThrown) {
if($.isFunction(self.options.onError)) {
self.options.onError(jqXHR, textStatus, errorThrown);
} else {
ajaxCallback(false);
}
},
dataType: dataType
});
}
};
/**
* Create or update an extra parameter for the remote request
* @param {string} name Parameter name
* @param {string} value Parameter value
* @public
*/
$.Autocompleter.prototype.setExtraParam = function(name, value) {
var index = $.trim(String(name));
if (index) {
if (!this.options.extraParams) {
this.options.extraParams = {};
}
if (this.options.extraParams[index] !== value) {
this.options.extraParams[index] = value;
this.cacheFlush();
}
}
};
/**
* Build the url for a remote request
* If options.queryParamName === false, append query to url instead of using a GET parameter
* @param {string} param The value parameter to pass to the backend
* @returns {string} The finished url with parameters
*/
$.Autocompleter.prototype.makeUrl = function(param) {
var self = this;
var url = this.options.url;
var params = $.extend({}, this.options.extraParams);
if (this.options.queryParamName === false) {
url += encodeURIComponent(param);
} else {
params[this.options.queryParamName] = param;
}
return makeUrl(url, params);
};
/**
* Parse data received from server
* @param remoteData Data received from remote server
* @returns {array} Parsed data
*/
$.Autocompleter.prototype.parseRemoteData = function(remoteData) {
var remoteDataType;
var data = remoteData;
if (this.options.remoteDataType === 'json') {
remoteDataType = typeof(remoteData);
switch (remoteDataType) {
case 'object':
data = remoteData;
break;
case 'string':
data = $.parseJSON(remoteData);
break;
default:
throw new Error("Unexpected remote data type: " + remoteDataType);
}
return data;
}
return plainTextParser(data, this.options.lineSeparator, this.options.cellSeparator);
};
/**
* Filter result
* @param {Object} result
* @param {String} filter
* @returns {boolean} Include this result
* @private
*/
$.Autocompleter.prototype.filterResult = function(result, filter) {
if (!result.value) {
return false;
}
if (this.options.filterResults) {
var pattern = this.matchStringConverter(filter);
var testValue = this.matchStringConverter(result.value);
if (!this.options.matchCase) {
pattern = pattern.toLowerCase();
testValue = testValue.toLowerCase();
}
var patternIndex = testValue.indexOf(pattern);
if (this.options.matchInside) {
return patternIndex > -1;
} else {
return patternIndex === 0;
}
}
return true;
};
/**
* Filter results
* @param results
* @param filter
*/
$.Autocompleter.prototype.filterResults = function(results, filter) {
var filtered = [];
var i, result;
for (i = 0; i < results.length; i++) {
result = sanitizeResult(results[i]);
if (this.filterResult(result, filter)) {
filtered.push(result);
}
}
if (this.options.sortResults) {
filtered = this.sortResults(filtered, filter);
}
if (this.options.maxItemsToShow > 0 && this.options.maxItemsToShow < filtered.length) {
filtered.length = this.options.maxItemsToShow;
}
return filtered;
};
/**
* Sort results
* @param results
* @param filter
*/
$.Autocompleter.prototype.sortResults = function(results, filter) {
var self = this;
var sortFunction = this.options.sortFunction;
if (!$.isFunction(sortFunction)) {
sortFunction = function(a, b, f) {
return sortValueAlpha(a, b, self.options.matchCase);
};
}
results.sort(function(a, b) {
return sortFunction(a, b, filter, self.options);
});
return results;
};
/**
* Convert string before matching
* @param s
* @param a
* @param b
*/
$.Autocompleter.prototype.matchStringConverter = function(s, a, b) {
var converter = this.options.matchStringConverter;
if ($.isFunction(converter)) {
s = converter(s, a, b);
}
return s;
};
/**
* Convert string before use
* @param s
* @param a
* @param b
*/
$.Autocompleter.prototype.beforeUseConverter = function(s, a, b) {
s = this.getValue();
var converter = this.options.beforeUseConverter;
if ($.isFunction(converter)) {
s = converter(s, a, b);
}
return s;
};
/**
* Enable finish on blur event
*/
$.Autocompleter.prototype.enableFinishOnBlur = function() {
this.finishOnBlur_ = true;
};
/**
* Disable finish on blur event
*/
$.Autocompleter.prototype.disableFinishOnBlur = function() {
this.finishOnBlur_ = false;
};
/**
* Create a results item (LI element) from a result
* @param result
*/
$.Autocompleter.prototype.createItemFromResult = function(result) {
var self = this;
var $li = $('<li>' + this.showResult(result.value, result.data) + '</li>');
$li.data({value: result.value, data: result.data})
.click(function() {
self.selectItem($li);
})
.mousedown(self.disableFinishOnBlur)
.mouseup(self.enableFinishOnBlur)
;
return $li;
};
/**
* Get all items from the results list
* @param result
*/
$.Autocompleter.prototype.getItems = function() {
return $('>ul>li', this.dom.$results);
};
/**
* Show all results
* @param results
* @param filter
*/
$.Autocompleter.prototype.showResults = function(results, filter) {
var numResults = results.length;
var self = this;
var $ul = $('<ul></ul>');
var i, result, $li, autoWidth, first = false, $first = false;
if (numResults) {
for (i = 0; i < numResults; i++) {
result = results[i];
$li = this.createItemFromResult(result);
$ul.append($li);
if (first === false) {
first = String(result.value);
$first = $li;
$li.addClass(this.options.firstItemClass);
}
if (i === numResults - 1) {
$li.addClass(this.options.lastItemClass);
}
}
// Always recalculate position before showing since window size or
// input element location may have changed.
this.position();
this.dom.$results.html($ul).show();
if (this.options.autoWidth) {
autoWidth = this.dom.$elem.outerWidth() - this.dom.$results.outerWidth() + this.dom.$results.width();
this.dom.$results.css(this.options.autoWidth, autoWidth);
}
this.getItems().hover(
function() { self.focusItem(this); },
function() { /* void */ }
);
if (this.autoFill(first, filter) || this.options.selectFirst || (this.options.selectOnly && numResults === 1)) {
this.focusItem($first);
}
this.active_ = true;
} else {
this.hideResults();
this.active_ = false;
}
};
$.Autocompleter.prototype.showResult = function(value, data) {
if ($.isFunction(this.options.showResult)) {
return this.options.showResult(value, data);
} else {
return value;
}
};
$.Autocompleter.prototype.autoFill = function(value, filter) {
var lcValue, lcFilter, valueLength, filterLength;
if (this.options.autoFill && this.lastKeyPressed_ !== 8) {
lcValue = String(value).toLowerCase();
lcFilter = String(filter).toLowerCase();
valueLength = value.length;
filterLength = filter.length;
if (lcValue.substr(0, filterLength) === lcFilter) {
var d = this.getDelimiterOffsets();
var pad = d.start ? ' ' : ''; // if there is a preceding delimiter
this.setValue( pad + value );
var start = filterLength + d.start + pad.length;
var end = valueLength + d.start + pad.length;
this.selectRange(start, end);
return true;
}
}
return false;
};
$.Autocompleter.prototype.focusNext = function() {
this.focusMove(+1);
};
$.Autocompleter.prototype.focusPrev = function() {
this.focusMove(-1);
};
$.Autocompleter.prototype.focusMove = function(modifier) {
var $items = this.getItems();
modifier = sanitizeInteger(modifier, 0);
if (modifier) {
for (var i = 0; i < $items.length; i++) {
if ($($items[i]).hasClass(this.selectClass_)) {
this.focusItem(i + modifier);
return;
}
}
}
this.focusItem(0);
};
$.Autocompleter.prototype.focusItem = function(item) {
var $item, $items = this.getItems();
if ($items.length) {
$items.removeClass(this.selectClass_).removeClass(this.options.selectClass);
if (typeof item === 'number') {
if (item < 0) {
item = 0;
} else if (item >= $items.length) {
item = $items.length - 1;
}
$item = $($items[item]);
} else {
$item = $(item);
}
if ($item) {
$item.addClass(this.selectClass_).addClass(this.options.selectClass);
}
}
};
$.Autocompleter.prototype.selectCurrent = function() {
var $item = $('li.' + this.selectClass_, this.dom.$results);
if ($item.length === 1) {
this.selectItem($item);
} else {
this.deactivate(false);
}
};
$.Autocompleter.prototype.selectItem = function($li) {
var value = $li.data('value');
var data = $li.data('data');
var displayValue = this.displayValue(value, data);
var processedDisplayValue = this.beforeUseConverter(displayValue);
this.lastProcessedValue_ = processedDisplayValue;
this.lastSelectedValue_ = processedDisplayValue;
var d = this.getDelimiterOffsets();
var delimiter = this.options.delimiterChar;
var elem = this.dom.$elem;
var extraCaretPos = 0;
if ( this.options.useDelimiter ) {
// if there is a preceding delimiter, add a space after the delimiter
if ( elem.val().substring(d.start-1, d.start) == delimiter && delimiter != ' ' ) {
displayValue = ' ' + displayValue;
}
// if there is not already a delimiter trailing this value, add it
if ( elem.val().substring(d.end, d.end+1) != delimiter && this.lastKeyPressed_ != this.options.delimiterKeyCode ) {
displayValue = displayValue + delimiter;
} else {
// move the cursor after the existing trailing delimiter
extraCaretPos = 1;
}
}
this.setValue(displayValue);
this.setCaret(d.start + displayValue.length + extraCaretPos);
this.callHook('onItemSelect', { value: value, data: data });
this.deactivate(true);
elem.focus();
};
$.Autocompleter.prototype.displayValue = function(value, data) {
if ($.isFunction(this.options.displayValue)) {
return this.options.displayValue(value, data);
}
return value;
};
$.Autocompleter.prototype.hideResults = function() {
this.dom.$results.hide();
};
$.Autocompleter.prototype.deactivate = function(finish) {
if (this.finishTimeout_) {
clearTimeout(this.finishTimeout_);
}
if (this.keyTimeout_) {
clearTimeout(this.keyTimeout_);
}
if (finish) {
if (this.lastProcessedValue_ !== this.lastSelectedValue_) {
if (this.options.mustMatch) {
this.setValue('');
}
this.callHook('onNoMatch');
}
if (this.active_) {
this.callHook('onFinish');
}
this.lastKeyPressed_ = null;
this.lastProcessedValue_ = null;
this.lastSelectedValue_ = null;
this.active_ = false;
}
this.hideResults();
};
$.Autocompleter.prototype.selectRange = function(start, end) {
var input = this.dom.$elem.get(0);
if (input.setSelectionRange) {
input.focus();
input.setSelectionRange(start, end);
} else if (input.createTextRange) {
var range = input.createTextRange();
range.collapse(true);
range.moveEnd('character', end);
range.moveStart('character', start);
range.select();
}
};
/**
* Move caret to position
* @param {Number} pos
*/
$.Autocompleter.prototype.setCaret = function(pos) {
this.selectRange(pos, pos);
};
/**
* Get caret position
*/
$.Autocompleter.prototype.getCaret = function() {
var elem = this.dom.$elem;
if ($.browser.msie) {
// ie
var selection = document.selection;
if (elem[0].tagName.toLowerCase() != 'textarea') {
var val = elem.val();
var range = selection.createRange().duplicate();
range.moveEnd('character', val.length);
var s = ( range.text == '' ? val.length : val.lastIndexOf(range.text) );
range = selection.createRange().duplicate();
range.moveStart('character', -val.length);
var e = range.text.length;
} else {
var range = selection.createRange();
var stored_range = range.duplicate();
stored_range.moveToElementText(elem[0]);
stored_range.setEndPoint('EndToEnd', range);
var s = stored_range.text.length - range.text.length;
var e = s + range.text.length;
}
} else {
// ff, chrome, safari
var s = elem[0].selectionStart;
var e = elem[0].selectionEnd;
}
return {
start: s,
end: e
};
};
/**
* Set the value that is currently being autocompleted
* @param {String} value
*/
$.Autocompleter.prototype.setValue = function(value) {
if ( this.options.useDelimiter ) {
// set the substring between the current delimiters
var val = this.dom.$elem.val();
var d = this.getDelimiterOffsets();
var preVal = val.substring(0, d.start);
var postVal = val.substring(d.end);
value = preVal + value + postVal;
}
this.dom.$elem.val(value);
};
/**
* Get the value currently being autocompleted
* @param {String} value
*/
$.Autocompleter.prototype.getValue = function() {
var val = this.dom.$elem.val();
if ( this.options.useDelimiter ) {
var d = this.getDelimiterOffsets();
return val.substring(d.start, d.end).trim();
} else {
return val;
}
};
/**
* Get the offsets of the value currently being autocompleted
*/
$.Autocompleter.prototype.getDelimiterOffsets = function() {
var val = this.dom.$elem.val();
if ( this.options.useDelimiter ) {
var preCaretVal = val.substring(0, this.getCaret().start);
var start = preCaretVal.lastIndexOf(this.options.delimiterChar) + 1;
var postCaretVal = val.substring(this.getCaret().start);
var end = postCaretVal.indexOf(this.options.delimiterChar);
if ( end == -1 ) end = val.length;
end += this.getCaret().start;
} else {
start = 0;
end = val.length;
}
return {
start: start,
end: end
};
};
})(jQuery);
/*
jQuery Tags Input Plugin 1.3.3
Copyright (c) 2011 XOXCO, Inc
Documentation for this plugin lives here:
http://xoxco.com/clickable/jquery-tags-input
Licensed under the MIT license:
http://www.opensource.org/licenses/mit-license.php
ben@xoxco.com
*/
(function($) {
var delimiter = new Array();
var tags_callbacks = new Array();
$.fn.doAutosize = function(o){
var minWidth = $(this).data('minwidth'),
maxWidth = $(this).data('maxwidth'),
val = '',
input = $(this),
testSubject = $('#'+$(this).data('tester_id'));
if (val === (val = input.val())) {return;}
// Enter new content into testSubject
var escaped = val.replace(/&/g, '&amp;').replace(/\s/g,' ').replace(/</g, '&lt;').replace(/>/g, '&gt;');
testSubject.html(escaped);
// Calculate new width + whether to change
var testerWidth = testSubject.width(),
newWidth = (testerWidth + o.comfortZone) >= minWidth ? testerWidth + o.comfortZone : minWidth,
currentWidth = input.width(),
isValidWidthChange = (newWidth < currentWidth && newWidth >= minWidth)
|| (newWidth > minWidth && newWidth < maxWidth);
// Animate width
if (isValidWidthChange) {
input.width(newWidth);
}
};
$.fn.resetAutosize = function(options){
// alert(JSON.stringify(options));
var minWidth = $(this).data('minwidth') || options.minInputWidth || $(this).width(),
maxWidth = $(this).data('maxwidth') || options.maxInputWidth || ($(this).closest('.tagsinput').width() - options.inputPadding),
val = '',
input = $(this),
testSubject = $('<tester/>').css({
position: 'absolute',
top: -9999,
left: -9999,
width: 'auto',
fontSize: input.css('fontSize'),
fontFamily: input.css('fontFamily'),
fontWeight: input.css('fontWeight'),
letterSpacing: input.css('letterSpacing'),
whiteSpace: 'nowrap'
}),
testerId = $(this).attr('id')+'_autosize_tester';
if(! $('#'+testerId).length > 0){
testSubject.attr('id', testerId);
testSubject.appendTo('body');
}
input.data('minwidth', minWidth);
input.data('maxwidth', maxWidth);
input.data('tester_id', testerId);
input.css('width', minWidth);
};
$.fn.addTag = function(value,options) {
options = jQuery.extend({focus:false,callback:true},options);
this.each(function() {
var id = $(this).attr('id');
var tagslist = $(this).val().split(delimiter[id]);
if (tagslist[0] == '') {
tagslist = new Array();
}
value = jQuery.trim(value);
if (options.unique) {
var skipTag = $(this).tagExist(value);
if(skipTag == true) {
//Marks fake input as not_valid to let styling it
$('#'+id+'_tag').addClass('not_valid');
}
} else {
var skipTag = false;
}
if (value !='' && skipTag != true) {
$('<span>').addClass('tag').append(
$('<span>').text(value).append('&nbsp;&nbsp;'),
$('<a>', {
href : '#',
title : 'Removing tag',
text : 'x'
}).click(function () {
return $('#' + id).removeTag(escape(value));
})
).insertBefore('#' + id + '_addTag');
tagslist.push(value);
$('#'+id+'_tag').val('');
if (options.focus) {
$('#'+id+'_tag').focus();
} else {
$('#'+id+'_tag').blur();
}
$.fn.tagsInput.updateTagsField(this,tagslist);
if (options.callback && tags_callbacks[id] && tags_callbacks[id]['onAddTag']) {
var f = tags_callbacks[id]['onAddTag'];
f.call(this, value);
}
if(tags_callbacks[id] && tags_callbacks[id]['onChange'])
{
var i = tagslist.length;
var f = tags_callbacks[id]['onChange'];
f.call(this, $(this), tagslist[i-1]);
}
}
});
return false;
};
$.fn.removeTag = function(value) {
value = unescape(value);
this.each(function() {
var id = $(this).attr('id');
var old = $(this).val().split(delimiter[id]);
$('#'+id+'_tagsinput .tag').remove();
str = '';
for (i=0; i< old.length; i++) {
if (old[i]!=value) {
str = str + delimiter[id] +old[i];
}
}
$.fn.tagsInput.importTags(this,str);
if (tags_callbacks[id] && tags_callbacks[id]['onRemoveTag']) {
var f = tags_callbacks[id]['onRemoveTag'];
f.call(this, value);
}
});
return false;
};
$.fn.tagExist = function(val) {
var id = $(this).attr('id');
var tagslist = $(this).val().split(delimiter[id]);
return (jQuery.inArray(val, tagslist) >= 0); //true when tag exists, false when not
};
// clear all existing tags and import new ones from a string
$.fn.importTags = function(str) {
id = $(this).attr('id');
$('#'+id+'_tagsinput .tag').remove();
$.fn.tagsInput.importTags(this,str);
}
$.fn.tagsInput = function(options) {
var settings = jQuery.extend({
interactive:true,
defaultText:'add a tag',
minChars:0,
width:'300px',
height:'100px',
autocomplete: {selectFirst: false },
'hide':true,
'delimiter':',',
'unique':true,
removeWithBackspace:true,
placeholderColor:'#666666',
autosize: true,
comfortZone: 20,
inputPadding: 6*2
},options);
this.each(function() {
if (settings.hide) {
$(this).hide();
}
var id = $(this).attr('id');
if (!id || delimiter[$(this).attr('id')]) {
id = $(this).attr('id', 'tags' + new Date().getTime()).attr('id');
}
var data = jQuery.extend({
pid:id,
real_input: '#'+id,
holder: '#'+id+'_tagsinput',
input_wrapper: '#'+id+'_addTag',
fake_input: '#'+id+'_tag'
},settings);
delimiter[id] = data.delimiter;
if (settings.onAddTag || settings.onRemoveTag || settings.onChange) {
tags_callbacks[id] = new Array();
tags_callbacks[id]['onAddTag'] = settings.onAddTag;
tags_callbacks[id]['onRemoveTag'] = settings.onRemoveTag;
tags_callbacks[id]['onChange'] = settings.onChange;
}
var markup = '<div id="'+id+'_tagsinput" class="tagsinput"><div id="'+id+'_addTag">';
if (settings.interactive) {
markup = markup + '<input id="'+id+'_tag" value="" data-default="'+settings.defaultText+'" />';
}
markup = markup + '</div><div class="tags_clear"></div></div>';
$(markup).insertAfter(this);
$(data.holder).css('width',settings.width);
$(data.holder).css('min-height',settings.height);
$(data.holder).css('height','100%');
if ($(data.real_input).val()!='') {
$.fn.tagsInput.importTags($(data.real_input),$(data.real_input).val());
}
if (settings.interactive) {
$(data.fake_input).val($(data.fake_input).attr('data-default'));
$(data.fake_input).css('color',settings.placeholderColor);
$(data.fake_input).resetAutosize(settings);
$(data.holder).bind('click',data,function(event) {
$(event.data.fake_input).focus();
});
$(data.fake_input).bind('focus',data,function(event) {
if ($(event.data.fake_input).val()==$(event.data.fake_input).attr('data-default')) {
$(event.data.fake_input).val('');
}
$(event.data.fake_input).css('color','#000000');
});
if (settings.autocomplete_url != undefined) {
autocomplete_options = {source: settings.autocomplete_url};
for (attrname in settings.autocomplete) {
autocomplete_options[attrname] = settings.autocomplete[attrname];
}
if (jQuery.Autocompleter !== undefined) {
onSelectCallback = settings.autocomplete.onItemSelect;
settings.autocomplete.onItemSelect = function() {
console.log("here");
$(data.real_input).addTag($(data.fake_input).val(), {focus: true, unique: (settings.unique)});
$(data.fake_input).resetAutosize(settings);
if (onSelectCallback) {
onSelectCallback();
}
}
$(data.fake_input).autocomplete(settings.autocomplete_url, settings.autocomplete);
$(data.fake_input).bind('result',data,function(event,data,formatted) {
if (data) {
$('#'+id).addTag(data[0] + "",{focus:true,unique:(settings.unique)});
}
});
} else if (jQuery.ui.autocomplete !== undefined) {
$(data.fake_input).autocomplete(autocomplete_options);
$(data.fake_input).bind('autocompleteselect',data,function(event,ui) {
$(event.data.real_input).addTag(ui.item.value,{focus:true,unique:(settings.unique)});
return false;
});
}
} else {
// if a user tabs out of the field, create a new tag
// this is only available if autocomplete is not used.
$(data.fake_input).bind('blur',data,function(event) {
var d = $(this).attr('data-default');
if ($(event.data.fake_input).val()!='' && $(event.data.fake_input).val()!=d) {
if( (event.data.minChars <= $(event.data.fake_input).val().length) && (!event.data.maxChars || (event.data.maxChars >= $(event.data.fake_input).val().length)) )
$(event.data.real_input).addTag($(event.data.fake_input).val(),{focus:true,unique:(settings.unique)});
} else {
$(event.data.fake_input).val($(event.data.fake_input).attr('data-default'));
$(event.data.fake_input).css('color',settings.placeholderColor);
}
return false;
});
}
// if user types a comma, create a new tag
$(data.fake_input).bind('keypress',data,function(event) {
if (event.which==event.data.delimiter.charCodeAt(0) || event.which==13 ) {
event.preventDefault();
if( (event.data.minChars <= $(event.data.fake_input).val().length) && (!event.data.maxChars || (event.data.maxChars >= $(event.data.fake_input).val().length)) )
$(event.data.real_input).addTag($(event.data.fake_input).val(),{focus:true,unique:(settings.unique)});
$(event.data.fake_input).resetAutosize(settings);
return false;
} else if (event.data.autosize) {
$(event.data.fake_input).doAutosize(settings);
}
});
//Delete last tag on backspace
data.removeWithBackspace && $(data.fake_input).bind('keydown', function(event)
{
if(event.keyCode == 8 && $(this).val() == '')
{
event.preventDefault();
var last_tag = $(this).closest('.tagsinput').find('.tag:last').text();
var id = $(this).attr('id').replace(/_tag$/, '');
last_tag = last_tag.replace(/[\s]+x$/, '');
$('#' + id).removeTag(escape(last_tag));
$(this).trigger('focus');
}
});
$(data.fake_input).blur();
//Removes the not_valid class when user changes the value of the fake input
if(data.unique) {
$(data.fake_input).keydown(function(event){
if(event.keyCode == 8 || String.fromCharCode(event.which).match(/\w+|[áéíóúÁÉÍÓÚñÑ,/]+/)) {
$(this).removeClass('not_valid');
}
});
}
} // if settings.interactive
});
return this;
};
$.fn.tagsInput.updateTagsField = function(obj,tagslist) {
var id = $(obj).attr('id');
$(obj).val(tagslist.join(delimiter[id]));
};
$.fn.tagsInput.importTags = function(obj,val) {
$(obj).val('');
var id = $(obj).attr('id');
var tags = val.split(delimiter[id]);
for (i=0; i<tags.length; i++) {
$(obj).addTag(tags[i],{focus:false,callback:false});
}
if(tags_callbacks[id] && tags_callbacks[id]['onChange'])
{
var f = tags_callbacks[id]['onChange'];
f.call(obj, obj, tags[i]);
}
};
})(jQuery);
......@@ -4,6 +4,7 @@ import django_comment_client.base.views
urlpatterns = patterns('django_comment_client.base.views',
url(r'upload$', 'upload', name='upload'),
url(r'threads/tags/autocomplete$', 'tags_autocomplete', name='tags_autocomplete'),
url(r'threads/(?P<thread_id>[\w\-]+)/update$', 'update_thread', name='update_thread'),
url(r'threads/(?P<thread_id>[\w\-]+)/reply$', 'create_comment', name='create_comment'),
url(r'threads/(?P<thread_id>[\w\-]+)/delete', 'delete_thread', name='delete_thread'),
......@@ -22,4 +23,6 @@ urlpatterns = patterns('django_comment_client.base.views',
url(r'(?P<commentable_id>[\w\-]+)/threads/create$', 'create_thread', name='create_thread'),
url(r'(?P<commentable_id>[\w\-]+)/watch$', 'watch_commentable', name='watch_commentable'),
url(r'(?P<commentable_id>[\w\-]+)/unwatch$', 'unwatch_commentable', name='unwatch_commentable'),
url(r'search$', 'search', name='search'),
)
......@@ -65,7 +65,7 @@ def extract(dic, keys):
@login_required
@require_POST
def create_thread(request, course_id, commentable_id):
attributes = extract(request.POST, ['body', 'title'])
attributes = extract(request.POST, ['body', 'title', 'tags'])
attributes['user_id'] = request.user.id
attributes['course_id'] = course_id
response = comment_client.create_thread(commentable_id, attributes)
......@@ -75,7 +75,7 @@ def create_thread(request, course_id, commentable_id):
@login_required
@require_POST
def update_thread(request, course_id, thread_id):
attributes = extract(request.POST, ['body', 'title'])
attributes = extract(request.POST, ['body', 'title', 'tags'])
response = comment_client.update_thread(thread_id, attributes)
return JsonResponse(response)
......@@ -188,14 +188,26 @@ def unfollow(request, course_id, followed_user_id):
response = comment_client.unfollow(user_id, followed_user_id)
return JsonResponse(response)
@login_required
@require_GET
def search(request, course_id):
text = request.GET.get('text', None)
commentable_id = request.GET.get('commentable_id', None)
response = comment_client.search(text, commentable_id)
tags = request.GET.get('tags', None)
response = comment_client.search_threads({
'text': text,
'commentable_id': commentable_id,
'tags': tags,
})
return JsonResponse(response)
@require_GET
def tags_autocomplete(request, course_id):
value = request.GET.get('q', None)
results = []
if value:
results = comment_client.tags_autocomplete(value)
return JsonResponse(results)
@csrf.csrf_exempt
@login_required
@require_POST
......
......@@ -101,8 +101,21 @@ def single_thread(request, course_id, thread_id):
def search(request, course_id):
course = check_course(course_id)
text = request.GET.get('text', None)
threads = comment_client.search(text)
commentable_id = request.GET.get('commentable_id', None)
tags = request.GET.get('tags', None)
print text
print commentable_id
print tags
threads = comment_client.search_threads({
'text': text,
'commentable_id': commentable_id,
'tags': tags,
})
context = {
'csrf': csrf(request)['csrf_token'],
'init': '',
......
......@@ -14,6 +14,9 @@ def get_threads(commentable_id, recursive=False, *args, **kwargs):
def get_threads_tags(*args, **kwargs):
return _perform_request('get', _url_for_threads_tags(), {}, *args, **kwargs)
def tags_autocomplete(value, *args, **kwargs):
return _perform_request('get', _url_for_threads_tags_autocomplete(), {'value': value}, *args, **kwargs)
def create_thread(commentable_id, attributes, *args, **kwargs):
return _perform_request('post', _url_for_threads(commentable_id), attributes, *args, **kwargs)
......@@ -87,8 +90,8 @@ def unsubscribe_thread(user_id, thread_id, *args, **kwargs):
def unsubscribe_commentable(user_id, commentable_id, *args, **kwargs):
return unsubscribe(user_id, {'source_type': 'other', 'source_id': commentable_id})
def search(text, commentable_id=None, *args, **kwargs):
return _perform_request('get', _url_for_search(), {'text': text, 'commentable_id': commentable_id}, *args, **kwargs)
def search_threads(attributes, *args, **kwargs):
return _perform_request('get', _url_for_search_threads(), attributes, *args, **kwargs)
def _perform_request(method, url, data_or_params=None, *args, **kwargs):
if method in ['post', 'put', 'patch']:
......@@ -127,8 +130,11 @@ def _url_for_subscription(user_id):
def _url_for_user(user_id):
return "{prefix}/users/{user_id}".format(prefix=PREFIX, user_id=user_id)
def _url_for_search():
return "{prefix}/search".format(prefix=PREFIX)
def _url_for_search_threads():
return "{prefix}/search/threads".format(prefix=PREFIX)
def _url_for_threads_tags():
return "{prefix}/threads/tags".format(prefix=PREFIX)
def _url_for_threads_tags_autocomplete():
return "{prefix}/threads/tags/autocomplete".format(prefix=PREFIX)
......@@ -96,18 +96,24 @@ $ ->
deTilde(@blocks.join(""))
@removeMathWrapper: (_this) ->
(text) -> _this.removeMath(text)
replaceMath: (text) ->
text = text.replace /@@(\d+)@@/g, ($0, $1) => @math[$1]
@math = null
text
@replaceMathWrapper: (_this) ->
(text) -> _this.replaceMath(text)
if Markdown?
Markdown.getMathCompatibleConverter = ->
converter = Markdown.getSanitizingConverter()
processor = new MathJaxProcessor()
converter.hooks.chain "preConversion", processor.removeMath
converter.hooks.chain "postConversion", processor.replaceMath
converter.hooks.chain "preConversion", MathJaxProcessor.removeMathWrapper(processor)#processor.removeMath
converter.hooks.chain "postConversion", MathJaxProcessor.replaceMathWrapper(processor)#.replaceMath
converter
Markdown.makeWmdEditor = (elem, appended_id, imageUploadUrl) ->
......
......@@ -45,6 +45,7 @@ Discussion =
downvote_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/downvote"
upload : "/courses/#{$$course_id}/discussion/upload"
search : "/courses/#{$$course_id}/discussion/forum/search"
tags_autocomplete : "/courses/#{$$course_id}/discussion/threads/tags/autocomplete"
}[name]
handleAnchorAndReload: (response) ->
......@@ -111,6 +112,7 @@ Discussion =
Discussion.handleAnchorAndReload(response)
, 'json'
if id in $$user_info.subscribed_thread_ids
unwatchThread = generateDiscussionLink("discussion-unwatch-thread", "Unwatch", handleUnwatchThread)
$local(".info").append(unwatchThread)
......@@ -118,11 +120,23 @@ Discussion =
watchThread = generateDiscussionLink("discussion-watch-thread", "Watch", handleWatchThread)
$local(".info").append(watchThread)
$local = generateLocal(discussion)
if $$user_info?
$(discussion).find(".comment").each(initializeVote)
$(discussion).find(".thread").each(initializeVote).each(initializeWatchThreads)
$local(".comment").each(initializeVote)
$local(".thread").each(initializeVote).each(initializeWatchThreads)
initializeWatchDiscussion(discussion)
if $$tags?
$local(".new-post-tags").tagsInput
autocomplete_url: Discussion.urlFor('tags_autocomplete')
autocomplete:
remoteDataType: 'json'
interactive: true
defaultText: "add a tag"
height: "30px"
removeWithBackspace: true
bindContentEvents: (content) ->
$content = $(content)
......@@ -255,8 +269,9 @@ Discussion =
handleSubmitNewThread = (elem) ->
title = $local(".new-post-title").val()
body = $local("#wmd-input-new-post-body-#{id}").val()
tags = $local(".new-post-tags").val()
url = Discussion.urlFor('create_thread', $local(".new-post-form").attr("_id"))
$.post url, {title: title, body: body}, (response, textStatus) ->
$.post url, {title: title, body: body, tags: tags}, (response, textStatus) ->
if textStatus == "success"
Discussion.handleAnchorAndReload(response)
, 'json'
......
.acInput {
width: 200px;
}
.acResults {
padding: 0px;
border: 1px solid WindowFrame;
background-color: Window;
overflow: hidden;
}
.acResults ul {
margin: 0px;
padding: 0px;
list-style-position: outside;
list-style: none;
}
.acResults ul li {
margin: 0px;
padding: 2px 5px;
cursor: pointer;
display: block;
font: menu;
font-size: 12px;
overflow: hidden;
}
.acLoading {
background : url('indicator.gif') right center no-repeat;
}
.acSelect {
background-color: Highlight;
color: HighlightText;
}
div.tagsinput { border:1px solid #CCC; background: #FFF; padding:5px; width:300px; height:100px; overflow-y: auto;}
div.tagsinput span.tag { border: 1px solid #a5d24a; -moz-border-radius:2px; -webkit-border-radius:2px; display: block; float: left; padding: 5px; text-decoration:none; background: #cde69c; color: #638421; margin-right: 5px; margin-bottom:5px;font-family: helvetica; font-size:13px;}
div.tagsinput span.tag a { font-weight: bold; color: #82ad2b; text-decoration:none; font-size: 11px; }
div.tagsinput input { width:80px; margin:0px; font-family: helvetica; font-size: 13px; border:1px solid transparent; padding:5px; background: transparent; color: #000; outline:0px; margin-right:5px; margin-bottom:5px; }
div.tagsinput div { display:block; float: left; }
.tags_clear { clear: both; width: 100%; height: 0px; }
.not_valid {background: #FBD8DB !important; color: #90111A !important;}
......@@ -109,6 +109,9 @@ $discussion_input_width: 60%;
margin-top: 10px;
font-weight: bold;
}
.tagsinput {
margin-top: 20px;
}
}
.thread {
//display: none;
......@@ -121,11 +124,32 @@ $discussion_input_width: 60%;
font-weight: bold;
display: block;
}
.thread-body {
.thread-body, .content-body {
@include discussion-font;
font-size: $comment_body_size;
margin-top: 7px;
margin-bottom: 2px;
p {
@include discussion-font;
margin: 0;
}
}
.thread-tags {
.thread-tag {
@include discussion-font;
font-size: 0.75em;
border-width: 0px 1px 1px 0px;
border-style: solid;
border-color: #205C85;
padding: 0px 3px 3px 0px;
margin-right: 5px;
border-radius: 4px;
color: #205C85;
&:hover {
color: #3CC5E7;
border-color: #3CC5E7;
}
}
}
.info {
@include discussion-font;
......@@ -171,7 +195,7 @@ $discussion_input_width: 60%;
margin-left: $comment_margin_left;
overflow: hidden;
.comment {
.comment-body {
.comment-body, .content-body {
@include discussion-font;
font-size: $comment_body_size;
margin-top: 3px;
......
......@@ -31,6 +31,10 @@
<script type="text/javascript" src="${static.url('js/vendor/Markdown.Converter.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/Markdown.Sanitizer.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/Markdown.Editor.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/jquery.autocomplete.js')}"></script>
<script type="text/javascript" src="${static.url('js/vendor/jquery.tagsinput.js')}"></script>
<link href="${static.url('css/vendor/jquery.tagsinput.css')}" rel="stylesheet" type="text/css">
<link href="${static.url('css/vendor/jquery.autocomplete.css')}" rel="stylesheet" type="text/css">
</%block>
<%include file="../course_navigation.html" args="active_page='discussion'" />
......
......@@ -9,7 +9,7 @@
<form class="new-post-form" _id="${discussion_id}">
<input type="text" class="new-post-title" placeholder="Title"/>
<div class="new-post-body"></div>
<input type="text" class="new-pot-tags" placeholder="tag1, tag2"/>
<input class="new-post-tags" placeholder="Tags"/>
<a class="discussion-new-post" href="javascript:void(0)">New Post</a>
</form>
</div>
......
<%! from django.core.urlresolvers import reverse %>
<%! from datehelper import time_ago_in_words %>
<%! from dateutil.parser import parse %>
<%! import urllib %>
<%def name="render_thread(course_id, thread, edit_thread=False, show_comments=False)">
<%
......@@ -9,6 +10,8 @@
else:
thread_id = thread['id']
url_for_thread = reverse('django_comment_client.forum.views.single_thread', args=[course_id, thread_id])
def url_for_tags(tags):
return reverse('django_comment_client.forum.views.search', args=[course_id]) + '?' + urllib.urlencode({'tags': ",".join(tags)})
%>
<div class="thread" _id="${thread['id']}">
<div class="discussion-content">
......@@ -18,6 +21,11 @@
<a class="thread-title" name="${thread['id']}" href="${url_for_thread}">${thread['title'] | h}</a>
<div class="discussion-content-view">
<div class="content-body thread-body">${thread['body'] | h}</div>
<div class="thread-tags">
% for tag in thread['tags']:
<a class="thread-tag" href="${url_for_tags([tag])}">${tag}</a>
% endfor
</div>
<div class="info">
${render_info(thread)}
% if edit_thread:
......@@ -83,7 +91,6 @@
<a class="discussion-link discussion-watch-thread" href="javascript:void(0)">Watch</a>
</%def>
<%def name="render_vote(content)">
<%
upvote = "&#x2C4;"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment