Commit 48da780b by Samuel Padgett

Combine connection error messages

When multiple connection errors happen close together, show them in one
toast notification to avoid spamming the user.
parent 176e824b
...@@ -2,6 +2,10 @@ ...@@ -2,6 +2,10 @@
color: @gray-light; color: @gray-light;
} }
.toast-notification-details .truncated-content {
white-space: pre-line;
}
.toast-notification-message { .toast-notification-message {
font-weight: 700; font-weight: 700;
margin-right: 5px; margin-right: 5px;
......
...@@ -1211,6 +1211,46 @@ angular.module('openshiftCommonServices') ...@@ -1211,6 +1211,46 @@ angular.module('openshiftCommonServices')
} }
} }
// If several connection errors happen close together, display them as one
// notification. This prevents us spamming the user with many failed requests
// at once.
var queuedErrors = [];
var addQueuedNotifications = _.debounce(function() {
if (!queuedErrors.length) {
return;
}
// Show all queued messages together. If the details is extremely long, it
// will be truncated with a see more link.
var notification = {
type: 'error',
message: 'An error occurred connecting to the server.',
details: queuedErrors.join('\n'),
links: [{
label: 'Refresh',
onClick: function() {
window.location.reload();
}
}]
};
// Use `$rootScope.$emit` instead of NotificationsService directly
// so that DataService doesn't add a dependency on `openshiftCommonUI`
$rootScope.$emit('NotificationsService.addNotification', notification);
// Clear the queue.
queuedErrors = [];
}, 300, { maxWait: 1000 });
var showRequestError = function(message, status) {
if (status) {
message += " (status " + status + ")";
}
// Queue the message and call debounced `addQueuedNotifications`.
queuedErrors.push(message);
addQueuedNotifications();
};
function DataService() { function DataService() {
this._listDeferredMap = {}; this._listDeferredMap = {};
this._watchCallbacksMap = {}; this._watchCallbacksMap = {};
...@@ -1547,16 +1587,7 @@ angular.module('openshiftCommonServices') ...@@ -1547,16 +1587,7 @@ angular.module('openshiftCommonServices')
}) })
.error(function(data, status, headers, config) { .error(function(data, status, headers, config) {
if (opts.errorNotification !== false) { if (opts.errorNotification !== false) {
var msg = "Failed to get " + resource + "/" + name; showRequestError("Failed to get " + resource + "/" + name, status);
if (status !== 0) {
msg += " (" + status + ")";
}
// Use `$rootScope.$emit` instead of NotificationsService directly
// so that DataService doesn't add a dependency on `openshiftCommonUI`
$rootScope.$emit('NotificationsService.addNotification', {
type: 'error',
message: msg
});
} }
deferred.reject({ deferred.reject({
data: data, data: data,
...@@ -2092,16 +2123,7 @@ DataService.prototype.createStream = function(resource, name, context, opts, isR ...@@ -2092,16 +2123,7 @@ DataService.prototype.createStream = function(resource, name, context, opts, isR
return; return;
} }
var msg = "Failed to list " + resource; showRequestError("Failed to list " + resource, status);
if (status !== 0) {
msg += " (" + status + ")";
}
// Use `$rootScope.$emit` instead of NotificationsService directly
// so that DataService doesn't add a dependency on `openshiftCommonUI`
$rootScope.$emit('NotificationsService.addNotification', {
type: 'error',
message: msg
});
}); });
}); });
} }
...@@ -2123,16 +2145,7 @@ DataService.prototype.createStream = function(resource, name, context, opts, isR ...@@ -2123,16 +2145,7 @@ DataService.prototype.createStream = function(resource, name, context, opts, isR
return; return;
} }
var msg = "Failed to list " + resource; showRequestError("Failed to list " + resource, status);
if (status !== 0) {
msg += " (" + status + ")";
}
// Use `$rootScope.$emit` instead of NotificationsService directly
// so that DataService doesn't add a dependency on `openshiftCommonUI`
$rootScope.$emit('NotificationsService.addNotification', {
type: 'error',
message: msg
});
}); });
} }
}; };
......
...@@ -405,7 +405,7 @@ hawtioPluginLoader.addModule('openshiftCommonUI'); ...@@ -405,7 +405,7 @@ hawtioPluginLoader.addModule('openshiftCommonUI');
" <span class=\"{{notification.type | alertIcon}}\" aria-hidden=\"true\"></span>\n" + " <span class=\"{{notification.type | alertIcon}}\" aria-hidden=\"true\"></span>\n" +
" <span class=\"sr-only\">{{notification.type}}</span>\n" + " <span class=\"sr-only\">{{notification.type}}</span>\n" +
" <span class=\"toast-notification-message\" ng-if=\"notification.message\">{{notification.message}}</span>\n" + " <span class=\"toast-notification-message\" ng-if=\"notification.message\">{{notification.message}}</span>\n" +
" <span ng-if=\"notification.details\">\n" + " <div ng-if=\"notification.details\" class=\"toast-notification-details\">\n" +
" <truncate-long-text\n" + " <truncate-long-text\n" +
" limit=\"200\"\n" + " limit=\"200\"\n" +
" content=\"notification.details\"\n" + " content=\"notification.details\"\n" +
...@@ -413,7 +413,7 @@ hawtioPluginLoader.addModule('openshiftCommonUI'); ...@@ -413,7 +413,7 @@ hawtioPluginLoader.addModule('openshiftCommonUI');
" expandable=\"true\"\n" + " expandable=\"true\"\n" +
" hide-collapse=\"true\">\n" + " hide-collapse=\"true\">\n" +
" </truncate-long-text>\n" + " </truncate-long-text>\n" +
" </span>\n" + " </div>\n" +
" <span ng-repeat=\"link in notification.links\">\n" + " <span ng-repeat=\"link in notification.links\">\n" +
" <a ng-if=\"!link.href\" href=\"\" ng-click=\"onClick(notification, link)\" role=\"button\">{{link.label}}</a>\n" + " <a ng-if=\"!link.href\" href=\"\" ng-click=\"onClick(notification, link)\" role=\"button\">{{link.label}}</a>\n" +
" <a ng-if=\"link.href\" ng-href=\"{{link.href}}\" ng-attr-target=\"{{link.target}}\">{{link.label}}</a>\n" + " <a ng-if=\"link.href\" ng-href=\"{{link.href}}\" ng-attr-target=\"{{link.target}}\">{{link.label}}</a>\n" +
...@@ -1081,16 +1081,18 @@ angular.module('openshiftCommonUI') ...@@ -1081,16 +1081,18 @@ angular.module('openshiftCommonUI')
// Listen for updates from NotificationsService to show a notification. // Listen for updates from NotificationsService to show a notification.
var deregisterNotificationListener = $rootScope.$on('NotificationsService.onNotificationAdded', function(event, notification) { var deregisterNotificationListener = $rootScope.$on('NotificationsService.onNotificationAdded', function(event, notification) {
$scope.notifications.push(notification); $scope.$evalAsync(function() {
if (NotificationsService.isAutoDismiss(notification)) { $scope.notifications.push(notification);
$timeout(function () { if (NotificationsService.isAutoDismiss(notification)) {
notification.hidden = true; $timeout(function () {
}, NotificationsService.dismissDelay); notification.hidden = true;
} }, NotificationsService.dismissDelay);
}
// Whenever we add a new notification, also remove any hidden toasts // Whenever we add a new notification, also remove any hidden toasts
// so that the array doesn't grow indefinitely. // so that the array doesn't grow indefinitely.
pruneRemovedNotifications(); pruneRemovedNotifications();
});
}); });
$scope.$on('$destroy', function() { $scope.$on('$destroy', function() {
......
...@@ -267,6 +267,9 @@ div.hopscotch-bubble .hopscotch-nav-button.prev { ...@@ -267,6 +267,9 @@ div.hopscotch-bubble .hopscotch-nav-button.prev {
.toast-action-divider { .toast-action-divider {
color: #9c9c9c; color: #9c9c9c;
} }
.toast-notification-details .truncated-content {
white-space: pre-line;
}
.toast-notification-message { .toast-notification-message {
font-weight: 700; font-weight: 700;
margin-right: 5px; margin-right: 5px;
......
...@@ -576,7 +576,7 @@ hawtioPluginLoader.addModule('openshiftCommonUI'); ...@@ -576,7 +576,7 @@ hawtioPluginLoader.addModule('openshiftCommonUI');
" <span class=\"{{notification.type | alertIcon}}\" aria-hidden=\"true\"></span>\n" + " <span class=\"{{notification.type | alertIcon}}\" aria-hidden=\"true\"></span>\n" +
" <span class=\"sr-only\">{{notification.type}}</span>\n" + " <span class=\"sr-only\">{{notification.type}}</span>\n" +
" <span class=\"toast-notification-message\" ng-if=\"notification.message\">{{notification.message}}</span>\n" + " <span class=\"toast-notification-message\" ng-if=\"notification.message\">{{notification.message}}</span>\n" +
" <span ng-if=\"notification.details\">\n" + " <div ng-if=\"notification.details\" class=\"toast-notification-details\">\n" +
" <truncate-long-text\n" + " <truncate-long-text\n" +
" limit=\"200\"\n" + " limit=\"200\"\n" +
" content=\"notification.details\"\n" + " content=\"notification.details\"\n" +
...@@ -584,7 +584,7 @@ hawtioPluginLoader.addModule('openshiftCommonUI'); ...@@ -584,7 +584,7 @@ hawtioPluginLoader.addModule('openshiftCommonUI');
" expandable=\"true\"\n" + " expandable=\"true\"\n" +
" hide-collapse=\"true\">\n" + " hide-collapse=\"true\">\n" +
" </truncate-long-text>\n" + " </truncate-long-text>\n" +
" </span>\n" + " </div>\n" +
" <span ng-repeat=\"link in notification.links\">\n" + " <span ng-repeat=\"link in notification.links\">\n" +
" <a ng-if=\"!link.href\" href=\"\" ng-click=\"onClick(notification, link)\" role=\"button\">{{link.label}}</a>\n" + " <a ng-if=\"!link.href\" href=\"\" ng-click=\"onClick(notification, link)\" role=\"button\">{{link.label}}</a>\n" +
" <a ng-if=\"link.href\" ng-href=\"{{link.href}}\" ng-attr-target=\"{{link.target}}\">{{link.label}}</a>\n" + " <a ng-if=\"link.href\" ng-href=\"{{link.href}}\" ng-attr-target=\"{{link.target}}\">{{link.label}}</a>\n" +
...@@ -1252,16 +1252,18 @@ angular.module('openshiftCommonUI') ...@@ -1252,16 +1252,18 @@ angular.module('openshiftCommonUI')
// Listen for updates from NotificationsService to show a notification. // Listen for updates from NotificationsService to show a notification.
var deregisterNotificationListener = $rootScope.$on('NotificationsService.onNotificationAdded', function(event, notification) { var deregisterNotificationListener = $rootScope.$on('NotificationsService.onNotificationAdded', function(event, notification) {
$scope.notifications.push(notification); $scope.$evalAsync(function() {
if (NotificationsService.isAutoDismiss(notification)) { $scope.notifications.push(notification);
$timeout(function () { if (NotificationsService.isAutoDismiss(notification)) {
notification.hidden = true; $timeout(function () {
}, NotificationsService.dismissDelay); notification.hidden = true;
} }, NotificationsService.dismissDelay);
}
// Whenever we add a new notification, also remove any hidden toasts // Whenever we add a new notification, also remove any hidden toasts
// so that the array doesn't grow indefinitely. // so that the array doesn't grow indefinitely.
pruneRemovedNotifications(); pruneRemovedNotifications();
});
}); });
$scope.$on('$destroy', function() { $scope.$on('$destroy', function() {
...@@ -2975,6 +2977,46 @@ angular.module('openshiftCommonServices') ...@@ -2975,6 +2977,46 @@ angular.module('openshiftCommonServices')
} }
} }
// If several connection errors happen close together, display them as one
// notification. This prevents us spamming the user with many failed requests
// at once.
var queuedErrors = [];
var addQueuedNotifications = _.debounce(function() {
if (!queuedErrors.length) {
return;
}
// Show all queued messages together. If the details is extremely long, it
// will be truncated with a see more link.
var notification = {
type: 'error',
message: 'An error occurred connecting to the server.',
details: queuedErrors.join('\n'),
links: [{
label: 'Refresh',
onClick: function() {
window.location.reload();
}
}]
};
// Use `$rootScope.$emit` instead of NotificationsService directly
// so that DataService doesn't add a dependency on `openshiftCommonUI`
$rootScope.$emit('NotificationsService.addNotification', notification);
// Clear the queue.
queuedErrors = [];
}, 300, { maxWait: 1000 });
var showRequestError = function(message, status) {
if (status) {
message += " (status " + status + ")";
}
// Queue the message and call debounced `addQueuedNotifications`.
queuedErrors.push(message);
addQueuedNotifications();
};
function DataService() { function DataService() {
this._listDeferredMap = {}; this._listDeferredMap = {};
this._watchCallbacksMap = {}; this._watchCallbacksMap = {};
...@@ -3311,16 +3353,7 @@ angular.module('openshiftCommonServices') ...@@ -3311,16 +3353,7 @@ angular.module('openshiftCommonServices')
}) })
.error(function(data, status, headers, config) { .error(function(data, status, headers, config) {
if (opts.errorNotification !== false) { if (opts.errorNotification !== false) {
var msg = "Failed to get " + resource + "/" + name; showRequestError("Failed to get " + resource + "/" + name, status);
if (status !== 0) {
msg += " (" + status + ")";
}
// Use `$rootScope.$emit` instead of NotificationsService directly
// so that DataService doesn't add a dependency on `openshiftCommonUI`
$rootScope.$emit('NotificationsService.addNotification', {
type: 'error',
message: msg
});
} }
deferred.reject({ deferred.reject({
data: data, data: data,
...@@ -3856,16 +3889,7 @@ DataService.prototype.createStream = function(resource, name, context, opts, isR ...@@ -3856,16 +3889,7 @@ DataService.prototype.createStream = function(resource, name, context, opts, isR
return; return;
} }
var msg = "Failed to list " + resource; showRequestError("Failed to list " + resource, status);
if (status !== 0) {
msg += " (" + status + ")";
}
// Use `$rootScope.$emit` instead of NotificationsService directly
// so that DataService doesn't add a dependency on `openshiftCommonUI`
$rootScope.$emit('NotificationsService.addNotification', {
type: 'error',
message: msg
});
}); });
}); });
} }
...@@ -3887,16 +3911,7 @@ DataService.prototype.createStream = function(resource, name, context, opts, isR ...@@ -3887,16 +3911,7 @@ DataService.prototype.createStream = function(resource, name, context, opts, isR
return; return;
} }
var msg = "Failed to list " + resource; showRequestError("Failed to list " + resource, status);
if (status !== 0) {
msg += " (" + status + ")";
}
// Use `$rootScope.$emit` instead of NotificationsService directly
// so that DataService doesn't add a dependency on `openshiftCommonUI`
$rootScope.$emit('NotificationsService.addNotification', {
type: 'error',
message: msg
});
}); });
} }
}; };
......
...@@ -91,7 +91,7 @@ $templateCache.put("src/components/binding/bindServiceForm.html", '<div class="b ...@@ -91,7 +91,7 @@ $templateCache.put("src/components/binding/bindServiceForm.html", '<div class="b
$templateCache.put("src/components/create-project/createProject.html", '<form name="createProjectForm" novalidate>\n <fieldset ng-disabled="disableInputs">\n <div class="form-group">\n <label for="name" class="required">Name</label>\n <span ng-class="{\'has-error\': (createProjectForm.name.$error.pattern && createProjectForm.name.$touched) || nameTaken}">\n <input class="form-control input-lg"\n name="name"\n id="name"\n placeholder="my-project"\n type="text"\n required\n take-focus\n minlength="2"\n maxlength="63"\n pattern="[a-z0-9]([-a-z0-9]*[a-z0-9])?"\n aria-describedby="nameHelp"\n ng-model="name"\n ng-model-options="{ updateOn: \'default blur\' }"\n ng-change="nameTaken = false"\n autocorrect="off"\n autocapitalize="off"\n spellcheck="false">\n </span>\n <div>\n <span class="help-block">A unique name for the project.</span>\n </div>\n <div class="has-error">\n <span id="nameHelp" class="help-block" ng-if="createProjectForm.name.$error.required && createProjectForm.name.$dirty">\n Name is required.\n </span>\n </div>\n <div class="has-error">\n <span id="nameHelp" class="help-block" ng-if="createProjectForm.name.$error.minlength && createProjectForm.name.$touched">\n Name must have at least two characters.\n </span>\n </div>\n <div class="has-error">\n <span id="nameHelp" class="help-block" ng-if="createProjectForm.name.$error.pattern && createProjectForm.name.$touched">\n Project names may only contain lower-case letters, numbers, and dashes.\n They may not start or end with a dash.\n </span>\n </div>\n <div class="has-error">\n <span class="help-block" ng-if="nameTaken">\n This name is already in use. Please choose a different name.\n </span>\n </div>\n </div>\n\n <div class="form-group">\n <label for="displayName">Display Name</label>\n <input class="form-control input-lg"\n name="displayName"\n id="displayName"\n placeholder="My Project"\n type="text"\n ng-model="displayName">\n </div>\n\n <div class="form-group">\n <label for="description">Description</label>\n <textarea class="form-control input-lg"\n name="description"\n id="description"\n placeholder="A short description."\n ng-model="description"></textarea>\n </div>\n\n <div class="button-group">\n <button type="submit"\n class="btn btn-primary btn-lg"\n ng-class="{\'dialog-btn\': isDialog}"\n ng-click="createProject()"\n ng-disabled="createProjectForm.$invalid || nameTaken || disableInputs"\n value="">\n Create\n </button>\n <button\n class="btn btn-default btn-lg"\n ng-class="{\'dialog-btn\': isDialog}"\n ng-click="cancelCreateProject()">\n Cancel\n </button>\n </div>\n </fieldset>\n</form>\n'), $templateCache.put("src/components/create-project/createProject.html", '<form name="createProjectForm" novalidate>\n <fieldset ng-disabled="disableInputs">\n <div class="form-group">\n <label for="name" class="required">Name</label>\n <span ng-class="{\'has-error\': (createProjectForm.name.$error.pattern && createProjectForm.name.$touched) || nameTaken}">\n <input class="form-control input-lg"\n name="name"\n id="name"\n placeholder="my-project"\n type="text"\n required\n take-focus\n minlength="2"\n maxlength="63"\n pattern="[a-z0-9]([-a-z0-9]*[a-z0-9])?"\n aria-describedby="nameHelp"\n ng-model="name"\n ng-model-options="{ updateOn: \'default blur\' }"\n ng-change="nameTaken = false"\n autocorrect="off"\n autocapitalize="off"\n spellcheck="false">\n </span>\n <div>\n <span class="help-block">A unique name for the project.</span>\n </div>\n <div class="has-error">\n <span id="nameHelp" class="help-block" ng-if="createProjectForm.name.$error.required && createProjectForm.name.$dirty">\n Name is required.\n </span>\n </div>\n <div class="has-error">\n <span id="nameHelp" class="help-block" ng-if="createProjectForm.name.$error.minlength && createProjectForm.name.$touched">\n Name must have at least two characters.\n </span>\n </div>\n <div class="has-error">\n <span id="nameHelp" class="help-block" ng-if="createProjectForm.name.$error.pattern && createProjectForm.name.$touched">\n Project names may only contain lower-case letters, numbers, and dashes.\n They may not start or end with a dash.\n </span>\n </div>\n <div class="has-error">\n <span class="help-block" ng-if="nameTaken">\n This name is already in use. Please choose a different name.\n </span>\n </div>\n </div>\n\n <div class="form-group">\n <label for="displayName">Display Name</label>\n <input class="form-control input-lg"\n name="displayName"\n id="displayName"\n placeholder="My Project"\n type="text"\n ng-model="displayName">\n </div>\n\n <div class="form-group">\n <label for="description">Description</label>\n <textarea class="form-control input-lg"\n name="description"\n id="description"\n placeholder="A short description."\n ng-model="description"></textarea>\n </div>\n\n <div class="button-group">\n <button type="submit"\n class="btn btn-primary btn-lg"\n ng-class="{\'dialog-btn\': isDialog}"\n ng-click="createProject()"\n ng-disabled="createProjectForm.$invalid || nameTaken || disableInputs"\n value="">\n Create\n </button>\n <button\n class="btn btn-default btn-lg"\n ng-class="{\'dialog-btn\': isDialog}"\n ng-click="cancelCreateProject()">\n Cancel\n </button>\n </div>\n </fieldset>\n</form>\n'),
$templateCache.put("src/components/delete-project/delete-project-button.html", '<div class="actions">\n <!-- Avoid whitespace inside the link -->\n <a href=""\n ng-click="$event.stopPropagation(); openDeleteModal()"\n role="button"\n class="action-button"\n ng-attr-aria-disabled="{{disableDelete ? \'true\' : undefined}}"\n ng-class="{ \'disabled-link\': disableDelete }"\n ><i class="fa fa-trash-o" aria-hidden="true"\n ></i><span class="sr-only">Delete Project {{projectName}}</span></a>\n</div>\n'), $templateCache.put("src/components/delete-project/delete-project-modal.html", '<div class="delete-resource-modal">\n <!-- Use a form so that the enter key submits when typing a project name to confirm. -->\n <form>\n <div class="modal-body">\n <h1>Are you sure you want to delete the project\n \'<strong>{{displayName ? displayName : projectName}}</strong>\'?</h1>\n <p>\n This will <strong>delete all resources</strong> associated with\n the project {{displayName ? displayName : projectName}} and <strong>cannot be\n undone</strong>. Make sure this is something you really want to do!\n </p>\n <div ng-show="typeNameToConfirm">\n <p>Type the name of the project to confirm.</p>\n <p>\n <label class="sr-only" for="resource-to-delete">project to delete</label>\n <input\n ng-model="confirmName"\n id="resource-to-delete"\n type="text"\n class="form-control input-lg"\n autocorrect="off"\n autocapitalize="off"\n spellcheck="false"\n autofocus>\n </p>\n </div>\n </div>\n <div class="modal-footer">\n <button ng-disabled="typeNameToConfirm && confirmName !== projectName && confirmName !== displayName" class="btn btn-lg btn-danger" type="submit" ng-click="delete();">Delete</button>\n <button class="btn btn-lg btn-default" type="button" ng-click="cancel();">Cancel</button>\n </div>\n </form>\n</div>\n'), $templateCache.put("src/components/delete-project/delete-project-button.html", '<div class="actions">\n <!-- Avoid whitespace inside the link -->\n <a href=""\n ng-click="$event.stopPropagation(); openDeleteModal()"\n role="button"\n class="action-button"\n ng-attr-aria-disabled="{{disableDelete ? \'true\' : undefined}}"\n ng-class="{ \'disabled-link\': disableDelete }"\n ><i class="fa fa-trash-o" aria-hidden="true"\n ></i><span class="sr-only">Delete Project {{projectName}}</span></a>\n</div>\n'), $templateCache.put("src/components/delete-project/delete-project-modal.html", '<div class="delete-resource-modal">\n <!-- Use a form so that the enter key submits when typing a project name to confirm. -->\n <form>\n <div class="modal-body">\n <h1>Are you sure you want to delete the project\n \'<strong>{{displayName ? displayName : projectName}}</strong>\'?</h1>\n <p>\n This will <strong>delete all resources</strong> associated with\n the project {{displayName ? displayName : projectName}} and <strong>cannot be\n undone</strong>. Make sure this is something you really want to do!\n </p>\n <div ng-show="typeNameToConfirm">\n <p>Type the name of the project to confirm.</p>\n <p>\n <label class="sr-only" for="resource-to-delete">project to delete</label>\n <input\n ng-model="confirmName"\n id="resource-to-delete"\n type="text"\n class="form-control input-lg"\n autocorrect="off"\n autocapitalize="off"\n spellcheck="false"\n autofocus>\n </p>\n </div>\n </div>\n <div class="modal-footer">\n <button ng-disabled="typeNameToConfirm && confirmName !== projectName && confirmName !== displayName" class="btn btn-lg btn-danger" type="submit" ng-click="delete();">Delete</button>\n <button class="btn btn-lg btn-default" type="button" ng-click="cancel();">Cancel</button>\n </div>\n </form>\n</div>\n'),
$templateCache.put("src/components/delete-project/delete-project.html", '<a href="javascript:void(0)"\n ng-click="openDeleteModal()"\n role="button"\n ng-attr-aria-disabled="{{disableDelete ? \'true\' : undefined}}"\n ng-class="{ \'disabled-link\': disableDelete }"\n>{{label || \'Delete\'}}</a>\n'), $templateCache.put("src/components/edit-project/editProject.html", '<form name="editProjectForm">\n <fieldset ng-disabled="disableInputs">\n <div class="form-group">\n <label for="displayName">Display Name</label>\n <input class="form-control input-lg"\n name="displayName"\n id="displayName"\n placeholder="My Project"\n type="text"\n ng-model="editableFields.displayName">\n </div>\n\n <div class="form-group">\n <label for="description">Description</label>\n <textarea class="form-control input-lg"\n name="description"\n id="description"\n placeholder="A short description."\n ng-model="editableFields.description"></textarea>\n </div>\n\n <div class="button-group">\n <button type="submit"\n class="btn btn-primary btn-lg"\n ng-class="{\'dialog-btn\': isDialog}"\n ng-click="update()"\n ng-disabled="editProjectForm.$invalid || disableInputs"\n value="">{{submitButtonLabel}}</button>\n <button\n class="btn btn-default btn-lg"\n ng-class="{\'dialog-btn\': isDialog}"\n ng-click="cancelEditProject()">\n Cancel\n </button>\n </div>\n </fieldset>\n</form>\n'), $templateCache.put("src/components/delete-project/delete-project.html", '<a href="javascript:void(0)"\n ng-click="openDeleteModal()"\n role="button"\n ng-attr-aria-disabled="{{disableDelete ? \'true\' : undefined}}"\n ng-class="{ \'disabled-link\': disableDelete }"\n>{{label || \'Delete\'}}</a>\n'), $templateCache.put("src/components/edit-project/editProject.html", '<form name="editProjectForm">\n <fieldset ng-disabled="disableInputs">\n <div class="form-group">\n <label for="displayName">Display Name</label>\n <input class="form-control input-lg"\n name="displayName"\n id="displayName"\n placeholder="My Project"\n type="text"\n ng-model="editableFields.displayName">\n </div>\n\n <div class="form-group">\n <label for="description">Description</label>\n <textarea class="form-control input-lg"\n name="description"\n id="description"\n placeholder="A short description."\n ng-model="editableFields.description"></textarea>\n </div>\n\n <div class="button-group">\n <button type="submit"\n class="btn btn-primary btn-lg"\n ng-class="{\'dialog-btn\': isDialog}"\n ng-click="update()"\n ng-disabled="editProjectForm.$invalid || disableInputs"\n value="">{{submitButtonLabel}}</button>\n <button\n class="btn btn-default btn-lg"\n ng-class="{\'dialog-btn\': isDialog}"\n ng-click="cancelEditProject()">\n Cancel\n </button>\n </div>\n </fieldset>\n</form>\n'),
$templateCache.put("src/components/toast-notifications/toast-notifications.html", '<div class="toast-notifications-list-pf">\n <div ng-repeat="(notificationID, notification) in notifications track by (notificationID + (notification.message || notification.details))" ng-if="!notification.hidden || notification.isHover"\n ng-mouseenter="setHover(notification, true)" ng-mouseleave="setHover(notification, false)">\n <div class="toast-pf alert {{notification.type | alertStatus}}" ng-class="{\'alert-dismissable\': !hideCloseButton}">\n <button ng-if="!hideCloseButton" type="button" class="close" ng-click="close(notification)">\n <span class="pficon pficon-close" aria-hidden="true"></span>\n <span class="sr-only">Close</span>\n </button>\n <span class="{{notification.type | alertIcon}}" aria-hidden="true"></span>\n <span class="sr-only">{{notification.type}}</span>\n <span class="toast-notification-message" ng-if="notification.message">{{notification.message}}</span>\n <span ng-if="notification.details">\n <truncate-long-text\n limit="200"\n content="notification.details"\n use-word-boundary="true"\n expandable="true"\n hide-collapse="true">\n </truncate-long-text>\n </span>\n <span ng-repeat="link in notification.links">\n <a ng-if="!link.href" href="" ng-click="onClick(notification, link)" role="button">{{link.label}}</a>\n <a ng-if="link.href" ng-href="{{link.href}}" ng-attr-target="{{link.target}}">{{link.label}}</a>\n <span ng-if="!$last" class="toast-action-divider">|</span>\n </span>\n </div>\n </div>\n</div>\n'), $templateCache.put("src/components/toast-notifications/toast-notifications.html", '<div class="toast-notifications-list-pf">\n <div ng-repeat="(notificationID, notification) in notifications track by (notificationID + (notification.message || notification.details))" ng-if="!notification.hidden || notification.isHover"\n ng-mouseenter="setHover(notification, true)" ng-mouseleave="setHover(notification, false)">\n <div class="toast-pf alert {{notification.type | alertStatus}}" ng-class="{\'alert-dismissable\': !hideCloseButton}">\n <button ng-if="!hideCloseButton" type="button" class="close" ng-click="close(notification)">\n <span class="pficon pficon-close" aria-hidden="true"></span>\n <span class="sr-only">Close</span>\n </button>\n <span class="{{notification.type | alertIcon}}" aria-hidden="true"></span>\n <span class="sr-only">{{notification.type}}</span>\n <span class="toast-notification-message" ng-if="notification.message">{{notification.message}}</span>\n <div ng-if="notification.details" class="toast-notification-details">\n <truncate-long-text\n limit="200"\n content="notification.details"\n use-word-boundary="true"\n expandable="true"\n hide-collapse="true">\n </truncate-long-text>\n </div>\n <span ng-repeat="link in notification.links">\n <a ng-if="!link.href" href="" ng-click="onClick(notification, link)" role="button">{{link.label}}</a>\n <a ng-if="link.href" ng-href="{{link.href}}" ng-attr-target="{{link.target}}">{{link.label}}</a>\n <span ng-if="!$last" class="toast-action-divider">|</span>\n </span>\n </div>\n </div>\n</div>\n'),
$templateCache.put("src/components/truncate-long-text/truncateLongText.html", '<!--\n Do not remove class `truncated-content` (here or below) even though it\'s not\n styled directly in origin-web-common. `truncated-content` is used by\n origin-web-console in certain contexts.\n-->\n<span ng-if="!truncated" ng-bind-html="content | highlightKeywords : keywords" class="truncated-content"></span>\n<span ng-if="truncated">\n <span ng-if="!toggles.expanded">\n <span ng-attr-title="{{content}}" class="truncation-block">\n <span ng-bind-html="truncatedContent | highlightKeywords : keywords" class="truncated-content"></span>&hellip;\n </span>\n <a ng-if="expandable" href="" ng-click="toggles.expanded = true" class="nowrap">See All</a>\n </span>\n <span ng-if="toggles.expanded">\n <div ng-if="prettifyJson" class="well">\n <span ng-if="!hideCollapse" class="pull-right" style="margin-top: -10px;"><a href="" ng-click="toggles.expanded = false" class="truncation-collapse-link">Collapse</a></span>\n <span ng-bind-html="content | prettifyJSON | highlightKeywords : keywords" class="pretty-json truncated-content"></span>\n </div>\n <span ng-if="!prettifyJson">\n <span ng-if="!hideCollapse" class="pull-right"><a href="" ng-click="toggles.expanded = false" class="truncation-collapse-link">Collapse</a></span>\n <span ng-bind-html="content | highlightKeywords : keywords" class="truncated-content"></span>\n </span>\n </span>\n</span>\n'); $templateCache.put("src/components/truncate-long-text/truncateLongText.html", '<!--\n Do not remove class `truncated-content` (here or below) even though it\'s not\n styled directly in origin-web-common. `truncated-content` is used by\n origin-web-console in certain contexts.\n-->\n<span ng-if="!truncated" ng-bind-html="content | highlightKeywords : keywords" class="truncated-content"></span>\n<span ng-if="truncated">\n <span ng-if="!toggles.expanded">\n <span ng-attr-title="{{content}}" class="truncation-block">\n <span ng-bind-html="truncatedContent | highlightKeywords : keywords" class="truncated-content"></span>&hellip;\n </span>\n <a ng-if="expandable" href="" ng-click="toggles.expanded = true" class="nowrap">See All</a>\n </span>\n <span ng-if="toggles.expanded">\n <div ng-if="prettifyJson" class="well">\n <span ng-if="!hideCollapse" class="pull-right" style="margin-top: -10px;"><a href="" ng-click="toggles.expanded = false" class="truncation-collapse-link">Collapse</a></span>\n <span ng-bind-html="content | prettifyJSON | highlightKeywords : keywords" class="pretty-json truncated-content"></span>\n </div>\n <span ng-if="!prettifyJson">\n <span ng-if="!hideCollapse" class="pull-right"><a href="" ng-click="toggles.expanded = false" class="truncation-collapse-link">Collapse</a></span>\n <span ng-bind-html="content | highlightKeywords : keywords" class="truncated-content"></span>\n </span>\n </span>\n</span>\n');
} ]), angular.module("openshiftCommonUI").component("bindApplicationForm", { } ]), angular.module("openshiftCommonUI").component("bindApplicationForm", {
controllerAs:"ctrl", controllerAs:"ctrl",
...@@ -427,10 +427,12 @@ close && removeNotification(notification); ...@@ -427,10 +427,12 @@ close && removeNotification(notification);
isRemoved(notification) || (notification.isHover = isHover); isRemoved(notification) || (notification.isHover = isHover);
}; };
var deregisterNotificationListener = $rootScope.$on("NotificationsService.onNotificationAdded", function(event, notification) { var deregisterNotificationListener = $rootScope.$on("NotificationsService.onNotificationAdded", function(event, notification) {
$scope.$evalAsync(function() {
$scope.notifications.push(notification), NotificationsService.isAutoDismiss(notification) && $timeout(function() { $scope.notifications.push(notification), NotificationsService.isAutoDismiss(notification) && $timeout(function() {
notification.hidden = !0; notification.hidden = !0;
}, NotificationsService.dismissDelay), pruneRemovedNotifications(); }, NotificationsService.dismissDelay), pruneRemovedNotifications();
}); });
});
$scope.$on("$destroy", function() { $scope.$on("$destroy", function() {
deregisterNotificationListener && (deregisterNotificationListener(), deregisterNotificationListener = null); deregisterNotificationListener && (deregisterNotificationListener(), deregisterNotificationListener = null);
}); });
...@@ -1224,7 +1226,28 @@ _objectByAttribute(object, "metadata.name", this._data, action); ...@@ -1224,7 +1226,28 @@ _objectByAttribute(object, "metadata.name", this._data, action);
angular.forEach(objects, function(obj, key) { angular.forEach(objects, function(obj, key) {
_objectByAttribute(obj, attr, map, actions ? actions[key] :null); _objectByAttribute(obj, attr, map, actions ? actions[key] :null);
}); });
}, DataService.prototype.list = function(resource, context, callback, opts) { };
var queuedErrors = [], addQueuedNotifications = _.debounce(function() {
if (queuedErrors.length) {
var notification = {
type:"error",
message:"An error occurred connecting to the server.",
details:queuedErrors.join("\n"),
links:[ {
label:"Refresh",
onClick:function() {
window.location.reload();
}
} ]
};
$rootScope.$emit("NotificationsService.addNotification", notification), queuedErrors = [];
}
}, 300, {
maxWait:1e3
}), showRequestError = function(message, status) {
status && (message += " (status " + status + ")"), queuedErrors.push(message), addQueuedNotifications();
};
DataService.prototype.list = function(resource, context, callback, opts) {
resource = APIService.toResourceGroupVersion(resource); resource = APIService.toResourceGroupVersion(resource);
var key = this._uniqueKey(resource, null, context, _.get(opts, "http.params")), deferred = this._listDeferred(key); var key = this._uniqueKey(resource, null, context, _.get(opts, "http.params")), deferred = this._listDeferred(key);
return callback && deferred.promise.then(callback), this._isCached(key) ? deferred.resolve(this._data(key)) :this._listInFlight(key) || this._startListOp(resource, context, opts), deferred.promise; return callback && deferred.promise.then(callback), this._isCached(key) ? deferred.resolve(this._data(key)) :this._listInFlight(key) || this._startListOp(resource, context, opts), deferred.promise;
...@@ -1362,14 +1385,7 @@ url:self._urlForResource(resource, name, context, !1, ns) ...@@ -1362,14 +1385,7 @@ url:self._urlForResource(resource, name, context, !1, ns)
}, opts.http || {})).success(function(data, status, headerFunc, config, statusText) { }, opts.http || {})).success(function(data, status, headerFunc, config, statusText) {
self._isImmutable(resource) && (existingImmutableData ? existingImmutableData.update(data, "ADDED") :self._immutableData(key, [ data ])), deferred.resolve(data); self._isImmutable(resource) && (existingImmutableData ? existingImmutableData.update(data, "ADDED") :self._immutableData(key, [ data ])), deferred.resolve(data);
}).error(function(data, status, headers, config) { }).error(function(data, status, headers, config) {
if (opts.errorNotification !== !1) { opts.errorNotification !== !1 && showRequestError("Failed to get " + resource + "/" + name, status), deferred.reject({
var msg = "Failed to get " + resource + "/" + name;
0 !== status && (msg += " (" + status + ")"), $rootScope.$emit("NotificationsService.addNotification", {
type:"error",
message:msg
});
}
deferred.reject({
data:data, data:data,
status:status, status:status,
headers:headers, headers:headers,
...@@ -1574,13 +1590,7 @@ self._listOpComplete(key, resource, context, opts, data); ...@@ -1574,13 +1590,7 @@ self._listOpComplete(key, resource, context, opts, data);
}).error(function(data, status, headers, config) { }).error(function(data, status, headers, config) {
self._listInFlight(key, !1); self._listInFlight(key, !1);
var deferred = self._listDeferred(key); var deferred = self._listDeferred(key);
if (delete self._listDeferredMap[key], deferred.reject(data, status, headers, config), _.get(opts, "errorNotification", !0)) { delete self._listDeferredMap[key], deferred.reject(data, status, headers, config), _.get(opts, "errorNotification", !0) && showRequestError("Failed to list " + resource, status);
var msg = "Failed to list " + resource;
0 !== status && (msg += " (" + status + ")"), $rootScope.$emit("NotificationsService.addNotification", {
type:"error",
message:msg
});
}
}); });
}) :$http({ }) :$http({
method:"GET", method:"GET",
...@@ -1591,13 +1601,7 @@ self._listOpComplete(key, resource, context, opts, data); ...@@ -1591,13 +1601,7 @@ self._listOpComplete(key, resource, context, opts, data);
}).error(function(data, status, headers, config) { }).error(function(data, status, headers, config) {
self._listInFlight(key, !1); self._listInFlight(key, !1);
var deferred = self._listDeferred(key); var deferred = self._listDeferred(key);
if (delete self._listDeferredMap[key], deferred.reject(data, status, headers, config), _.get(opts, "errorNotification", !0)) { delete self._listDeferredMap[key], deferred.reject(data, status, headers, config), _.get(opts, "errorNotification", !0) && showRequestError("Failed to list " + resource, status);
var msg = "Failed to list " + resource;
0 !== status && (msg += " (" + status + ")"), $rootScope.$emit("NotificationsService.addNotification", {
type:"error",
message:msg
});
}
}); });
}, DataService.prototype._listOpComplete = function(key, resource, context, opts, data) { }, DataService.prototype._listOpComplete = function(key, resource, context, opts, data) {
data.items || console.warn("List request for " + resource + " returned a null items array. This is an invalid API response."); data.items || console.warn("List request for " + resource + " returned a null items array. This is an invalid API response.");
......
...@@ -376,7 +376,7 @@ angular.module('openshiftCommonUI').run(['$templateCache', function($templateCac ...@@ -376,7 +376,7 @@ angular.module('openshiftCommonUI').run(['$templateCache', function($templateCac
" <span class=\"{{notification.type | alertIcon}}\" aria-hidden=\"true\"></span>\n" + " <span class=\"{{notification.type | alertIcon}}\" aria-hidden=\"true\"></span>\n" +
" <span class=\"sr-only\">{{notification.type}}</span>\n" + " <span class=\"sr-only\">{{notification.type}}</span>\n" +
" <span class=\"toast-notification-message\" ng-if=\"notification.message\">{{notification.message}}</span>\n" + " <span class=\"toast-notification-message\" ng-if=\"notification.message\">{{notification.message}}</span>\n" +
" <span ng-if=\"notification.details\">\n" + " <div ng-if=\"notification.details\" class=\"toast-notification-details\">\n" +
" <truncate-long-text\n" + " <truncate-long-text\n" +
" limit=\"200\"\n" + " limit=\"200\"\n" +
" content=\"notification.details\"\n" + " content=\"notification.details\"\n" +
...@@ -384,7 +384,7 @@ angular.module('openshiftCommonUI').run(['$templateCache', function($templateCac ...@@ -384,7 +384,7 @@ angular.module('openshiftCommonUI').run(['$templateCache', function($templateCac
" expandable=\"true\"\n" + " expandable=\"true\"\n" +
" hide-collapse=\"true\">\n" + " hide-collapse=\"true\">\n" +
" </truncate-long-text>\n" + " </truncate-long-text>\n" +
" </span>\n" + " </div>\n" +
" <span ng-repeat=\"link in notification.links\">\n" + " <span ng-repeat=\"link in notification.links\">\n" +
" <a ng-if=\"!link.href\" href=\"\" ng-click=\"onClick(notification, link)\" role=\"button\">{{link.label}}</a>\n" + " <a ng-if=\"!link.href\" href=\"\" ng-click=\"onClick(notification, link)\" role=\"button\">{{link.label}}</a>\n" +
" <a ng-if=\"link.href\" ng-href=\"{{link.href}}\" ng-attr-target=\"{{link.target}}\">{{link.label}}</a>\n" + " <a ng-if=\"link.href\" ng-href=\"{{link.href}}\" ng-attr-target=\"{{link.target}}\">{{link.label}}</a>\n" +
......
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
<span class="{{notification.type | alertIcon}}" aria-hidden="true"></span> <span class="{{notification.type | alertIcon}}" aria-hidden="true"></span>
<span class="sr-only">{{notification.type}}</span> <span class="sr-only">{{notification.type}}</span>
<span class="toast-notification-message" ng-if="notification.message">{{notification.message}}</span> <span class="toast-notification-message" ng-if="notification.message">{{notification.message}}</span>
<span ng-if="notification.details"> <div ng-if="notification.details" class="toast-notification-details">
<truncate-long-text <truncate-long-text
limit="200" limit="200"
content="notification.details" content="notification.details"
...@@ -17,7 +17,7 @@ ...@@ -17,7 +17,7 @@
expandable="true" expandable="true"
hide-collapse="true"> hide-collapse="true">
</truncate-long-text> </truncate-long-text>
</span> </div>
<span ng-repeat="link in notification.links"> <span ng-repeat="link in notification.links">
<a ng-if="!link.href" href="" ng-click="onClick(notification, link)" role="button">{{link.label}}</a> <a ng-if="!link.href" href="" ng-click="onClick(notification, link)" role="button">{{link.label}}</a>
<a ng-if="link.href" ng-href="{{link.href}}" ng-attr-target="{{link.target}}">{{link.label}}</a> <a ng-if="link.href" ng-href="{{link.href}}" ng-attr-target="{{link.target}}">{{link.label}}</a>
......
...@@ -56,16 +56,18 @@ angular.module('openshiftCommonUI') ...@@ -56,16 +56,18 @@ angular.module('openshiftCommonUI')
// Listen for updates from NotificationsService to show a notification. // Listen for updates from NotificationsService to show a notification.
var deregisterNotificationListener = $rootScope.$on('NotificationsService.onNotificationAdded', function(event, notification) { var deregisterNotificationListener = $rootScope.$on('NotificationsService.onNotificationAdded', function(event, notification) {
$scope.notifications.push(notification); $scope.$evalAsync(function() {
if (NotificationsService.isAutoDismiss(notification)) { $scope.notifications.push(notification);
$timeout(function () { if (NotificationsService.isAutoDismiss(notification)) {
notification.hidden = true; $timeout(function () {
}, NotificationsService.dismissDelay); notification.hidden = true;
} }, NotificationsService.dismissDelay);
}
// Whenever we add a new notification, also remove any hidden toasts // Whenever we add a new notification, also remove any hidden toasts
// so that the array doesn't grow indefinitely. // so that the array doesn't grow indefinitely.
pruneRemovedNotifications(); pruneRemovedNotifications();
});
}); });
$scope.$on('$destroy', function() { $scope.$on('$destroy', function() {
......
...@@ -76,6 +76,46 @@ angular.module('openshiftCommonServices') ...@@ -76,6 +76,46 @@ angular.module('openshiftCommonServices')
} }
} }
// If several connection errors happen close together, display them as one
// notification. This prevents us spamming the user with many failed requests
// at once.
var queuedErrors = [];
var addQueuedNotifications = _.debounce(function() {
if (!queuedErrors.length) {
return;
}
// Show all queued messages together. If the details is extremely long, it
// will be truncated with a see more link.
var notification = {
type: 'error',
message: 'An error occurred connecting to the server.',
details: queuedErrors.join('\n'),
links: [{
label: 'Refresh',
onClick: function() {
window.location.reload();
}
}]
};
// Use `$rootScope.$emit` instead of NotificationsService directly
// so that DataService doesn't add a dependency on `openshiftCommonUI`
$rootScope.$emit('NotificationsService.addNotification', notification);
// Clear the queue.
queuedErrors = [];
}, 300, { maxWait: 1000 });
var showRequestError = function(message, status) {
if (status) {
message += " (status " + status + ")";
}
// Queue the message and call debounced `addQueuedNotifications`.
queuedErrors.push(message);
addQueuedNotifications();
};
function DataService() { function DataService() {
this._listDeferredMap = {}; this._listDeferredMap = {};
this._watchCallbacksMap = {}; this._watchCallbacksMap = {};
...@@ -412,16 +452,7 @@ angular.module('openshiftCommonServices') ...@@ -412,16 +452,7 @@ angular.module('openshiftCommonServices')
}) })
.error(function(data, status, headers, config) { .error(function(data, status, headers, config) {
if (opts.errorNotification !== false) { if (opts.errorNotification !== false) {
var msg = "Failed to get " + resource + "/" + name; showRequestError("Failed to get " + resource + "/" + name, status);
if (status !== 0) {
msg += " (" + status + ")";
}
// Use `$rootScope.$emit` instead of NotificationsService directly
// so that DataService doesn't add a dependency on `openshiftCommonUI`
$rootScope.$emit('NotificationsService.addNotification', {
type: 'error',
message: msg
});
} }
deferred.reject({ deferred.reject({
data: data, data: data,
...@@ -957,16 +988,7 @@ DataService.prototype.createStream = function(resource, name, context, opts, isR ...@@ -957,16 +988,7 @@ DataService.prototype.createStream = function(resource, name, context, opts, isR
return; return;
} }
var msg = "Failed to list " + resource; showRequestError("Failed to list " + resource, status);
if (status !== 0) {
msg += " (" + status + ")";
}
// Use `$rootScope.$emit` instead of NotificationsService directly
// so that DataService doesn't add a dependency on `openshiftCommonUI`
$rootScope.$emit('NotificationsService.addNotification', {
type: 'error',
message: msg
});
}); });
}); });
} }
...@@ -988,16 +1010,7 @@ DataService.prototype.createStream = function(resource, name, context, opts, isR ...@@ -988,16 +1010,7 @@ DataService.prototype.createStream = function(resource, name, context, opts, isR
return; return;
} }
var msg = "Failed to list " + resource; showRequestError("Failed to list " + resource, status);
if (status !== 0) {
msg += " (" + status + ")";
}
// Use `$rootScope.$emit` instead of NotificationsService directly
// so that DataService doesn't add a dependency on `openshiftCommonUI`
$rootScope.$emit('NotificationsService.addNotification', {
type: 'error',
message: msg
});
}); });
} }
}; };
......
...@@ -2,6 +2,10 @@ ...@@ -2,6 +2,10 @@
color: @gray-light; color: @gray-light;
} }
.toast-notification-details .truncated-content {
white-space: pre-line;
}
.toast-notification-message { .toast-notification-message {
font-weight: 700; font-weight: 700;
margin-right: 5px; margin-right: 5px;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment