added "tagfield" module

This commit is contained in:
chillu 2008-09-30 16:00:21 +00:00
commit aa0fb00c7a
8 changed files with 631 additions and 0 deletions

0
tagfield/CHANGELOG Normal file
View File

0
tagfield/LICENSE Normal file
View File

0
tagfield/README Normal file
View File

2
tagfield/_config.php Normal file
View File

@ -0,0 +1,2 @@
<?php
?>

212
tagfield/code/TagField.php Normal file
View File

@ -0,0 +1,212 @@
<?php
/**
* Features:
* - Bundled with jQuery-based autocomplete library which is applied to a textfield
* - Autosuggest functionality (currently JSON only)
* @author Ingo Schommer, SilverStripe Ltd. (<firstname>@silverstripe.com)
* @package formfields
* @subpackage tagfield
*/
class TagField extends TextField {
/**
* @var string $tagTextbasedClass The DataObject class with a text property
* or many_many relation matching the name of the Field.
*/
protected $tagTopicClass;
/**
* @var string $tagObjectField Only applies to object-based tagging.
* The fieldname for textbased tagging is inferred from the formfield name.
*/
protected $tagObjectField = 'Title';
/**
* @var string $tagFilter
*/
protected $tagFilter;
/**
* @var string $tagSort If {@link suggest()} finds multiple matches, in which order should they
* be presented.
*/
protected $tagSort;
/**
* @var $separator
*/
protected $separator = ' ';
/**
* @var array $customTags Override the tagging behaviour with a custom set
* used by {@link suggest()}.
*/
protected $customTags;
function __construct($name, $title = null, $value = null, $tagTopicClass) {
$this->tagTopicClass = $tagTopicClass;
parent::__construct($name, $title, $value);
}
public function Field() {
Requirements::javascript(THIRDPARTY_DIR . "/jquery/jquery.js");
Requirements::javascript("tagfield/javascript/jquery.tags.js");
Requirements::customScript("$.ready(function() {
$('#" . $this->id() . "').tagSuggest({
url: '" . $this->Link() . "/suggest'
});
});");
return parent::Field();
}
/**
* @return string JSON array
*/
public function suggest($request) {
$tagTopicClassObj = singleton($this->tagTopicClass);
$searchString = $request->requestVar($this->Name());
if($this->customTags) {
$tags = $this->customTags;
} else if($tagTopicClassObj->many_many($this->Name())) {
$tags = $this->getObjectTags($searchString);
} else if($tagTopicClassObj->hasField($this->Name())) {
$tags = $this->getTextbasedTags($searchString);
} else {
user_error('TagField::suggest(): Cant find valid relation or text property with name "' . $this->Name . '"', E_USER_ERROR);
}
return Convert::raw2json($tags);
}
function saveInto($record) {
if($this->value) {
// $record should match the $tagTopicClass
if($record->many_many($this->Name()) {
$this->saveIntoObjectTags($record);
} elseif($record->hasField($this->Name())) {
$this->saveIntoTextbasedTags($record);
} else {
user_error('TagField::saveInto(): Cant find valid field or relation to save into', E_USER_ERROR);
}
}
}
protected function saveIntoObjectTags($record) {
$tagsArr = $this->splitTagsToArray($this->value);
$relationName = $this->Name();
$existingTagsComponentSet = $record->$relationName();
$tagClass = $this->getTagClass();
$tagBaseClass = ClassInfo::baseDataClass($tagClass);
$tagsToAdd = array();
if($tagsArr) foreach($tagsArr as $tagString) {
$SQL_filter = sprintf('`%s`.`%s` = "%s"',
$tagBaseClass,
$this->tagObjectField,
Convert::raw2sql($tagString)
);
$tagObj = DataObject::get_one($tagClass, $SQL_filter);
if(!$tagObj) {
$tagObj = new $tagClass();
$tagObj->{$this->tagObjectField} = $this->value;
$tabObj->write();
}
$tagsToAdd[] = $tagObj;
}
// remove all before readding
$existingTagsComponentSet->removeAll();
$existingTagsComponentSet->addMany($tagsToAdd);
}
protected function saveIntoTextbasedTags($record) {
$tagFieldName = $this->Name();
$record->$tagFieldName = $this->value;
}
protected function splitTagsToArray($tagsString) {
return array_unique(split("*" . escape($this->separator) . "*", trim($tagsString)));
}
/**
* Use only when storing tags in objects
*/
protected function getTagClass() {
$tagManyMany = singleton($this->tagTopicClass)->many_many($this->Name());
if(!$tagManyMany) {
user_error('TagField::getTagClass(): Cant find relation with name "' . $this->Name() . '"', E_USER_ERROR);
}
return $tagManyMany[1];
}
protected function getObjectTags($searchString) {
$tagClass = $this->getTagClass();
$tagBaseClass = ClassInfo::baseDataClass($tagClass);
$SQL_filter = sprintf("`%s`.`%s` LIKE '%%%s%%'",
$tagBaseClass,
$this->tagObjectField,
Convert::raw2sql($searchString)
);
if($this->tagFilter) $SQL_filter .= ' AND ' . $this->tagFilter;
$tagObjs = DataObject::get($tagClass, $SQL_filter, $this->tagSort);
$tagArr = ($tagObjs) ? array_values($tagObjs->map('ID', $this->tagObjectField)) : array();
return $tagArr;
}
protected function getTextbasedTags($searchString) {
$baseClass = ClassInfo::baseDataClass($this->tagTopicClass);
$SQL_filter = sprintf("`%s`.`%s` LIKE '%%%s%%'",
$baseClass,
$this->Name(),
Convert::raw2sql($searchString)
);
if($this->tagFilter) $SQL_filter .= ' AND ' . $this->tagFilter;
$allTopicObjs = DataObject::get($this->tagTopicClass, $SQL_filter, $this->tagSort);
$multipleTagsArr = ($allTopicObjs) ? array_values($allTopicObjs->map('ID', $this->Name)) : array();
$tagArr = array();
foreach($multipleTagsArr as $multipleTags) {
$tagArr += $this->splitTagsToArray($multipleTags);
}
// remove duplicates (retains case sensitive duplicates)
$tagArr = array_unique($tagArr);
return $tagArr;
}
public function setTagFilter($sql) {
$this->tagFilter = $sql;
}
public function getTagFilter() {
return $this->tagFilter;
}
public function setTagSort($sql) {
$this->tagSort = $sql;
}
public function getTagSort() {
return $this->tagSort;
}
public function setSeparator($separator) {
$this->separator = $separator;
}
public function getSeparator() {
return $this->separator;
}
}
?>

View File

@ -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);

View File

@ -0,0 +1,130 @@
<?php
/**
* @author Ingo Schommer, SilverStripe Ltd. (<firstname>@silverstripe.com)
* @package testing
*
* @todo Test filtering and sorting
*/
class TagFieldTest extends FunctionalTest {
function testExistingObjectSaving() {
}
function testNewObjectSaving() {
}
function testTextbasedSaving() {
}
function testObjectSuggest() {
// partial
$response = $this->post('TagFieldTestController/ObjectTestForm/fields/Tags/suggest', array('tag','tag'));
$this->assertEquals($response->getBody(), '["tag1","tag2"]');
// full
$response = $this->post('TagFieldTestController/ObjectTestForm/fields/Tags/suggest', array('tag','tag1'));
$this->assertEquals($response->getBody(), '["tag1"]');
// case insensitive
$response = $this->post('TagFieldTestController/ObjectTestForm/fields/Tags/suggest', array('tag','TAG1'));
$this->assertEquals($response->getBody(), '["tag1"]');
// no match
$response = $this->post('TagFieldTestController/ObjectTestForm/fields/Tags/suggest', array('tag','unknown'));
$this->assertEquals($response->getBody(), '[]');
}
function testTextbasedSuggest() {
// partial
$response = $this->post('TagFieldTestController/TextbasedTestForm/fields/Tags/suggest', array('tag','tag'));
$this->assertEquals($response->getBody(), '["tag1","tag2"]');
// full
$response = $this->post('TagFieldTestController/TextbasedTestForm/fields/Tags/suggest', array('tag','tag1'));
$this->assertEquals($response->getBody(), '["tag1"]');
// case insensitive
$response = $this->post('TagFieldTestController/TextbasedTestForm/fields/Tags/suggest', array('tag','TAG1'));
$this->assertEquals($response->getBody(), '["tag1"]');
// no match
$response = $this->post('TagFieldTestController/TextbasedTestForm/fields/Tags/suggest', array('tag','unknown'));
$this->assertEquals($response->getBody(), '[]');
}
}
class TagFieldTest_Tag extends DataObject implements TestOnly {
static $db = array(
'Title' => 'Varchar(200)'
);
static $belongs_many_many = array(
'BlogEntries' => 'TagFieldTest_BlogEntry'
);
}
class TagFieldTest_BlogEntry extends DataObject implements TestOnly {
static $db = array(
'Title' => 'Text',
'Content' => 'Text',
'TextbasedTags' => 'Text'
);
static $many_many = array(
'Tags' => 'TagFieldTest_Tag'
);
}
class TagFieldTest_Controller extends Controller {
static $url_handlers = array(
// The double-slash is need here to ensure that
'$Action//$ID/$OtherID' => "handleAction",
);
public function ObjectTestForm() {
$fields = new FieldSet(
$tagField = new TagField('Tags', null, null, 'TagFieldTest_BlogEntry')
);
$actions = new FieldSet(
new FormAction('ObjectTestForm_submit')
);
$form = new Form($this, 'ObjectTestForm', $fields, $actions);
return $form;
}
public function ObjectTestForm_submit($data, $form) {
$data->saveInto($form);
}
public function TextbasedTestForm() {
$fields = new FieldSet(
$tagField = new TagField('TextbasedTags', null, null, 'TagFieldTest_BlogEntry')
);
$actions = new FieldSet(
new FormAction('TextbasedTestForm_submit')
);
$form = new Form($this, 'TextbasedTestForm', $fields, $actions);
return $form;
}
public function TextbasedTestForm_submit($data, $form) {
$data->saveInto($form);
}
}
Director::addRules(50, array(
'TagFieldTestController' => "TagFieldTest_Controller",
));
?>

View File

@ -0,0 +1,20 @@
TagFieldTest_Tag:
tag1:
Title: tag1
tag2:
Title: tag2
TagFieldTest_BlogEntry:
blogentry1:
Title: blogentry1
Tags: => TagFieldTest_Tag.tag1,TagFieldTest_Tag.tag2
blogentry2:
Title: blogentry2
Tags: => TagFieldTest_Tag.tag1
blogentry3:
Title: blogentry3
blogentry4:
Title: blogentry4
TextbasedTags: textbasedtag1 textbasedtag2
blogentry5:
Title: blogentry5
TextbasedTags: textbasedtag1