diff --git a/application/controllers/SuggestController.php b/application/controllers/SuggestController.php
new file mode 100644
index 00000000..cf264d0f
--- /dev/null
+++ b/application/controllers/SuggestController.php
@@ -0,0 +1,122 @@
+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, '' . $search . '', $val);
+ }
+}
diff --git a/application/views/scripts/suggest/index.phtml b/application/views/scripts/suggest/index.phtml
new file mode 100644
index 00000000..5f804e1f
--- /dev/null
+++ b/application/views/scripts/suggest/index.phtml
@@ -0,0 +1,3 @@
+
+
= $suggest ?>
+
diff --git a/public/css/module.less b/public/css/module.less
index 311678c1..38f65078 100644
--- a/public/css/module.less
+++ b/public/css/module.less
@@ -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;
diff --git a/public/js/module.js b/public/js/module.js
index 677d721e..f07f1e90 100644
--- a/public/js/module.js
+++ b/public/js/module.js
@@ -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.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);