diff --git a/service/test/functional/features/keyboard_shortcuts_to_compose.feature b/service/test/functional/features/keyboard_shortcuts_to_compose.feature new file mode 100644 index 000000000..092620752 --- /dev/null +++ b/service/test/functional/features/keyboard_shortcuts_to_compose.feature @@ -0,0 +1,33 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see . + +@wip +Feature: Using keyboard shortcuts to compose and send a mail + As a user of pixelated + I want to use keyboard shortcuts + So I can compose mails + + Scenario: User composes a mail and sends it using shortcuts + When I use a shortcut to compose a message with + | subject | body | + | Pixelated rocks! | You should definitely use it. Cheers, User. | + And for the 'To' field I enter 'pixelated@friends.org' + And I use a shortcut to send it + When I select the tag 'sent' + And I open the first mail in the mail list + Then I see that the subject reads 'Pixelated rocks!' + Then I see that the body reads 'You should definitely use it. Cheers, User.' + diff --git a/service/test/functional/features/steps/compose.py b/service/test/functional/features/steps/compose.py index 67b1bd518..4ebc66648 100644 --- a/service/test/functional/features/steps/compose.py +++ b/service/test/functional/features/steps/compose.py @@ -13,9 +13,11 @@ # # You should have received a copy of the GNU Affero General Public License # along with Pixelated. If not, see . +from behave import when +from selenium.webdriver.common.action_chains import ActionChains +from selenium.webdriver.common.keys import Keys from time import sleep -from behave import when from common import * @@ -29,6 +31,16 @@ def impl(context): fill_by_css_selector(context, 'textarea#text-box', row['body']) +@when('I use a shortcut to compose a message with') +def compose_with_shortcut(context): + body = context.browser.find_element_by_tag_name('body') + body.send_keys('c') + + for row in context.table: + fill_by_css_selector(context, 'input#subject', row['subject']) + fill_by_css_selector(context, 'textarea#text-box', row['body']) + + @when("for the '{recipients_field}' field I enter '{to_type}'") def enter_address_impl(context, recipients_field, to_type): _enter_recipient(context, recipients_field, to_type + "\n") @@ -47,6 +59,11 @@ def send_impl(context): send_button.click() +@when('I use a shortcut to send it') +def send_with_shortcut(context): + ActionChains(context.browser).key_down(Keys.CONTROL).send_keys(Keys.ENTER).key_up(Keys.CONTROL).perform() + + @when(u'I toggle the cc and bcc fields') def collapse_cc_bcc_fields(context): cc_and_bcc_chevron = wait_until_element_is_visible_by_locator(context, (By.CSS_SELECTOR, '#cc-bcc-collapse')) diff --git a/web-ui/app/js/dispatchers/right_pane_dispatcher.js b/web-ui/app/js/dispatchers/right_pane_dispatcher.js index 870bcd92e..ecaca4ad8 100644 --- a/web-ui/app/js/dispatchers/right_pane_dispatcher.js +++ b/web-ui/app/js/dispatchers/right_pane_dispatcher.js @@ -24,10 +24,23 @@ define( 'mail_view/ui/draft_box', 'mail_view/ui/no_message_selected_pane', 'mail_view/ui/feedback_box', - 'page/events' + 'page/events', + 'mail_view/ui/mail_composition_shortcuts', + 'mail_view/ui/mail_view_shortcuts' ], - function(defineComponent, ComposeBox, MailView, ReplySection, DraftBox, NoMessageSelectedPane, FeedbackBox, events) { + function( + defineComponent, + ComposeBox, + MailView, + ReplySection, + DraftBox, + NoMessageSelectedPane, + FeedbackBox, + events, + mailCompositionShortcuts, + mailViewShortcuts + ) { 'use strict'; return defineComponent(rightPaneDispatcher); @@ -53,13 +66,14 @@ define( this.reset = function (newContainer) { this.trigger(document, events.dispatchers.rightPane.clear); this.select('rightPane').empty(); - var stage = this.createAndAttach(newContainer); - return stage; + return this.createAndAttach(newContainer); }; this.openComposeBox = function() { - var stage = this.reset(this.attr.composeBox); - ComposeBox.attachTo(stage, {currentTag: this.attr.currentTag}); + var stageId = this.reset(this.attr.composeBox); + ComposeBox.attachTo(stageId, {currentTag: this.attr.currentTag}); + mailCompositionShortcuts.attachTo(stageId); + mailViewShortcuts.attachTo(stageId); }; this.openFeedbackBox = function() { @@ -68,11 +82,13 @@ define( }; this.openMail = function(ev, data) { - var stage = this.reset(this.attr.mailView); - MailView.attachTo(stage, data); + var stageId = this.reset(this.attr.mailView); + MailView.attachTo(stageId, data); var replySectionContainer = this.createAndAttach(this.attr.replySection); ReplySection.attachTo(replySectionContainer, { ident: data.ident }); + + mailViewShortcuts.attachTo(stageId); }; this.initializeNoMessageSelectedPane = function () { @@ -88,8 +104,10 @@ define( }; this.openDraft = function (ev, data) { - var stage = this.reset(this.attr.draftBox); - DraftBox.attachTo(stage, { mailIdent: data.ident, currentTag: this.attr.currentTag }); + var stageId = this.reset(this.attr.draftBox); + DraftBox.attachTo(stageId, { mailIdent: data.ident, currentTag: this.attr.currentTag }); + mailCompositionShortcuts.attachTo(stageId); + mailViewShortcuts.attachTo(stageId); }; this.selectTag = function(ev, data) { diff --git a/web-ui/app/js/mail_view/ui/mail_composition_shortcuts.js b/web-ui/app/js/mail_view/ui/mail_composition_shortcuts.js new file mode 100644 index 000000000..d8a7066a9 --- /dev/null +++ b/web-ui/app/js/mail_view/ui/mail_composition_shortcuts.js @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2014 ThoughtWorks, Inc. + * + * Pixelated is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Pixelated is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Pixelated. If not, see . + */ + +define([ + 'flight/lib/component', + 'page/events' + ], + function (defineComponent, events) { + 'use strict'; + + return defineComponent(mailViewShortcuts); + + function mailViewShortcuts() { + var keyCodes = { + ENTER: 13 + }; + var modifierKeys = { + META: "META", + CTRL: "CTRL" + }; + + // make constants public + this.keyCodes = keyCodes; + + this.after('initialize', function () { + this.on('keydown', _.partial(tryKeyEvents, _.bind(this.trigger, this, document))); + }); + + function tryKeyEvents(triggerFunc, event) { + var keyEvents = {}; + keyEvents[modifierKeys.CTRL + keyCodes.ENTER] = events.ui.mail.send; + keyEvents[modifierKeys.META + keyCodes.ENTER] = events.ui.mail.send; + + if (!keyEvents.hasOwnProperty(modifierKey(event) + event.which)) { + return; + } + + event.preventDefault(); + return triggerFunc(keyEvents[modifierKey(event) + event.which]); + } + + function modifierKey(event) { + var modifierKey = ""; + if (event.ctrlKey === true) { + modifierKey = modifierKeys.CTRL; + } + if (event.metaKey === true) { + modifierKey = modifierKeys.META; + } + return modifierKey; + } + } + }); diff --git a/web-ui/app/js/mail_view/ui/mail_view_shortcuts.js b/web-ui/app/js/mail_view/ui/mail_view_shortcuts.js new file mode 100644 index 000000000..295683c4a --- /dev/null +++ b/web-ui/app/js/mail_view/ui/mail_view_shortcuts.js @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2014 ThoughtWorks, Inc. + * + * Pixelated is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Pixelated is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Pixelated. If not, see . + */ + +define([ + 'flight/lib/component', + 'page/events' + ], + function (defineComponent, events) { + 'use strict'; + + return defineComponent(mailViewShortcuts); + + function mailViewShortcuts() { + var keyCodes = { + ESC: 27 + }; + + // make constants public + this.keyCodes = keyCodes; + + this.after('initialize', function () { + this.on(document, 'keydown', _.partial(tryKeyEvents, _.bind(this.trigger, this, document))); + }); + + function tryKeyEvents(triggerFunc, event) { + var keyEvents = {}; + keyEvents[keyCodes.ESC] = events.dispatchers.rightPane.openNoMessageSelected; + + if (!keyEvents.hasOwnProperty(event.which)) { + return; + } + + event.preventDefault(); + return triggerFunc(keyEvents[event.which]); + } + } + }); diff --git a/web-ui/app/js/page/default.js b/web-ui/app/js/page/default.js index ecaedfd81..747617d5a 100644 --- a/web-ui/app/js/page/default.js +++ b/web-ui/app/js/page/default.js @@ -52,7 +52,8 @@ define( 'page/version', 'page/unread_count_title', 'page/pix_logo', - 'helpers/browser' + 'helpers/browser', + 'page/shortcuts' ], function ( @@ -92,7 +93,8 @@ define( version, unreadCountTitle, pixLogo, - browser) { + browser, + shortcuts) { 'use strict'; function initialize(path) { @@ -137,6 +139,8 @@ define( pixLogo.attachTo(document); + shortcuts.attachTo(document); + $.ajaxSetup({headers: {'X-XSRF-TOKEN': browser.getCookie('XSRF-TOKEN')}}); }); } diff --git a/web-ui/app/js/page/events.js b/web-ui/app/js/page/events.js index 68a6aad1e..af1ccdd04 100644 --- a/web-ui/app/js/page/events.js +++ b/web-ui/app/js/page/events.js @@ -109,7 +109,8 @@ define(function () { results: 'search:results', empty: 'search:empty', highlightResults: 'search:highlightResults', - resetHighlight: 'search:resetHighlight' + resetHighlight: 'search:resetHighlight', + focus: 'search:focus' }, feedback: { submit: 'feedback:submit', diff --git a/web-ui/app/js/page/shortcuts.js b/web-ui/app/js/page/shortcuts.js new file mode 100644 index 000000000..17771da15 --- /dev/null +++ b/web-ui/app/js/page/shortcuts.js @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2014 ThoughtWorks, Inc. + * + * Pixelated is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Pixelated is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Pixelated. If not, see . + */ + +define([ + 'flight/lib/component', + 'page/events' + ], + function (defineComponent, events) { + 'use strict'; + + return defineComponent(shortcuts); + + function shortcuts() { + var composeBoxId = 'compose-box'; + var keyCodes = { + C: 67, + FORWARD_SLASH: 191, + S: 83 + }; + + // make constants public + this.keyCodes = keyCodes; + this.composeBoxId = composeBoxId; + + this.after('initialize', function () { + this.on('keydown', _.partial(tryMailHandlingKeyEvents, _.bind(this.trigger, this, document))); + }); + + function tryMailHandlingKeyEvents(triggerFunc, event) { + if (isTriggeredOnInputField(event.target) || composeBoxIsShown()) { + return; + } + + var mailHandlingKeyEvents = {}; + mailHandlingKeyEvents[keyCodes.S] = events.search.focus; + mailHandlingKeyEvents[keyCodes.FORWARD_SLASH] = events.search.focus; + mailHandlingKeyEvents[keyCodes.C] = events.dispatchers.rightPane.openComposeBox; + + if (!mailHandlingKeyEvents.hasOwnProperty(event.which)) { + return; + } + + event.preventDefault(); + return triggerFunc(mailHandlingKeyEvents[event.which]); + } + + function isTriggeredOnInputField(element) { + return $(element).is('input') || $(element).is('textarea'); + } + + function composeBoxIsShown() { + return $('#' + composeBoxId).length; + } + } + }); diff --git a/web-ui/app/js/search/search_trigger.js b/web-ui/app/js/search/search_trigger.js index 2aff027ca..662242c0f 100644 --- a/web-ui/app/js/search/search_trigger.js +++ b/web-ui/app/js/search/search_trigger.js @@ -68,6 +68,10 @@ define( } }; + this.focus = function () { + this.select('input').focus(); + }; + this.after('initialize', function () { this.render(); this.on(this.select('form'), 'submit', this.search); @@ -75,6 +79,7 @@ define( this.on(this.select('input'), 'blur', this.showSearchTermsAndPlaceHolder); this.on(document, events.ui.tag.selected, this.clearInput); this.on(document, events.ui.tag.select, this.clearInput); + this.on(document, events.search.focus, this.focus); }); } } diff --git a/web-ui/test/spec/mail_view/ui/mail_composition_shortcuts.spec.js b/web-ui/test/spec/mail_view/ui/mail_composition_shortcuts.spec.js new file mode 100644 index 000000000..6838dd8af --- /dev/null +++ b/web-ui/test/spec/mail_view/ui/mail_composition_shortcuts.spec.js @@ -0,0 +1,25 @@ +describeComponent('mail_view/ui/mail_composition_shortcuts', function () { + 'use strict'; + + beforeEach(function () { + this.setupComponent(); + }); + + describe('mail composition shortcuts', function () { + it('triggers ui.mail.send when [Ctrl] + [Enter] is pressed', function () { + var eventSpy = spyOnEvent(document, Pixelated.events.ui.mail.send); + + this.component.trigger(jQuery.Event('keydown', {ctrlKey: true, which: this.component.keyCodes.ENTER})); + + expect(eventSpy).toHaveBeenTriggeredOn(document); + }); + + it('triggers ui.mail.send when [Meta] + [Enter] is pressed', function () { + var eventSpy = spyOnEvent(document, Pixelated.events.ui.mail.send); + + this.component.trigger(jQuery.Event('keydown', {metaKey: true, which: this.component.keyCodes.ENTER})); + + expect(eventSpy).toHaveBeenTriggeredOn(document); + }); + }); +}); diff --git a/web-ui/test/spec/mail_view/ui/mail_view_shortcuts.spec.js b/web-ui/test/spec/mail_view/ui/mail_view_shortcuts.spec.js new file mode 100644 index 000000000..8cfc16ba0 --- /dev/null +++ b/web-ui/test/spec/mail_view/ui/mail_view_shortcuts.spec.js @@ -0,0 +1,16 @@ +describeComponent('mail_view/ui/mail_view_shortcuts', function () { + 'use strict'; + + beforeEach(function () { + this.setupComponent(); + }); + + describe('generic mail view shortcuts', function () { + it('triggers openNoMessageSelected when [Esc] is pressed', function () { + var eventSpy = spyOnEvent(document, Pixelated.events.dispatchers.rightPane.openNoMessageSelected); + + this.component.trigger(jQuery.Event('keydown', {which: this.component.keyCodes.ESC})); + expect(eventSpy).toHaveBeenTriggeredOn(document); + }); + }); +}); diff --git a/web-ui/test/spec/page/shortcuts.spec.js b/web-ui/test/spec/page/shortcuts.spec.js new file mode 100644 index 000000000..0afe43cfc --- /dev/null +++ b/web-ui/test/spec/page/shortcuts.spec.js @@ -0,0 +1,77 @@ +describeComponent('page/shortcuts', function () { + 'use strict'; + + beforeEach(function () { + this.setupComponent(); + }); + + describe('mail list shortcuts', function () { + function shortcutEventAndTriggeredEventSpy() { + return [ + { + eventSpy: spyOnEvent(document, Pixelated.events.dispatchers.rightPane.openComposeBox), + shortcutEvent: keydownEvent(this.component.keyCodes.C) + }, + { + eventSpy: spyOnEvent(document, Pixelated.events.search.focus), + shortcutEvent: keydownEvent(this.component.keyCodes.FORWARD_SLASH) + }, + { + eventSpy: spyOnEvent(document, Pixelated.events.search.focus), + shortcutEvent: keydownEvent(this.component.keyCodes.S) + } + ]; + } + + it('are triggered when no input or textarea is focused', function () { + _.each(shortcutEventAndTriggeredEventSpy.call(this), function (args) { + var eventSpy = args.eventSpy; + + this.component.trigger(args.shortcutEvent); + + expect(eventSpy).toHaveBeenTriggeredOn(document); + }, this); + }); + + it('are not triggered when an input is focused', function () { + _.each(shortcutEventAndTriggeredEventSpy.call(this), function (args) { + this.$node.append(''); + var eventSpy = args.eventSpy; + + this.$node.find('input').trigger(args.shortcutEvent); + + expect(eventSpy).not.toHaveBeenTriggeredOn(document); + }, this); + }); + + it('are not triggered when a textarea is focused', function () { + _.each(shortcutEventAndTriggeredEventSpy.call(this), function (args) { + this.$node.append(''); + var eventSpy = args.eventSpy; + + this.$node.find('textarea').trigger(args.shortcutEvent); + + expect(eventSpy).not.toHaveBeenTriggeredOn(document); + }, this); + }); + + it('are not triggered when the composeBox is opened', function () { + _.each(shortcutEventAndTriggeredEventSpy.call(this), function (args) { + addComposeBox.call(this); + var eventSpy = args.eventSpy; + + this.component.trigger(args.shortcutEvent); + + expect(eventSpy).not.toHaveBeenTriggeredOn(document); + }, this); + }); + }); + + function keydownEvent(code) { + return jQuery.Event('keydown', {which: code}); + } + + function addComposeBox() { + this.$node.append($('
', {id: this.component.composeBoxId})); + } +}); diff --git a/web-ui/test/spec/search/search_trigger.spec.js b/web-ui/test/spec/search/search_trigger.spec.js index 6ba474891..12026966d 100644 --- a/web-ui/test/spec/search/search_trigger.spec.js +++ b/web-ui/test/spec/search/search_trigger.spec.js @@ -49,5 +49,9 @@ describeComponent('search/search_trigger', function () { expect(self.component.select('input').val()).toBe(''); }); + it('should focus search input field on focus event', function () { + $(document).trigger(Pixelated.events.search.focus); + expect($(document.activeElement)).toEqual(this.component.select('input')); + }); });