Merge branch 'feature/autocomplete'

This commit is contained in:
Thomas Gelf 2016-11-17 20:38:06 +01:00
commit ea141b7e0a
4 changed files with 519 additions and 4 deletions

View File

@ -0,0 +1,122 @@
<?php
namespace Icinga\Module\Director\Controllers;
use Icinga\Module\Director\Objects\IcingaHost;
use Icinga\Module\Director\Objects\IcingaService;
use Icinga\Module\Director\Web\Controller\ActionController;
use Icinga\Data\Filter\Filter;
class SuggestController extends ActionController
{
/*
// TODO: Allow any once applying restrictions here
protected function checkDirectorPermissions()
{
}
*/
public function indexAction()
{
// TODO: Using some temporarily hardcoded methods, should use DataViews later on
$context = $this->getRequest()->getPost('context');
$func = 'suggest' . ucfirst($context);
if (method_exists($this, $func)) {
$all = $this->$func();
} else {
$all = array();
}
$search = $this->getRequest()->getPost('value');
$begins = array();
$matches = array();
$begin = Filter::expression('value', '=', $search . '*');
$middle = Filter::expression('value', '=', '*' . $search . '*');
$prefixes = array();
foreach ($all as $str) {
if (false !== ($pos = strrpos($str, '.'))) {
$prefix = substr($str, 0, $pos) . '.';
$prefixes[$prefix] = $prefix;
}
if (strlen($search)) {
$row = (object) array('value' => $str);
if ($begin->matches($row)) {
$begins[] = $this->highlight($str, $search);
} elseif ($middle->matches($row)) {
$matches[] = $this->highlight($str, $search);
}
}
}
$containing = array_slice(array_merge($begins, $matches), 0, 30);
$suggestions = $containing;
if ($func === 'suggestHostFilterColumns' || $func === 'suggestHostaddresses') {
ksort($prefixes);
if (count($suggestions) < 5) {
$suggestions = array_merge($suggestions, array_keys($prefixes));
}
}
$this->view->suggestions = $suggestions;
}
protected function suggestHostnames()
{
$db = $this->db()->getDbAdapter();
$query = $db->select()->from('icinga_host', 'object_name')->order('object_name');
return $db->fetchCol($query);
}
protected function suggestCheckcommandnames()
{
$db = $this->db()->getDbAdapter();
$query = $db->select()->from('icinga_command', 'object_name')->order('object_name');
return $db->fetchCol($query);
}
protected function suggestHostgroupnames()
{
$db = $this->db()->getDbAdapter();
$query = $db->select()->from('icinga_hostgroup', 'object_name')->order('object_name');
return $db->fetchCol($query);
}
protected function suggestHostaddresses()
{
$db = $this->db()->getDbAdapter();
$query = $db->select()->from('icinga_host', 'address')->order('address');
return $db->fetchCol($query);
}
protected function suggestHostFilterColumns()
{
$all = IcingaHost::enumProperties($this->db(), 'host.');
$all = array_merge(
array_keys($all[$this->translate('Host properties')]),
array_keys($all[$this->translate('Custom variables')])
);
natsort($all);
return $all;
}
protected function suggestServiceFilterColumns()
{
$all = IcingaService::enumProperties($this->db(), 'service.');
$all = array_merge(
array_keys($all[$this->translate('Service properties')]),
array_keys($all[$this->translate('Host properties')]),
array_keys($all[$this->translate('Host Custom variables')]),
array_keys($all[$this->translate('Custom variables')])
);
// natsort($all);
return $all;
}
protected function highlight($val, $search)
{
$search = $this->view->escape($search);
$val = $this->view->escape($val);
return str_replace($search, '<strong>' . $search . '</strong>', $val);
}
}

View File

@ -0,0 +1,3 @@
<?php foreach ($suggestions as $suggest): ?>
<li><?= $suggest ?></li>
<?php endforeach ?>

View File

@ -1152,7 +1152,7 @@ ul.filter-root {
padding-left: 0.5em;
list-style-type: none;
ul {
ul.filter {
padding-left: 1.5em;
list-style-type: none;
width: 100%;
@ -1251,9 +1251,37 @@ div.filter-expression {
}
}
ul.director-suggestions {
width: 20em;
max-width: 30em;
max-height: 25em;
overflow-y: auto;
overflow-x: hidden;
border: 1px solid @gray-light;
position: absolute;
z-index: 2000;
padding: 0;
margin: 0;
list-style-type: none;
background-color: rgba(255, 255, 255, 100);
li {
margin: 0;
padding: 0.5em 1em;
}
li:hover {
background-color: @gray-lighter;
cursor: pointer;
}
li.active {
background-color: @icinga-blue;
color: white;
&:hover {
color: inherit;
}
}
}
.tree li a {
display: inline-block;

View File

@ -26,6 +26,340 @@
this.module.on('click', 'input.related-action', this.extensibleSetAction);
this.module.on('focus', 'form input, form textarea, form select', this.formElementFocus);
this.module.icinga.logger.debug('Director module initialized');
this.module.on('keyup', '.director-suggest', this.autoSuggest);
this.module.on('keydown', '.director-suggest', this.suggestionKeyDown);
this.module.on('dblclick', '.director-suggest', this.suggestionDoubleClick);
this.module.on('focus', '.director-suggest', this.enterSuggestionField);
this.module.on('focusout', '.director-suggest', this.leaveSuggestionField);
this.module.on('click', '.director-suggestions li', this.clickSuggestion);
this.module.on('change', 'form input.autosubmit, form select.autosubmit', this.setAutoSubmitted);
},
/**
* Autocomplete/suggestion eventhandler
*
* Triggered when pressing a key in a form element with suggestions
*
* @param ev
*/
suggestionKeyDown: function(ev) {
var $suggestions, $active;
var $el = $(ev.currentTarget);
if (ev.keyCode === 13) {
/**
* RETURN key pressed. In case there are any suggestions:
* - let's choose the active one (if set)
* - stop the event
*
* This let's return bubble up in case there is no suggestion list shown
*/
if (this.hasSuggestions($el)) {
this.chooseActiveSuggestion($el);
ev.stopPropagation();
ev.preventDefault();
} else {
this.removeSuggestionList($el);
$el.trigger('change');
}
} else if (ev.keyCode == 27) {
// ESC key pressed. Remove suggestions if any
this.removeSuggestionList($el);
} else if (ev.keyCode == 39) {
/**
* RIGHT ARROW key pressed. In case there are any suggestions:
* - let's choose the active one (if set)
* - stop the event only if an element has been chosen
*
* This allows to use the right arrow key normally in all other situations
*/
if (this.hasSuggestions($el)) {
if (this.chooseActiveSuggestion($el)) {
ev.stopPropagation();
ev.preventDefault();
}
}
} else if (ev.keyCode == 38 ) {
/**
* UP ARROW key pressed. In any case:
* - stop the event
* - activate the previous suggestion if any
*/
ev.stopPropagation();
ev.preventDefault();
this.activatePrevSuggestion($el);
} else if (ev.keyCode == 40 ) { // down
/**
* DOWN ARROW key pressed. In any case:
* - stop the event
* - activate the next suggestion if any
*/
ev.stopPropagation();
ev.preventDefault();
this.activateNextSuggestion($el);
}
},
suggestionDoubleClick: function (ev)
{
var $el = $(ev.currentTarget);
this.getSuggestionList($el)
},
/**
* Autocomplete/suggestion eventhandler
*
* Triggered when releasing a key in a form element with suggestions
*
* @param ev
*/
autoSuggest: function(ev)
{
// Ignore special keys, most of them have already been handled on 'keydown'
if (ev.keyCode == 9 || // TAB
ev.keyCode == 13 || // RETURN
ev.keyCode == 27 || // ESC
ev.keyCode == 37 || // LEFT ARROW
ev.keyCode == 38 || // UP ARROW
ev.keyCode == 39 ) { // RIGHT ARROW
return;
}
var $el = $(ev.currentTarget);
if (ev.keyCode == 40) { // DOWN ARROW
this.getSuggestionList($el);
} else {
this.getSuggestionList($el, true);
}
},
/**
* Activate the next related suggestion if any
*
* This walks down the suggestion list, takes care about scrolling and restarts from
* top once reached the bottom
*
* @param $el
*/
activateNextSuggestion: function($el)
{
var $list = this.getSuggestionList($el);
var $next;
var $active = $list.find('li.active');
if ($active.length) {
$next = $active.next('li');
if ($next.length === 0) {
$next = $list.find('li').first();
}
} else {
$next = $list.find('li').first();
}
if ($next.length) {
// Will not happen when list is empty or last element is active
$list.find('li.active').removeClass('active');
$next.addClass('active');
$list.scrollTop($next.offset().top - $list.offset().top - 64 + $list.scrollTop());
}
},
/**
* Activate the previous related suggestion if any
*
* This walks up through the suggestion list and takes care about scrolling.
* Puts the focus back on the input field once reached the top and restarts
* from bottom when moving up from there
*
* @param $el
*/
activatePrevSuggestion: function($el)
{
var $list = this.getSuggestionList($el);
var $prev;
var $active = $list.find('li.active');
if ($active.length) {
$prev = $active.prev('li');
} else {
$prev = $list.find('li').last();
}
$list.find('li.active').removeClass('active');
if ($prev.length) {
$prev.addClass('active');
$list.scrollTop($prev.offset().top - $list.offset().top - 64 + $list.scrollTop());
} else {
$el.focus();
}
},
/**
* Whether a related suggestion list element exists
*
* @param $input
* @returns {boolean}
*/
hasSuggestionList: function($input) {
var $ul = $input.siblings('ul.director-suggestions');
return $ul.length > 0;
},
/**
* Whether any related suggestions are currently being shown
*
* @param $input
* @returns {boolean}
*/
hasSuggestions: function($input) {
var $ul = $input.siblings('ul.director-suggestions');
return $ul.length > 0 && $ul.is(':visible');
},
/**
* Get a suggestion list. Optionally force refresh
*
* @param $input
* @param $forceRefresh
*
* @returns {jQuery}
*/
getSuggestionList: function($input, $forceRefresh)
{
var $ul = $input.siblings('ul.director-suggestions');
if ($ul.length) {
if ($forceRefresh) {
return this.refreshSuggestionList($ul, $input);
} else {
return $ul;
}
} else {
$ul = $('<ul class="director-suggestions"></ul>');
$ul.insertAfter($input);
return this.refreshSuggestionList($ul, $input);
}
},
/**
* Refresh a given suggestion list
*
* @param $suggestions
*
* @param $el
* @returns {jQuery}
*/
refreshSuggestionList: function($suggestions, $el)
{
$suggestions.load(icinga.config.baseUrl + '/director/suggest', {
value: $el.val(),
context: $el.data('suggestion-context')
}, function (responseText, textStatus, jqXHR) {
var $li = $suggestions.find('li');
if ($li.length) {
$suggestions.show();
} else {
$suggestions.hide();
}
});
return $suggestions;
},
/**
* Click handler for proposed suggestions
*
* @param ev
*/
clickSuggestion: function(ev) {
this.chooseSuggestion($(ev.currentTarget));
},
/**
* Choose a specific suggestion
* @param $suggestion
*/
chooseSuggestion: function($suggestion)
{
var $el = $suggestion.closest('ul').siblings('.director-suggest');
var val = $suggestion.text();
var $list =
$el.val(val);
if (val.match(/\.$/)) {
this.getSuggestionList($el, true);
} else {
$el.focus();
$el.trigger('change');
this.getSuggestionList($el).remove();
}
},
/**
* Choose the current active suggestion related to a given element
*
* Returns true in case there was any, false otherwise
*
* @param $el
* @returns {boolean}
*/
chooseActiveSuggestion: function($el)
{
var $list = this.getSuggestionList($el);
var $active = $list.find('li.active');
if ($active.length) {
this.chooseSuggestion($active);
return true;
} else {
$list.remove();
return false;
}
},
/**
* Remove related suggestion list if any
*
* @param $el
*/
removeSuggestionList: function($el)
{
if (this.hasSuggestionList($el)) {
this.getSuggestionList($el).remove();
}
},
/**
* Show suggestions when arriving to an empte autocompletion field
*
* @param ev
*/
enterSuggestionField: function(ev) {
var $el = $(ev.currentTarget);
if ($el.val() === '' || $el.val().match(/\.$/)) {
this.getSuggestionList($el)
}
},
/**
* Close suggestions when leaving the related form element
*
* @param ev
*/
leaveSuggestionField: function(ev) {
return;
var _this = this;
setTimeout(function() {
_this.removeSuggestionList($(ev.currentTarget));
}, 100);
},
/**
* Sets an autosubmit flag on the container related to an event
*
* This will be used in beforeRender to determine whether the request has been triggered by an
* auto-submission
*
* @param ev
*/
setAutoSubmitted: function(ev) {
$(ev.currentTarget).closest('.container').data('directorAutosubmit', 'yes');
},
/**
@ -240,7 +574,6 @@
beforeRender: function(ev) {
var $container = $(ev.currentTarget);
var id = $container.attr('id');
var requests = this.module.icinga.loader.requests;
if (typeof requests[id] !== 'undefined' && requests[id].autorefresh) {
@ -248,8 +581,34 @@
} else {
$container.removeData('director-autorefreshed');
}
// Remove the temporary directorAutosubmit flag and set or remove
// the directorAutosubmitted property accordingly
if ($container.data('directorAutosubmit') === 'yes') {
$container.removeData('directorAutosubmit');
$container.data('directorAutosubmitted', 'yes');
} else {
$container.removeData('directorAutosubmitted');
}
},
/**
* Whether the given container has been autosubmitted
*
* @param $container
* @returns {boolean}
*/
containerIsAutoSubmitted: function($container)
{
return $container.data('directorAutosubmitted') === 'yes';
},
/**
* Whether the given container has been autorefreshed
*
* @param $container
* @returns {boolean}
*/
containerIsAutorefreshed: function($container)
{
return $container.data('director-autorefreshed') === 'yes';
@ -271,7 +630,7 @@
// Disabled for now
// this.alignDetailLinks();
if (! this.containerIsAutorefreshed($container)) {
if (! this.containerIsAutorefreshed($container) && ! this.containerIsAutoSubmitted($container)) {
this.putFocusOnFirstFormElement($container);
}
},
@ -326,6 +685,9 @@
$('fieldset', $form).each(function(idx, fieldset) {
var $fieldset = $(fieldset);
if ($fieldset.attr('id') === 'fieldset-assign') {
return;
}
if ($fieldset.find('.required').length == 0 && (! self.fieldsetWasOpened($fieldset))) {
$fieldset.addClass('collapsed');
self.fixFieldsetInfo($fieldset);