(css,js) Improve progress feedback

This ads a "ripple" effect that blocks the context when login in or
sending a message. Generic enough to be used elsewhere.

Fixes #3765
pull/216/merge
Francis Lachapelle 2016-07-15 14:03:16 -04:00
parent 0fe472b5e9
commit 6bbb56c386
13 changed files with 334 additions and 63 deletions

6
NEWS
View File

@ -1,3 +1,9 @@
3.1.5 (2016-MM-DD)
------------------
Enhancements
- [web] improve action progress when login in or sending a message (#3765, #3761)
3.1.4 (2016-07-12) 3.1.4 (2016-07-12)
------------------ ------------------

View File

@ -305,7 +305,12 @@
"Error while uploading the file \"%{0}\":" = "Error while uploading the file \"%{0}\":"; "Error while uploading the file \"%{0}\":" = "Error while uploading the file \"%{0}\":";
"There is an active file upload. Closing the window will interrupt it." = "There is an active file upload. Closing the window will interrupt it."; "There is an active file upload. Closing the window will interrupt it." = "There is an active file upload. Closing the window will interrupt it.";
/* Message sending */ /* Appears while sending the message */
"Sending" = "Sending";
/* Appears when the message is successfuly sent */
"Sent" = "Sent";
"cannot send message: (smtp) all recipients discarded" = "Cannot send message: all recipients are invalid."; "cannot send message: (smtp) all recipients discarded" = "Cannot send message: all recipients are invalid.";
"cannot send message (smtp) - recipients discarded" = "Cannot send message. The following addresses are invalid"; "cannot send message (smtp) - recipients discarded" = "Cannot send message. The following addresses are invalid";
"cannot send message: (smtp) error when connecting" = "Cannot send message: error when connecting to the SMTP server."; "cannot send message: (smtp) error when connecting" = "Cannot send message: error when connecting to the SMTP server.";

View File

@ -6,6 +6,13 @@
"Domain" = "Domain"; "Domain" = "Domain";
"Remember username" = "Remember username"; "Remember username" = "Remember username";
"Connect" = "Connect"; "Connect" = "Connect";
/* Appears while authentication is in progress */
"Authenticating" = "Authenticating";
/* Appears when authentication succeeds */
"Success" = "Success";
"Authentication Failed" = "Authentication Failed"; "Authentication Failed" = "Authentication Failed";
"Wrong username or password." = "Wrong username or password."; "Wrong username or password." = "Wrong username or password.";
"cookiesNotEnabled" = "You cannot login because your browser's cookies are disabled. Please enable cookies in your browser's settings and try again."; "cookiesNotEnabled" = "You cannot login because your browser's cookies are disabled. Please enable cookies in your browser's settings and try again.";

View File

@ -5,7 +5,7 @@
xmlns:const="http://www.skyrix.com/od/constant" xmlns:const="http://www.skyrix.com/od/constant"
xmlns:label="OGo:label"> xmlns:label="OGo:label">
<md-dialog class="sg-mail-editor" flex="80" flex-sm="90" flex-xs="100" <md-dialog id="mailEditor" class="sg-mail-editor" flex="80" flex-sm="90" flex-xs="100"
nv-file-drop="nv-file-drop" nv-file-drop="nv-file-drop"
nv-file-over="nv-file-over" nv-file-over="nv-file-over"
over-class="sg-over-dropzone" over-class="sg-over-dropzone"
@ -26,7 +26,8 @@
</md-input-container> </md-input-container>
<div flex="flex"><!-- spacer --></div> <div flex="flex"><!-- spacer --></div>
<md-button class="sg-icon-button" ng-click="editor.send()" <md-button class="sg-icon-button" ng-click="editor.send()"
ng-disabled="!(editor.message.editable.to.length > 0 || editor.message.editable.cc.length > 0 || editor.message.editable.bcc.length > 0) || messageForm.$invalid"> ng-disabled="!(editor.message.editable.to.length > 0 || editor.message.editable.cc.length > 0 || editor.message.editable.bcc.length > 0) || editor.uploader.isUploading || messageForm.$invalid"
sg-ripple-click="mailEditor">
<md-icon>send</md-icon> <md-icon>send</md-icon>
</md-button> </md-button>
<md-button class="sg-icon-button" ng-click="editor.save()"> <md-button class="sg-icon-button" ng-click="editor.save()">
@ -254,5 +255,43 @@
</div> </div>
</md-dialog-actions> </md-dialog-actions>
</form> </form>
<sg-ripple class="md-default-theme md-accent md-bg"
ng-class="{ 'md-warn': editor.sendState == 'error' }"><!-- ripple background --></sg-ripple>
<sg-ripple-content class="md-default-theme md-accent md-hue-1 md-fg md-flex ng-hide"
layout="column" layout-align="center center" layout-fill="layout-fill"
ng-switch="editor.sendState">
<!-- Sending -->
<div layout="column" layout-align="center center"
ng-switch-when="sending">
<md-progress-circular class="md-hue-1"
md-mode="indeterminate"
md-diameter="48"><!-- mailbox loading progress --></md-progress-circular>
<div class="md-padding">
<var:string label:value="Sending"/>
</div>
</div>
<!-- Sent -->
<div layout="column" layout-align="center center"
ng-switch-when="sent">
<md-icon class="md-accent md-hue-1 sg-icon--large">done</md-icon>
<div class="md-default-theme md-accent md-hue-1 md-fg md-padding">
<var:string label:value="Sent"/>
</div>
</div>
<!-- Error -->
<div layout="column" layout-align="center center"
ng-switch-when="error">
<md-icon class="md-accent md-hue-1 sg-icon--large">error</md-icon>
<div class="md-padding">
{{editor.errorMessage}}
</div>
<md-button sg-ripple-click="mailEditor"><var:string label:value="Close"/></md-button>
</div>
</sg-ripple-content>
</md-dialog> </md-dialog>
</container> </container>

View File

@ -9,30 +9,31 @@
xmlns:label="OGo:label" xmlns:label="OGo:label"
const:jsFiles="Main.js, Common.js" const:jsFiles="Main.js, Common.js"
> >
<script type="text/javascript">
var cookieUsername = '<var:string var:value="cookieUsername" const:escapeHTML="NO"/>';
</script>
<!-- <!--
MAIN CONTENT ROW MAIN CONTENT ROW
Content of the application view injected injected in the element bellow Content of the application view injected injected in the element bellow
MUST be the first html element after body MUST be the first html element after body
SHOULD be a main tag (with role="main") SHOULD be a main tag (with role="main")
--> -->
<main class="view layout-padding md-default-theme md-background md-hue-1 md-bg" <main class="view md-default-theme md-background md-hue-1 md-bg"
layout="row" layout-align="center start" layout-fill="layout-fill" layout-gt-md="row" layout-align-gt-md="center start" layout-fill="layout-fill"
ui-view="login" ui-view="login"
ng-controller="LoginController as app"> ng-controller="LoginController as app">
<md-content class="ng-cloak md-whiteframe-z1" <md-content id="loginContent" class="ng-cloak md-whiteframe-3dp" flex="100"
layout-gt-md="row" layout-align-gt-md="start center" layout="column" layout-gt-md="row" layout-align="start stretch"
layout="column" layout-align="space-between center"
md-scroll-y="true"
ng-show="app.showLogin"> ng-show="app.showLogin">
<div id="logo" class="md-padding"> <div class="sg-logo" flex-gt-md="50">
<img const:alt="*" id="splash" rsrc:src="img/sogo-full.svg"/> <div layout="row" class="md-padding">
<div class="md-flex hide show-gt-md"><!-- push logo to the right on larger screens --></div>
<img const:alt="*" class="md-margin" rsrc:src="img/sogo-full.svg"/>
</div>
</div> </div>
<div class="sg-login md-padding md-default-theme md-bg md-accent"> <div class="sg-login md-default-theme md-bg md-accent" flex-gt-md="50">
<script type="text/javascript"> <div id="login" class="sg-login-content md-padding">
var cookieUsername = '<var:string var:value="cookieUsername" const:escapeHTML="NO"/>';
</script>
<div id="login">
<form name="loginForm" layout="column" <form name="loginForm" layout="column"
ng-cloak="ng-cloak" ng-cloak="ng-cloak"
ng-submit="app.login()"> ng-submit="app.login()">
@ -52,7 +53,7 @@
<!-- CONNECT BUTTON --> <!-- CONNECT BUTTON -->
<div layout="row" layout-align="end center"> <div layout="row" layout-align="end center">
<md-button class="md-raised md-accent md-hue-2" type="submit" ng-disabled='app.loginForm.$invalid'> <md-button type="submit" ng-disabled='app.loginForm.$invalid' sg-ripple-click="loginContent">
<var:string label:value="Connect"/> <var:string label:value="Connect"/>
</md-button> </md-button>
</div> </div>
@ -101,6 +102,43 @@
<md-icon class="md-fg">info</md-icon> <md-icon class="md-fg">info</md-icon>
</md-button> </md-button>
</div> </div>
<sg-ripple class="md-default-theme md-accent md-bg"
ng-class="{ 'md-warn': app.loginState == 'error' }"><!-- ripple background --></sg-ripple>
<sg-ripple-content class="md-flex ng-hide"
layout="column" layout-align="center center" layout-fill="layout-fill"
ng-switch="app.loginState">
<!-- Authenticating -->
<div layout="column" layout-align="center center"
ng-switch-when="authenticating">
<md-progress-circular class="md-hue-1"
md-mode="indeterminate"
md-diameter="32"><!-- mailbox loading progress --></md-progress-circular>
<div class="md-default-theme md-accent md-hue-1 md-fg md-padding">
<var:string label:value="Authenticating"/>
</div>
</div>
<!-- Logged in -->
<div layout="column" layout-align="center center"
ng-switch-when="logged">
<md-icon class="md-accent md-hue-1 sg-icon--large">done</md-icon>
<div class="md-default-theme md-accent md-hue-1 md-fg md-padding">
<var:string label:value="Success"/>
</div>
</div>
<!-- Error -->
<div layout="column" layout-align="center center"
ng-switch-when="error">
<md-icon class="md-accent md-hue-1 sg-icon--large">error</md-icon>
<div class="md-default-theme md-accent md-hue-1 md-fg md-padding">
{{app.errorMessage}}
</div>
<md-button sg-ripple-click="loginContent"><var:string label:value="Retry"/></md-button>
</div>
</sg-ripple-content>
</div> </div>
</div> </div>
</md-content> </md-content>

View File

@ -0,0 +1,108 @@
/* -*- Mode: javascript; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
(function() {
'use strict';
angular
.module('SOGo.Common')
.directive('sgRippleClick', sgRippleClick);
/*
* sgRippleClick - A ripple effect to cover the parent element.
* @memberof SOGo.Common
* @restrict attribute
*
* @example:
<md-dialog id="mailEditor">
<md-button ng-click="editor.send()"
sg-ripple-click="mailEditor">Send</md-button>
</md-dialog>
*/
sgRippleClick.$inject = ['$log', '$timeout'];
function sgRippleClick($log, $timeout) {
return {
restrict: 'A',
compile: compile
};
function compile(tElement, tAttrs) {
return function postLink(scope, element, attr) {
var ripple, content, container, containerId;
// Lookup container element
containerId = element.attr('sg-ripple-click');
container = element[0].parentNode;
while (container && container.id != containerId) {
container = container.parentNode;
}
if (!container) {
$log.error('No parent element found with id ' + containerId);
return undefined;
}
// Lookup sg-ripple-content element
content = container.querySelector('sg-ripple-content');
if (!content) {
$log.error('sg-ripple-content not found inside #' + containerId);
return undefined;
}
// Lookup sg-ripple element
ripple = container.querySelector('sg-ripple');
if (ripple) {
ripple = angular.element(ripple);
}
else {
// If ripple layer doesn't exit, create it with the primary background color
ripple = angular.element('<sg-ripple class="md-default-theme md-bg"></sg-ripple>');
container.appendChild(ripple[0]);
// Hide ripple content on initialization
if (!content.classList.contains('ng-hide'))
content.classList.add('ng-hide');
}
// Register listener
element.on('click', listener);
function listener(event) {
if (element[0].hasAttribute('disabled')) {
return;
}
if (content.classList.contains('ng-hide')) {
// Show ripple
angular.element(container).css({ 'overflow': 'hidden' });
content.classList.remove('ng-hide');
angular.element(content).css({ top: container.scrollTop + 'px' });
ripple.css({
'top': (event.pageY - container.offsetTop + container.scrollTop) + 'px',
'left': (event.pageX - container.offsetLeft) + 'px',
'width': '400vmin',
'height': '400vmin'
});
}
else {
// Hide ripple layer
ripple.css({
'top': (event.pageY - container.offsetTop + container.scrollTop) + 'px',
'left': (event.pageX - container.offsetLeft) + 'px',
'height': '0px',
'width': '0px'
});
// Hide ripple content
content.classList.add('ng-hide');
// Restore overflow of container once the animation is completed
$timeout(function() {
angular.element(container).css({ 'overflow': '' });
}, 800);
}
}
};
}
}
})();

View File

@ -636,18 +636,18 @@
Message.$log.debug('send = ' + JSON.stringify(data, undefined, 2)); Message.$log.debug('send = ' + JSON.stringify(data, undefined, 2));
return Message.$$resource.post(this.$absolutePath({asDraft: true}), 'send', data).then(function(data) { return Message.$$resource.post(this.$absolutePath({asDraft: true}), 'send', data).then(function(response) {
if (data.status == 'success') { if (response.status == 'success') {
if (angular.isDefined(_this.origin)) { if (angular.isDefined(_this.origin)) {
if (_this.origin.action.startsWith('reply')) if (_this.origin.action.startsWith('reply'))
_this.origin.message.isanswered = true; _this.origin.message.isanswered = true;
else if (_this.origin.action == 'forward') else if (_this.origin.action == 'forward')
_this.origin.message.isforwarded = true; _this.origin.message.isforwarded = true;
} }
return data; return response;
} }
else { else {
return Message.$q.reject(data); return Message.$q.reject(response.data);
} }
}); });
}; };

View File

@ -6,9 +6,9 @@
/** /**
* @ngInject * @ngInject
*/ */
MessageEditorController.$inject = ['$window', '$stateParams', '$mdConstant', '$mdDialog', '$mdToast', 'FileUploader', 'stateAccount', 'stateMessage', 'encodeUriFilter', '$timeout', 'Dialog', 'AddressBook', 'Card', 'Preferences']; MessageEditorController.$inject = ['$scope', '$window', '$stateParams', '$mdConstant', '$mdDialog', '$mdToast', 'FileUploader', 'stateAccount', 'stateMessage', 'encodeUriFilter', '$timeout', 'Dialog', 'AddressBook', 'Card', 'Preferences'];
function MessageEditorController($window, $stateParams, $mdConstant, $mdDialog, $mdToast, FileUploader, stateAccount, stateMessage, encodeUriFilter, $timeout, Dialog, AddressBook, Card, Preferences) { function MessageEditorController($scope, $window, $stateParams, $mdConstant, $mdDialog, $mdToast, FileUploader, stateAccount, stateMessage, encodeUriFilter, $timeout, Dialog, AddressBook, Card, Preferences) {
var vm = this, semicolon = 186; var vm = this;
vm.addRecipient = addRecipient; vm.addRecipient = addRecipient;
vm.autocomplete = {to: {}, cc: {}, bcc: {}}; vm.autocomplete = {to: {}, cc: {}, bcc: {}};
@ -19,10 +19,16 @@
vm.cancel = cancel; vm.cancel = cancel;
vm.save = save; vm.save = save;
vm.send = send; vm.send = send;
vm.sendState = false;
vm.removeAttachment = removeAttachment; vm.removeAttachment = removeAttachment;
vm.contactFilter = contactFilter; vm.contactFilter = contactFilter;
vm.identities = _.map(stateAccount.identities, 'full'); vm.identities = _.map(stateAccount.identities, 'full');
vm.recipientSeparatorKeys = [$mdConstant.KEY_CODE.ENTER, $mdConstant.KEY_CODE.TAB, $mdConstant.KEY_CODE.COMMA, semicolon]; vm.recipientSeparatorKeys = [
$mdConstant.KEY_CODE.ENTER,
$mdConstant.KEY_CODE.TAB,
$mdConstant.KEY_CODE.COMMA,
$mdConstant.KEY_CODE.SEMICOLON
];
vm.uploader = new FileUploader({ vm.uploader = new FileUploader({
url: stateMessage.$absolutePath({asDraft: true}) + '/save', url: stateMessage.$absolutePath({asDraft: true}) + '/save',
autoUpload: true, autoUpload: true,
@ -54,6 +60,9 @@
} }
}); });
// Destroy file uploader when the controller is being deactivated
$scope.$on('$destroy', function() { vm.uploader.destroy(); });
if ($stateParams.actionName == 'reply') { if ($stateParams.actionName == 'reply') {
stateMessage.$reply().then(function(msgObject) { stateMessage.$reply().then(function(msgObject) {
vm.message = msgObject; vm.message = msgObject;
@ -168,10 +177,13 @@
function send() { function send() {
var ctrls = $parentControllers(); var ctrls = $parentControllers();
vm.sendState = 'sending';
if (vm.autosave) if (vm.autosave)
$timeout.cancel(vm.autosave); $timeout.cancel(vm.autosave);
vm.message.$send().then(function(data) { vm.message.$send().then(function(data) {
vm.sendState = 'sent';
if (ctrls.draftMailboxCtrl) { if (ctrls.draftMailboxCtrl) {
// We're sending a draft from a popup window and the draft mailbox is opened. // We're sending a draft from a popup window and the draft mailbox is opened.
// Reload draft mailbox // Reload draft mailbox
@ -192,7 +204,12 @@
.content(l('Your email has been sent')) .content(l('Your email has been sent'))
.position('top right') .position('top right')
.hideDelay(3000)); .hideDelay(3000));
$mdDialog.hide();
// Let the user see the succesfull message before closing the dialog
$timeout($mdDialog.hide, 1000);
}, function(response) {
vm.sendState = 'error';
vm.errorMessage = response.data? response.data.message : response.statusText;
}); });
} }

View File

@ -15,20 +15,29 @@
vm.creds = { username: cookieUsername, password: null }; vm.creds = { username: cookieUsername, password: null };
vm.login = login; vm.login = login;
vm.loginState = false;
vm.showAbout = showAbout; vm.showAbout = showAbout;
// Show login once everything is initialized
vm.showLogin = false; vm.showLogin = false;
$timeout(function() { vm.showLogin = true; }, 100); $timeout(function() { vm.showLogin = true; }, 100);
function login() { function login() {
vm.loginState = 'authenticating';
Authentication.login(vm.creds) Authentication.login(vm.creds)
.then(function(url) { .then(function(url) {
if (window.location.href === url) vm.loginState = 'logged';
window.location.reload(true);
else // Let the user see the succesfull message before reloading the page
window.location.href = url; $timeout(function() {
if (window.location.href === url)
window.location.reload(true);
else
window.location.href = url;
}, 1000);
}, function(msg) { }, function(msg) {
Dialog.alert(l('Authentication Failed'), msg.error); vm.loginState = 'error';
vm.errorMessage = msg.error;
}); });
return false; return false;
} }

View File

@ -0,0 +1,21 @@
/// ripple.scss -*- Mode: scss; indent-tabs-mode: nil; basic-offset: 2 -*-
sg-ripple {
border-radius: 100%;
height: 0px;
width: 0px;
position: absolute;
transition: width 800ms linear, height 800ms linear, background-color 400ms linear;
transform: translate(-50%, -50%);
opacity: 1;
z-index: $z-index-toolbar + 1;
}
sg-ripple-content {
bottom: 0;
left: 0;
position: absolute;
right: 0;
top: 0;
z-index: $z-index-toolbar + 2;
}

View File

@ -29,16 +29,6 @@ html * {
} }
} }
.sg-logo {
background-image: url("../img/sogo-full.svg");
background-size: contain;
background-repeat: no-repeat;
height: 7 * $bl;
min-width: 18 * $bl;
//optical adjustement
transform: translateY(-10%);
}
.sg-category { .sg-category {
position: absolute; position: absolute;
top: 0; top: 0;

View File

@ -66,6 +66,7 @@
@import 'components/whiteframe/whiteframe'; @import 'components/whiteframe/whiteframe';
// Inverse components // Inverse components
@import 'components/ripple/ripple';
@import 'components/timepicker/timepicker'; @import 'components/timepicker/timepicker';
// @import '../angular-material/src/core/style/color-palette'; // @import '../angular-material/src/core/style/color-palette';

View File

@ -1,46 +1,76 @@
/// LoginUI.scss -*- Mode: scss; indent-tabs-mode: nil; basic-offset: 2 -*- /// LoginUI.scss -*- Mode: scss; indent-tabs-mode: nil; basic-offset: 2 -*-
$sg-login-width: grid-step(5);
[ui-view="login"] { [ui-view="login"] {
md-content { md-content {
padding: 0; // Keep content centered
@include to(sm) { margin: auto;
[id=logo] {
text-align: center; .sg-logo img {
width: grid-step(5);
}
/**
* On small screens, we dispose the logo and login form as column.
*/
@include to(md) {
& {
// Fill the screen
min-height: 100%;
}
.sg-logo {
// Center content
margin: auto;
img { img {
margin: auto;
height: 100%;
max-width: 75%; max-width: 75%;
} }
} }
.sg-login {
// Let the form take all available space
flex-grow: 1;
margin: 0;
}
.sg-login-content {
margin: auto;
max-width: $sg-login-width;
}
} }
@include from(sm) {
/**
* On larger screen, we dispose the logo and login form as a row.
*/
@include from(md) {
&.ng-hide { &.ng-hide {
[id=logo] { .sg-logo img {
opacity: 0; opacity: 0;
transform: translateX(50%); transform: translateX($sg-login-width/2 + $baseline-grid + $baseline-grid*2);
} }
.sg-login { .sg-login {
opacity: 0; opacity: 0;
transform: translateX(100%); transform: translateX(100%);
} }
} }
[id=logo], .sg-login { .sg-logo img, .sg-login {
opacity: 1; opacity: 1;
//transform: translateX(0%);
} }
[id=logo] { .sg-logo {
transition: transform $swift-ease-out-duration $swift-ease-out-timing-function 600ms, max-height: 100%;
opacity 400ms linear; max-width: 50%;
img {
transition: transform $swift-ease-out-duration $swift-ease-out-timing-function 600ms,
opacity 400ms linear;
}
} }
.sg-login { .sg-login {
max-width: 50%;
transition: all $swift-ease-out-duration $swift-ease-out-timing-function 600ms; transition: all $swift-ease-out-duration $swift-ease-out-timing-function 600ms;
} }
} .sg-login-content {
width: $sg-login-width;
}
} // from(md)
} }
} }
[ui-view="login"] > md-content > div {
width: grid-step(5);
}
[id=logo] img {
max-width: 100%;
}