Added tests and extra ajax functionality

This commit is contained in:
Christopher Pitt 2015-04-24 07:19:10 +12:00
parent e3aa3e20ae
commit c72903dc39
6 changed files with 713 additions and 72 deletions

26
.travis.yml Normal file
View File

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

View File

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

View File

@ -11,7 +11,7 @@
"authors": [
{
"name": "Christopher Pitt",
"email": "cgpitt@gmail.com",
"email": "chris@silverstripe.com",
"homepage": "http://github.com/assertchris"
}
],

View File

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

365
tests/unit/TagFieldTest.php Executable file
View File

@ -0,0 +1,365 @@
<?php
class TagFieldTest extends SapphireTest {
/**
* @var string
*/
public static $fixture_file = 'tagfield/tests/unit/TagFieldTest.yml';
/**
* @var array
*/
protected $extraDataObjects = array(
'TagFieldTest_BlogTag',
'TagFieldTest_BlogPost',
);
public function testItSavesLinksToExistingTagsOnExistingRecords() {
$record = $this->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);
}
}

14
tests/unit/TagFieldTest.yml Executable file
View File

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