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'));
+ });
});