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:
Vivian Leung 2025-06-02 12:17:43 +02:00 committed by Sybren A. Stüvel
parent ccc002036b
commit 984b132d81
5 changed files with 186 additions and 110 deletions

View File

@ -29,16 +29,18 @@ export default {
}, },
_handleTaskActionPromise(promise, description) { _handleTaskActionPromise(promise, description) {
// const numTasks = this.tasks.numSelected;
const numTasks = 1;
return promise return promise
.then(() => { .then((values) => {
// There used to be a call to `this.notifs.add(message)` here, but now const { incompatibleTasks, compatibleTasks, totalTaskCount } = values;
// that task status changes are logged in the notifications anyway,
// it's no longer necessary. // TODO: messages could be improved to specify the names of tasks that failed
// This function is still kept, in case we want to bring back the const failedMessage = `Could not apply ${description} status to ${incompatibleTasks.length} out of ${totalTaskCount} task(s).`;
// notifications when multiple tasks can be selected. Then a summary const successMessage = `${compatibleTasks.length} task(s) successfully ${description}.`;
// ("N tasks requeued") could be logged here.
this.notifs.add(
`${compatibleTasks.length > 0 ? successMessage : ''}
${incompatibleTasks.length > 0 ? failedMessage : ''}`
);
}) })
.catch((error) => { .catch((error) => {
const errorMsg = JSON.stringify(error); // TODO: handle API errors better. const errorMsg = JSON.stringify(error); // TODO: handle API errors better.

View File

@ -26,7 +26,6 @@ import TaskActionsBar from '@/components/jobs/TaskActionsBar.vue';
import StatusFilterBar from '@/components/StatusFilterBar.vue'; import StatusFilterBar from '@/components/StatusFilterBar.vue';
export default { export default {
emits: ['tableRowClicked'],
props: [ props: [
'jobID', // ID of the job of which the tasks are shown here. 'jobID', // ID of the job of which the tasks are shown here.
'taskID', // The active task. 'taskID', // The active task.
@ -40,6 +39,7 @@ export default {
tasks: useTasks(), tasks: useTasks(),
shownStatuses: [], shownStatuses: [],
availableStatuses: [], // Will be filled after data is loaded from the backend. availableStatuses: [], // Will be filled after data is loaded from the backend.
lastSelectedTaskPosition: null,
}; };
}, },
mounted() { mounted() {
@ -105,7 +105,7 @@ export default {
jobID() { jobID() {
this.fetchTasks(); this.fetchTasks();
}, },
taskID(oldID, newID) { taskID(newID, oldID) {
this._reformatRow(oldID); this._reformatRow(oldID);
this._reformatRow(newID); this._reformatRow(newID);
}, },
@ -122,6 +122,14 @@ export default {
// updates. Just fetch the data and start from scratch. // updates. Just fetch the data and start from scratch.
this.fetchTasks(); 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() { sortData() {
const tab = this.tabulator; const tab = this.tabulator;
tab.setSort(tab.getSorters()); // This triggers re-sorting. tab.setSort(tab.getSorters()); // This triggers re-sorting.
@ -130,27 +138,62 @@ export default {
this.tabulator.setFilter(this._filterByStatus); this.tabulator.setFilter(this._filterByStatus);
this.fetchTasks(); 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() { fetchTasks() {
// No active job
if (!this.jobID) { if (!this.jobID) {
this.tabulator.setData([]); this.tabulator.setData([]);
return; return;
} }
const jobsApi = new API.JobsApi(getAPIClient()); // 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.
jobsApi.fetchJobTasks(this.jobID).then(this.onTasksFetched, function (error) { this.tabulator.deselectRow();
// TODO: error handling.
console.error(error); const jobsApi = new API.JobsApi(getAPIClient()); // init the API
});
}, jobsApi.fetchJobTasks(this.jobID).then(
onTasksFetched(data) { (data) => {
// "Down-cast" to TaskUpdate to only get those fields, just for debugging things:
// let tasks = data.tasks.map((j) => API.TaskUpdate.constructFromObject(j));
this.tabulator.setData(data.tasks); this.tabulator.setData(data.tasks);
this._refreshAvailableStatuses(); this._refreshAvailableStatuses();
this.recalcTableHeight(); this.recalcTableHeight();
this.fetchActiveTask();
},
(error) => {
// TODO: error handling.
console.error(error);
}
);
}, },
processTaskUpdate(taskUpdate) { 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 // updateData() will only overwrite properties that are actually set on
// taskUpdate, and leave the rest as-is. // taskUpdate, and leave the rest as-is.
if (this.tabulator.initialized) { if (this.tabulator.initialized) {
@ -161,15 +204,68 @@ export default {
this.tabulator.redraw(); this.tabulator.redraw();
}); // Resize columns based on new data. }); // Resize columns based on new data.
} }
this.tasks.setSelectedTasks(this.getSelectedTasks()); // Update Pinia stores
this._refreshAvailableStatuses(); 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) { onRowClick(event, row) {
// Take a copy of the data, so that it's decoupled from the tabulator data // Handles Shift + Click, Ctrl + Click, and regular Click
// store. There were some issues where navigating to another job would this.handleMultiSelect(event, row, this.tabulator);
// overwrite the old job's ID, and this prevents that.
const rowData = plain(row.getData()); // Update the app route, Pinia store, and component state
this.$emit('tableRowClicked', rowData); 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) { toggleStatusFilter(status) {
const asSet = new Set(this.shownStatuses); const asSet = new Set(this.shownStatuses);

View File

@ -25,10 +25,10 @@ export const useJobs = defineStore('jobs', {
}), }),
getters: { getters: {
canDelete() { canDelete() {
return this._anyJobWithStatus(['queued', 'paused', 'failed', 'completed', 'canceled']); return this._anyJobWithStatus(['completed', 'canceled', 'failed', 'paused', 'queued']);
}, },
canCancel() { canCancel() {
return this._anyJobWithStatus(['queued', 'active', 'failed']); return this._anyJobWithStatus(['active', 'failed', 'queued']);
}, },
canRequeue() { canRequeue() {
return this._anyJobWithStatus(['canceled', 'completed', 'failed', 'paused']); return this._anyJobWithStatus(['canceled', 'completed', 'failed', 'paused']);

View File

@ -6,6 +6,8 @@ import { useJobs } from '@/stores/jobs';
const jobsAPI = new API.JobsApi(getAPIClient()); const jobsAPI = new API.JobsApi(getAPIClient());
const taskStatusCanCancel = ['active', 'queued', 'soft-failed'];
const taskStatusCanRequeue = ['canceled', 'completed', 'failed'];
// 'use' prefix is idiomatic for Pinia stores. // 'use' prefix is idiomatic for Pinia stores.
// See https://pinia.vuejs.org/core-concepts/ // See https://pinia.vuejs.org/core-concepts/
export const useTasks = defineStore('tasks', { export const useTasks = defineStore('tasks', {
@ -17,6 +19,7 @@ export const useTasks = defineStore('tasks', {
* @type {string} * @type {string}
*/ */
activeTaskID: '', activeTaskID: '',
selectedTasks: [],
}), }),
getters: { getters: {
canCancel() { canCancel() {
@ -24,24 +27,33 @@ export const useTasks = defineStore('tasks', {
const activeJob = jobs.activeJob; const activeJob = jobs.activeJob;
if (!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; return false;
} }
if (activeJob.status == 'pause-requested') { 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. // In the future this might be supported, see issue #104315.
return false; return false;
} }
// Allow cancellation for specified task statuses. // Allow cancellation for specified task statuses.
return this._anyTaskWithStatus(['queued', 'active', 'soft-failed']); return this._anyTaskWithStatus(taskStatusCanCancel);
}, },
canRequeue() { canRequeue() {
return this._anyTaskWithStatus(['canceled', 'completed', 'failed']); return this._anyTaskWithStatus(taskStatusCanRequeue);
}, },
}, },
actions: { actions: {
setSelectedTasks(tasks) {
this.$patch({
selectedTasks: tasks,
});
},
clearSelectedTasks() {
this.$patch({
selectedTasks: [],
});
},
setActiveTaskID(taskID) { setActiveTaskID(taskID) {
this.$patch({ this.$patch({
activeTask: { id: taskID }, activeTask: { id: taskID },
@ -54,7 +66,7 @@ export const useTasks = defineStore('tasks', {
activeTaskID: task.id, activeTaskID: task.id,
}); });
}, },
deselectAllTasks() { clearActiveTask() {
this.$patch({ this.$patch({
activeTask: null, activeTask: null,
activeTaskID: '', activeTaskID: '',
@ -65,43 +77,64 @@ export const useTasks = defineStore('tasks', {
* Actions on the selected tasks. * Actions on the selected tasks.
* *
* All the action functions return a promise that resolves when the action has been performed. * 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() { cancelTasks() {
return this._setTaskStatus('canceled'); return this._setTaskStatus('canceled', taskStatusCanCancel);
}, },
requeueTasks() { requeueTasks() {
return this._setTaskStatus('queued'); return this._setTaskStatus('queued', taskStatusCanRequeue);
}, },
// Internal methods. // Internal methods.
/** /**
* *
* @param {string[]} statuses * @param {string[]} task_statuses
* @returns bool indicating whether there is a selected task with any of the given statuses. * @returns bool indicating whether there is a selected task with any of the given statuses.
*/ */
_anyTaskWithStatus(statuses) { _anyTaskWithStatus(task_statuses) {
return ( if (this.selectedTasks.length) {
!!this.activeTask && !!this.activeTask.status && statuses.includes(this.activeTask.status) return this.selectedTasks.some((task) => task_statuses.includes(task.status));
); }
// return this.selectedTasks.reduce((foundTask, task) => (foundTask || statuses.includes(task.status)), false); return false;
}, },
/** /**
* Transition the selected task(s) to the new status. * Transition the selected task(s) to the new status.
* @param {string} newStatus * @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) { _setTaskStatus(newStatus, task_statuses) {
if (!this.activeTaskID) { const totalTaskCount = this.selectedTasks.length;
console.warn(`_setTaskStatus(${newStatus}) impossible, no active task ID`);
if (!totalTaskCount) {
console.warn(`_setTaskStatus(${newStatus}) impossible, no selected tasks`);
return; 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'); 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,
}));
}, },
}, },
}); });

View File

@ -13,12 +13,7 @@
ref="jobDetails" ref="jobDetails"
:jobData="jobs.activeJob" :jobData="jobs.activeJob"
@reshuffled="_recalcTasksTableHeight" /> @reshuffled="_recalcTasksTableHeight" />
<tasks-table <tasks-table v-if="hasJobData" ref="tasksTable" :jobID="jobID" :taskID="taskID" />
v-if="hasJobData"
ref="tasksTable"
:jobID="jobID"
:taskID="taskID"
@tableRowClicked="onTableTaskClicked" />
</template> </template>
</div> </div>
<div class="col col-3"> <div class="col col-3">
@ -116,7 +111,6 @@ export default {
// }) // })
this._fetchJob(this.jobID); this._fetchJob(this.jobID);
this._fetchTask(this.taskID);
window.addEventListener('resize', this._recalcTasksTableHeight); window.addEventListener('resize', this._recalcTasksTableHeight);
}, },
@ -127,9 +121,6 @@ export default {
jobID(newJobID, oldJobID) { jobID(newJobID, oldJobID) {
this._fetchJob(newJobID); this._fetchJob(newJobID);
}, },
taskID(newTaskID, oldTaskID) {
this._fetchTask(newTaskID);
},
showFooterPopup(shown) { showFooterPopup(shown) {
if (shown) localStorage.setItem('footer-popover-visible', 'true'); if (shown) localStorage.setItem('footer-popover-visible', 'true');
else localStorage.removeItem('footer-popover-visible'); else localStorage.removeItem('footer-popover-visible');
@ -139,31 +130,12 @@ export default {
methods: { methods: {
onTableJobClicked(rowData) { onTableJobClicked(rowData) {
// Don't route to the current job, as that'll deactivate the current task. // 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); this._routeToJob(rowData.id);
}, },
onTableTaskClicked(rowData) {
this._routeToTask(rowData.id);
},
onActiveJobDeleted(deletedJobUUID) { onActiveJobDeleted(deletedJobUUID) {
this._routeToJobOverview(); 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() { showTaskLogTail() {
this.showFooterPopup = true; this.showFooterPopup = true;
this.$nextTick(() => { this.$nextTick(() => {
@ -186,7 +158,6 @@ export default {
this._fetchJob(this.jobID); this._fetchJob(this.jobID);
if (jobUpdate.refresh_tasks) { if (jobUpdate.refresh_tasks) {
if (this.$refs.tasksTable) this.$refs.tasksTable.fetchTasks(); if (this.$refs.tasksTable) this.$refs.tasksTable.fetchTasks();
this._fetchTask(this.taskID);
} }
}, },
@ -196,7 +167,7 @@ export default {
*/ */
onSioTaskUpdate(taskUpdate) { onSioTaskUpdate(taskUpdate) {
if (this.$refs.tasksTable) this.$refs.tasksTable.processTaskUpdate(taskUpdate); if (this.$refs.tasksTable) this.$refs.tasksTable.processTaskUpdate(taskUpdate);
if (this.taskID == taskUpdate.id) this._fetchTask(this.taskID);
this.notifs.addTaskUpdate(taskUpdate); this.notifs.addTaskUpdate(taskUpdate);
}, },
@ -230,14 +201,6 @@ export default {
const route = { name: 'jobs', params: { jobID: jobID } }; const route = { name: 'jobs', params: { jobID: jobID } };
this.$router.push(route); 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. * 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) { onChatMessage(message) {
console.log('chat message received:', message); console.log('chat message received:', message);
this.messages.push(`${message.text}`); this.messages.push(`${message.text}`);