diff options
-rw-r--r-- | lib/client/kolab_client_task_group.php | 34 | ||||
-rw-r--r-- | lib/client/kolab_client_task_main.php | 3 | ||||
-rw-r--r-- | lib/kolab_client_task.php | 23 | ||||
-rw-r--r-- | lib/kolab_form.php | 4 | ||||
-rw-r--r-- | lib/kolab_html.php | 7 | ||||
-rw-r--r-- | lib/kolab_utils.php | 9 | ||||
-rw-r--r-- | lib/locale/en_US.php | 3 | ||||
-rw-r--r-- | public_html/js/kolab_admin.js | 294 | ||||
-rw-r--r-- | public_html/skins/default/images/buttons.png | bin | 831 -> 1193 bytes | |||
-rw-r--r-- | public_html/skins/default/style.css | 73 | ||||
-rw-r--r-- | public_html/skins/default/ui.js | 161 |
11 files changed, 563 insertions, 48 deletions
diff --git a/lib/client/kolab_client_task_group.php b/lib/client/kolab_client_task_group.php index 3d8c876..821d438 100644 --- a/lib/client/kolab_client_task_group.php +++ b/lib/client/kolab_client_task_group.php @@ -261,6 +261,27 @@ class kolab_client_task_group extends kolab_client_task ); } + // Members (get member names) + if (!empty($data['group'])) { + // find members attribute name + foreach (array('member', 'uniquemember') as $attr) { + if (isset($fields[$attr]) && isset($data[$attr])) { + $attr_name = $attr; + } + } + if (!empty($attr_name)) { + $result = $this->api->get('group.members_list', array('group' => $data['group'])); + $list = (array) $result->get('list'); + $data[$attr_name] = $this->parse_members($list); + } + } + +$fields['debug'] = array( + 'label' => 'debug', + 'section' => 'system', + 'value' => '<pre>'.kolab_html::escape(print_r($data, true)).'</pre>', +); + // Create form object and populate with fields $form = $this->form_create('group', $attribs, $sections, $fields, $fields_map, $data); @@ -271,6 +292,19 @@ class kolab_client_task_group extends kolab_client_task return $form->output(); } + private function parse_members($list) + { + // convert to key=>value array, see kolab_api_service_form_value::list_options_uniquemember() + foreach ($list as $idx => $value) { + $list[$idx] = $value['displayname']; + if (!empty($value['mail'])) { + $list[$idx] .= ' <' . $value['mail'] . '>'; + } + } + + return $list; + } + /** * Returns list of group types. * diff --git a/lib/client/kolab_client_task_main.php b/lib/client/kolab_client_task_main.php index 9996526..b7b4621 100644 --- a/lib/client/kolab_client_task_main.php +++ b/lib/client/kolab_client_task_main.php @@ -40,7 +40,8 @@ class kolab_client_task_main extends kolab_client_task $this->output->set_env('watermark', $this->output->get_template('watermark')); // assign default set of translations - $this->output->add_translation('loading', 'saving', 'deleting', 'servererror', 'search'); + $this->output->add_translation('loading', 'saving', 'deleting', 'servererror', + 'search', 'search.loading', 'search.acchars'); // Create list of tasks for dashboard // @TODO: check capabilities diff --git a/lib/kolab_client_task.php b/lib/kolab_client_task.php index bd49cd3..6ee696f 100644 --- a/lib/kolab_client_task.php +++ b/lib/kolab_client_task.php @@ -618,6 +618,9 @@ class kolab_client_task if (!empty($field['maxlength'])) { $result['data-maxlength'] = $field['maxlength']; } + if (!empty($field['autocomplete'])) { + $result['data-autocomplete'] = true; + } break; default: @@ -781,6 +784,7 @@ class kolab_client_task } $form = new kolab_form($attribs); + $assoc_fields = array(); // Parse elements and add them to the form object foreach ($sections as $section_idx => $section) { @@ -792,20 +796,24 @@ class kolab_client_task } if (empty($field['label'])) { - $field['label'] = "user.$idx"; + $field['label'] = "$name.$idx"; } $field['label'] = kolab_html::escape($this->translate($field['label'])); - $field['description'] = "user.$idx.desc"; + $field['description'] = "$name.$idx.desc"; $field['section'] = $section_idx; if (!empty($data[$idx])) { - if (is_array($data[$idx])) { - $field['value'] = array_map(array('kolab_html', 'escape'), $data[$idx]); - $field['value'] = implode("\n", $field['value']); + $field['value'] = $data[$idx]; + + // Convert data for the list field with autocompletion + if ($field['data-type'] == kolab_form::TYPE_LIST && kolab_utils::is_assoc($data[$idx])) { + $assoc_fields[$idx] = $data[$idx]; + $field['value'] = array_keys($data[$idx]); } - else { - $field['value'] = kolab_html::escape($data[$idx]); + + if (is_array($field['value'])) { + $field['value'] = implode("\n", $field['value']); } } /* @@ -858,6 +866,7 @@ class kolab_client_task } $this->output->set_env('form_id', $attribs['id']); + $this->output->set_env('assoc_fields', $assoc_fields); return $form; } diff --git a/lib/kolab_form.php b/lib/kolab_form.php index c78486f..850ca8f 100644 --- a/lib/kolab_form.php +++ b/lib/kolab_form.php @@ -34,6 +34,7 @@ class kolab_form const INPUT_SUBMIT = 7; const INPUT_SELECT = 8; const INPUT_HIDDEN = 9; + const INPUT_CUSTOM = 10; const TYPE_LIST = 1; @@ -279,9 +280,10 @@ class kolab_form break; case self::INPUT_SELECT: - $content = kolab_html::select($attribs); + $content = kolab_html::select($attribs, true); break; + case self::INPUT_CUSTOM: default: if (is_array($attribs)) { $content = isset($attribs['value']) ? $attribs['value'] : ''; diff --git a/lib/kolab_html.php b/lib/kolab_html.php index afce918..9680ef5 100644 --- a/lib/kolab_html.php +++ b/lib/kolab_html.php @@ -316,6 +316,13 @@ class kolab_html public static function escape($value) { + if (is_array($value)) { + foreach ($value as $idx => $val) { + $value[$idx] = self::escape($val); + } + return $value; + } + return htmlspecialchars($value, ENT_COMPAT, KADM_CHARSET); } } diff --git a/lib/kolab_utils.php b/lib/kolab_utils.php index 2694f71..b80bc6a 100644 --- a/lib/kolab_utils.php +++ b/lib/kolab_utils.php @@ -142,4 +142,13 @@ class kolab_utils return false; } + + /** + * Finds wether an array is associative or not. + */ + public static function is_assoc ($arr) + { + return is_array($arr) && count(array_filter(array_keys($arr), 'is_string')) == count($arr); + } + } diff --git a/lib/locale/en_US.php b/lib/locale/en_US.php index efd7439..c0bbbf1 100644 --- a/lib/locale/en_US.php +++ b/lib/locale/en_US.php @@ -25,6 +25,8 @@ $LANG['search.prefix'] = 'begins with'; $LANG['search.name'] = 'name'; $LANG['search.email'] = 'email'; $LANG['search.uid'] = 'UID'; +$LANG['search.loading'] = 'Searching...'; +$LANG['search.acchars'] = 'At least $min characters required for autocompletion'; $LANG['menu.users'] = 'Users'; $LANG['menu.groups'] = 'Groups'; @@ -98,6 +100,7 @@ $LANG['group.group_type_id'] = 'Group type'; $LANG['group.add.success'] = 'Group created successfully.'; $LANG['group.delete.success'] = 'Group deleted successfully.'; $LANG['group.gidnumber'] = 'Primary group number'; +$LANG['group.uniquemember'] = 'Members'; $LANG['group.system'] = 'System'; $LANG['group.other'] = 'Other'; diff --git a/public_html/js/kolab_admin.js b/public_html/js/kolab_admin.js index bdcf1e2..f5075e2 100644 --- a/public_html/js/kolab_admin.js +++ b/public_html/js/kolab_admin.js @@ -38,7 +38,6 @@ function kolab_admin() beforeSend: function(xmlhttp) { xmlhttp.setRequestHeader('X-Session-Token', ref.env.token); } }); - /*********************************************************/ /********* basic utilities *********/ /*********************************************************/ @@ -357,7 +356,7 @@ function kolab_admin() this.display_message(msg, 'error'); // Logout on invalid-session error - if (response.code == 403) + if (response && response.code == 403) this.main_logout(); return false; @@ -412,6 +411,262 @@ function kolab_admin() } }; + + /*********************************************************/ + /********* keyboard autocomplete methods *********/ + /*********************************************************/ + + this.ac_init = function(obj, props) + { + obj.keydown(function(e) { return kadm.ac_keydown(e, props); }) + .attr('autocomplete', 'off'); + }; + + // handler for keyboard events on autocomplete-fields + this.ac_keydown = function(e, props) + { + if (this.ac_timer) + clearTimeout(this.ac_timer); + + var highlight, key = e.which; + + switch (key) { + case 38: // arrow up + case 40: // arrow down + if (!this.ac_visible()) + break; + + var dir = key == 38 ? 1 : 0; + + highlight = $('.selected', this.ac_pane).get(0); + + if (!highlight) + highlight = this.ac_pane.__ul.firstChild; + + if (highlight) + this.ac_select(dir ? highlight.previousSibling : highlight.nextSibling); + + return e.stopPropagation(); + + case 9: // tab + if (e.shiftKey || !this.ac_visible()) { + this.ac_stop(); + return; + } + + case 13: // enter + if (!this.ac_visible()) + return false; + + // insert selected item and hide selection pane + this.ac_insert(this.ac_selected); + this.ac_stop(); + + return e.stopPropagation(); + + case 27: // escape + this.ac_stop(); + return; + + case 37: // left + case 39: // right + if (!e.shiftKey) + return; + } + + // start timer + this.ac_timer = window.setTimeout(function() { kadm.ac_start(props); }, 200); + this.ac_input = e.target; + + return true; + }; + + this.ac_visible = function() + { + return (this.ac_selected !== null && this.ac_selected !== undefined && this.ac_value); + }; + + this.ac_select = function(node) + { + if (!node) + return; + + var current = $('.selected', this.ac_pane); + + if (current.length) + current.removeClass('selected'); + + $(node).addClass('selected'); + this.ac_selected = node._id; + }; + + // autocomplete search processor + this.ac_start = function(props) + { + var q = this.ac_input ? this.ac_input.value : null, + min = this.env.autocomplete_min_length, + old_value = this.ac_value, + ac = this.ac_data; + + if (q === null) + return; + + // trim query string + q = $.trim(q); + + // Don't (re-)search if the last results are still active + if (q == old_value) + return; + + // Stop and destroy last search + this.ac_stop(); + + if (q.length && q.length < min) { + if (!this.ac_info) { + this.ac_info = this.display_message( + this.t('search.acchars').replace('$min', min)); + } + return; + } + + this.ac_value = q; + + // ...string is empty + if (!q.length) + return; + + // ...new search value contains old one, but the old result was empty + if (old_value && old_value.length && q.indexOf(old_value) == 0 && this.ac_result && !this.ac_result.length) + return; + + var i, xhr, data = props, + action = props && props.action ? props.action : 'form_value.list_options'; + + this.ac_oninsert = props.oninsert; + data.search = q; + delete data['action']; + delete data['insert_func']; + + this.display_message(this.t('search.loading'), 'loading'); + xhr = this.api_post(action, data, 'ac_result'); + this.ac_data = xhr; + }; + + this.ac_result = function(response) + { + // search stopped in meantime? + if (!this.ac_value) + return; + + if (!this.api_response(response)) + return; + + // ignore this outdated search response + if (this.ac_input && response.result.search != this.ac_value) + return; + + // display search results + var i, ul, li, text, + result = response.result.list, + pos = $(this.ac_input).offset(), + value = this.ac_value, + rx = new RegExp('(' + RegExp.escape(value) + ')', 'ig'); + + // create results pane if not present + if (!this.ac_pane) { + ul = $('<ul>'); + this.ac_pane = $('<div>').attr('id', 'autocompletepane') + .css({ position:'absolute', 'z-index':30000 }).append(ul).appendTo(document.body); + this.ac_pane.__ul = ul[0]; + } + + ul = this.ac_pane.__ul; + + // reset content + ul.innerHTML = ''; + // move the results pane right under the input box + this.ac_pane.css({left: (pos.left - 1)+'px', top: (pos.top + this.ac_input.offsetHeight - 1)+'px', display: 'none'}); + + // add each result line to the list + for (i in result) { + text = result[i]; + li = document.createElement('LI'); + li.innerHTML = text.replace(rx, '##$1%%').replace(/</g, '<').replace(/>/g, '>').replace(/##([^%]+)%%/g, '<b>$1</b>'); + li.onmouseover = function() { kadm.ac_select(this); }; + li.onmouseup = function() { kadm.ac_click(this) }; + li._id = i; + ul.appendChild(li); + } + + if (ul.childNodes.length) { + this.ac_pane.show(); + + // select the first + li = $('li:first', ul); + li.addClass('selected'); + this.ac_selected = li.get(0)._id; + } + + this.env.ac_result = result; + }; + + this.ac_click = function(node) + { + if (this.ac_input) + this.ac_input.focus(); + + this.ac_insert(node._id); + this.ac_stop(); + }; + + this.ac_insert = function(id) + { + var val = this.env.ac_result[id]; + + if (typeof this.ac_oninsert == 'function') + this.ac_oninsert(id, val); + else + $(this.ac_input).val(val); + }; + + this.ac_blur = function() + { + if (this.ac_timer) + clearTimeout(this.ac_timer); + + this.ac_input = null; + this.ac_stop(); + }; + + this.ac_stop = function() + { + this.ac_selected = null; + this.ac_value = ''; + + if (this.ac_pane) + this.ac_pane.hide(); + + this.ac_destroy(); + }; + + // Clears autocomplete data/requests + this.ac_destroy = function() + { + if (this.ac_data) + this.ac_data.abort(); + + if (this.ac_info) + this.hide_message(this.ac_info); + + if (this.ac_msg) + this.hide_message(this.ac_msg); + + this.ac_data = null; + this.ac_info = null; + this.ac_msg = null; + }; + + /*********************************************************/ /********* Forms *********/ /*********************************************************/ @@ -615,9 +870,32 @@ function kolab_admin() this.command('group.list', {page: page}); }; -}; + this.group_save = function(reload, section) + { + var data = this.serialize_form('#'+this.env.form_id); -var kadm = new kolab_admin(); + if (reload) { + data.section = section; + this.http_post('group.add', {data: data}); + return; + } + + this.form_error_clear(); + + this.set_busy(true, 'saving'); + this.api_post('group.add', data, 'group_save_response'); + }; + + this.group_save_response = function(response) + { + if (!this.api_response(response)) + return; + + this.display_message('group.add.success'); + this.command('group.list', {page: this.env.list_page}); + }; + +}; // Add escape() method to RegExp object // http://dev.rubyonrails.org/changeset/7271 @@ -625,3 +903,11 @@ RegExp.escape = function(str) { return String(str).replace(/([.*+?^=!:${}()|[\]\/\\])/g, '\\$1'); }; + +var kadm = new kolab_admin(); + +// general click handler +$(document).click(function() { + // destroy autocompletion + kadm.ac_stop(); +}); diff --git a/public_html/skins/default/images/buttons.png b/public_html/skins/default/images/buttons.png Binary files differindex 0492d09..586851e 100644 --- a/public_html/skins/default/images/buttons.png +++ b/public_html/skins/default/images/buttons.png diff --git a/public_html/skins/default/style.css b/public_html/skins/default/style.css index 96cfa30..bb52537 100644 --- a/public_html/skins/default/style.css +++ b/public_html/skins/default/style.css @@ -25,6 +25,7 @@ textarea { -moz-border-radius: 3px; -webkit-border-radius: 3px; padding-left: 2px; + color: black; } table.list { @@ -494,6 +495,9 @@ textarea.readonly { span.listarea { display: block; width: 370px; + max-height: 209px; + overflow-y: auto; + overflow-x: hidden; margin: 0 0 1px; padding: 0; background-color: white; @@ -520,7 +524,8 @@ span.listelement input { border: none; background-color: transparent; padding-left: 2px; - width: 330px; + /* FIXME: it should be 330px, but when listarea has scroller, input jumps below action buttons */ + width: 314px; height: 19px; } @@ -561,18 +566,84 @@ span.listelement span.actions span.add { background-position: -43px 0; } +span.listelement span.actions span.search { + background-position: -65px 0; + cursor: default; +} + span.listarea.readonly { background-color: #f5f5f5; } +input.readonly, span.listarea.readonly span.listelement input { color: #a0a0a0; + cursor: default; +} + +span.listarea.autocomplete span.listelement input { + color: #514949; +} + +span.listarea.autocomplete span.listelement input.autocomplete { + color: black; } span.listarea.readonly span.listelement span.actions { opacity: .5; } +.autocomplete > span.listelement input { + /* FIXME: it should be 348px, but when listarea has scroller, input jumps below action buttons */ + width: 332px; +} + +.autocomplete > span.listelement span.actions { + width: 18px; +} + +.autocomplete > span.listelement span.actions span.reset { + border-left: none; +} + +.autocomplete > span.listelement span.actions span.search:hover { + background-color: #f0f0f0; +} + +/***** autocomplete list *****/ + +#autocompletepane +{ + background-color: white; + border: 1px solid #d0d0d0; + min-width: 351px; +} + +#autocompletepane ul +{ + margin: 0px; + padding: 2px; + list-style-image: none; + list-style-type: none; +} + +#autocompletepane ul li +{ + display: block; + height: 16px; + font-size: 11px; + padding-left: 6px; + padding-top: 2px; + padding-right: 6px; + white-space: nowrap; + cursor: pointer; +} + +#autocompletepane ul li.selected +{ + background-color: #d6efff; +} + /***** tabbed interface elements *****/ div.tabsbar diff --git a/public_html/skins/default/ui.js b/public_html/skins/default/ui.js index 850eb93..32f5dfc 100644 --- a/public_html/skins/default/ui.js +++ b/public_html/skins/default/ui.js @@ -143,7 +143,7 @@ function form_serialize(data) // replace some textarea fields with pretty/smart input lists $('textarea[data-type="list"]', form).not('disabled').each(function() { var i, v, value = [], - re = RegExp('^' + RegExp.escape(this.name) + '\[[0-9]+\]$'); + re = RegExp('^' + RegExp.escape(this.name) + '\[[0-9-]+\]$'); for (i in data.json) { if (i.match(re)) { @@ -152,6 +152,14 @@ function form_serialize(data) delete data.json[i]; } } + + // autocompletion lists data is stored in env variable + if (kadm.env.assoc_fields[this.name]) { + value = []; + for (i in kadm.env.assoc_fields[this.name]) + value.push(i); + } + data.json[this.name] = value; }); @@ -187,67 +195,152 @@ function form_init(id) // Replaces form element with smart element function form_element_wrapper(form_element) { - var i, len, elem, e = $(form_element), - list = form_element.value.split("\n"), - area = $('<span class="listarea"></span>'), - disabled = e.attr('disabled') || e.attr('readonly'); + var i, j = 0, len, elem, e = $(form_element), + list = kadm.env.assoc_fields[form_element.name], + disabled = e.attr('disabled') || e.attr('readonly'), + autocomplete = e.attr('data-autocomplete'), + maxlength = e.attr('data-maxlength'), + area = $('<span class="listarea"></span>'); e.hide(); - for (i=0, len=list.length; i<len; i++) { + // add autocompletion input + if (!disabled && autocomplete) { + elem = form_list_element(form_element.form, { + maxlength: maxlength, + autocomplete: autocomplete, + element: e + }, -1); + + elem.appendTo(area); + kadm.ac_init(elem, {attribute: form_element.name, oninsert: form_element_insert_func}); + } + + if (!list && form_element.value) + list = $.extend({}, form_element.value.split("\n")); + + // add input rows + for (i in list) { elem = form_list_element(form_element.form, { - name: form_element.name+'['+i+']', value: list[i], + key: i, disabled: disabled, - maxlength: e.attr('data-maxlength') - }); + maxlength: maxlength, + autocomplete: autocomplete, + element: e + }, j++); + elem.appendTo(area); } if (disabled) area.addClass('readonly'); + if (autocomplete) + area.addClass('autocomplete'); area.appendTo(form_element.parentNode); } // Creates smart list element -function form_list_element(form, data) +function form_list_element(form, data, idx) { - var elem = $('<span class="listelement"><span class="actions">' - + '<span title="" class="add"></span><span title="" class="reset"></span>' - + '</span><input></span>'); + var content, elem, input, + key = data.key, + orig = data.element + ac = data.autocomplete; + + data.name = data.name || orig.attr('name') + '[' + idx + ']'; + data.disabled = data.disabled || (ac && idx >= 0); + data.readonly = data.readonly || (ac && idx >= 0); + + // remove internal attributes + delete data['element']; + delete data['autocomplete']; + delete data['key']; + + // build element content + content = '<span class="listelement"><span class="actions">' + + (!ac ? '<span title="" class="add"></span>' : ac && idx == -1 ? '<span title="" class="search"></span>' : '') + + (!ac || idx >= 0 ? '<span title="" class="reset"></span>' : '') + + '</span><input></span>'; + + elem = $(content); + input = $('input', elem); + + // Set INPUT attributes + input.attr(data); - $('input', elem).attr(data); + if (data.readonly) + input.addClass('readonly'); - if (data.disabled) + if (ac && idx == -1) + input.addClass('autocomplete'); + + if (data.disabled && !ac) return elem; // attach element creation event - $('span[class="add"]', elem).click(function() { - var dt = (new Date()).getTime(), - span = $(this.parentNode.parentNode), - name = data.name.replace(/\[[0-9]+\]$/, ''), - elem = form_list_element(form, {name: name+'['+dt+']'}); - - span.after(elem); - $('input', elem).focus(); - }); + if (!ac) + $('span[class="add"]', elem).click(function() { + var dt = (new Date()).getTime(), + span = $(this.parentNode.parentNode), + name = data.name.replace(/\[[0-9]+\]$/, ''), + elem = form_list_element(form, {name: name}, dt); + + span.after(elem); + $('input', elem).focus(); + kadm.ac_stop(); + }); // attach element deletion event - $('span[class="reset"]', elem).click(function() { - var l, span = $(this.parentNode.parentNode), - name = data.name.replace(/\[[0-9]+\]$/, ''), - l = $('input[name^="' + name + '"]', form); - - if (l.length > 1) - span.remove(); - else - $('input', span).val('').focus(); - }); + if (!ac || idx >= 0) + $('span[class="reset"]', elem).click(function() { + var span = $(this.parentNode.parentNode), + name = data.name.replace(/\[[0-9]+\]$/, ''), + l = $('input[name^="' + name + '"]', form), + key = $(this).data('key'); + + if (ac || l.length > 1) + span.remove(); + else + $('input', span).val('').focus(); + + // delete key from internal field representation + if (key !== undefined && kadm.env.assoc_fields[name]) + delete kadm.env.assoc_fields[name][key]; + + kadm.ac_stop(); + }).data('key', key); return elem; } +function form_element_insert_func(key, val) +{ + var elem, input = $(this.ac_input).get(0), + dt = (new Date()).getTime(), + span = $(input.parentNode), + name = input.name.replace(/\[-1\]$/, ''); + + // reset autocomplete input + input.value = ''; + + // check if element doesn't exist on the list already + if (kadm.env.assoc_fields[name][key]) + return; + + // add element + elem = form_list_element(input.form, { + name: name, + autocomplete: true, + value: val + }, dt); + span.after(elem); + + // update field variable + kadm.env.assoc_fields[name][key] = val; +} + /** * UI Initialization */ |