From 9a70c07aca34f59d4f3ee98cb17617e260c30617 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Wed, 4 Aug 2021 13:09:27 -0400 Subject: [PATCH] Task UI improvements, PoC move task dialog --- app/api/projects.py | 6 + app/static/app/js/classes/StatusCodes.js | 2 +- app/static/app/js/components/FormDialog.jsx | 4 +- .../app/js/components/MoveTaskDialog.jsx | 104 ++++++++++++++++++ .../app/js/components/ProjectListItem.jsx | 7 ++ app/static/app/js/components/TaskList.jsx | 15 ++- app/static/app/js/components/TaskListItem.jsx | 99 ++++++++++++----- app/static/app/js/css/TaskListItem.scss | 7 +- 8 files changed, 207 insertions(+), 37 deletions(-) create mode 100644 app/static/app/js/components/MoveTaskDialog.jsx diff --git a/app/api/projects.py b/app/api/projects.py index 8cbd3b04..b1c749b8 100644 --- a/app/api/projects.py +++ b/app/api/projects.py @@ -37,3 +37,9 @@ class ProjectViewSet(viewsets.ModelViewSet): serializer_class = ProjectSerializer queryset = models.Project.objects.prefetch_related('task_set').filter(deleting=False).order_by('-created_at') ordering_fields = '__all__' + + # Disable pagination when not requesting any page + def paginate_queryset(self, queryset): + if self.paginator and self.request.query_params.get(self.paginator.page_query_param, None) is None: + return None + return super().paginate_queryset(queryset) \ No newline at end of file diff --git a/app/static/app/js/classes/StatusCodes.js b/app/static/app/js/classes/StatusCodes.js index 7339230f..6e0cce90 100644 --- a/app/static/app/js/classes/StatusCodes.js +++ b/app/static/app/js/classes/StatusCodes.js @@ -12,7 +12,7 @@ let statusCodes = { icon: "far fa-hourglass fa-fw" }, [RUNNING]: { - descr: _("Running"), + descr: _("Processing"), icon: "fa fa-cog fa-spin fa-fw" }, [FAILED]: { diff --git a/app/static/app/js/components/FormDialog.jsx b/app/static/app/js/components/FormDialog.jsx index e82c1d88..5597710c 100644 --- a/app/static/app/js/components/FormDialog.jsx +++ b/app/static/app/js/components/FormDialog.jsx @@ -17,7 +17,7 @@ class FormDialog extends React.Component { static propTypes = { getFormData: PropTypes.func.isRequired, - reset: PropTypes.func.isRequired, + reset: PropTypes.func, saveAction: PropTypes.func.isRequired, onShow: PropTypes.func, onHide: PropTypes.func, @@ -88,7 +88,7 @@ class FormDialog extends React.Component { } show(){ - this.props.reset(); + if (this.props.reset) this.props.reset(); this.setState({showModal: true, saving: false, error: ""}); } diff --git a/app/static/app/js/components/MoveTaskDialog.jsx b/app/static/app/js/components/MoveTaskDialog.jsx new file mode 100644 index 00000000..734bb097 --- /dev/null +++ b/app/static/app/js/components/MoveTaskDialog.jsx @@ -0,0 +1,104 @@ +import React from 'react'; +import FormDialog from './FormDialog'; +import PropTypes from 'prop-types'; +import { _ } from '../classes/gettext'; +import $ from 'jquery'; + +class MoveTaskDialog extends React.Component { + static defaultProps = { + title: _("Move Task"), + saveLabel: _("Save Changes"), + savingLabel: _("Moving..."), + saveIcon: "far fa-edit", + show: true + }; + + static propTypes = { + task: PropTypes.object.isRequired, + saveAction: PropTypes.func.isRequired, + title: PropTypes.string, + saveLabel: PropTypes.string, + savingLabel: PropTypes.string, + saveIcon: PropTypes.string, + show: PropTypes.bool + }; + + constructor(props){ + super(props); + + this.state = { + projectId: props.task.project.id, + projects: [], + loading: true + }; + + this.getFormData = this.getFormData.bind(this); + this.onShow = this.onShow.bind(this); + } + + getFormData(){ + return this.state; + } + + onShow(){ + this.setState({loading: true, projects: []}); + + // Load projects from API + this.serverRequest = $.getJSON(`/api/projects/?ordering=-created_at`, json => { + this.setState({ + projects: json.filter(p => p.permissions.indexOf("add") !== -1) + }); + }) + .fail((jqXHR, textStatus, errorThrown) => { + this.dialog.setState({ + error: interpolate(_("Could not load projects list: %(error)s"), {error: textStatus}) + }); + }) + .always(() => { + this.setState({loading: false}); + this.serverRequest = null; + }); + } + + show(){ + this.dialog.show(); + } + + hide(){ + this.dialog.hide(); + } + + componentWillUnmount(){ + if (this.serverRquest) this.serverRquest.abort(); + } + + handleProjectChange = e => { + this.setState({projectId: e.target.value}); + } + + render(){ + return ( + { this.dialog = domNode; }}> + {!this.state.loading ? +
+ +
+ +
+
+ : } +
+ ); + } +} + +export default MoveTaskDialog; \ No newline at end of file diff --git a/app/static/app/js/components/ProjectListItem.jsx b/app/static/app/js/components/ProjectListItem.jsx index 4b26fc1f..eb49af61 100644 --- a/app/static/app/js/components/ProjectListItem.jsx +++ b/app/static/app/js/components/ProjectListItem.jsx @@ -47,6 +47,7 @@ class ProjectListItem extends React.Component { this.handleEditProject = this.handleEditProject.bind(this); this.updateProject = this.updateProject.bind(this); this.taskDeleted = this.taskDeleted.bind(this); + this.taskMoved = this.taskMoved.bind(this); this.hasPermission = this.hasPermission.bind(this); } @@ -304,6 +305,10 @@ class ProjectListItem extends React.Component { this.refresh(); } + taskMoved(){ + this.refresh(); + } + handleDelete(){ return $.ajax({ url: `/api/projects/${this.state.data.id}/`, @@ -570,6 +575,8 @@ class ProjectListItem extends React.Component { ref={this.setRef("taskList")} source={`/api/projects/${data.id}/tasks/?ordering=-created_at`} onDelete={this.taskDeleted} + onMove={this.taskMoved} + hasPermission={this.hasPermission} history={this.props.history} /> : ""} diff --git a/app/static/app/js/components/TaskList.jsx b/app/static/app/js/components/TaskList.jsx index 330c535b..6d6279a5 100644 --- a/app/static/app/js/components/TaskList.jsx +++ b/app/static/app/js/components/TaskList.jsx @@ -9,7 +9,9 @@ class TaskList extends React.Component { static propTypes = { history: PropTypes.object.isRequired, source: PropTypes.string.isRequired, // URL where to load task list - onDelete: PropTypes.func + onDelete: PropTypes.func, + onMove: PropTypes.func, + hasPermission: PropTypes.func.isRequired } constructor(props){ @@ -69,6 +71,13 @@ class TaskList extends React.Component { if (this.props.onDelete) this.props.onDelete(id); } + moveTask(id){ + this.setState({ + tasks: this.state.tasks.filter(t => t.id !== id) + }); + if (this.props.onMove) this.props.onMove(id); + } + render() { let message = ""; if (this.state.loading){ @@ -88,7 +97,9 @@ class TaskList extends React.Component { data={task} key={task.id} refreshInterval={3000} - onDelete={this.deleteTask} + onDelete={this.deleteTask} + onMove={this.moveTask} + hasPermission={this.props.hasPermission} history={this.props.history} /> ))} diff --git a/app/static/app/js/components/TaskListItem.jsx b/app/static/app/js/components/TaskListItem.jsx index 145d7537..d76f05f5 100644 --- a/app/static/app/js/components/TaskListItem.jsx +++ b/app/static/app/js/components/TaskListItem.jsx @@ -9,6 +9,7 @@ import AssetDownloadButtons from './AssetDownloadButtons'; import HistoryNav from '../classes/HistoryNav'; import PropTypes from 'prop-types'; import TaskPluginActionButtons from './TaskPluginActionButtons'; +import MoveTaskDialog from './MoveTaskDialog'; import PipelineSteps from '../classes/PipelineSteps'; import Css from '../classes/Css'; import Trans from './Trans'; @@ -19,7 +20,9 @@ class TaskListItem extends React.Component { history: PropTypes.object.isRequired, data: PropTypes.object.isRequired, // task json refreshInterval: PropTypes.number, // how often to refresh info - onDelete: PropTypes.func + onDelete: PropTypes.func, + onMove: PropTypes.func, + hasPermission: PropTypes.func } constructor(props){ @@ -37,7 +40,8 @@ class TaskListItem extends React.Component { memoryError: false, friendlyTaskError: "", pluginActionButtons: [], - view: "basic" + view: "basic", + showMoveDialog: false } for (let k in props.data){ @@ -271,6 +275,10 @@ class TaskListItem extends React.Component { this.setAutoRefresh(); } + handleMoveTask = () => { + this.setState({showMoveDialog: true}); + } + getRestartSubmenuItems(){ const { task } = this.state; @@ -406,21 +414,6 @@ class TaskListItem extends React.Component { }); } - // Ability to change options - if ([statusCodes.FAILED, statusCodes.COMPLETED, statusCodes.CANCELED].indexOf(task.status) !== -1 || - (!task.processing_node)){ - addActionButton(_("Edit"), "btn-primary pull-right edit-button", "glyphicon glyphicon-pencil", () => { - this.startEditing(); - }, { - className: "inline" - }); - } - - if ([statusCodes.QUEUED, statusCodes.RUNNING, null].indexOf(task.status) !== -1 && - (task.processing_node || imported)){ - addActionButton(_("Cancel"), "btn-primary", "glyphicon glyphicon-remove-circle", this.genActionApiCall("cancel", {defaultError: _("Cannot cancel task.")})); - } - if ([statusCodes.FAILED, statusCodes.COMPLETED, statusCodes.CANCELED].indexOf(task.status) !== -1 && task.processing_node && !imported){ @@ -435,11 +428,6 @@ class TaskListItem extends React.Component { }); } - addActionButton(_("Delete"), "btn-danger", "glyphicon glyphicon-trash", this.genActionApiCall("remove", { - confirm: _("All information related to this task, including images, maps and models will be deleted. Continue?"), - defaultError: _("Cannot delete task.") - })); - const disabled = this.state.actionButtonsDisabled || ([pendingActions.CANCEL, pendingActions.REMOVE, @@ -580,6 +568,8 @@ class TaskListItem extends React.Component { ; } } + + let statusIcon = statusCodes.icon(task.status); // @param type {String} one of: ['neutral', 'done', 'error'] const getStatusLabel = (text, type = 'neutral', progress = 100) => { @@ -589,11 +579,10 @@ class TaskListItem extends React.Component { return (
{text}
); + title={text}> {text}); } let statusLabel = ""; - let statusIcon = statusCodes.icon(task.status); let showEditLink = false; if (task.last_error){ @@ -624,8 +613,57 @@ class TaskListItem extends React.Component { statusLabel = getStatusLabel(status, type, progress); } + const taskActions = []; + const addTaskAction = (label, icon, onClick) => { + taskActions.push( +
  • {label}
  • + ); + }; + + if ([statusCodes.QUEUED, statusCodes.RUNNING, null].indexOf(task.status) !== -1 && + (task.processing_node || imported) && this.props.hasPermission("change")){ + addTaskAction(_("Cancel"), "glyphicon glyphicon-remove-circle", this.genActionApiCall("cancel", {defaultError: _("Cannot cancel task.")})); + } + + // Ability to change options + const canAddDelPerms = this.props.hasPermission("add") && this.props.hasPermission("delete"); + const editable = [statusCodes.FAILED, statusCodes.COMPLETED, statusCodes.CANCELED].indexOf(task.status) !== -1; + + if (canAddDelPerms){ + if (editable || (!task.processing_node)){ + taskActions.push(
  • {_("Edit")}
  • ); + } + + if (editable){ + taskActions.push( +
  • {_("Move")}
  • , +
  • {_("Duplicate")}
  • + ); + } + } + + + if (this.props.hasPermission("delete")){ + taskActions.push( +
  • , + ); + + addTaskAction(_("Delete"), "glyphicon glyphicon-trash", this.genActionApiCall("remove", { + confirm: _("All information related to this task, including images, maps and models will be deleted. Continue?"), + defaultError: _("Cannot delete task.") + })); + } + return (
    + {this.state.showMoveDialog ? + { this.moveTaskDialog = domNode; }} + onHide={() => this.setState({showMoveDialog: false})} + saveAction={() => {}} + /> + : ""}
    {name} @@ -642,9 +680,16 @@ class TaskListItem extends React.Component { : statusLabel}
    -
    - -
    + {taskActions.length > 0 ? +
    + +
      + {taskActions} +
    +
    + : ""}
    {expanded} diff --git a/app/static/app/js/css/TaskListItem.scss b/app/static/app/js/css/TaskListItem.scss index 74847d58..bb34217c 100644 --- a/app/static/app/js/css/TaskListItem.scss +++ b/app/static/app/js/css/TaskListItem.scss @@ -31,11 +31,8 @@ } } - .status-icon{ - margin-top: 3px; - .fa-cog{ - width: auto; - } + .fa-cog{ + width: auto; } .status-label{