Attract multi edit: Edit multiple tasks/shots/assets at the same time

For the user:
Ctrl + L-Mouse to select multiple tasks/shots/assets and then edit
the nodes as before. When multiple items are selected a chain icon
can be seen in editor next to the fields. If the chain is broken
it indicates that the values are not the same on all the selected
items.

When a field has been edited it will be marked with a green background
color.

The items are saved one by one in parallel. This means that one item
could fail to be saved, while the others get updated.

For developers:
The editor and activities has been ported to Vue. The table and has
been updated to support multi select.

MultiEditEngine is the core of the multi edit. It keeps track of
what values differs and what has been edited.
This commit is contained in:
2019-03-13 13:53:40 +01:00
parent f4c7101427
commit bae39ce01d
49 changed files with 1879 additions and 516 deletions

View File

@@ -1,92 +1,7 @@
/**
* Open an item such as tasks/shots in the #item-details div
*/
function item_open(item_id, item_type, pushState, project_url)
{
if (item_id === undefined || item_type === undefined) {
throw new ReferenceError("item_open(" + item_id + ", " + item_type + ") called.");
}
if (typeof project_url === 'undefined') {
project_url = ProjectUtils.projectUrl();
if (typeof project_url === 'undefined') {
throw new ReferenceError("ProjectUtils.projectUrl() undefined");
}
}
// Special case to highlight the shot row when opening task in shot or asset context
var pu_ctx = ProjectUtils.context();
var pc_ctx_shot_asset = (pu_ctx == 'shot' || pu_ctx == 'asset');
var item_url = '/attract/' + project_url + '/' + item_type + 's/' + item_id;
var push_url = item_url;
if (pc_ctx_shot_asset && item_type == 'task'){
push_url = '/attract/' + project_url + '/' + pu_ctx + 's/with-task/' + item_id;
}
item_url += '?context=' + pu_ctx;
$.get(item_url, function(item_data) {
$('#item-details').html(item_data);
$('#col_right .col_header span.header_text').text(item_type + ' details');
}).fail(function(xhr) {
if (console) {
console.log('Error fetching task', item_id, 'from', item_url);
console.log('XHR:', xhr);
}
toastr.error('Failed to open ' + item_type);
if (xhr.status) {
$('#item-details').html(xhr.responseText);
} else {
$('#item-details').html('<p class="text-danger">Opening ' + item_type + ' failed. There possibly was ' +
'an error connecting to the server. Please check your network connection and ' +
'try again.</p>');
}
});
// Determine whether we should push the new state or not.
pushState = (typeof pushState !== 'undefined') ? pushState : true;
if (!pushState) return;
// Push the correct URL onto the history.
var push_state = {itemId: item_id, itemType: item_type};
window.history.pushState(
push_state,
item_type + ': ' + item_id,
push_url
);
}
// Fine if project_url is undefined, but that requires ProjectUtils.projectUrl().
function task_open(task_id, project_url)
{
item_open(task_id, 'task', true, project_url);
}
function shot_open(shot_id)
{
item_open(shot_id, 'shot');
}
function asset_open(asset_id)
{
item_open(asset_id, 'asset');
}
window.onpopstate = function(event)
{
var state = event.state;
if(!state) return;
item_open(state.itemId, state.itemType, false);
}
/**
* Create a asset and show it in the #item-details div.
*/
function asset_create(project_url)
function thenCreateAsset(project_url)
{
if (project_url === undefined) {
throw new ReferenceError("asset_create(" + project_url+ ") called.");
@@ -97,10 +12,9 @@ function asset_create(project_url)
project_url: project_url
};
$.post(url, data, function(asset_data) {
/* window.location.href = asset_data.asset_id; */
return $.post(url, data, function(asset_data) {
pillar.events.Nodes.triggerCreated(asset_data);
asset_open(asset_data._id);
return asset_data;
})
.fail(function(xhr) {
if (console) {
@@ -118,7 +32,7 @@ function asset_create(project_url)
* 'shot_id' may be undefined, in which case the task will not
* be attached to a shot.
*/
function task_create(shot_id, task_type)
function thenCreateTask(shot_id, task_type)
{
if (task_type === undefined) {
throw new ReferenceError("task_create(" + shot_id + ", " + task_type + ") called.");
@@ -133,10 +47,10 @@ function task_create(shot_id, task_type)
};
if (has_shot_id) data.parent = shot_id;
$.post(url, data, function(task_data) {
return $.post(url, data, function(task_data) {
if (console) console.log('Task created:', task_data);
pillar.events.Nodes.triggerCreated(task_data);
task_open(task_data._id);
return task_data;
})
.fail(function(xhr) {
if (console) {
@@ -144,167 +58,9 @@ function task_create(shot_id, task_type)
console.log('XHR:', xhr);
}
$('#item-details').html(xhr.responseText);
})
.done(function(){
$('#item-details input[name="name"]').focus();
});
}
function attract_form_save(form_id, item_id, item_save_url, options)
{
// Mandatory option.
if (typeof options === 'undefined' || typeof options.type === 'undefined') {
throw new ReferenceError('attract_form_save(): options.type is mandatory.');
}
var $form = $('#' + form_id);
var $button = $form.find("button[type='submit']");
var payload = $form.serialize();
var $item = $('#' + item_id);
$button.attr('disabled', true);
if (console) console.log('Sending:', payload);
$.post(item_save_url, payload)
.done(function(saved_item) {
if (console) console.log('Done saving', saved_item);
toastr.success('Saved ' + options.type + '. ' + saved_item._updated);
pillar.events.Nodes.triggerUpdated(saved_item);
$form.find("input[name='_etag']").val(saved_item._etag);
if (options.done) options.done($item, saved_item);
})
.fail(function(xhr_or_response_data) {
// jQuery sends the response data (if JSON), or an XHR object (if not JSON).
if (console) console.log('Failed saving', options.type, xhr_or_response_data);
$button.removeClass('btn-outline-success').addClass('btn-danger');
toastr.error('Failed saving. ' + xhr_or_response_data.status);
if (options.fail) options.fail($item, xhr_or_response_data);
})
.always(function() {
$button.attr('disabled', false);
if (options.always) options.always($item);
})
;
return false; // prevent synchronous POST to current page.
}
function task_save(task_id, task_url) {
return attract_form_save('item_form', 'task-' + task_id, task_url, {
done: function($task, saved_task) {
task_open(task_id);
},
fail: function($item, xhr_or_response_data) {
if (xhr_or_response_data.status == 412) {
// TODO: implement something nice here. Just make sure we don't throw
// away the user's edits. It's up to the user to handle this.
} else {
$('#item-details').html(xhr_or_response_data.responseText);
}
},
type: 'task'
});
}
function shot_save(shot_id, shot_url) {
return attract_form_save('item_form', 'shot-' + shot_id, shot_url, {
done: function($shot, saved_shot) {
shot_open(shot_id);
},
fail: function($item, xhr_or_response_data) {
if (xhr_or_response_data.status == 412) {
// TODO: implement something nice here. Just make sure we don't throw
// away the user's edits. It's up to the user to handle this.
} else {
$('#item-details').html(xhr_or_response_data.responseText);
}
},
type: 'shot'
});
}
function asset_save(asset_id, asset_url) {
return attract_form_save('item_form', 'asset-' + asset_id, asset_url, {
done: function($asset, saved_asset) {
asset_open(asset_id);
},
fail: function($item, xhr_or_response_data) {
if (xhr_or_response_data.status == 412) {
// TODO: implement something nice here. Just make sure we don't throw
// away the user's edits. It's up to the user to handle this.
} else {
$('#item-details').html(xhr_or_response_data.responseText);
}
},
type: 'asset'
});
}
function task_delete(task_id, task_etag, task_delete_url) {
if (task_id === undefined || task_etag === undefined || task_delete_url === undefined) {
throw new ReferenceError("task_delete(" + task_id + ", " + task_etag + ", " + task_delete_url + ") called.");
}
$.ajax({
type: 'DELETE',
url: task_delete_url,
data: {'etag': task_etag}
})
.done(function(e) {
if (console) console.log('Task', task_id, 'was deleted.');
$('#item-details').fadeOutAndClear();
pillar.events.Nodes.triggerDeleted(task_id);
toastr.success('Task deleted');
})
.fail(function(xhr) {
toastr.error('Unable to delete task, code ' + xhr.status);
if (xhr.status == 412) {
alert('Someone else edited this task before you deleted it; refresh to try again.');
// TODO: implement something nice here. Just make sure we don't throw
// away the user's edits. It's up to the user to handle this.
// TODO: refresh activity feed and point user to it.
} else {
// TODO: find a better place to put this error message, without overwriting the
// task the user is looking at in-place.
$('#task-view-feed').html(xhr.responseText);
}
});
}
function loadActivities(url)
{
return $.get(url)
.done(function(data) {
if(console) console.log('Activities loaded OK');
$('#activities').html(data);
})
.fail(function(xhr) {
if (console) {
console.log('Error fetching activities');
console.log('XHR:', xhr);
}
toastr.error('Opening activity log failed.');
if (xhr.status) {
$('#activities').html(xhr.responseText);
} else {
$('#activities').html('<p class="text-danger">Opening activity log failed. There possibly was ' +
'an error connecting to the server. Please check your network connection and ' +
'try again.</p>');
}
});
}
var save_on_ctrl_enter = ['shot', 'asset', 'task'];
$(document).on('keyup', function(e){
if ($.inArray(save_on_ctrl_enter, ProjectUtils.context())) {