diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..7ccf56c --- /dev/null +++ b/.travis.yml @@ -0,0 +1,26 @@ +# See https://github.com/silverstripe-labs/silverstripe-travis-support for setup details + +language: php +php: + - 5.3 + - 5.4 + - 5.5 + - 5.6 + +env: + - DB=MYSQL CORE_RELEASE=3.1 + +matrix: + allow_failures: + - php: 5.5 + env: DB=MYSQL CORE_RELEASE=3.1 + - php: 5.6 + env: DB=MYSQL CORE_RELEASE=3.1 + +before_script: + - git clone git://github.com/silverstripe-labs/silverstripe-travis-support.git ~/travis-support + - php ~/travis-support/travis_setup.php --source `pwd` --target ~/builds/ss + - cd ~/builds/ss + +script: + - phpunit tagfield/tests diff --git a/code/TagField.php b/code/TagField.php index 3a70eaa..015942c 100644 --- a/code/TagField.php +++ b/code/TagField.php @@ -7,35 +7,143 @@ * @subpackage fields-formattedinput */ class TagField extends DropdownField { + /** + * @var array + */ + public static $allowed_actions = array( + 'suggest', + ); + /** * @var bool */ - protected $readOnly; + protected $ajax = false; + + /** + * @var bool + */ + protected $readOnly = false; + + /** + * @var string + */ + protected $relationTitle = 'Title'; + + /** + * @var int + */ + protected $ajaxItemLimit = 10; /** * @var null|string */ - protected $relationTitleField; + protected $recordClass; /** - * @param string $name + * @param string $name * @param null|string $title - * @param array $source - * @param array $value - * @param bool $readOnly - * @param string $relationTitleField + * @param array $source + * @param array $value + * @param bool $readOnly */ - public function __construct($name, $title = null, $source = array(), $value = array(), $readOnly = false, $relationTitleField = 'Title') { - $this->readOnly = $readOnly; - $this->relationTitleField = $relationTitleField; + public function __construct($name, $title = null, $source = array(), $value = array(), $readOnly = false) { + $this->setReadOnly($readOnly); parent::__construct($name, $title, $source, $value); } /** - * @param array $properties + * @return bool + */ + public function getAjax() { + return $this->ajax; + } + + /** + * @param bool $ajax * - * @return string + * @return static + */ + public function setAjax($ajax) { + $this->ajax = $ajax; + + return $this; + } + + /** + * @return bool + */ + public function getReadOnly() { + return $this->readOnly; + } + + /** + * @param bool $readOnly + * + * @return static + */ + public function setReadOnly($readOnly) { + $this->readOnly = $readOnly; + + return $this; + } + + /** + * @return null|string + */ + public function getRelationTitle() { + return $this->relationTitle; + } + + /** + * @param string $relationTitle + * + * @return static + */ + public function setRelationTitle($relationTitle) { + $this->relationTitle = $relationTitle; + + return $this; + } + + /** + * @return int + */ + public function getAjaxItemLimit() { + return $this->ajaxItemLimit; + } + + /** + * @param int $ajaxItemLimit + * + * @return static + */ + public function setAjaxItemLimit($ajaxItemLimit) { + $this->ajaxItemLimit = $ajaxItemLimit; + + return $this; + } + + /** + * @return null|string + */ + public function getRecordClass() { + return $this->recordClass; + } + + /** + * @param string $recordClass + * + * @return static + */ + public function setRecordClass($recordClass) { + $this->recordClass = $recordClass; + + return $this; + } + + /** + * {@inheritdoc} */ public function Field($properties = array()) { Requirements::css(TAG_FIELD_DIR . '/css/select2.min.css'); @@ -43,7 +151,6 @@ class TagField extends DropdownField { Requirements::javascript(TAG_FIELD_DIR . '/js/TagField.js'); Requirements::javascript(TAG_FIELD_DIR . '/js/select2.js'); - Requirements::javascript(THIRDPARTY_DIR . '/jquery/jquery.js'); Requirements::javascript(THIRDPARTY_DIR . '/jquery-entwine/dist/jquery.entwine-dist.js'); @@ -51,11 +158,41 @@ class TagField extends DropdownField { $this->setAttribute('multiple', 'multiple'); + if($this->ajax) { + $this->setAttribute('data-suggest-url', $this->getSuggestURL()); + } + + $properties = array_merge($properties, array( + 'Options' => $this->getOptions() + )); + + return $this + ->customise($properties) + ->renderWith(array("templates/TagField")); + } + + /** + * @return string + */ + protected function getSuggestURL() { + return Controller::join_links($this->Link(), 'suggest'); + } + + /** + * @return ArrayList + */ + protected function getOptions() { $options = ArrayList::create(); + $source = $this->getSource(); + + if($source instanceof Iterator) { + $source = iterator_to_array($source); + } + $values = $this->Value(); - foreach(iterator_to_array($this->source) as $key => $value) { + foreach($source as $key => $value) { $options->push( ArrayData::create(array( "Title" => $value, @@ -65,53 +202,21 @@ class TagField extends DropdownField { ); } - $properties = array_merge($properties, array( - 'Options' => $options - )); - - return $this - ->customise($properties) - ->renderWith(array("templates/TagField")); + return $options; } /** - * Loads the related record values into this field. TagField can be uploaded - * in one of three ways: - * - * - By passing in a list of object IDs in the $value parameter (an array with a single - * key 'Files', with the value being the actual array of IDs). - * - By passing in an explicit list of File objects in the $record parameter, and - * leaving $value blank. - * - By passing in a dataobject in the $record parameter, from which file objects - * will be extracting using the field name as the relation field. - * - * Each of these methods will update both the items (list of File objects) and the - * field value (list of file ID values). - * - * @param array $value Array of submitted form data, if submitting from a - * form - * @param array|DataObject|SS_List $record Full source record, either as a DataObject, - * SS_List of items, or an array of submitted form data - * - * @return UploadField Self reference + * {@inheritdoc} */ public function setValue($value, $record = null) { - // If we're not passed a value directly, we can attempt to infer the field - // value from the second parameter by inspecting its relations - - // Determine format of presented data if(empty($value) && $record) { - // If a record is given as a second parameter, but no submitted values, - // then we should inspect this instead for the form values + if($record instanceof DataObject) { + $name = $this->getName(); - if(($record instanceof DataObject) - && $record->hasMethod($this->getName()) - ) { - $value = $record - ->{$this->getName()}() - ->getIDList(); + if($record->hasMethod($name)) { + $value = $record->$name()->getIDList(); + } } elseif($record instanceof SS_List) { - // If directly passing a list then save the items directly $value = $record->column('ID'); } } @@ -120,7 +225,7 @@ class TagField extends DropdownField { } /** - * @return array + * {@inheritdoc} */ public function getAttributes() { return array_merge( @@ -130,21 +235,17 @@ class TagField extends DropdownField { } /** - * Save the current value of this TagField into a DataObject. - * If the field it is saving to is a has_many or many_many relationship, - * it is saved by setByIDList(), otherwise it creates a comma separated - * list for a standard DB text/varchar field. - * - * @param DataObjectInterface $record + * {@inheritdoc} */ public function saveInto(DataObjectInterface $record) { parent::saveInto($record); - $name = $this->name; + $name = $this->getName(); + $relationTitle = $this->getRelationTitle(); $values = $this->Value(); - if(empty($values) || empty($record) || empty($this->relationTitleField)) { + if(empty($values) || empty($record) || empty($relationTitle)) { return; } @@ -155,12 +256,12 @@ class TagField extends DropdownField { foreach($values as $i => $value) { if(!is_numeric($value)) { - if($this->readOnly) { + if($this->getReadOnly()) { unset($values[$i]); continue; } else { $instance = new $class(); - $instance->{$this->relationTitleField} = $value; + $instance->{$relationTitle} = $value; $instance->write(); $values[$i] = $instance->ID; @@ -173,4 +274,118 @@ class TagField extends DropdownField { $record->$name = implode(',', $values); } } + + /** + * Returns a JSON string of tags, for ajax-based search. + * + * @param SS_HTTPRequest $request + * + * @return SS_HTTPResponse + */ + public function suggest(SS_HTTPRequest $request) { + $recordClass = $this->getRecordClass(); + + $response = new SS_HTTPResponse(); + + $response->addHeader('Content-Type', 'application/json'); + + $response->setBody(Convert::raw2json( + array('items' => array()) + )); + + if($recordClass !== null) { + $name = $this->getName(); + + /** + * @var DataObject $object + */ + $object = singleton($recordClass); + + $term = $request->getVar('term'); + + $tags = array(); + + if($object->hasMethod($name)) { + $tags = $this->getObjectTags($object, $term); + } elseif($object->hasField($name)) { + $tags = $this->getStringTags($term); + } + + $response->setBody(Convert::raw2json( + array('items' => $tags) + )); + } + + return $response; + } + + /** + * Returns array of arrays representing DataObject-based tags. + * + * @param DataObject $instance + * @param string $term + * + * @return array + */ + protected function getObjectTags(DataObject $instance, $term) { + $name = $this->getName(); + $relationTitle = $this->getRelationTitle(); + + $relation = $instance->{$name}(); + + $term = Convert::raw2sql($term); + + $query = DataList::create($relation->dataClass()) + ->filter($relationTitle . ':PartialMatch:nocase', $term) + ->sort($relationTitle) + ->limit($this->getAjaxItemLimit()); + + $items = array(); + + foreach($query->map('ID', $relationTitle) as $id => $title) { + if(!in_array($title, $items)) { + $items[] = array( + 'id' => $id, + 'text' => $title + ); + } + } + + return $items; + } + + /** + * Returns array of arrays representing string-based tags. + * + * @param string $term + * + * @return array + */ + protected function getStringTags($term) { + $name = $this->getName(); + $recordClass = $this->getRecordClass(); + + $term = Convert::raw2sql($term); + + $query = DataObject::get($recordClass) + ->filter($name . ':PartialMatch:nocase', $term) + ->limit($this->getAjaxItemLimit()); + + $items = array(); + + foreach($query->column($name) as $tags) { + $tags = explode(',', $tags); + + foreach($tags as $i => $tag) { + if(stripos($tag, $term) !== false && !in_array($tag, $items)) { + $items[] = array( + 'id' => $tag, + 'text' => $tag + ); + } + } + } + + return $items; + } } diff --git a/composer.json b/composer.json index 22faff3..b320d16 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ "authors": [ { "name": "Christopher Pitt", - "email": "cgpitt@gmail.com", + "email": "chris@silverstripe.com", "homepage": "http://github.com/assertchris" } ], diff --git a/js/TagField.js b/js/TagField.js index 636e220..3f0b6ef 100644 --- a/js/TagField.js +++ b/js/TagField.js @@ -14,12 +14,12 @@ .removeClass('chzn-done') .removeClass('has-chzn') .next() - .remove(); + .remove(); return $(this); }; - $.entwine('ss', function($) { + $.entwine('ss', function ($) { $('.silverstripe-tag-field + .chzn-container').entwine({ applySelect2: function () { @@ -35,12 +35,33 @@ }, 0); } + var options = { + 'tags': true, + 'tokenSeparators': [',', ' '] + }; + + if ($select.attr('data-suggest-url')) { + options.ajax = { + 'url': $select.attr('data-suggest-url'), + 'dataType': 'json', + 'delay': 250, + 'data': function (params) { + return { + 'term': params.term + }; + }, + 'processResults': function (data) { + return { + 'results': data.items + }; + }, + 'cache': true + } + } + $select .chosenDestroy() - .select2({ - 'tags': true, - 'tokenSeparators': [',', ' '] - }); + .select2(options); /* * Delay a cycle so select2 is initialised before @@ -54,7 +75,7 @@ } }, 0); }, - onmatch: function() { + onmatch: function () { this.applySelect2(); } }); diff --git a/tests/unit/TagFieldTest.php b/tests/unit/TagFieldTest.php new file mode 100755 index 0000000..775b0d5 --- /dev/null +++ b/tests/unit/TagFieldTest.php @@ -0,0 +1,365 @@ +objFromFixture( + 'TagFieldTest_BlogPost', + 'BlogPost1' + ); + + $field = new TagField('Tags'); + $field->setValue(array('Object1', 'Object2')); + $field->saveInto($record); + + $record->write(); + + $this->compareExpectedAndActualTags( + $record, + array('Object1', 'Object2') + ); + } + + /** + * @param DataObject $record + * @param array $expected + */ + protected function compareExpectedAndActualTags(DataObject $record, array $expected) { + $compare1 = array_values($record->Tags()->map('ID', 'Title')->toArray()); + $compare2 = $expected; + + sort($compare1); + sort($compare2); + + $this->assertEquals( + $compare1, + $compare2 + ); + } + + public function testItSavesLinksToExistingTagsOnNewRecords() { + $record = new TagFieldTest_BlogPost(); + $record->write(); + + $field = new TagField('Tags'); + $field->setValue(array('Object1', 'Object2')); + $field->saveInto($record); + + $record->write(); + + $this->compareExpectedAndActualTags( + $record, + array('Object1', 'Object2') + ); + } + + public function testItSavesLinksToNewTagsOnExistingRecords() { + $record = $this->objFromFixture( + 'TagFieldTest_BlogPost', + 'BlogPost1' + ); + + $field = new TagField('Tags'); + $field->setValue(array('Object3', 'Object4')); + $field->saveInto($record); + + $record->write(); + + $this->compareExpectedAndActualTags( + $record, + array('Object3', 'Object4') + ); + } + + function testItSavesLinksToNewTagsOnNewRecords() { + $record = new TagFieldTest_BlogPost(); + $record->write(); + + $field = new TagField('Tags'); + $field->setValue(array('Object3', 'Object4')); + $field->saveInto($record); + + $this->compareExpectedAndActualTags( + $record, + array('Object3', 'Object4') + ); + } + + function testItSavesTextBasedTagsOnExistingRecords() { + $record = $this->objFromFixture( + 'TagFieldTest_BlogPost', + 'BlogPost1' + ); + + $field = new TagField('TextBasedTags'); + $field->setValue(array('Text1', 'Text2')); + $field->saveInto($record); + + $record->write(); + + $this->assertEquals( + $record->TextBasedTags, + 'Text1,Text2' + ); + } + + function testItSavesTextBasedTagsOnNewRecords() { + $record = new TagFieldTest_BlogPost(); + $record->write(); + + $field = new TagField('TextBasedTags'); + $field->setValue(array('Text1', 'Text2')); + $field->saveInto($record); + + $record->write(); + + $this->assertEquals( + $record->TextBasedTags, + 'Text1,Text2' + ); + } + + function testItSuggestsObjectTags() { + $field = new TagField('Tags'); + $field->setRecordClass('TagFieldTest_BlogPost'); + + /** + * Partial tag title match. + */ + $request = new SS_HTTPRequest( + 'get', + 'TagFieldTest_Controller/ObjectTestForm/fields/Tags/suggest', + array('term' => 'Object') + ); + + $this->assertEquals($field->suggest($request)->getBody(), '{"items":[{"id":1,"text":"Object1"},{"id":2,"text":"Object2"}]}'); + + /** + * Exact tag title match. + */ + $request = new SS_HTTPRequest( + 'get', + 'TagFieldTest_Controller/ObjectTestForm/fields/Tags/suggest', + array('term' => 'Object1') + ); + + $this->assertEquals($field->suggest($request)->getBody(), '{"items":[{"id":1,"text":"Object1"}]}'); + + /** + * Case-insensitive tag title match. + */ + $request = new SS_HTTPRequest( + 'get', + 'TagFieldTest_Controller/ObjectTestForm/fields/Tags/suggest', + array('term' => 'OBJECT1') + ); + $this->assertEquals($field->suggest($request)->getBody(), '{"items":[{"id":1,"text":"Object1"}]}'); + + /** + * No tag title match. + */ + $request = new SS_HTTPRequest( + 'get', + 'TagFieldTest_Controller/ObjectTestForm/fields/Tags/suggest', + array('term' => 'unknown') + ); + + $this->assertEquals($field->suggest($request)->getBody(), '{"items":[]}'); + } + + function testItSuggestsTextTags() { + $field = new TagField('TextBasedTags'); + $field->setRecordClass('TagFieldTest_BlogPost'); + + /** + * Partial tag title match. + */ + $request = new SS_HTTPRequest( + 'get', + 'TagFieldTest_Controller/TextBasedTestForm/fields/Tags/suggest', + array('term' => 'Text') + ); + + $this->assertEquals($field->suggest($request)->getBody(), '{"items":[{"id":"Text1","text":"Text1"},{"id":"Text2","text":"Text2"}]}'); + + /** + * Exact tag title match. + */ + $request = new SS_HTTPRequest( + 'get', + 'TagFieldTest_Controller/TextBasedTestForm/fields/Tags/suggest', + array('term' => 'Text1') + ); + + $this->assertEquals($field->suggest($request)->getBody(), '{"items":[{"id":"Text1","text":"Text1"}]}'); + + /** + * Case-insensitive tag title match. + */ + $request = new SS_HTTPRequest( + 'get', + 'TagFieldTest_Controller/TextBasedTestForm/fields/Tags/suggest', + array('term' => 'TEXT1') + ); + + $this->assertEquals($field->suggest($request)->getBody(), '{"items":[{"id":"Text1","text":"Text1"}]}'); + + /** + * No tag title match. + */ + $request = new SS_HTTPRequest( + 'get', + 'TagFieldTest_Controller/TextBasedTestForm/fields/Tags/suggest', + array('term' => 'unknown') + ); + + $this->assertEquals($field->suggest($request)->getBody(), '{"items":[]}'); + } + + function testItDisplaysValuesFromRelations() { + $form = new Form( + $this, + 'Form', + new FieldList( + $field = new TagField('Tags') + ), + new FieldList() + ); + + $form->loadDataFrom( + $this->objFromFixture('TagFieldTest_BlogPost', 'BlogPost3') + ); + + $this->assertEquals($field->Value(), array(1 => 1, 2 => 2)); + } + + function testItIgnoresNewTagsIfReadOnly() { + $record = new TagFieldTest_BlogPost(); + $record->write(); + + $tag = TagFieldTest_BlogTag::get()->filter('Title', 'Object1')->first(); + + $field = new TagField('Tags'); + $field->setReadOnly(true); + $field->setValue(array($tag->ID, 'Object3')); + $field->saveInto($record); + + $record = DataObject::get_by_id('TagFieldTest_BlogPost', $record->ID); + + $this->compareExpectedAndActualTags( + $record, + array('Object1') + ); + } +} + +class TagFieldTest_BlogTag extends DataObject implements TestOnly { + /** + * @var string + */ + private static $default_sort = '"TagFieldTest_BlogTag"."ID" ASC'; + + /** + * @var array + */ + private static $db = array( + 'Title' => 'Varchar(200)', + ); + + /** + * @var array + */ + private static $belongs_many_many = array( + 'BlogEntries' => 'TagFieldTest_BlogPost', + ); +} + +class TagFieldTest_BlogPost extends DataObject implements TestOnly { + /** + * @var string + */ + private static $default_sort = '"TagFieldTest_BlogPost"."ID" ASC'; + + /** + * @var array + */ + private static $db = array( + 'Title' => 'Text', + 'Content' => 'Text', + 'TextBasedTags' => 'Text', + ); + + /** + * @var array + */ + private static $many_many = array( + 'Tags' => 'TagFieldTest_BlogTag', + ); +} + +class TagFieldTest_Controller extends Controller implements TestOnly { + /** + * @var array + */ + private static $url_handlers = array( + '$Action//$ID/$OtherID' => "handleAction", + ); + + /** + * @return Form + */ + public function ObjectTestForm() { + $fields = new FieldList( + $tagField = new TagField('Tags') + ); + + $actions = new FieldList( + new FormAction('ObjectTestForm_submit') + ); + + return new Form($this, 'ObjectTestForm', $fields, $actions); + } + + /** + * @param array $data + * @param Form $form + */ + public function ObjectTestForm_submit(array $data, Form $form) { + $data->saveInto($form); + } + + /** + * @return Form + */ + public function TextBasedTestForm() { + $fields = new FieldList( + $tagField = new TagField('TextBasedTags') + ); + + $actions = new FieldList( + new FormAction('TextBasedTestForm_submit') + ); + + return new Form($this, 'TextBasedTestForm', $fields, $actions); + } + + /** + * @param array $data + * @param Form $form + */ + public function TextBasedTestForm_submit(array $data, Form $form) { + $data->saveInto($form); + } +} diff --git a/tests/unit/TagFieldTest.yml b/tests/unit/TagFieldTest.yml new file mode 100755 index 0000000..0b832ce --- /dev/null +++ b/tests/unit/TagFieldTest.yml @@ -0,0 +1,14 @@ +TagFieldTest_BlogTag: + Object1: + Title: Object1 + Object2: + Title: Object2 +TagFieldTest_BlogPost: + BlogPost1: + Title: BlogPost1 + BlogPost2: + Title: BlogPost2 + TextBasedTags: Text1,Text2 + BlogPost3: + Title: BlogPost3 + Tags: =>TagFieldTest_BlogTag.Object1,=>TagFieldTest_BlogTag.Object2