commit aa0fb00c7ad36b5689837114a58bea47091daf72 Author: chillu Date: Tue Sep 30 16:00:21 2008 +0000 added "tagfield" module diff --git a/tagfield/CHANGELOG b/tagfield/CHANGELOG new file mode 100644 index 0000000..e69de29 diff --git a/tagfield/LICENSE b/tagfield/LICENSE new file mode 100644 index 0000000..e69de29 diff --git a/tagfield/README b/tagfield/README new file mode 100644 index 0000000..e69de29 diff --git a/tagfield/_config.php b/tagfield/_config.php new file mode 100644 index 0000000..67c94a5 --- /dev/null +++ b/tagfield/_config.php @@ -0,0 +1,2 @@ + \ No newline at end of file diff --git a/tagfield/code/TagField.php b/tagfield/code/TagField.php new file mode 100644 index 0000000..e4b5b71 --- /dev/null +++ b/tagfield/code/TagField.php @@ -0,0 +1,212 @@ +@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; + } + +} +?> \ No newline at end of file diff --git a/tagfield/javascript/jquery.tags.js b/tagfield/javascript/jquery.tags.js new file mode 100644 index 0000000..508a806 --- /dev/null +++ b/tagfield/javascript/jquery.tags.js @@ -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] + ''; + } + + 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); diff --git a/tagfield/tests/TagFieldTest.php b/tagfield/tests/TagFieldTest.php new file mode 100644 index 0000000..aecaded --- /dev/null +++ b/tagfield/tests/TagFieldTest.php @@ -0,0 +1,130 @@ +@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", +)); +?> \ No newline at end of file diff --git a/tagfield/tests/TagFieldTest.yml b/tagfield/tests/TagFieldTest.yml new file mode 100644 index 0000000..a004380 --- /dev/null +++ b/tagfield/tests/TagFieldTest.yml @@ -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 \ No newline at end of file