Skip to content

Commit

Permalink
Add task configurations download as csv (#4491)
Browse files Browse the repository at this point in the history
* add csv download on task creation

* add downloading settings of all tasks in task list view

* fix flow

* add changelog entry

* make csv download optional and add download of failed tasks
 * remove individual download in task list view

* download tasks as csv now fetches the tasks before downloading

* fix flow

* undo useless extract method

* improve modal UI

* typos, grammer and renaming
  • Loading branch information
MichaelBuessemeyer authored Mar 31, 2020
1 parent fc378a3 commit 969d6ff
Show file tree
Hide file tree
Showing 5 changed files with 151 additions and 43 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.md).
- Added a notification when downloading nml including volume that informs that the fallback data is excluded in the download. [#4413](https://github.com/scalableminds/webknossos/pull/4413)
- Added the possibility to reopen finished tasks as non-admin for a configurable time. [#4415](https://github.com/scalableminds/webknossos/pull/4415)
- Added support for drag-and-drop import of NML files even if the current view is read-only (e.g., because a dataset was opened in "view" mode). In this case, a new tracing is directly created into which the NML file is imported. [#4459](https://github.com/scalableminds/webknossos/pull/4459)
- Added download of task configurations as CSV after task creation and in the task list view. [#4491](https://github.com/scalableminds/webknossos/pull/4491)
- Added indication for reloading a dataset in the dataset actions in the dashboard. [#4421](https://github.com/scalableminds/webknossos/pull/4421)
- Added support for creating a tree group when importing a single NML into an existing annotation. [#4489](https://github.com/scalableminds/webknossos/pull/4489)
- Added login prompt to the tracing page when fetching the dataset fails. Upon successful login, the dataset gets fetched with the rights of the newly logged-in user. [#4467](https://github.com/scalableminds/webknossos/pull/4467)
Expand Down
104 changes: 85 additions & 19 deletions frontend/javascripts/admin/task/task_create_form_view.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type RouterHistory, withRouter } from "react-router-dom";
import {
Row,
Col,
Divider,
Form,
Select,
Button,
Expand All @@ -18,7 +19,7 @@ import {
import React from "react";
import _ from "lodash";

import type { APIDataset, APITaskType, APIProject, APIScript } from "admin/api_flow_types";
import type { APIDataset, APITaskType, APIProject, APIScript, APITask } from "admin/api_flow_types";
import type { BoundingBoxObject } from "oxalis/store";
import type { TaskCreationResponse } from "admin/task/task_create_bulk_view";
import { Vector3Input, Vector6Input } from "libs/vector_input";
Expand All @@ -38,12 +39,15 @@ import { tryToAwaitPromise } from "libs/utils";
import SelectExperienceDomain from "components/select_experience_domain";
import messages from "messages";
import Enum from "Enumjs";
import { saveAs } from "file-saver";
import { formatDateInLocalTimeZone } from "components/formatted_date";

const FormItem = Form.Item;
const { Option } = Select;
const RadioGroup = Radio.Group;

const fullWidth = { width: "100%" };
const maxDisplayedTasksCount = 50;

type Props = {
form: Object,
Expand All @@ -67,42 +71,104 @@ type State = {
isUploading: boolean,
};

export function taskToText(task: APITask) {
const { id, creationInfo, editPosition } = task;
return `${id},${creationInfo || "null"},(${editPosition.join(",")})`;
}

export function downloadTasksAsCSV(tasks: Array<APITask>) {
if (tasks.length < 0) {
return;
}
const maybeTaskPlural = tasks.length > 2 ? "tasks" : "task";
const lastCreationTime = Math.max(...tasks.map(task => task.created));
const currentDateAsString = formatDateInLocalTimeZone(lastCreationTime);
const allTeamNames = _.uniq(tasks.map(task => task.team));
const teamName = allTeamNames.length > 1 ? "multiple_teams" : allTeamNames[0];
const allTasksAsStrings = tasks.map(task => taskToText(task)).join("\n");
const filename = `${teamName}-${maybeTaskPlural}-${currentDateAsString}.csv`;
const blob = new Blob([allTasksAsStrings], { type: "text/plain;charset=utf-8" });
saveAs(blob, filename);
}

export function handleTaskCreationResponse(responses: Array<TaskCreationResponse>) {
const successfulTasks = [];
const failedTasks = [];
let teamName = null;

responses.forEach((response: TaskCreationResponse, i: number) => {
if (response.status === 200 && response.success) {
successfulTasks.push(
`${response.success.id},${response.success.creationInfo ||
"null"},(${response.success.editPosition.join(",")}) \n`,
);
if (!teamName) {
teamName = response.success.team;
}
successfulTasks.push(response.success);
} else if (response.error) {
failedTasks.push(`Line ${i}: ${response.error} \n`);
}
});

const failedTasksAsString = failedTasks.join("");
const successfulTasksContent =
successfulTasks.length <= maxDisplayedTasksCount ? (
<pre>
taskId,filename,position
<br />
{successfulTasks.map(task => taskToText(task)).join("\n")}
</pre>
) : (
`The number of successful tasks is too large to show them in the browser.
Please download them as a CSV file if you need to view the output.`
);
const failedTasksContent =
failedTasks.length <= maxDisplayedTasksCount ? (
<pre>{failedTasksAsString}</pre>
) : (
`The number of failed tasks is too large to show them in the browser.
Please download them as a CSV file if you need to view the output.`
);
const subHeadingStyle = { fontWeight: "bold" };
const displayResultsStyle = { maxHeight: 300, overflow: "auto" };
const downloadButtonStyle = { float: "right" };
Modal.info({
title: `${successfulTasks.length} tasks were successfully created. ${
failedTasks.length
} tasks failed.`,
title: `Failed to create ${failedTasks.length} tasks.`,
content: (
<div>
{successfulTasks.length > 0 ? (
<div>
Successful Tasks:
<pre>
taskId,filename,position
<br />
{successfulTasks}
</pre>
<div style={subHeadingStyle}> Successful Tasks: </div>
<div style={displayResultsStyle}>{successfulTasksContent}</div>
</div>
) : null}
{successfulTasks.length > 0 ? (
<React.Fragment>
<br />
<Button style={downloadButtonStyle} onClick={() => downloadTasksAsCSV(successfulTasks)}>
Download tasks as CSV
</Button>
<br />
</React.Fragment>
) : null}
{failedTasks.length > 0 ? (
<div>
Failed Tasks:
<pre>{failedTasks}</pre>
</div>
<React.Fragment>
<Divider />
<div>
<br />
<div style={subHeadingStyle}> Failed Tasks:</div>
<div style={displayResultsStyle}> {failedTasksContent}</div>
<br />
<Button
style={downloadButtonStyle}
onClick={() => {
const blob = new Blob([failedTasksAsString], {
type: "text/plain;charset=utf-8",
});
saveAs(blob, "failed-tasks.csv");
}}
>
Download failed tasks as CSV
</Button>
<br />
</div>
</React.Fragment>
) : null}
</div>
),
Expand Down
47 changes: 32 additions & 15 deletions frontend/javascripts/admin/task/task_list_view.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { handleGenericError } from "libs/error_handling";
import FormattedDate from "components/formatted_date";
import Persistence from "libs/persistence";
import TaskAnnotationView from "admin/task/task_annotation_view";
import { downloadTasksAsCSV } from "admin/task/task_create_form_view";
import TaskSearchForm, {
type QueryObject,
type TaskFormFieldValues,
Expand Down Expand Up @@ -105,6 +106,33 @@ class TaskListView extends React.PureComponent<Props, State> {
});
};

getFilteredTasks = () => {
const { searchQuery, tasks } = this.state;
return Utils.filterWithSearchQueryAND(
tasks,
[
"team",
"projectName",
"id",
"dataSet",
"created",
"type",
task => task.neededExperience.domain,
],
searchQuery,
);
};

downloadSettingsFromAllTasks = async (queryObject: QueryObject) => {
await this.fetchData(queryObject);
const filteredTasks = this.getFilteredTasks();
if (filteredTasks.length > 0) {
downloadTasksAsCSV(filteredTasks);
} else {
Toast.warning(messages["task.no_tasks_to_download"]);
}
};

getAnonymousTaskLinkModal() {
const anonymousTaskId = Utils.getUrlParamValue("showAnonymousLinks");
if (!this.state.isAnonymousTaskLinkModalVisible) {
Expand All @@ -131,7 +159,7 @@ class TaskListView extends React.PureComponent<Props, State> {

render() {
const marginRight = { marginRight: 20 };
const { searchQuery, isLoading, tasks } = this.state;
const { searchQuery, isLoading } = this.state;

return (
<div className="container">
Expand All @@ -156,24 +184,13 @@ class TaskListView extends React.PureComponent<Props, State> {
onChange={queryObject => this.fetchData(queryObject)}
initialFieldValues={this.props.initialFieldValues}
isLoading={isLoading}
onDownloadAllTasks={this.downloadSettingsFromAllTasks}
/>
</Card>

<Spin spinning={isLoading} size="large">
<FixedExpandableTable
dataSource={Utils.filterWithSearchQueryAND(
tasks,
[
"team",
"projectName",
"id",
"dataSet",
"created",
"type",
task => task.neededExperience.domain,
],
searchQuery,
)}
dataSource={this.getFilteredTasks()}
rowKey="id"
pagination={{
defaultPageSize: 50,
Expand Down Expand Up @@ -281,7 +298,7 @@ class TaskListView extends React.PureComponent<Props, State> {
<Column
title="Action"
key="actions"
width={130}
width={170}
fixed="right"
render={(__, task: APITask) => (
<span>
Expand Down
41 changes: 32 additions & 9 deletions frontend/javascripts/admin/task/task_search_form.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { getEditableUsers, getProjects, getTaskTypes } from "admin/admin_rest_ap
import Persistence from "libs/persistence";

const FormItem = Form.Item;
const Option = Select.Option;
const { Option } = Select;

export type QueryObject = {
taskType?: string,
Expand All @@ -30,10 +30,11 @@ export type TaskFormFieldValues = {

type Props = {
form: Object,
onChange: Function,
onChange: QueryObject => Promise<void>,
initialFieldValues: ?TaskFormFieldValues,
isLoading: boolean,
history: RouterHistory,
onDownloadAllTasks: QueryObject => Promise<void>,
};

type State = {
Expand Down Expand Up @@ -78,7 +79,7 @@ class TaskSearchForm extends React.Component<Props, State> {
: this.state.fieldValues;
if (_.size(fieldValues) > 0) {
this.props.form.setFieldsValue(fieldValues);
this.handleFormSubmit(false);
this.handleSearchFormSubmit(false);
}
}

Expand All @@ -95,7 +96,11 @@ class TaskSearchForm extends React.Component<Props, State> {
this.setState({ users, projects, taskTypes });
}

handleFormSubmit = (isRandom: boolean, event: ?SyntheticInputEvent<*>) => {
handleFormSubmit = (
isRandom: boolean,
onFinishCallback: QueryObject => Promise<void>,
event: ?SyntheticInputEvent<*>,
) => {
if (event) {
event.preventDefault();
}
Expand Down Expand Up @@ -130,25 +135,34 @@ class TaskSearchForm extends React.Component<Props, State> {
}

this.setState({ fieldValues: formValues });
this.props.onChange(queryObject);
onFinishCallback(queryObject);
});
};

handleSearchFormSubmit = (isRandom: boolean, event: ?SyntheticInputEvent<*>) => {
this.handleFormSubmit(isRandom, this.props.onChange, event);
};

handleDownloadAllTasks = () => {
this.handleFormSubmit(false, this.props.onDownloadAllTasks);
};

handleReset = () => {
this.props.form.resetFields();
this.setState({ fieldValues: {} });
this.props.onChange({});
};

render() {
const { isLoading } = this.props;
const { getFieldDecorator } = this.props.form;
const formItemLayout = {
labelCol: { span: 5 },
wrapperCol: { span: 19 },
};

return (
<Form onSubmit={evt => this.handleFormSubmit(false, evt)}>
<Form onSubmit={evt => this.handleSearchFormSubmit(false, evt)}>
<Row gutter={40}>
<Col span={12}>
<FormItem {...formItemLayout} label="Task Id">
Expand Down Expand Up @@ -221,7 +235,7 @@ class TaskSearchForm extends React.Component<Props, State> {
<Col span={24} style={{ textAlign: "right" }}>
<Dropdown
overlay={
<Menu onClick={() => this.handleFormSubmit(true)}>
<Menu onClick={() => this.handleSearchFormSubmit(true)}>
<Menu.Item key="1">
<Icon type="retweet" />
Show random subset
Expand All @@ -232,8 +246,8 @@ class TaskSearchForm extends React.Component<Props, State> {
<Button
type="primary"
htmlType="submit"
disabled={this.props.isLoading}
loading={this.props.isLoading}
disabled={isLoading}
loading={isLoading}
style={{ paddingRight: 3 }}
>
Search <Icon type="down" />
Expand All @@ -242,6 +256,15 @@ class TaskSearchForm extends React.Component<Props, State> {
<Button style={{ marginLeft: 8 }} onClick={this.handleReset}>
Clear
</Button>
<Button
style={{ marginLeft: 8 }}
onClick={this.handleDownloadAllTasks}
disabled={isLoading}
loading={isLoading}
>
Download tasks as CSV
<Icon type="download" />
</Button>
</Col>
</Row>
</Form>
Expand Down
1 change: 1 addition & 0 deletions frontend/javascripts/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ In order to restore the current window, a reload is necessary.`,
"dataset.clear_cache_success": _.template(
"The dataset <%- datasetName %> was reloaded successfully.",
),
"task.no_tasks_to_download": "There are no tasks available to download.",
"dataset.upload_success": "The dataset was uploaded successfully.",
"dataset.add_success": "The dataset was added successfully.",
"dataset.add_error": "Could not reach the datastore.",
Expand Down

0 comments on commit 969d6ff

Please sign in to comment.