Webapp: Add multi-select of tasks and support actions for multiple tasks (#104386)
Add the following features: - `Ctrl + Click` / `Cmd + Click` to toggle selection of additional tasks. - `Shift + Click` to select a range of additional tasks. - Ability to perform `Cancel` and `Requeue` actions on multiple tasks concurrently. - Notifications on how many tasks successfully/failed to have an action performed. Tabulator has selectable rows built-in and provides a function that can return an array of all selected rows. However, tabulator's default behavior for multi-selection does not reset to a single task after each regular click. Therefore, I built a custom multi-select using the tabulator API, introducing `Shift + click` and `Ctrl + click` and matching their behaviors as they work in most file explorers, including Blender's. In addition to manipulating the Tabulator's row selection, the state of selected Tasks is also needs to be copied to Pinia stores. This stores will allow us to access selected Tasks from any component and make API calls on them. Ref: #99396 Reviewed-on: https://projects.blender.org/studio/flamenco/pulls/104386 Reviewed-by: Sybren A. Stüvel <sybren@blender.org>
This commit is contained in:
parent
ccc002036b
commit
984b132d81
@ -29,16 +29,18 @@ export default {
|
||||
},
|
||||
|
||||
_handleTaskActionPromise(promise, description) {
|
||||
// const numTasks = this.tasks.numSelected;
|
||||
const numTasks = 1;
|
||||
return promise
|
||||
.then(() => {
|
||||
// There used to be a call to `this.notifs.add(message)` here, but now
|
||||
// that task status changes are logged in the notifications anyway,
|
||||
// it's no longer necessary.
|
||||
// This function is still kept, in case we want to bring back the
|
||||
// notifications when multiple tasks can be selected. Then a summary
|
||||
// ("N tasks requeued") could be logged here.
|
||||
.then((values) => {
|
||||
const { incompatibleTasks, compatibleTasks, totalTaskCount } = values;
|
||||
|
||||
// TODO: messages could be improved to specify the names of tasks that failed
|
||||
const failedMessage = `Could not apply ${description} status to ${incompatibleTasks.length} out of ${totalTaskCount} task(s).`;
|
||||
const successMessage = `${compatibleTasks.length} task(s) successfully ${description}.`;
|
||||
|
||||
this.notifs.add(
|
||||
`${compatibleTasks.length > 0 ? successMessage : ''}
|
||||
${incompatibleTasks.length > 0 ? failedMessage : ''}`
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMsg = JSON.stringify(error); // TODO: handle API errors better.
|
||||
|
@ -26,7 +26,6 @@ import TaskActionsBar from '@/components/jobs/TaskActionsBar.vue';
|
||||
import StatusFilterBar from '@/components/StatusFilterBar.vue';
|
||||
|
||||
export default {
|
||||
emits: ['tableRowClicked'],
|
||||
props: [
|
||||
'jobID', // ID of the job of which the tasks are shown here.
|
||||
'taskID', // The active task.
|
||||
@ -40,6 +39,7 @@ export default {
|
||||
tasks: useTasks(),
|
||||
shownStatuses: [],
|
||||
availableStatuses: [], // Will be filled after data is loaded from the backend.
|
||||
lastSelectedTaskPosition: null,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
@ -105,7 +105,7 @@ export default {
|
||||
jobID() {
|
||||
this.fetchTasks();
|
||||
},
|
||||
taskID(oldID, newID) {
|
||||
taskID(newID, oldID) {
|
||||
this._reformatRow(oldID);
|
||||
this._reformatRow(newID);
|
||||
},
|
||||
@ -122,6 +122,14 @@ export default {
|
||||
// updates. Just fetch the data and start from scratch.
|
||||
this.fetchTasks();
|
||||
},
|
||||
/**
|
||||
* @param {string} taskID task ID to navigate to within this job, can be
|
||||
* empty string for "no active task".
|
||||
*/
|
||||
_routeToTask(taskID) {
|
||||
const route = { name: 'jobs', params: { jobID: this.jobID, taskID: taskID } };
|
||||
this.$router.push(route);
|
||||
},
|
||||
sortData() {
|
||||
const tab = this.tabulator;
|
||||
tab.setSort(tab.getSorters()); // This triggers re-sorting.
|
||||
@ -130,27 +138,62 @@ export default {
|
||||
this.tabulator.setFilter(this._filterByStatus);
|
||||
this.fetchTasks();
|
||||
},
|
||||
/**
|
||||
* Fetch task info and set the active task once it's received.
|
||||
*/
|
||||
fetchActiveTask() {
|
||||
// If there's no active task, reset the state and Pinia stores
|
||||
if (!this.taskID) {
|
||||
this.tasks.clearActiveTask();
|
||||
this.tasks.clearSelectedTasks();
|
||||
this.lastSelectedTaskPosition = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, set the state and Pinia stores
|
||||
const jobsApi = new API.JobsApi(getAPIClient()); // init the API
|
||||
jobsApi.fetchTask(this.taskID).then((task) => {
|
||||
this.tasks.setActiveTask(task);
|
||||
this.tasks.setSelectedTasks([task]);
|
||||
|
||||
const activeRow = this.tabulator.getRow(this.taskID);
|
||||
// If the page is reloaded, re-initialize the last selected task (or active task) position, allowing the user to multi-select from that task.
|
||||
this.lastSelectedTaskPosition = activeRow.getPosition();
|
||||
// Make sure the active row on tabulator has the selected status toggled as well
|
||||
this.tabulator.selectRow(activeRow);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Fetch all tasks and set the Tabulator data
|
||||
*/
|
||||
fetchTasks() {
|
||||
// No active job
|
||||
if (!this.jobID) {
|
||||
this.tabulator.setData([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const jobsApi = new API.JobsApi(getAPIClient());
|
||||
jobsApi.fetchJobTasks(this.jobID).then(this.onTasksFetched, function (error) {
|
||||
// TODO: error handling.
|
||||
console.error(error);
|
||||
});
|
||||
},
|
||||
onTasksFetched(data) {
|
||||
// "Down-cast" to TaskUpdate to only get those fields, just for debugging things:
|
||||
// let tasks = data.tasks.map((j) => API.TaskUpdate.constructFromObject(j));
|
||||
// Deselect all rows before setting new task data. This prevents the error caused by trying to deselect rows that don't exist on the new data.
|
||||
this.tabulator.deselectRow();
|
||||
|
||||
const jobsApi = new API.JobsApi(getAPIClient()); // init the API
|
||||
|
||||
jobsApi.fetchJobTasks(this.jobID).then(
|
||||
(data) => {
|
||||
this.tabulator.setData(data.tasks);
|
||||
this._refreshAvailableStatuses();
|
||||
|
||||
this.recalcTableHeight();
|
||||
|
||||
this.fetchActiveTask();
|
||||
},
|
||||
(error) => {
|
||||
// TODO: error handling.
|
||||
console.error(error);
|
||||
}
|
||||
);
|
||||
},
|
||||
processTaskUpdate(taskUpdate) {
|
||||
// Any updates to tasks i.e. status changes will need to reflect its changes to the rows on Tabulator here.
|
||||
// updateData() will only overwrite properties that are actually set on
|
||||
// taskUpdate, and leave the rest as-is.
|
||||
if (this.tabulator.initialized) {
|
||||
@ -161,15 +204,68 @@ export default {
|
||||
this.tabulator.redraw();
|
||||
}); // Resize columns based on new data.
|
||||
}
|
||||
this.tasks.setSelectedTasks(this.getSelectedTasks()); // Update Pinia stores
|
||||
this._refreshAvailableStatuses();
|
||||
},
|
||||
getSelectedTasks() {
|
||||
return this.tabulator.getSelectedData();
|
||||
},
|
||||
handleMultiSelect(event, row, tabulator) {
|
||||
const position = row.getPosition();
|
||||
|
||||
// Manage the click event and Tabulator row selection
|
||||
if (event.shiftKey && this.lastSelectedTaskPosition) {
|
||||
// Shift + Click - selects a range of rows
|
||||
let start = Math.min(position, this.lastSelectedTaskPosition);
|
||||
let end = Math.max(position, this.lastSelectedTaskPosition);
|
||||
const rowsToSelect = [];
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
const currRow = this.tabulator.getRowFromPosition(i);
|
||||
rowsToSelect.push(currRow);
|
||||
}
|
||||
tabulator.selectRow(rowsToSelect);
|
||||
|
||||
// Remove text-selection that occurs during Shift + Click
|
||||
document.getSelection().removeAllRanges();
|
||||
} else if (event.ctrlKey || event.metaKey) {
|
||||
// Supports Cmd key on MacOS
|
||||
// Ctrl + Click - toggles additional rows
|
||||
if (tabulator.getSelectedRows().includes(row)) {
|
||||
tabulator.deselectRow(row);
|
||||
} else {
|
||||
tabulator.selectRow(row);
|
||||
}
|
||||
} else if (!event.ctrlKey && !event.metaKey) {
|
||||
// Regular Click - resets the selection to one row
|
||||
tabulator.deselectRow(); // De-select all rows
|
||||
tabulator.selectRow(row);
|
||||
}
|
||||
},
|
||||
onRowClick(event, row) {
|
||||
// Take a copy of the data, so that it's decoupled from the tabulator data
|
||||
// store. There were some issues where navigating to another job would
|
||||
// overwrite the old job's ID, and this prevents that.
|
||||
const rowData = plain(row.getData());
|
||||
this.$emit('tableRowClicked', rowData);
|
||||
// Handles Shift + Click, Ctrl + Click, and regular Click
|
||||
this.handleMultiSelect(event, row, this.tabulator);
|
||||
|
||||
// Update the app route, Pinia store, and component state
|
||||
if (this.tabulator.getSelectedRows().includes(row)) {
|
||||
// The row was toggled -> selected
|
||||
const rowData = row.getData();
|
||||
this._routeToTask(rowData.id);
|
||||
|
||||
const jobsApi = new API.JobsApi(getAPIClient()); // init the API
|
||||
jobsApi.fetchTask(rowData.id).then((task) => {
|
||||
// row.getData() will return the API.TaskSummary data, while tasks.setActiveTask() needs the entire API.Task
|
||||
this.tasks.setActiveTask(task);
|
||||
});
|
||||
this.lastSelectedTaskPosition = row.getPosition();
|
||||
} else {
|
||||
// The row was toggled -> de-selected
|
||||
this._routeToTask('');
|
||||
this.tasks.clearActiveTask();
|
||||
this.lastSelectedTaskPosition = null;
|
||||
}
|
||||
|
||||
this.tasks.setSelectedTasks(this.getSelectedTasks()); // Set the selected tasks according to tabulator's selected rows
|
||||
},
|
||||
toggleStatusFilter(status) {
|
||||
const asSet = new Set(this.shownStatuses);
|
||||
|
@ -25,10 +25,10 @@ export const useJobs = defineStore('jobs', {
|
||||
}),
|
||||
getters: {
|
||||
canDelete() {
|
||||
return this._anyJobWithStatus(['queued', 'paused', 'failed', 'completed', 'canceled']);
|
||||
return this._anyJobWithStatus(['completed', 'canceled', 'failed', 'paused', 'queued']);
|
||||
},
|
||||
canCancel() {
|
||||
return this._anyJobWithStatus(['queued', 'active', 'failed']);
|
||||
return this._anyJobWithStatus(['active', 'failed', 'queued']);
|
||||
},
|
||||
canRequeue() {
|
||||
return this._anyJobWithStatus(['canceled', 'completed', 'failed', 'paused']);
|
||||
|
@ -6,6 +6,8 @@ import { useJobs } from '@/stores/jobs';
|
||||
|
||||
const jobsAPI = new API.JobsApi(getAPIClient());
|
||||
|
||||
const taskStatusCanCancel = ['active', 'queued', 'soft-failed'];
|
||||
const taskStatusCanRequeue = ['canceled', 'completed', 'failed'];
|
||||
// 'use' prefix is idiomatic for Pinia stores.
|
||||
// See https://pinia.vuejs.org/core-concepts/
|
||||
export const useTasks = defineStore('tasks', {
|
||||
@ -17,6 +19,7 @@ export const useTasks = defineStore('tasks', {
|
||||
* @type {string}
|
||||
*/
|
||||
activeTaskID: '',
|
||||
selectedTasks: [],
|
||||
}),
|
||||
getters: {
|
||||
canCancel() {
|
||||
@ -24,24 +27,33 @@ export const useTasks = defineStore('tasks', {
|
||||
const activeJob = jobs.activeJob;
|
||||
|
||||
if (!activeJob) {
|
||||
console.warn('no active job, unable to determine whether the active task is cancellable');
|
||||
console.warn('no active job, unable to determine whether the task(s) is cancellable');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (activeJob.status == 'pause-requested') {
|
||||
// Cancelling a task should not be possible while the job is being paused.
|
||||
// Cancelling task(s) should not be possible while the job is being paused.
|
||||
// In the future this might be supported, see issue #104315.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow cancellation for specified task statuses.
|
||||
return this._anyTaskWithStatus(['queued', 'active', 'soft-failed']);
|
||||
return this._anyTaskWithStatus(taskStatusCanCancel);
|
||||
},
|
||||
canRequeue() {
|
||||
return this._anyTaskWithStatus(['canceled', 'completed', 'failed']);
|
||||
return this._anyTaskWithStatus(taskStatusCanRequeue);
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
setSelectedTasks(tasks) {
|
||||
this.$patch({
|
||||
selectedTasks: tasks,
|
||||
});
|
||||
},
|
||||
clearSelectedTasks() {
|
||||
this.$patch({
|
||||
selectedTasks: [],
|
||||
});
|
||||
},
|
||||
setActiveTaskID(taskID) {
|
||||
this.$patch({
|
||||
activeTask: { id: taskID },
|
||||
@ -54,7 +66,7 @@ export const useTasks = defineStore('tasks', {
|
||||
activeTaskID: task.id,
|
||||
});
|
||||
},
|
||||
deselectAllTasks() {
|
||||
clearActiveTask() {
|
||||
this.$patch({
|
||||
activeTask: null,
|
||||
activeTaskID: '',
|
||||
@ -65,43 +77,64 @@ export const useTasks = defineStore('tasks', {
|
||||
* Actions on the selected tasks.
|
||||
*
|
||||
* All the action functions return a promise that resolves when the action has been performed.
|
||||
*
|
||||
* TODO: actually have these work on all selected tasks. For simplicity, the
|
||||
* code now assumes that only the active task needs to be operated on.
|
||||
*/
|
||||
cancelTasks() {
|
||||
return this._setTaskStatus('canceled');
|
||||
return this._setTaskStatus('canceled', taskStatusCanCancel);
|
||||
},
|
||||
requeueTasks() {
|
||||
return this._setTaskStatus('queued');
|
||||
return this._setTaskStatus('queued', taskStatusCanRequeue);
|
||||
},
|
||||
|
||||
// Internal methods.
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string[]} statuses
|
||||
* @param {string[]} task_statuses
|
||||
* @returns bool indicating whether there is a selected task with any of the given statuses.
|
||||
*/
|
||||
_anyTaskWithStatus(statuses) {
|
||||
return (
|
||||
!!this.activeTask && !!this.activeTask.status && statuses.includes(this.activeTask.status)
|
||||
);
|
||||
// return this.selectedTasks.reduce((foundTask, task) => (foundTask || statuses.includes(task.status)), false);
|
||||
_anyTaskWithStatus(task_statuses) {
|
||||
if (this.selectedTasks.length) {
|
||||
return this.selectedTasks.some((task) => task_statuses.includes(task.status));
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Transition the selected task(s) to the new status.
|
||||
* @param {string} newStatus
|
||||
* @returns a Promise for the API request.
|
||||
* @param {string[]} task_statuses The task statuses compatible with the transition to new status
|
||||
* @returns a Promise for the API request(s).
|
||||
*/
|
||||
_setTaskStatus(newStatus) {
|
||||
if (!this.activeTaskID) {
|
||||
console.warn(`_setTaskStatus(${newStatus}) impossible, no active task ID`);
|
||||
_setTaskStatus(newStatus, task_statuses) {
|
||||
const totalTaskCount = this.selectedTasks.length;
|
||||
|
||||
if (!totalTaskCount) {
|
||||
console.warn(`_setTaskStatus(${newStatus}) impossible, no selected tasks`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { compatibleTasks, incompatibleTasks } = this.selectedTasks.reduce(
|
||||
(result, task) => {
|
||||
if (task_statuses.includes(task.status)) {
|
||||
result.compatibleTasks.push(task);
|
||||
} else {
|
||||
result.incompatibleTasks.push(task);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
{ compatibleTasks: [], incompatibleTasks: [] }
|
||||
);
|
||||
|
||||
const statuschange = new API.TaskStatusChange(newStatus, 'requested from web interface');
|
||||
return jobsAPI.setTaskStatus(this.activeTaskID, statuschange);
|
||||
const setTaskStatusPromises = compatibleTasks.map((task) =>
|
||||
jobsAPI.setTaskStatus(task.id, statuschange)
|
||||
);
|
||||
|
||||
return Promise.allSettled(setTaskStatusPromises).then((results) => ({
|
||||
compatibleTasks: results,
|
||||
incompatibleTasks,
|
||||
totalTaskCount,
|
||||
}));
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -13,12 +13,7 @@
|
||||
ref="jobDetails"
|
||||
:jobData="jobs.activeJob"
|
||||
@reshuffled="_recalcTasksTableHeight" />
|
||||
<tasks-table
|
||||
v-if="hasJobData"
|
||||
ref="tasksTable"
|
||||
:jobID="jobID"
|
||||
:taskID="taskID"
|
||||
@tableRowClicked="onTableTaskClicked" />
|
||||
<tasks-table v-if="hasJobData" ref="tasksTable" :jobID="jobID" :taskID="taskID" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="col col-3">
|
||||
@ -116,7 +111,6 @@ export default {
|
||||
// })
|
||||
|
||||
this._fetchJob(this.jobID);
|
||||
this._fetchTask(this.taskID);
|
||||
|
||||
window.addEventListener('resize', this._recalcTasksTableHeight);
|
||||
},
|
||||
@ -127,9 +121,6 @@ export default {
|
||||
jobID(newJobID, oldJobID) {
|
||||
this._fetchJob(newJobID);
|
||||
},
|
||||
taskID(newTaskID, oldTaskID) {
|
||||
this._fetchTask(newTaskID);
|
||||
},
|
||||
showFooterPopup(shown) {
|
||||
if (shown) localStorage.setItem('footer-popover-visible', 'true');
|
||||
else localStorage.removeItem('footer-popover-visible');
|
||||
@ -139,31 +130,12 @@ export default {
|
||||
methods: {
|
||||
onTableJobClicked(rowData) {
|
||||
// Don't route to the current job, as that'll deactivate the current task.
|
||||
if (rowData.id == this.jobID) return;
|
||||
if (rowData.id === this.jobID) return;
|
||||
this._routeToJob(rowData.id);
|
||||
},
|
||||
onTableTaskClicked(rowData) {
|
||||
this._routeToTask(rowData.id);
|
||||
},
|
||||
onActiveJobDeleted(deletedJobUUID) {
|
||||
this._routeToJobOverview();
|
||||
},
|
||||
|
||||
onSelectedTaskChanged(taskSummary) {
|
||||
if (!taskSummary) {
|
||||
// There is no active task.
|
||||
this.tasks.deselectAllTasks();
|
||||
return;
|
||||
}
|
||||
|
||||
const jobsAPI = new API.JobsApi(getAPIClient());
|
||||
jobsAPI.fetchTask(taskSummary.id).then((task) => {
|
||||
this.tasks.setActiveTask(task);
|
||||
// Forward the full task to Tabulator, so that that gets updated too.
|
||||
if (this.$refs.tasksTable) this.$refs.tasksTable.processTaskUpdate(task);
|
||||
});
|
||||
},
|
||||
|
||||
showTaskLogTail() {
|
||||
this.showFooterPopup = true;
|
||||
this.$nextTick(() => {
|
||||
@ -186,7 +158,6 @@ export default {
|
||||
this._fetchJob(this.jobID);
|
||||
if (jobUpdate.refresh_tasks) {
|
||||
if (this.$refs.tasksTable) this.$refs.tasksTable.fetchTasks();
|
||||
this._fetchTask(this.taskID);
|
||||
}
|
||||
},
|
||||
|
||||
@ -196,7 +167,7 @@ export default {
|
||||
*/
|
||||
onSioTaskUpdate(taskUpdate) {
|
||||
if (this.$refs.tasksTable) this.$refs.tasksTable.processTaskUpdate(taskUpdate);
|
||||
if (this.taskID == taskUpdate.id) this._fetchTask(this.taskID);
|
||||
|
||||
this.notifs.addTaskUpdate(taskUpdate);
|
||||
},
|
||||
|
||||
@ -230,14 +201,6 @@ export default {
|
||||
const route = { name: 'jobs', params: { jobID: jobID } };
|
||||
this.$router.push(route);
|
||||
},
|
||||
/**
|
||||
* @param {string} taskID task ID to navigate to within this job, can be
|
||||
* empty string for "no active task".
|
||||
*/
|
||||
_routeToTask(taskID) {
|
||||
const route = { name: 'jobs', params: { jobID: this.jobID, taskID: taskID } };
|
||||
this.$router.push(route);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch job info and set the active job once it's received.
|
||||
@ -268,24 +231,6 @@ export default {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch task info and set the active task once it's received.
|
||||
* @param {string} taskID task ID, can be empty string for "no task".
|
||||
*/
|
||||
_fetchTask(taskID) {
|
||||
if (!taskID) {
|
||||
this.tasks.deselectAllTasks();
|
||||
return;
|
||||
}
|
||||
|
||||
const jobsAPI = new API.JobsApi(getAPIClient());
|
||||
return jobsAPI.fetchTask(taskID).then((task) => {
|
||||
this.tasks.setActiveTask(task);
|
||||
// Forward the full task to Tabulator, so that that gets updated too.\
|
||||
if (this.$refs.tasksTable) this.$refs.tasksTable.processTaskUpdate(task);
|
||||
});
|
||||
},
|
||||
|
||||
onChatMessage(message) {
|
||||
console.log('chat message received:', message);
|
||||
this.messages.push(`${message.text}`);
|
||||
|
Loading…
x
Reference in New Issue
Block a user