diff --git a/src/controllers/TagsController.php b/src/controllers/TagsController.php index d644d7dd484..67a9029fbf8 100644 --- a/src/controllers/TagsController.php +++ b/src/controllers/TagsController.php @@ -114,4 +114,61 @@ public function actionDeleteTagSet() craft()->tags->deleteTagSetById($sectionId); $this->returnJson(array('success' => true)); } + + /** + * Searches for tags. + */ + public function actionSearchForTags() + { + $this->requirePostRequest(); + $this->requireAjaxRequest(); + + $search = craft()->request->getPost('search'); + $source = craft()->request->getPost('source'); + $excludeIds = craft()->request->getPost('excludeIds'); + + $criteria = craft()->elements->getCriteria(ElementType::Tag, array( + 'search' => 'name:'.$search.'*', + 'source' => $source, + 'id' => 'not '.implode(',', $excludeIds) + )); + + $tags = $criteria->find(); + + $return = array(); + $exactMatches = array(); + $tagNameLengths = array(); + $exactMatch = false; + + $normalizedSearch = StringHelper::normalizeKeywords($search); + + foreach ($tags as $tag) + { + $return[] = array( + 'id' => $tag->id, + 'name' => $tag->name + ); + + $tagNameLengths[] = strlen($tag->name); + + $normalizedName = StringHelper::normalizeKeywords($tag->name); + + if ($normalizedName == $normalizedSearch) + { + $exactMatches[] = 1; + $exactMatch = true; + } + else + { + $exactMatches[] = 0; + } + } + + array_multisort($exactMatches, SORT_DESC, $tagNameLengths, $return); + + $this->returnJson(array( + 'tags' => $return, + 'exactMatch' => $exactMatch + )); + } } diff --git a/src/elementtypes/TagElementType.php b/src/elementtypes/TagElementType.php index 7da9066d892..a82d45abfcf 100644 --- a/src/elementtypes/TagElementType.php +++ b/src/elementtypes/TagElementType.php @@ -31,7 +31,7 @@ public function getSources() $sources[$key] = array( 'label' => $tagSet->name, - 'criteria' => array('tagset' => $tagSet->id) + 'criteria' => array('tagsetId' => $tagSet->id) ); } @@ -69,8 +69,10 @@ public function defineTableAttributes($source = null) public function defineCriteriaAttributes() { return array( - 'name' => AttributeType::String, - 'order' => array(AttributeType::String, 'default' => 'name desc'), + 'name' => AttributeType::String, + 'tagset' => AttributeType::Mixed, + 'tagsetId' => AttributeType::Mixed, + 'order' => array(AttributeType::String, 'default' => 'name desc'), ); } @@ -86,6 +88,22 @@ public function modifyElementsQuery(DbCommand $query, ElementCriteriaModel $crit $query ->addSelect('tags.name') ->join('tags tags', 'tags.id = elements.id'); + + if ($criteria->name) + { + $query->andWhere(DbHelper::parseParam('tags.name', $criteria->name, $query->params)); + } + + if ($criteria->tagsetId) + { + $query->andWhere(DbHelper::parseParam('tags.setId', $criteria->tagsetId, $query->params)); + } + + if ($criteria->tagset) + { + $query->join('tagsets tagsets', 'tagsets.id = tags.setId'); + $query->andWhere(DbHelper::parseParam('tagsets.handle', $criteria->tagset, $query->params)); + } } /** diff --git a/src/fieldtypes/BaseElementFieldType.php b/src/fieldtypes/BaseElementFieldType.php index 469ee2bdad7..9ec703b88a9 100644 --- a/src/fieldtypes/BaseElementFieldType.php +++ b/src/fieldtypes/BaseElementFieldType.php @@ -31,7 +31,7 @@ abstract class BaseElementFieldType extends BaseFieldType */ public function getName() { - return $this->_getElementType()->getName(); + return $this->getElementType()->getName(); } /** @@ -75,7 +75,7 @@ public function getSettingsHtml() { return craft()->templates->render('_components/fieldtypes/elementfieldsettings', array( 'allowMultipleSources' => $this->allowMultipleSources, - 'sources' => $this->_getElementType()->getSources(), + 'sources' => $this->getElementType()->getSources(), 'settings' => $this->getSettings(), 'type' => $this->getName() )); @@ -146,7 +146,7 @@ public function getInputHtml($name, $elements) return craft()->templates->render('_includes/forms/elementSelect', array( 'jsClass' => $this->inputJsClass, - 'elementType' => new ElementTypeVariable($this->_getElementType()), + 'elementType' => new ElementTypeVariable($this->getElementType()), 'id' => $id, 'name' => $name, 'elements' => $elements->all, @@ -176,18 +176,18 @@ public function onAfterElementSave() protected function getAddButtonLabel() { return Craft::t('Add {type}', array( - 'type' => strtolower($this->_getElementType()->getClassHandle()) + 'type' => strtolower($this->getElementType()->getClassHandle()) )); } /** * Returns the element type. * - * @access private + * @access protected * @return BaseElementType * @throws Exception */ - private function _getElementType() + protected function getElementType() { $elementType = craft()->elements->getElementType($this->elementType); diff --git a/src/fieldtypes/TagsFieldType.php b/src/fieldtypes/TagsFieldType.php index 29a05347fde..98f7915d57d 100644 --- a/src/fieldtypes/TagsFieldType.php +++ b/src/fieldtypes/TagsFieldType.php @@ -6,6 +6,8 @@ */ class TagsFieldType extends BaseElementFieldType { + private $_tagSetId; + /** * @access protected * @var string $elementType The element type this field deals with. @@ -19,13 +21,104 @@ class TagsFieldType extends BaseElementFieldType protected $allowMultipleSources = false; /** - * Returns the label for the "Add" button. + * Returns the field's input HTML. * - * @access protected + * @param string $name + * @param mixed $elements * @return string */ - protected function getAddButtonLabel() + public function getInputHtml($name, $elements) { - return Craft::t('Add a tag'); + $id = rtrim(preg_replace('/[\[\]]+/', '-', $name), '-'); + + if (!($elements instanceof RelationFieldData)) + { + $elements = new RelationFieldData(); + } + + return craft()->templates->render('_components/fieldtypes/Tags/input', array( + 'elementType' => new ElementTypeVariable($this->getElementType()), + 'id' => $id, + 'name' => $name, + 'elements' => $elements->all, + 'source' => $this->getSettings()->source, + 'limit' => $this->getSettings()->limit, + 'elementId' => (!empty($this->element->id) ? $this->element->id : null), + )); + } + + /** + * Performs any additional actions after the element has been saved. + */ + public function onAfterElementSave() + { + $tagSetId = $this->_getTagSetId(); + + if ($tagSetId === false) + { + return; + } + + $rawValue = $this->element->getRawContent($this->model->handle); + $tagIds = is_array($rawValue) ? array_filter($rawValue) : array(); + + foreach ($tagIds as $i => $tagId) + { + if (strncmp($tagId, 'new:', 4) == 0) + { + $name = substr($tagId, 4); + + // Last-minute check + $criteria = craft()->elements->getCriteria(Elementtype::Tag, array( + 'tagsetId' => $tagSetId, + 'name' => $name + )); + + $ids = $criteria->ids(); + + if ($ids) + { + $tagIds[$i] = $ids[0]; + } + else + { + $tag = new TagModel(); + $tag->setId = $tagSetId; + $tag->name = $name; + + if (craft()->tags->saveTag($tag)) + { + $tagIds[$i] = $tag->id; + } + } + } + } + + craft()->relations->saveRelations($this->model->id, $this->element->id, $tagIds); + } + + /** + * Returns the tag set ID this field is associated with. + * + * @access private + * @return int|false + */ + private function _getTagSetId() + { + if (!isset($this->_tagSetId)) + { + $source = $this->getSettings()->source; + + if (strncmp($source, 'tagset:', 7) == 0) + { + $this->_tagSetId = (int) substr($source, 7); + } + else + { + $this->_tagSetId = false; + } + } + + return $this->_tagSetId; } } diff --git a/src/migrations/m130715_000001_tags.php b/src/migrations/m130715_000001_tags.php index 27529e06390..6dae4087aee 100644 --- a/src/migrations/m130715_000001_tags.php +++ b/src/migrations/m130715_000001_tags.php @@ -37,9 +37,9 @@ public function safeUp() MigrationHelper::makeElemental('tags', 'Tag'); // Make some tweaks on the tags table - $this->alterColumn('tags', 'name', array('column' => ColumnType::Varchar)); + $this->alterColumn('tags', 'name', array('column' => ColumnType::Varchar, 'null' => false)); $this->dropColumn('tags', 'count'); - $this->addColumnBefore('tags', 'setId', array('column' => ColumnType::Int), 'name'); + $this->addColumnBefore('tags', 'setId', array('column' => ColumnType::Int, 'null' => false), 'name'); $this->dropIndex('tags', 'name', true); // Place all current tags into the Default group @@ -76,10 +76,11 @@ public function safeUp() } $this->insert('fields', array( - 'groupId' => $groupId, - 'name' => 'Tags', - 'handle' => $handle, - 'type' => 'Tags' + 'groupId' => $groupId, + 'name' => 'Tags', + 'handle' => $handle, + 'type' => 'Tags', + 'settings' => JsonHelper::encode(array('source' => 'tagset:'.$tagSetId)), )); $fieldId = craft()->db->getLastInsertID(); diff --git a/src/models/TagModel.php b/src/models/TagModel.php index 424d6410b9a..9df1a47414e 100644 --- a/src/models/TagModel.php +++ b/src/models/TagModel.php @@ -25,8 +25,8 @@ function __toString() protected function defineAttributes() { return array_merge(parent::defineAttributes(), array( - 'setId' => AttributeType::Number, - 'name' => AttributeType::String, + 'setId' => AttributeType::Number, + 'name' => AttributeType::String, )); } diff --git a/src/records/TagRecord.php b/src/records/TagRecord.php index 9e4b546b108..65ea841de22 100644 --- a/src/records/TagRecord.php +++ b/src/records/TagRecord.php @@ -21,7 +21,7 @@ public function getTableName() protected function defineAttributes() { return array( - 'name' => AttributeType::String, + 'name' => array(AttributeType::String, 'required' => true), ); } diff --git a/src/resources/css/craft.scss b/src/resources/css/craft.scss index 1237dcbd08c..2a3f2535dd1 100644 --- a/src/resources/css/craft.scss +++ b/src/resources/css/craft.scss @@ -498,6 +498,17 @@ table.data.collapsed tbody td form { display: inline-block; } .elementselect .element { z-index: 1; } .elementselect .caboose { float: left; } +/* tag select fields */ +.tagselect .add { position: relative; z-index: 1; display: inline-block; width: 12em; } +.tagselect .add .text { padding-right: 30px; } +.tagselect .add .spinner { position: absolute; top: 0; right: 5px; } + +.tagmenu { margin-left: -1px !important; min-width: 12em; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} + /* editable tables */ table.editable tbody tr:not(:first-child) td { border-top: 1px solid rgba(0,0,0,0.07); } table.editable thead tr th, diff --git a/src/resources/js/classes/BaseElementSelectInput.js b/src/resources/js/classes/BaseElementSelectInput.js index fd1572e4599..f51c7202668 100644 --- a/src/resources/js/classes/BaseElementSelectInput.js +++ b/src/resources/js/classes/BaseElementSelectInput.js @@ -35,7 +35,7 @@ Craft.BaseElementSelectInput = Garnish.Base.extend({ this.totalElements = this.$elements.length; - if (this.limit && this.totalElements == this.limit) + if (this.limit && this.totalElements >= this.limit) { this.$addElementBtn.addClass('disabled'); } @@ -79,7 +79,11 @@ Craft.BaseElementSelectInput = Garnish.Base.extend({ } this.totalElements--; - this.$addElementBtn.removeClass('disabled'); + + if (this.$addElementBtn) + { + this.$addElementBtn.removeClass('disabled'); + } $element.css('z-index', 0); diff --git a/src/resources/js/classes/FieldLayoutDesigner.js b/src/resources/js/classes/FieldLayoutDesigner.js index 3d0b70b1b86..a431be2a222 100644 --- a/src/resources/js/classes/FieldLayoutDesigner.js +++ b/src/resources/js/classes/FieldLayoutDesigner.js @@ -97,7 +97,7 @@ Craft.FieldLayoutDesigner = Garnish.Base.extend({ } var $option = $(option), - $tab = $option.data('menu').$btn.parent().parent().parent(), + $tab = $option.data('menu').$trigger.parent().parent().parent(), action = $option.data('action'); switch (action) @@ -118,7 +118,7 @@ Craft.FieldLayoutDesigner = Garnish.Base.extend({ onFieldOptionSelect: function(option) { var $option = $(option), - $field = $option.data('menu').$btn.parent(), + $field = $option.data('menu').$trigger.parent(), action = $option.data('action'); switch (action) diff --git a/src/resources/js/classes/TagSelectInput.js b/src/resources/js/classes/TagSelectInput.js new file mode 100644 index 00000000000..d35205a9a70 --- /dev/null +++ b/src/resources/js/classes/TagSelectInput.js @@ -0,0 +1,220 @@ +/** + * Tag select input + */ +Craft.TagSelectInput = Craft.BaseElementSelectInput.extend({ + + id: null, + name: null, + source: null, + limit: null, + elementId: null, + elementSort: null, + searchTimeout: null, + menu: null, + + $container: null, + $elementsContainer: null, + $elements: null, + $addTagInput: null, + $spinner: null, + + init: function(id, name, source, limit, elementId) + { + this.id = id; + this.name = name; + this.source = source; + this.limit = limit; + this.elementId = elementId; + + this.$container = $('#'+this.id); + this.$elementsContainer = this.$container.children('.elements'); + this.$elements = this.$elementsContainer.children(); + this.$addTagInput = this.$container.children('.add').children('.text'); + this.$spinner = this.$addTagInput.next(); + + this.totalElements = this.$elements.length; + + if (this.limit && this.totalElements >= this.limit) + { + this.$addTagInput.addClass('disabled').attr('disabled', 'disabled'); + } + + this.elementSelect = new Garnish.Select(this.$elements, { + multi: true, + filter: ':not(.delete)' + }); + + this.elementSort = new Garnish.DragSort({ + container: this.$elementsContainer, + filter: $.proxy(function() { + return this.elementSelect.getSelectedItems(); + }, this), + caboose: $('
'), + onSortChange: $.proxy(function() { + this.elementSelect.resetItemOrder(); + }, this) + }); + + this.initElements(this.$elements); + + this.addListener(this.$addTagInput, 'textchange', $.proxy(function() + { + if (this.searchTimeout) + { + clearTimeout(this.searchTimeout); + } + + this.searchTimeout = setTimeout($.proxy(this, 'searchForTags'), 500); + }, this)); + + this.addListener(this.$addTagInput, 'keypress', function(ev) + { + if (ev.keyCode == Garnish.RETURN_KEY) + { + ev.preventDefault(); + + if (this.searchMenu) + { + this.selectTag(this.searchMenu.$options[0]); + } + } + }); + + this.addListener(this.$addTagInput, 'focus', function() + { + if (this.searchMenu) + { + this.searchMenu.show(); + } + }); + + this.addListener(this.$addTagInput, 'blur', function() + { + setTimeout($.proxy(function() + { + if (this.searchMenu) + { + this.searchMenu.hide(); + } + }, this), 1); + }); + }, + + searchForTags: function() + { + if (this.searchMenu) + { + this.killSearchMenu(); + } + + var val = this.$addTagInput.val(); + + if (val) + { + this.$spinner.removeClass('hidden'); + + var excludeIds = []; + + for (var i = 0; i < this.$elements.length; i++) + { + var id = $(this.$elements[i]).data('id'); + + if (id) + { + excludeIds.push(id); + } + } + + if (this.elementId) + { + excludeIds.push(this.elementId); + } + + var data = { + search: this.$addTagInput.val(), + source: this.source, + excludeIds: excludeIds, + }; + + Craft.postActionRequest('tags/searchForTags', data, $.proxy(function(response) + { + var $menu = $('