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:
parent
675a54aa05
commit
3ec2d7bd14
5 changed files with 349 additions and 7 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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, "#")});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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> | <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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue