From 532d562845686255e1881928300240888f3d1186 Mon Sep 17 00:00:00 2001 From: Martin Edenhofer Date: Thu, 25 Feb 2016 21:42:33 +0100 Subject: [PATCH] Added keyboard support to online notifications. Added browser tests. Improved search keyboard support. --- LICENSE-3RD-PARTY.txt | 5 ++ .../_application_controller.coffee | 74 +++++++++---------- .../app/controllers/navigation.coffee | 26 ++++--- .../widget/keyboard_shortcuts.coffee | 13 ++++ .../widget/online_notification.coffee | 45 +++++++++++ .../app/lib/base/jquery.visible.js | 68 +++++++++++++++++ app/assets/stylesheets/zammad.scss | 7 +- test/browser/keyboard_shortcuts_test.rb | 32 ++++++++ test/browser/prefereces_test.rb | 44 +++++------ 9 files changed, 244 insertions(+), 70 deletions(-) create mode 100644 app/assets/javascripts/app/lib/base/jquery.visible.js diff --git a/LICENSE-3RD-PARTY.txt b/LICENSE-3RD-PARTY.txt index 98274dce3..384ca04bd 100644 --- a/LICENSE-3RD-PARTY.txt +++ b/LICENSE-3RD-PARTY.txt @@ -82,6 +82,11 @@ Copyright: @leChanteaux Mural.ly Dev Team License: dfyw ----------------------------------------------------------------------------- +jquery.visible.js +Source: https://github.com/customd/jquery-visible +Copyright: 2012, Digital Fusion +License: MIT license +----------------------------------------------------------------------------- marked.js Source: https://github.com/chjj/marked Copyright: 2011-2014, Christopher Jeffrey diff --git a/app/assets/javascripts/app/controllers/_application_controller.coffee b/app/assets/javascripts/app/controllers/_application_controller.coffee index 157a17f87..d4561fbee 100644 --- a/app/assets/javascripts/app/controllers/_application_controller.coffee +++ b/app/assets/javascripts/app/controllers/_application_controller.coffee @@ -128,32 +128,32 @@ class App.Controller extends Spine.Controller return if !$('#navigation').is(':visible') $('#navigation').addClass('hide') - scrollTo: ( x = 0, y = 0, delay = 0 ) -> + scrollTo: (x = 0, y = 0, delay = 0) -> a = -> - window.scrollTo( x, y ) + window.scrollTo(x, y) - @delay( a, delay ) + @delay(a, delay) shake: (element) -> # this part is from wordpress 3, thanks to open source shakeMe = (element, position, positionEnd) -> positionStart = position.shift() - element.css( 'left', positionStart + 'px' ) + element.css('left', positionStart + 'px') if position.length > 0 - setTimeout( -> - shakeMe( element, position, positionEnd ) + setTimeout(-> + shakeMe(element, position, positionEnd) , positionEnd) else try - element.css( 'position', 'static' ) + element.css('position', 'static') catch e console.log 'error', e position = [ 15, 30, 15, 0, -15, -30, -15, 0 ] - position = position.concat( position.concat( position ) ) - element.css( 'position', 'relative' ) - shakeMe( element, position, 20 ) + position = position.concat(position.concat(position)) + element.css('position', 'relative') + shakeMe(element, position, 20) isRole: (name) -> roles = @Session.get('roles') @@ -183,8 +183,8 @@ class App.Controller extends Spine.Controller App.Utils.humanFileSize(size) # human readable time - humanTime: ( time, escalation, long = true ) -> - App.PrettyDate.humanTime( time, escalation, long ) + humanTime: (time, escalation, long = true) -> + App.PrettyDate.humanTime(time, escalation, long) userInfo: (data) -> el = data.el || $('[data-id="customer_info"]') @@ -206,7 +206,7 @@ class App.Controller extends Spine.Controller if !checkOnly location = window.location.hash if location isnt '#login' && location isnt '#logout' && location isnt '#keyboard_shortcuts' - @Config.set( 'requested_url', location) + @Config.set('requested_url', location) return false if checkOnly @@ -262,16 +262,16 @@ class App.Controller extends Spine.Controller placement: position title: -> ticket_id = $(@).data('id') - ticket = App.Ticket.fullLocal( ticket_id ) - App.Utils.htmlEscape( ticket.title ) + ticket = App.Ticket.fullLocal(ticket_id) + App.Utils.htmlEscape(ticket.title) content: -> ticket_id = $(@).data('id') - ticket = App.Ticket.fullLocal( ticket_id ) + ticket = App.Ticket.fullLocal(ticket_id) html = App.view('popover/ticket')( ticket: ticket ) html = $(html) - html.find('.humanTimeFromNow').each( -> + html.find('.humanTimeFromNow').each(-> item = $(@) ui.frontendTimeUpdateItem(item) ) @@ -306,11 +306,11 @@ class App.Controller extends Spine.Controller placement: "auto #{position}" title: -> user_id = $(@).data('id') - user = App.User.fullLocal( user_id ) - App.Utils.htmlEscape( user.displayName() ) + user = App.User.fullLocal(user_id) + App.Utils.htmlEscape(user.displayName()) content: -> user_id = $(@).data('id') - user = App.User.fullLocal( user_id ) + user = App.User.fullLocal(user_id) # get display data userData = [] @@ -318,7 +318,7 @@ class App.Controller extends Spine.Controller # check if value for _id exists name = attributeName - nameNew = name.substr( 0, name.length - 3 ) + nameNew = name.substr(0, name.length - 3) if nameNew of user name = nameNew @@ -364,11 +364,11 @@ class App.Controller extends Spine.Controller placement: "auto #{position}" title: -> organization_id = $(@).data('id') - organization = App.Organization.fullLocal( organization_id ) - App.Utils.htmlEscape( organization.name ) + organization = App.Organization.fullLocal(organization_id) + App.Utils.htmlEscape(organization.name) content: -> organization_id = $(@).data('id') - organization = App.Organization.fullLocal( organization_id ) + organization = App.Organization.fullLocal(organization_id) # get display data organizationData = [] @@ -376,7 +376,7 @@ class App.Controller extends Spine.Controller # check if value for _id exists name = attributeName - nameNew = name.substr( 0, name.length - 3 ) + nameNew = name.substr(0, name.length - 3) if nameNew of organization name = nameNew @@ -424,13 +424,13 @@ class App.Controller extends Spine.Controller tickets = [] if ticket_list[type] for ticket_id in ticket_list[type] - tickets.push App.Ticket.fullLocal( ticket_id ) + tickets.push App.Ticket.fullLocal(ticket_id) # insert data html = App.view('popover/user_ticket_list')( tickets: tickets ) - html = $( html ) + html = $(html ) html.find('.humanTimeFromNow').each( -> item = $(@) ui.frontendTimeUpdateItem(item) @@ -440,15 +440,14 @@ class App.Controller extends Spine.Controller fetch = (params) => @ajax( - type: 'GET', - url: @Config.get('api_path') + '/ticket_customer', - data: { - customer_id: params.user_id, - } - processData: true, + type: 'GET' + url: @Config.get('api_path') + '/ticket_customer' + data: + customer_id: params.user_id + processData: true success: (data, status, xhr) -> - App.Collection.loadAssets( data.assets ) - show( params, { open: data.ticket_ids_open, closed: data.ticket_ids_closed } ) + App.Collection.loadAssets(data.assets) + show(params, { open: data.ticket_ids_open, closed: data.ticket_ids_closed }) ) # get data @@ -514,10 +513,10 @@ class App.Controller extends Spine.Controller item.default = params[item.name] #if !item.default # delete item['default'] - newElement = ui.formGenItem( item, classname, form ) + newElement = ui.formGenItem(item, classname, form) # replace new option list - form.find('[name="' + fieldNameToChange + '"]').closest('.form-group').replaceWith( newElement ) + form.find('[name="' + fieldNameToChange + '"]').closest('.form-group').replaceWith(newElement) stopPropagation: (e) -> e.stopPropagation() @@ -712,7 +711,6 @@ class App.ControllerModal extends App.Controller 'hide.bs.modal': @onClose 'hidden.bs.modal': => @onClosed() - # remove modal from dom $('.modal').remove() close: (e) => diff --git a/app/assets/javascripts/app/controllers/navigation.coffee b/app/assets/javascripts/app/controllers/navigation.coffee index 38ef4c35b..f4ce226d3 100644 --- a/app/assets/javascripts/app/controllers/navigation.coffee +++ b/app/assets/javascripts/app/controllers/navigation.coffee @@ -195,7 +195,7 @@ class App.Navigation extends App.ControllerWidgetPermanent @$('form.search').on('submit', (e) -> e.preventDefault() ) - @$('#global-search').on('keydown', @navigate) + @$('#global-search').on('keydown', @listNavigate) # bind to empty search @$('.empty-search').on('click', => @@ -206,7 +206,7 @@ class App.Navigation extends App.ControllerWidgetPermanent el: @el ) - navigate: (e) => + listNavigate: (e) => if e.keyCode is 27 # close on esc @emptyAndClose() return @@ -217,7 +217,7 @@ class App.Navigation extends App.ControllerWidgetPermanent @nudge(e, 1) return else if e.keyCode is 13 # enter - href = @$('#global-search-result .nav-tab.is-active').attr('href') + href = @$('#global-search-result .nav-tab.is-hover').attr('href') @locationExecute(href) @emptyAndClose() return @@ -229,21 +229,29 @@ class App.Navigation extends App.ControllerWidgetPermanent # get current navigationResult = @$('#global-search-result') - current = navigationResult.find('.nav-tab.is-active') + current = navigationResult.find('.nav-tab.is-hover') if !current.get(0) - navigationResult.find('.nav-tab').first().addClass('is-active') + navigationResult.find('.nav-tab').first().addClass('is-hover') return if position is 1 next = current.closest('li').nextAll('li').not('.divider').first().find('.nav-tab') if next.get(0) - current.removeClass('is-active').popover('hide') - next.addClass('is-active').popover('show') + current.removeClass('is-hover').popover('hide') + next.addClass('is-hover').popover('show') else prev = current.closest('li').prevAll('li').not('.divider').first().find('.nav-tab') if prev.get(0) - current.removeClass('is-active').popover('hide') - prev.addClass('is-active').popover('show') + current.removeClass('is-hover').popover('hide') + prev.addClass('is-hover').popover('show') + + if next + element = next.get(0) + if prev + element = prev.get(0) + return if !element + return if $(element).visible(true) + element.scrollIntoView() emptyAndClose: => @$('#global-search').val('').blur() diff --git a/app/assets/javascripts/app/controllers/widget/keyboard_shortcuts.coffee b/app/assets/javascripts/app/controllers/widget/keyboard_shortcuts.coffee index e73c05bb7..1273f2bc5 100644 --- a/app/assets/javascripts/app/controllers/widget/keyboard_shortcuts.coffee +++ b/app/assets/javascripts/app/controllers/widget/keyboard_shortcuts.coffee @@ -59,6 +59,7 @@ App.Config.set( description: 'Dashboard' callback: (e) -> e.preventDefault() + $('#global-search').blur() App.Event.trigger('keyboard_shortcuts_close') window.location.hash = '#dashboard' } @@ -68,6 +69,7 @@ App.Config.set( description: 'Overviews' callback: (e) -> e.preventDefault() + $('#global-search').blur() App.Event.trigger('keyboard_shortcuts_close') window.location.hash = '#ticket/view' } @@ -80,12 +82,23 @@ App.Config.set( App.Event.trigger('keyboard_shortcuts_close') $('#global-search').focus() } + { + key: 'y' + hotkeys: true + description: 'Notifications' + callback: (e) -> + e.preventDefault() + $('#global-search').blur() + App.Event.trigger('keyboard_shortcuts_close') + $('#navigation .js-toggleNotifications').click() + } { key: 'n' hotkeys: true description: 'New Ticket' callback: (e) -> e.preventDefault() + $('#global-search').blur() App.Event.trigger('keyboard_shortcuts_close') window.location.hash = '#ticket/create' } diff --git a/app/assets/javascripts/app/controllers/widget/online_notification.coffee b/app/assets/javascripts/app/controllers/widget/online_notification.coffee index 0aead3db0..2db3d2e98 100644 --- a/app/assets/javascripts/app/controllers/widget/online_notification.coffee +++ b/app/assets/javascripts/app/controllers/widget/online_notification.coffee @@ -48,6 +48,7 @@ class App.OnlineNotificationWidget extends App.Controller release: -> @removeContainer() $(window).off 'click.notifications' + $(window).off 'keydown.notifications' App.OnlineNotification.unsubscribe(@subscribeId) access: -> @@ -56,6 +57,48 @@ class App.OnlineNotificationWidget extends App.Controller return true if @isRole('Admin') return false + listNavigate: (e) => + + if e.keyCode is 27 # close on esc + @hidePopover() + return + else if e.keyCode is 38 # up + @nudge(e, -1) + return + else if e.keyCode is 40 # down + @nudge(e, 1) + return + else if e.keyCode is 13 # enter + $('.js-notificationsContainer .popover-content .activity-entry.is-hover .js-locationVerify').click() + + nudge: (e, position) -> + + # get current + navigation = $('.js-notificationsContainer .popover-content') + current = navigation.find('.activity-entry.is-hover') + if !current.get(0) + navigation.find('.activity-entry').first().addClass('is-hover') + return + + if position is 1 + next = current.next('.activity-entry') + if next.get(0) + current.removeClass('is-hover') + next.addClass('is-hover') + else + prev = current.prev('.activity-entry') + if prev.get(0) + current.removeClass('is-hover') + prev.addClass('is-hover') + + if next + element = next.get(0) + if prev + element = prev.get(0) + return if !element + return if $(element).visible(true) + element.scrollIntoView() + counterUpdate: (count) => if !count @el.find('.js-counter').text('') @@ -100,9 +143,11 @@ class App.OnlineNotificationWidget extends App.Controller notificationsContainer.on 'click', @stopPropagation $(window).on 'click.notifications', @hidePopover + $(window).on 'keydown.notifications', @listNavigate onHide: -> $(window).off 'click.notifications' + $(window).off 'keydown.notifications' hidePopover: => @toggle.popover('hide') diff --git a/app/assets/javascripts/app/lib/base/jquery.visible.js b/app/assets/javascripts/app/lib/base/jquery.visible.js new file mode 100644 index 000000000..745956ad9 --- /dev/null +++ b/app/assets/javascripts/app/lib/base/jquery.visible.js @@ -0,0 +1,68 @@ +(function($){ + + /** + * Copyright 2012, Digital Fusion + * Licensed under the MIT license. + * http://teamdf.com/jquery-plugins/license/ + * + * @author Sam Sehnert + * @desc A small plugin that checks whether elements are within + * the user visible viewport of a web browser. + * only accounts for vertical position, not horizontal. + */ + var $w = $(window); + $.fn.visible = function(partial,hidden,direction){ + + if (this.length < 1) + return; + + var $t = this.length > 1 ? this.eq(0) : this, + t = $t.get(0), + vpWidth = $w.width(), + vpHeight = $w.height(), + direction = (direction) ? direction : 'both', + clientSize = hidden === true ? t.offsetWidth * t.offsetHeight : true; + + if (typeof t.getBoundingClientRect === 'function'){ + + // Use this native browser method, if available. + var rec = t.getBoundingClientRect(), + tViz = rec.top >= 0 && rec.top < vpHeight, + bViz = rec.bottom > 0 && rec.bottom <= vpHeight, + lViz = rec.left >= 0 && rec.left < vpWidth, + rViz = rec.right > 0 && rec.right <= vpWidth, + vVisible = partial ? tViz || bViz : tViz && bViz, + hVisible = partial ? lViz || rViz : lViz && rViz; + + if(direction === 'both') + return clientSize && vVisible && hVisible; + else if(direction === 'vertical') + return clientSize && vVisible; + else if(direction === 'horizontal') + return clientSize && hVisible; + } else { + + var viewTop = $w.scrollTop(), + viewBottom = viewTop + vpHeight, + viewLeft = $w.scrollLeft(), + viewRight = viewLeft + vpWidth, + offset = $t.offset(), + _top = offset.top, + _bottom = _top + $t.height(), + _left = offset.left, + _right = _left + $t.width(), + compareTop = partial === true ? _bottom : _top, + compareBottom = partial === true ? _top : _bottom, + compareLeft = partial === true ? _right : _left, + compareRight = partial === true ? _left : _right; + + if(direction === 'both') + return !!clientSize && ((compareBottom <= viewBottom) && (compareTop >= viewTop)) && ((compareRight <= viewRight) && (compareLeft >= viewLeft)); + else if(direction === 'vertical') + return !!clientSize && ((compareBottom <= viewBottom) && (compareTop >= viewTop)); + else if(direction === 'horizontal') + return !!clientSize && ((compareRight <= viewRight) && (compareLeft >= viewLeft)); + } + }; + +})(jQuery); diff --git a/app/assets/stylesheets/zammad.scss b/app/assets/stylesheets/zammad.scss index c23616bfa..b5c8c3875 100644 --- a/app/assets/stylesheets/zammad.scss +++ b/app/assets/stylesheets/zammad.scss @@ -2904,7 +2904,8 @@ footer { } .nav-tab.is-active, - .nav-tab.nav-tab--search:hover { + .nav-tab.nav-tab--search:hover, + .nav-tab.nav-tab--search.is-hover { background: #389ed9; color: white; @@ -3893,6 +3894,10 @@ footer { opacity: 0.5; } + &.is-hover { + background-color: #f8f9fa; + } + &.activity-entry--removeable { padding-right: 0; } diff --git a/test/browser/keyboard_shortcuts_test.rb b/test/browser/keyboard_shortcuts_test.rb index cb429f4cd..ad355a9e8 100644 --- a/test/browser/keyboard_shortcuts_test.rb +++ b/test/browser/keyboard_shortcuts_test.rb @@ -154,6 +154,38 @@ class KeyboardShortcutsTest < TestCase timeout: 6, ) + # open online notification + @browser_agent = browser_instance + login( + browser: @browser_agent, + username: 'agent1@example.com', + password: 'test', + url: browser_url, + ) + ticket2 = ticket_create( + browser: @browser_agent, + data: { + customer: 'nico', + group: 'Users', + title: 'Test Ticket for Shortcuts II - ABC123', + body: 'Test Ticket Body for Shortcuts II - ABC123', + }, + ) + sleep 5 + shortcut(key: 'y') + watch_for( + css: '.js-notificationsContainer', + value: 'Test Ticket for Shortcuts II', + timeout: 10, + ) + window_keys(value: :arrow_down) + window_keys(value: :enter) + watch_for( + css: '.active.content', + value: ticket2[:number], + timeout: 2, + ) + shortcut(key: 'e') watch_for( css: 'body', diff --git a/test/browser/prefereces_test.rb b/test/browser/prefereces_test.rb index 1a3f53b4f..eaf9a256e 100644 --- a/test/browser/prefereces_test.rb +++ b/test/browser/prefereces_test.rb @@ -9,8 +9,8 @@ class PreferencesTest < TestCase password: 'test', url: browser_url, ) - click( css: 'a[href="#current_user"]' ) - click( css: 'a[href="#profile"]' ) + click(css: 'a[href="#current_user"]') + click(css: 'a[href="#profile"]') match( css: '.content .NavBarProfile', value: 'Password', @@ -36,8 +36,8 @@ class PreferencesTest < TestCase password: 'test', url: browser_url, ) - click( css: 'a[href="#current_user"]' ) - click( css: 'a[href="#profile"]' ) + click(css: 'a[href="#current_user"]') + click(css: 'a[href="#profile"]') match( css: '.content .NavBarProfile', value: 'Password', @@ -96,14 +96,14 @@ class PreferencesTest < TestCase value: 'Zammad Foundation', ) - click( css: 'a[href="#current_user"]' ) - click( css: 'a[href="#profile"]' ) - click( css: 'a[href="#profile/language"]' ) + click(css: 'a[href="#current_user"]') + click(css: 'a[href="#profile"]') + click(css: 'a[href="#profile/language"]') select( css: '.language_item [name="locale"]', value: 'Deutsch', ) - click( css: '.content button[type="submit"]' ) + click(css: '.content button[type="submit"]') watch_for( css: 'body', value: 'Sprache', @@ -116,14 +116,14 @@ class PreferencesTest < TestCase ) # check language in dashboard - click( css: '.js-menu a[href="#dashboard"]' ) + click(css: '.js-menu a[href="#dashboard"]') watch_for( css: '.content.active', value: 'Meine Statistik' ) # check language in overview - click( css: '.js-menu a[href="#ticket/view"]' ) + click(css: '.js-menu a[href="#ticket/view"]') watch_for( css: '.content.active', value: 'Meine' @@ -209,14 +209,14 @@ class PreferencesTest < TestCase value: 'notiz' ) - click( css: 'a[href="#current_user"]' ) - click( css: 'a[href="#profile"]' ) - click( css: 'a[href="#profile/language"]' ) + click(css: 'a[href="#current_user"]') + click(css: 'a[href="#profile"]') + click(css: 'a[href="#profile/language"]') select( css: '.language_item [name="locale"]', value: 'English (United States)', ) - click( css: '.content button[type="submit"]' ) + click(css: '.content button[type="submit"]') sleep 2 watch_for( css: 'body', @@ -230,14 +230,14 @@ class PreferencesTest < TestCase ) # check language in dashboard - click( css: '.js-menu a[href="#dashboard"]' ) + click(css: '.js-menu a[href="#dashboard"]') watch_for( css: '.content.active', value: 'My Stats' ) # check language in overview - click( css: '.js-menu a[href="#ticket/view"]' ) + click(css: '.js-menu a[href="#ticket/view"]') watch_for( css: '.content.active', value: 'My' @@ -324,15 +324,15 @@ class PreferencesTest < TestCase ) # switch to de again - click( css: 'a[href="#current_user"]' ) - click( css: 'a[href="#profile"]' ) - click( css: 'a[href="#profile/language"]' ) + click(css: 'a[href="#current_user"]') + click(css: 'a[href="#profile"]') + click(css: 'a[href="#profile/language"]') sleep 4 select( css: '.language_item [name="locale"]', value: 'Deutsch', ) - click( css: '.content button[type="submit"]' ) + click(css: '.content button[type="submit"]') sleep 4 watch_for( css: 'body', @@ -356,14 +356,14 @@ class PreferencesTest < TestCase ) # check language in dashboard - click( css: '.js-menu a[href="#dashboard"]' ) + click(css: '.js-menu a[href="#dashboard"]') watch_for( css: '.content.active', value: 'Meine Statistik' ) # check language in overview - click( css: '.js-menu a[href="#ticket/view"]' ) + click(css: '.js-menu a[href="#ticket/view"]') watch_for( css: '.content.active', value: 'Meine'