1
0
mirror of https://github.com/processwire/processwire.git synced 2025-08-23 14:56:51 +02:00

Additional improvements to InputfieldTextTags, including support for ajax-loaded pages

This commit is contained in:
Ryan Cramer
2021-04-28 14:01:24 -04:00
parent a565e9539a
commit 6724c9fb32
3 changed files with 182 additions and 55 deletions

View File

@@ -10,7 +10,7 @@ function InputfieldTextTags($parent) {
openOnFocus: true, // Show the dropdown immediately when the control receives focus.
closeAfterSelect: true, // If true, the dropdown will be closed after a selection is made.
copyClassesToDropdown: false,
createOnBlur: true, // If true, when user exits the field (clicks outside of input), a new option is created and selected (if create setting is enabled).
createOnBlur: false, // If true, when user exits the field (clicks outside of input), a new option is created and selected (if create setting is enabled).
selectOnTab: true, // If true, the tab key will choose the currently selected item.
maxItems: null, // The max number of items the user can select. 1 makes the control mono-selection, null allows an unlimited number of items.
create: function(input) {
@@ -27,6 +27,7 @@ function InputfieldTextTags($parent) {
var options = defaults;
options.delimiter = o.delimiter;
options.closeAfterSelect = o.closeAfterSelect;
options.createOnBlur = o.createOnBlur;
options.persist = false;
$input.selectize(options);
}
@@ -49,6 +50,7 @@ function InputfieldTextTags($parent) {
allowUserTags: o.allowUserTags,
delimiter: o.delimiter,
closeAfterSelect: o.closeAfterSelect,
createOnBlur: o.createOnBlur,
persist: true,
valueField: 'value',
labelField: 'label',
@@ -89,10 +91,14 @@ function InputfieldTextTags($parent) {
options.load = function(query, callback) {
if(!query.length) return callback();
var tagsUrl = o.tagsUrl.replace('{q}', encodeURIComponent(query));
Inputfields.startSpinner($select);
jQuery.ajax({
url: tagsUrl,
type: 'GET',
error: function() { callback() },
error: function() {
Inputfields.stopSpinner($select);
callback();
},
success: function(items) {
for(var n = 0; n < items.length; n++) {
var item = items[n];
@@ -105,7 +111,8 @@ function InputfieldTextTags($parent) {
items[n] = { value: item, label: item };
}
}
callback(items)
Inputfields.stopSpinner($select);
callback(items);
}
});
}

View File

@@ -1 +1 @@
function InputfieldTextTags($parent){if(typeof $parent==="undefined")$parent=$(".InputfieldForm");var defaults={plugins:["remove_button","drag_drop"],delimiter:" ",persist:true,submitOnReturn:false,openOnFocus:true,closeAfterSelect:true,copyClassesToDropdown:false,createOnBlur:true,selectOnTab:true,maxItems:null,create:function(input){return{value:input,text:input}}};function initInput($input){var o=JSON.parse($input.attr("data-opts"));var options=defaults;options.delimiter=o.delimiter;options.closeAfterSelect=o.closeAfterSelect;options.persist=false;$input.selectize(options)}function initSelect($select){var o=JSON.parse($select.attr("data-opts"));var cfgName=typeof o.cfgName==="undefined"?"":o.cfgName;var tags=cfgName.length?ProcessWire.config[cfgName]:o.tags;var tagsList=[];var n=0;for(var tag in tags){var label=tags[tag];tagsList[n]={value:tag,label:label};n++}var options=jQuery.extend(defaults,{allowUserTags:o.allowUserTags,delimiter:o.delimiter,closeAfterSelect:o.closeAfterSelect,persist:true,valueField:"value",labelField:"label",searchField:["value","label"],options:tagsList,createFilter:function(input){if(o.allowUserTags)return true;allow=false;for(var n=0;n<tags.length;n++){if(typeof tags[input]!=="undefined"){allow=true;break}}return allow},render:{item:function(item,escape){if(typeof item.label==="undefined"||!item.label.length)item.label=item.value;return"<div>"+escape(item.label)+"</div>"},option:function(item,escape){if(typeof item.label==="undefined"||!item.label.length)item.label=item.value;return"<div>"+escape(item.label)+"</div>"}}});if(o.tagsUrl.length){options.load=function(query,callback){if(!query.length)return callback();var tagsUrl=o.tagsUrl.replace("{q}",encodeURIComponent(query));jQuery.ajax({url:tagsUrl,type:"GET",error:function(){callback()},success:function(items){for(var n=0;n<items.length;n++){var item=items[n];if(typeof item==="object"){if(typeof item.label==="undefined"){item.label=item.value;items[n]=item}}else{items[n]={value:item,label:item}}}callback(items)}})}}$select.selectize(options)}var $inputs=jQuery(".InputfieldTextTagsInput:not(.selectized)",$parent);var $selects=jQuery(".InputfieldTextTagsSelect:not(.selectized)",$parent);if($inputs.length){$inputs.each(function(){$input=$(this);initInput($input)})}if($selects.length){$selects.each(function(){var $select=$(this);initSelect($select)})}}jQuery(document).ready(function($){InputfieldTextTags();$(document).on("reloaded",".InputfieldTextTags",function(){InputfieldTextTags($(this))})});
function InputfieldTextTags($parent){if(typeof $parent==="undefined")$parent=$(".InputfieldForm");var defaults={plugins:["remove_button","drag_drop"],delimiter:" ",persist:true,submitOnReturn:false,openOnFocus:true,closeAfterSelect:true,copyClassesToDropdown:false,createOnBlur:false,selectOnTab:true,maxItems:null,create:function(input){return{value:input,text:input}}};function initInput($input){var o=JSON.parse($input.attr("data-opts"));var options=defaults;options.delimiter=o.delimiter;options.closeAfterSelect=o.closeAfterSelect;options.createOnBlur=o.createOnBlur;options.persist=false;$input.selectize(options)}function initSelect($select){var o=JSON.parse($select.attr("data-opts"));var cfgName=typeof o.cfgName==="undefined"?"":o.cfgName;var tags=cfgName.length?ProcessWire.config[cfgName]:o.tags;var tagsList=[];var n=0;for(var tag in tags){var label=tags[tag];tagsList[n]={value:tag,label:label};n++}var options=jQuery.extend(defaults,{allowUserTags:o.allowUserTags,delimiter:o.delimiter,closeAfterSelect:o.closeAfterSelect,createOnBlur:o.createOnBlur,persist:true,valueField:"value",labelField:"label",searchField:["value","label"],options:tagsList,createFilter:function(input){if(o.allowUserTags)return true;allow=false;for(var n=0;n<tags.length;n++){if(typeof tags[input]!=="undefined"){allow=true;break}}return allow},render:{item:function(item,escape){if(typeof item.label==="undefined"||!item.label.length)item.label=item.value;return"<div>"+escape(item.label)+"</div>"},option:function(item,escape){if(typeof item.label==="undefined"||!item.label.length)item.label=item.value;return"<div>"+escape(item.label)+"</div>"}}});if(o.tagsUrl.length){options.load=function(query,callback){if(!query.length)return callback();var tagsUrl=o.tagsUrl.replace("{q}",encodeURIComponent(query));Inputfields.startSpinner($select);jQuery.ajax({url:tagsUrl,type:"GET",error:function(){Inputfields.stopSpinner($select);callback()},success:function(items){for(var n=0;n<items.length;n++){var item=items[n];if(typeof item==="object"){if(typeof item.label==="undefined"){item.label=item.value;items[n]=item}}else{items[n]={value:item,label:item}}}Inputfields.stopSpinner($select);callback(items)}})}}$select.selectize(options)}var $inputs=jQuery(".InputfieldTextTagsInput:not(.selectized)",$parent);var $selects=jQuery(".InputfieldTextTagsSelect:not(.selectized)",$parent);if($inputs.length){$inputs.each(function(){$input=$(this);initInput($input)})}if($selects.length){$selects.each(function(){var $select=$(this);initSelect($select)})}}jQuery(document).ready(function($){InputfieldTextTags();$(document).on("reloaded",".InputfieldTextTags",function(){InputfieldTextTags($(this))})});

View File

@@ -25,22 +25,25 @@
* @property string $tagsUrl Remote URL to find tags from, must have a '{q}' in it somewhere, which will be replaced with the query.
* @property int|bool $allowUserTags Allow user-entered tags?
* @property int|bool $closeAfterSelect Close select dropdown box after user makes selection?
* @property bool|int $useAjax
* @property string $delimiter One of 's' (for space ' '), 'p' (for pipe '|') or 'c' (for comma).
* @property string $value
* @property-read array $arrayValue
* @property string|null $pageSelector
*
* ProcessWire 3.x, Copyright 2021 by Ryan Cramer
* https://processwire.com
*
*/
class InputfieldTextTags extends Inputfield
implements InputfieldHasTextValue, InputfieldSupportsArrayValue, InputfieldHasSelectableOptions, InputfieldHasSortableValue {
class InputfieldTextTags extends Inputfield implements
InputfieldHasTextValue, InputfieldSupportsArrayValue, InputfieldSupportsPageSelector,
InputfieldHasSelectableOptions, InputfieldHasSortableValue {
public static function getModuleInfo() {
return array(
'title' => __('Text Tags', __FILE__), // Module Title
'summary' => __('Enables input of user entered tags or selection of predefined tags.', __FILE__), // Module Summary
'version' => 2,
'version' => 3,
'icon' => 'tags',
);
}
@@ -65,6 +68,7 @@ class InputfieldTextTags extends Inputfield
$this->set('allowUserTags', 0);
$this->set('closeAfterSelect', 1);
$this->set('delimiter', 's');
parent::set('useAjax', false); // parent and boolean intentional
parent::__construct();
}
@@ -113,7 +117,7 @@ class InputfieldTextTags extends Inputfield
if($key === 'tagsList') return $this->setTagsList($value);
list(,$languageId) = explode('tagsList', $key, 2);
return $this->setTagsList($value, (int) $languageId);
} else if($key === 'allowUserTags' || $key === 'closeAfterSelect') {
} else if($key === 'allowUserTags' || $key === 'closeAfterSelect' || $key === 'useAjax') {
$value = (int) $value;
}
return parent::set($key, $value);
@@ -468,10 +472,6 @@ class InputfieldTextTags extends Inputfield
/** @var JqueryUI $jQueryUI */
$jQueryUI = $this->wire()->modules->get('JqueryUI');
$jQueryUI->use('selectize');
$config = $this->wire()->config;
$url = $config->urls($this->className());
$config->scripts->add($url . 'InputfieldTextTags.js');
$config->styles->add($url . 'InputfieldTextTags.css');
$this->addClass('InputfieldNoFocus', 'wrapClass');
return parent::renderReady($parent, $renderValueMode);
}
@@ -486,13 +486,26 @@ class InputfieldTextTags extends Inputfield
*/
public function ___render() {
$config = $this->wire()->config;
$tagsUrl = $this->useAjax() ? $this->tagsUrl : '';
if($tagsUrl && strpos($tagsUrl, '://') === false) {
if(strpos($tagsUrl, '//') === 0) {
$tagsUrl = ($config->https ? 'https:' : 'http:') . $tagsUrl;
} else if(strpos($tagsUrl, '/') === 0) {
$tagsUrl = $config->urls->httpRoot . ltrim($tagsUrl, '/');
} else {
$tagsUrl = $config->urls->httpRoot . $tagsUrl;
}
}
$attrs = $this->getAttributes();
unset($attrs['class']);
$language = $this->wire()->user->language;
$tags = $this->getTagsList($language && $language->id ? $language : null);
$classes = array();
$classes[] = count($tags) || $this->tagsUrl ? 'InputfieldTextTagsSelect' : 'InputfieldTextTagsInput';
$classes[] = count($tags) || $tagsUrl ? 'InputfieldTextTagsSelect' : 'InputfieldTextTagsInput';
if($this->allowUserTags) {
$value = $this->tagStringToArray($this->val());
@@ -511,14 +524,14 @@ class InputfieldTextTags extends Inputfield
$tags[$tag] = $label;
}
$config = $this->wire()->config;
$class = $this->className();
$opts = array(
'allowUserTags' => $this->allowUserTags(),
'closeAfterSelect' => $this->closeAfterSelect,
'createOnBlur' => $this->allowUserTags() && $this->isTextField(),
'delimiter' => $this->delimiter(),
'tagsUrl' => $this->tagsUrl,
'tagsUrl' => $tagsUrl,
);
if($this->hasField) {
@@ -571,6 +584,7 @@ class InputfieldTextTags extends Inputfield
$value = $this->validateValue($val);
if($val !== $value) $this->val($value);
if($this->isPageField() && count($this->addedTags)) {
// populate POST var recognized by InputfieldPage
$input['_' . $this->attr('name') . '_add_items'] = implode("\n", $this->addedTags);
$this->addedTags = array();
}
@@ -607,26 +621,29 @@ class InputfieldTextTags extends Inputfield
protected function validateValue($tags) {
$sanitizer = $this->wire()->sanitizer;
if(!is_array($tags)) $tags = $this->tagStringToArray($tags);
$allowUserTags = $this->allowUserTags();
$isPageField = $this->isPageField();
$validTags = $this->getTagsList();
$delimiter = $this->delimiter();
if(!is_array($tags)) $tags = $this->tagStringToArray($tags);
foreach(array_keys($tags) as $tag) {
if(!isset($validTags[$tag])) {
if(!$allowUserTags && ($isPageField || !$this->tagsUrl)) {
if(isset($validTags[$tag])) {
// tag is known/valid
} else if($isPageField && $this->tagsUrl && ctype_digit(ltrim($tag, '_'))) {
// tag is page ID from ajax: will be validated by InputfieldPage
} else if(!$allowUserTags && ($isPageField || !$this->tagsUrl)) {
// user tags not allowed
unset($tags[$tag]);
$this->error(sprintf($this->_('Removed invalid tag value: %s'), $tag));
} else {
// newly added tag
$tag = $sanitizer->text($tag);
$label = $tag;
if(strpos($tag, $delimiter)) $tag = str_replace($delimiter, '-', $tag);
if(strpos($tag, $delimiter) !== false) $tag = str_replace($delimiter, '-', $tag);
$this->addedTags[$tag] = $label;
if($isPageField) unset($tags[$tag]); // handled by processInput()
}
if($isPageField) unset($tags[$tag]); // stuffed into addedTags which is handled by processInput()
}
}
@@ -667,6 +684,25 @@ class InputfieldTextTags extends Inputfield
return $this->addTag($value, $label, $language);
}
/**
* Set page selector
*
* For InputfieldSupportsPageSelector interface
*
* @param string $selector
* @return bool Returns boolean false if page selector not supported for current settings
*
*/
public function setPageSelector($selector) {
if($this->hasInputfield) {
if(!$this->hasInputfield->getSetting('useAjax')) return false;
if(!strlen($this->tagsUrl)) $this->tagsUrl = $this->hasInputfield->getSetting('tagsUrl');
}
if(!strlen($this->tagsUrl) || !$this->useAjax()) return false;
if(strlen($selector)) $this->pageSelector = $selector;
return true;
}
/**
* Static utility function to convert a tags string to an array of [ 'tag' => 'label' ]
*
@@ -738,7 +774,6 @@ class InputfieldTextTags extends Inputfield
*/
protected function delimiter($getName = false) {
$ds = array('s' => ' ', 'c' => ',', 'p' => '|');
// $d = $this->delimiter;
$d = $this->isPageField() ? 'p' : $this->delimiter;
if($getName) return $d;
if(isset($ds[$d])) return $ds[$d];
@@ -769,6 +804,7 @@ class InputfieldTextTags extends Inputfield
*
*/
protected function isTextField() {
if($this->hasInputfield) return wireInstanceOf($this->hasInputfield, 'InputfieldText');
$fieldtype = $this->hasFieldtype;
return (!$fieldtype || "$fieldtype" === 'FieldtypeText');
}
@@ -785,6 +821,24 @@ class InputfieldTextTags extends Inputfield
return (bool) $this->allowUserTags;
}
/**
* Is ajax mode enabled?
*
* @return bool
*
*/
protected function useAjax() {
if(!is_bool($this->useAjax)) {
// integer value for useAjax indicates it has gone through a save or been set and is trustworthy
return (bool) $this->useAjax;
}
$hasTagsList = count($this->tagsList);
$hasTagsUrl = strlen($this->tagsUrl);
if($hasTagsList && !$hasTagsUrl) return false;
if($hasTagsUrl && !$hasTagsList) return true;
return (bool) $this->useAjax;
}
/**
* Config
*
@@ -800,6 +854,7 @@ class InputfieldTextTags extends Inputfield
$inputfields = parent::___getConfigInputfields();
$languages = $this->wire()->languages;
$isTextField = $this->isTextField();
$isPageField = $this->isPageField();
/** @var InputfieldFieldset $fieldset */
$fieldset = $modules->get('InputfieldFieldset');
@@ -808,23 +863,53 @@ class InputfieldTextTags extends Inputfield
$fieldset->icon = 'tags';
$inputfields->prepend($fieldset);
if($isTextField) {
/** @var InputfieldRadios $f */
$f = $modules->get('InputfieldRadios');
$f->attr('name', 'useAjax');
$f->label = $this->_('Selectable options/tags source');
$f->addOption(0, $this->_('Specify them here'));
$f->addOption(1, $this->_('Load from URL you specify (ajax)'));
$f->val((int) $this->useAjax());
$fieldset->add($f);
} else if($isPageField) {
/** @var InputfieldToggle $f */
$f = $modules->get('InputfieldToggle');
$f->attr('name', 'useAjax');
$f->label = $this->_('Use ajax options/pages?');
$f->description =
$this->_('When enabled, it will behave like an auto-complete where the user starts typing and it queries a URL for matching pages.') . ' ' .
$this->_('You will also have to provide a “Ajax URL” (shown below when “Yes” selected) to perform the query. Working example code is included.') . ' ' .
$this->_('When not enabled, all selectable options will be populated at runtime.');
$f->notes = $this->_('Using ajax options/pages is useful when potential quantity of selectable pages is large.') . ' ' .
$this->_('Consider it when there are several hundred or thousands (or more) of pages that can be selected.');
$f->val((int) $this->useAjax());
$fieldset->add($f);
} else {
/** @var InputfieldHidden $f */
$f = $modules->get('InputfieldHidden');
$f->attr('name', 'useAjax');
$f->val(0);
$fieldset->add($f);
}
if($isTextField) {
/** @var InputfieldTextarea $f */
$f = $modules->get('InputfieldTextarea');
$f->attr('name', 'tagsList');
$f->label = $this->label = $this->_('Predefined tags list');
$f->description = $this->_('Enter predefined tags, 1 per line. To define separate tag and label, specify `tag=label` on the line.');
$f->label = $this->label = $this->_('Predefined options/tags list');
$f->description = $this->_('Enter predefined tags, 1 per line. To define separate value and label for the tag, specify `value=label` on the line.');
$f->notes = $this->_('Tags may not contain the delimiter selected below but labels can.');
$f->val($this->tagsListArrayToString($this->tagsList));
$f->showIf = 'useAjax=0';
if($languages) {
$f->description .= ' ' . $this->_('To define separate labels per-language, re-enter each tag (with label) for each language.');
$f->description .= ' ' . $this->_('To define separate labels per-language, re-enter each tag `value=label` for each language, where the `value` is the same for each while the `label` differs.');
$f->useLanguages = true;
foreach($languages as $language) {
if(!$language->isDefault()) $f->set("value$language", $this->tagsListArrayToString($this->get("tagsList$language")));
}
}
$fieldset->add($f);
} else {
/** @var InputfieldHidden $f */
$f = $modules->get('InputfieldHidden');
@@ -833,29 +918,62 @@ class InputfieldTextTags extends Inputfield
$fieldset->add($f);
}
if($isTextField) {
if($isTextField || $isPageField) {
/** @var InputfieldText $f */
$exampleUrl = $this->wire()->config->urls->httpRoot . 'find-tags/?q={q}';
$examplePath = "find-$this->name";
$exampleUrl = "/$examplePath/?q={q}";
$exampleDescription =
sprintf($this->_('URL handler example in file %s for URL %s'), '<code>/site/init.php</code>', "<code>$exampleUrl</code>") . ' — ' .
$this->_('Copy and paste this URL into the field above if you want to use the example below.');
$exampleNotes = '';
$f = $modules->get('InputfieldText');
$f->attr('name', 'tagsUrl');
$f->label = $this->_('Predefined tags URL');
$f->label = $this->_('Ajax URL');
$f->description =
$this->_('When you enter a URL, it will be queried for tags matching user input in auto-complete fashion.') . ' ' .
$this->_('Use this instead of the “predefined tags list” above when the quantity of selectable tags is larger than is practical to list individually.') . ' ' .
$this->_('When you enter a URL, it will be queried for items matching user input in auto-complete fashion.') . ' ' .
$this->_('The given URL must contain the placeholder `{q}` in it somewhere, which will be replaced with the text the user types.') . ' ' .
$this->_('You will also have to define a URL handler like in the example shown below.');
$f->appendMarkup =
"<p class='description'>" . sprintf($this->_('URL handler example in %s for URL: %s'), '<u>/site/init.php</u>', "<u>$exampleUrl</u>") . '</p>' .
"<pre style='margin:0'><code>" .
'$wire->addHook("/find-tags/", function($e) { ' .
$this->_('You may specify a full http/https URL, or a relative URL.') . ' ' .
$this->_('If you specify a relative URL (without scheme/host), the current scheme, host and root URL will be prepended to it at runtime.') . ' ' .
$this->_('You will also have to define a URL handler like in the example shown below.') . ' ' .
sprintf($this->_('The URL path `%s` is just an example, feel free to replace it with whatever you want.'), "/$examplePath/");
if($isTextField) {
$exampleCode =
'$wire->addHook("/' . $examplePath . '/", function($e) { ' .
"\n " . '$q = $e->input->get("q", "text,selectorValue");' .
"\n " . 'if(strlen($q) < 3) return []; ' .
"\n " . 'return $e->pages->find("parent=/categories/, title%=$q")->explode("title");' .
"\n});" .
"</code></pre>" .
"<p class='description'>" . $this->_('This example finds titles from pages to use as tags, but you may use whatever data source you want.') . "</p>";
"\n " . 'return array_values($e->pages->findRaw("parent=/tags/, title%=$q, field=title"));' .
"\n});";
$exampleNotes = $this->_('This example finds titles from pages to use as tags, but you may use whatever data source you want.');
} else {
$selector = '';
$hasInputfield = $this->hasInputfield;
if($hasInputfield && $hasInputfield instanceof InputfieldPage) $selector = $hasInputfield->createFindPagesSelector();
if(!strlen($selector)) $selector = $this->_('your page finding selector here');
$exampleCode =
'$wire->addHook("/' . $examplePath . '/", function($e) { ' .
"\n " . '$q = $e->input->get("q", "text,selectorValue");' .
"\n " . 'if(strlen($q) < 3) return [];' .
"\n " . '$selector = "' . $selector . ', title%=$q";' .
"\n " . '$fields = [ "id" => "value", "title" => "label" ]; ' .
"\n " . 'return array_values($e->pages->findRaw($selector, $fields));' .
"\n});";
}
$f->appendMarkup .=
"<p class='description'>$exampleDescription</p>" .
"<pre style='margin:0'><code>$exampleCode</code></pre>" .
($exampleNotes ? "<p class='description'>$exampleNotes</p>" : "");
if($isPageField) $f->appendMarkup .=
"<p class='description'>" .
$this->_('Please note that if you change your “Selectable pages” or “Label field” settings, you will also have to update your URL handler code for it.') .
"</p>";
$f->val($this->tagsUrl);
$f->collapsed = Inputfield::collapsedBlank;
if($this->tagsUrl && $this->useAjax() && strpos($this->tagsUrl, '{q}') === false) {
$f->error($this->_('The placeholder “{q}” is required somewhere in your Ajax URL.'));
}
$f->showIf = 'useAjax=1';
$fieldset->add($f);
} else {
/** @var InputfieldHidden $f */
@@ -889,12 +1007,14 @@ class InputfieldTextTags extends Inputfield
if($isTextField) {
/** @var InputfieldRadios $f */
$singleWordLabel = $this->_('(for single-word tags)');
$multiWordLabel = $this->_('(for multi-word tags)');
$f = $modules->get('InputfieldRadios');
$f->attr('name', 'delimiter');
$f->label = $this->_('Tag delimiter');
$f->addOption('s', $this->_('Space'));
$f->addOption('c', $this->_('Comma'));
$f->addOption('p', $this->_('Pipe'));
$f->addOption('s', $this->_('Space') . " [span.detail] $singleWordLabel [/span]");
$f->addOption('c', $this->_('Comma') . " [span.detail] $multiWordLabel [/span]");
$f->addOption('p', $this->_('Pipe') . " [span.detail] $multiWordLabel [/span]");
$f->optionColumns = 1;
$f->val($this->delimiter(true));
$fieldset->add($f);