Delete confirmation & bulk delete for timelapses

Also introduced a new helper, a progress modal that can be used for
providing feedback about things such as bulk delete operations in the
background.

See #748 and discussion in #1807
This commit is contained in:
Gina Häußge 2017-03-22 16:28:33 +01:00
parent 675a54aa05
commit 3ec2d7bd14
5 changed files with 349 additions and 7 deletions

File diff suppressed because one or more lines are too long

View file

@ -162,6 +162,12 @@ function ItemListHelper(listType, supportedSorting, supportedFilters, defaultSor
return undefined;
};
self.resetPage = function() {
if (self.currentPage() > self.lastPage()) {
self.currentPage(self.lastPage());
}
};
//~~ searching
self.changeSearchFunction = function(searchFunction) {
@ -670,10 +676,163 @@ function showConfirmationDialog(msg, onacknowledge, options) {
return modal;
}
/**
* Shows a progress modal depending on a supplied promise.
*
* Will listen to the supplied promise, update the progress on .progress events and
* enabling the close button and (optionally) closing the dialog on promise resolve.
*
* The calling code should call "notify" on the deferred backing the promise and supply
* two parameters: the text to display on the progress bar and the optional output field and
* a boolean value indicating whether the operation behind that update was successful or not.
* Non-successful progress updates will remove the barClassSuccess class from the progress bar and
* apply the barClassFailure class and also apply the outputClassFailure to the produced line
* in the output.
*
* To determine the progress, calling code should supply the prognosed maximum number of
* progress events. An internal counter will increment on each progress event and used together
* with the max value to calculate the percentage to display on the progress bar.
*
* If no max value is set, the progress bar will show a striped animation at 100% fill status
* to visualize "unknown but ongoing" status.
*
* Available options:
*
* * title: the title of the modal, defaults to "Progress"
* * message: the message of the modal, defaults to ""
* * buttonText: the text on the close button, defaults to "Close"
* * max: maximum number of expected progress events (when 100% will be reached), defaults
* to undefined
* * close: whether to close the dialog on completion, defaults to false
* * output: whether to display the progress texts in an output field, defaults to false
* * dialogClass: additional class to apply to the dialog div
* * barClassSuccess: additional class for the progress bar while all progress events are
* successful
* * barClassFailure: additional class for the progress bar when a progress event was
* unsuccessful
* * outputClassSuccess: additional class for successful output lines
* * outputClassFailure: additional class for unsuccessful output lines
*
* @param options modal options
* @param promise promise to monitor
* @returns {*|jQuery} the modal object
*/
function showProgressModal(options, promise) {
var title = options.title || gettext("Progress");
var message = options.message || "";
var buttonText = options.button || gettext("Close");
var max = options.max || undefined;
var close = options.close || false;
var output = options.output || false;
var dialogClass = options.dialogClass || "";
var barClassSuccess = options.barClassSuccess || "";
var barClassFailure = options.barClassFailure || "bar-danger";
var outputClassSuccess = options.outputClassSuccess || "";
var outputClassFailure = options.outputClassFailure || "text-error";
var modalHeader = $('<h3>' + title + '</h3>');
var paragraph = $('<p>' + message + '</p>');
var progress = $('<div class="progress progress-text-centered"></div>');
var progressBar = $('<div class="bar"></div>')
.addClass(barClassSuccess);
var progressTextBack = $('<span class="progress-text-back"></span>');
var progressTextFront = $('<span class="progress-text-front"></span>')
.width(progress.width());
if (max == undefined) {
progress.addClass("progress-striped active");
progressBar.width("100%");
}
progressBar
.append(progressTextFront);
progress
.append(progressTextBack)
.append(progressBar);
var button = $('<button class="btn">' + buttonText + '</button>')
.prop("disabled", true)
.attr("data-dismiss", "modal")
.attr("aria-hidden", "true");
var modalBody = $('<div></div>')
.addClass('modal-body')
.append(paragraph)
.append(progress);
var pre;
if (output) {
pre = $("<pre class='terminal pre-scrollable' style='height: 70px; font-size: 0.8em'></pre>");
modalBody.append(pre);
}
var modal = $('<div></div>')
.addClass('modal hide fade')
.addClass(dialogClass)
.append($('<div></div>').addClass('modal-header').append(modalHeader))
.append(modalBody)
.append($('<div></div>').addClass('modal-footer').append(button));
modal.modal({keyboard: false, backdrop: "static", show: true});
var counter = 0;
promise
.progress(function(text, success) {
var value;
if (max === undefined || max <= 0) {
value = 100;
} else {
counter++;
value = Math.max(Math.min(counter * 100 / max, 100), 0);
}
// update progress bar
progressBar.width(String(value) + "%");
progressTextFront.text(text);
progressTextBack.text(text);
progressTextFront.width(progress.width());
// if not successful, apply failure class
if (!success && !progressBar.hasClass(barClassFailure)) {
progressBar
.removeClass(barClassSuccess)
.addClass(barClassFailure);
}
if (output && pre) {
if (success) {
pre.append($("<span class='" + outputClassSuccess + "'>" + text + "</span><br>"));
} else {
pre.append($("<span class='" + outputClassFailure + "'>" + text + "</span><br>"));
}
pre.scrollTop(pre[0].scrollHeight - pre.height());
}
})
.done(function() {
button.prop("disabled", false);
if (close) {
modal.modal("hide");
}
})
.fail(function() {
button.prop("disabled", false);
});
return modal;
}
function showReloadOverlay() {
$("#reloadui_overlay").show();
}
function wrapPromiseWithAlways(p) {
var deferred = $.Deferred();
p.always(function() { deferred.resolve.apply(deferred, arguments); });
return deferred.promise();
}
function commentableLinesToArray(lines) {
return splitTextToArray(lines, "\n", true, function(item) {return !_.startsWith(item, "#")});
}

View file

@ -30,6 +30,9 @@ $(function() {
self.isReady = ko.observable(undefined);
self.isLoading = ko.observable(undefined);
self.markedForFileDeletion = ko.observableArray([]);
self.markedForUnrenderedDeletion = ko.observableArray([]);
self.isTemporary = ko.pureComputed(function() {
return self.isDirty() && !self.persist();
});
@ -145,12 +148,17 @@ $(function() {
var config = response.config;
if (config === undefined) return;
self.timelapseType(config.type);
// timelapses & unrendered
self.listHelper.updateItems(response.files);
self.listHelper.resetPage();
if (response.unrendered) {
self.unrenderedListHelper.updateItems(response.unrendered);
self.unrenderedListHelper.resetPage();
}
// timelapse config
self.timelapseType(config.type);
if (config.type == "timed") {
if (config.interval != undefined && config.interval > 0) {
self.timelapseTimedInterval(config.interval);
@ -207,14 +215,135 @@ $(function() {
self.isLoading(data.flags.loading);
};
self.markFilesOnPage = function() {
self.markedForFileDeletion(_.uniq(self.markedForFileDeletion().concat(_.map(self.listHelper.paginatedItems(), "name"))));
};
self.markAllFiles = function() {
self.markedForFileDeletion(_.map(self.listHelper.allItems, "name"));
};
self.clearMarkedFiles = function() {
self.markedForFileDeletion.removeAll();
};
self.removeFile = function(filename) {
OctoPrint.timelapse.delete(filename)
.done(self.requestData);
var perform = function() {
OctoPrint.timelapse.delete(filename)
.done(function() {
self.markedForFileDeletion.remove(filename);
self.requestData()
});
};
showConfirmationDialog(_.sprintf(gettext("You are about to delete timelapse file \"%(name)s\"."), {name: filename}),
perform)
};
self.removeMarkedFiles = function() {
var perform = function() {
self._bulkRemove(self.markedForFileDeletion(), "files")
.done(function() {
self.markedForFileDeletion.removeAll();
});
};
showConfirmationDialog(_.sprintf(gettext("You are about to delete %(count)d timelapse files."), {count: self.markedForFileDeletion().length}),
perform);
};
self.markUnrenderedOnPage = function() {
self.markedForUnrenderedDeletion(_.uniq(self.markedForUnrenderedDeletion().concat(_.map(self.unrenderedListHelper.paginatedItems(), "name"))));
};
self.markAllUnrendered = function() {
self.markedForUnrenderedDeletion(_.map(self.unrenderedListHelper.allItems, "name"));
};
self.clearMarkedUnrendered = function() {
self.markedForUnrenderedDeletion.removeAll();
};
self.removeUnrendered = function(name) {
OctoPrint.timelapse.deleteUnrendered(name)
.done(self.requestData);
var perform = function() {
OctoPrint.timelapse.deleteUnrendered(name)
.done(function() {
self.markedForUnrenderedDeletion.remove(name);
self.requestData();
});
};
showConfirmationDialog(_.sprintf(gettext("You are about to delete unrendered timelapse \"%(name)s\"."), {name: name}),
perform)
};
self.removeMarkedUnrendered = function() {
var perform = function() {
self._bulkRemove(self.markedForUnrenderedDeletion(), "unrendered")
.done(function() {
self.markedForUnrenderedDeletion.removeAll();
});
};
showConfirmationDialog(_.sprintf(gettext("You are about to delete %(count)d unrendered timelapses."), {count: self.markedForUnrenderedDeletion().length}),
perform);
};
self._bulkRemove = function(files, type) {
var title, message, handler;
if (type == "files") {
title = gettext("Deleting timelapse files");
message = _.sprintf(gettext("Deleting %(count)d timelapse files..."), {count: files.length});
handler = function(filename) {
return OctoPrint.timelapse.delete(filename)
.done(function() {
deferred.notify(_.sprintf(gettext("Deleted %(filename)s..."), {filename: filename}), true);
})
.fail(function() {
deferred.notify(_.sprintf(gettext("Deletion of %(filename)s failed, continuing..."), {filename: filename}), false);
});
}
} else if (type == "unrendered") {
title = gettext("Deleting unrendered timelapses");
message = _.sprintf(gettext("Deleting %(count)d unrendered timelapses..."), {count: files.length});
handler = function(filename) {
return OctoPrint.timelapse.deleteUnrendered(filename)
.done(function() {
deferred.notify(_.sprintf(gettext("Deleted %(filename)s..."), {filename: filename}), true);
})
.fail(function() {
deferred.notify(_.sprintf(gettext("Deletion of %(filename)s failed, continuing..."), {filename: filename}), false);
});
}
} else {
return;
}
var deferred = $.Deferred();
var promise = deferred.promise();
var options = {
title: title,
message: message,
max: files.length,
output: true
};
showProgressModal(options, promise);
var requests = [];
_.each(files, function(filename) {
var request = handler(filename);
requests.push(request)
});
$.when.apply($, _.map(requests, wrapPromiseWithAlways))
.done(function() {
deferred.resolve();
self.requestData();
});
return promise;
};
self.renderUnrendered = function(name) {

View file

@ -311,6 +311,16 @@ table {
}
// timelapse files
&.timelapse_files_checkbox,
&.timelapse_unrendered_checkbox {
text-align: center;
width: 10px;
input[type="checkbox"] {
margin-top: 0;
}
}
&.timelapse_files_name,
&.timelapse_unrendered_name {
text-overflow: ellipsis;
@ -1021,6 +1031,15 @@ textarea.block {
text-decoration: underline;
}
.btn-mini .caret, .btn-small .caret {
margin-top: 8px;
}
.dropdown-menu-right {
right: 0;
left: auto;
}
/** Styles for Bootstrap Slider */
.slider {

View file

@ -64,11 +64,31 @@
<h1>{{ _('Finished Timelapses') }}</h1>
<div class="pull-right">
<small>{{ _('Sort by') }}: <a href="javascript:void(0)" data-bind="click: function() { listHelper.changeSorting('name'); }">{{ _('Name') }} ({{ _('ascending') }})</a> | <a href="javascript:void(0)" data-bind="click: function() { listHelper.changeSorting('creation'); }">{{ _('Creation date') }} ({{ _('descending') }})</a> | <a href="javascript:void(0)" data-bind="click: function() { listHelper.changeSorting('size'); }">{{ _('Size') }} ({{ _('descending') }})</a></small>
<div class="btn-group">
<button class="btn btn-small dropdown-toggle" data-toggle="dropdown"><i class="icon-wrench"></i> <span class="caret"></span></button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="javascript:void(0)" data-bind="click: function() { listHelper.changeSorting('name'); }"><i class="icon-ok" data-bind="style: {visibility: listHelper.currentSorting() == 'name' ? 'visible' : 'hidden'}"></i> {{ _('Sort by name') }} ({{ _('ascending') }})</a></li>
<li><a href="javascript:void(0)" data-bind="click: function() { listHelper.changeSorting('creation'); }"><i class="icon-ok" data-bind="style: {visibility: listHelper.currentSorting() == 'creation' ? 'visible' : 'hidden'}"></i> {{ _('Sort by creation date') }} ({{ _('descending') }})</a></li>
<li><a href="javascript:void(0)" data-bind="click: function() { listHelper.changeSorting('size'); }"><i class="icon-ok" data-bind="style: {visibility: listHelper.currentSorting() == 'size' ? 'visible' : 'hidden'}"></i> {{ _('Sort by file size') }} ({{ _('descending') }})</a></li>
</ul>
</div>
</div>
<div class="pull-left">
<div class="btn-group">
<button class="btn btn-small dropdown-toggle" data-toggle="dropdown"><i class="icon-check-empty"></i> <span class="caret"></span></button>
<ul class="dropdown-menu">
<li><a href="javascript:void(0)" data-bind="click: markFilesOnPage">{{ _('Select all on this page') }}</a></li>
<li><a href="javascript:void(0)" data-bind="click: markAllFiles">{{ _('Select all') }}</a></li>
<li class="divider"></li>
<li><a href="javascript:void(0)" data-bind="click: clearMarkedFiles">{{ _('Clear selection') }}</a></li>
</ul>
</div>
<button class="btn btn-small" data-bind="click: removeMarkedFiles, enable: markedForFileDeletion().length > 0">{{ _('Delete selected') }}</button>
</div>
<table class="table table-striped table-hover table-condensed table-hover" id="timelapse_files">
<thead>
<tr>
<th class="timelapse_files_checkbox"></th>
<th class="timelapse_files_name">{{ _('Name') }}</th>
<th class="timelapse_files_size">{{ _('Size') }}</th>
<th class="timelapse_files_action">{{ _('Action') }}</th>
@ -76,6 +96,7 @@
</thead>
<tbody data-bind="foreach: listHelper.paginatedItems">
<tr data-bind="attr: {title: name}">
<td class="timelapse_files_checkbox"><input type="checkbox" data-bind="value: name, checked: $root.markedForFileDeletion"></td>
<td class="timelapse_files_name" data-bind="text: name"></td>
<td class="timelapse_files_size" data-bind="text: size"></td>
<td class="timelapse_files_action"><a href="javascript:void(0)" class="icon-trash" data-bind="click: function() { if ($root.loginState.isUser()) { $parent.removeFile($data.name); } else { return; } }, css: {disabled: !$root.loginState.isUser()}"></a>&nbsp;|&nbsp;<a href="javascript:void(0)" class="icon-download" data-bind="attr: {href: url}"></a></td>
@ -97,9 +118,22 @@
<div data-bind="visible: unrenderedListHelper.allSize">
<div><small><a href="javascript:void(0)" class="muted" onclick="$(this).children().toggleClass('icon-caret-right icon-caret-down').parent().parent().parent().next().slideToggle('fast')"><i class="icon-caret-right"></i> {{ _('Unrendered Timelapses') }}</a></small></div>
<div class="hide">
<div class="pull-left">
<div class="btn-group">
<button class="btn btn-small dropdown-toggle" data-toggle="dropdown"><i class="icon-check-empty"></i> <span class="caret"></span></button>
<ul class="dropdown-menu">
<li><a href="javascript:void(0)" data-bind="click: markUnrenderedOnPage">{{ _('Select all on this page') }}</a></li>
<li><a href="javascript:void(0)" data-bind="click: markAllUnrendered">{{ _('Select all') }}</a></li>
<li class="divider"></li>
<li><a href="javascript:void(0)" data-bind="click: clearMarkedUnrendered">{{ _('Clear selection') }}</a></li>
</ul>
</div>
<button class="btn btn-small" data-bind="click: removeMarkedUnrendered, enable: markedForUnrenderedDeletion().length > 0">{{ _('Delete selected') }}</button>
</div>
<table class="table table-striped table-hover table-condensed table-hover" id="timelapse_unrendered">
<thead>
<tr>
<th class="timelapse_unrendered_checkbox"></th>
<th class="timelapse_unrendered_name">{{ _('Name') }}</th>
<th class="timelapse_unrendered_count">{{ _('Frames') }}</th>
<th class="timelapse_unrendered_size">{{ _('Size') }}</th>
@ -108,6 +142,7 @@
</thead>
<tbody data-bind="foreach: unrenderedListHelper.paginatedItems">
<tr data-bind="attr: {title: name}">
<td class="timelapse_unrendered_checkbox"><input type="checkbox" data-bind="value: name, checked: $root.markedForUnrenderedDeletion"></td>
<td class="timelapse_unrendered_name" data-bind="text: name"></td>
<td class="timelapse_unrendered_count" data-bind="text: count"></td>
<td class="timelapse_unrendered_size" data-bind="text: size"></td>