From 8e5216afb27d4495337b87dd7707f07ac3946f59 Mon Sep 17 00:00:00 2001 From: Yongchao Terry Ma Date: Mon, 14 Jun 2021 15:25:47 +0200 Subject: [PATCH] Add option to edit project metadata (#584) --- asreview/webapp/api.py | 38 ++----- .../{ProjectInit.js => ProjectInfo.js} | 49 +++++++-- .../src/PreReviewComponents/ProjectPage.js | 100 +++++++++++++----- .../src/PreReviewComponents/ProjectUpload.js | 6 +- .../webapp/src/PreReviewComponents/index.js | 2 +- asreview/webapp/src/Projects.js | 4 +- asreview/webapp/src/StatisticsZone.js | 17 +-- asreview/webapp/src/api/ProjectAPI.js | 9 +- asreview/webapp/tests/test_api.py | 12 +++ asreview/webapp/utils/project.py | 46 ++++++++ 10 files changed, 201 insertions(+), 82 deletions(-) rename asreview/webapp/src/PreReviewComponents/{ProjectInit.js => ProjectInfo.js} (78%) diff --git a/asreview/webapp/api.py b/asreview/webapp/api.py index 0e0fb819f..d0b1ca986 100644 --- a/asreview/webapp/api.py +++ b/asreview/webapp/api.py @@ -65,6 +65,7 @@ from asreview.webapp.utils.project import get_paper_data from asreview.webapp.utils.project import get_statistics from asreview.webapp.utils.project import init_project +from asreview.webapp.utils.project import update_project_info from asreview.webapp.utils.project import label_instance from asreview.webapp.utils.project import read_data from asreview.webapp.utils.project import move_label_from_labeled_to_pool @@ -227,38 +228,13 @@ def api_update_project_info(project_id): # noqa: F401 project_description = request.form['description'] project_authors = request.form['authors'] - project_id_new = re.sub('[^A-Za-z0-9]+', '-', project_name).lower() - - try: - - # read the file with project info - with open(get_project_file_path(project_id), "r") as fp: - project_info = json.load(fp) - - project_info["id"] = project_id_new - project_info["name"] = project_name - project_info["authors"] = project_authors - project_info["description"] = project_description - - # # backwards support <0.10 - # if "projectInitReady" not in project_info: - # project_info["projectInitReady"] = True - - # update the file with project info - with open(get_project_file_path(project_id), "w") as fp: - json.dump(project_info, fp) - - # rename the folder - get_project_path(project_id) \ - .rename(Path(asreview_path(), project_id_new)) - - except Exception as err: - logging.error(err) - response = jsonify(message="project-update-failure") - - return response, 500 + project_id_new = update_project_info( + project_id, + project_name=project_name, + project_description=project_description, + project_authors=project_authors) - return api_get_project_info(project_id_new) + return jsonify(id=project_id_new) @bp.route('/datasets', methods=["GET"]) diff --git a/asreview/webapp/src/PreReviewComponents/ProjectInit.js b/asreview/webapp/src/PreReviewComponents/ProjectInfo.js similarity index 78% rename from asreview/webapp/src/PreReviewComponents/ProjectInit.js rename to asreview/webapp/src/PreReviewComponents/ProjectInfo.js index dd887ddaa..0f4662493 100644 --- a/asreview/webapp/src/PreReviewComponents/ProjectInit.js +++ b/asreview/webapp/src/PreReviewComponents/ProjectInfo.js @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState, useEffect } from "react"; import { makeStyles } from "@material-ui/core/styles"; import { @@ -63,23 +63,29 @@ function mapDispatchToProps(dispatch) { }; } -const ProjectInit = (props) => { +const ProjectInfo = (props) => { const classes = useStyles(); // const [open, setOpen] = React.useState(props.open) // the state of the form data - const [info, setInfo] = React.useState({ + const [info, setInfo] = useState({ authors: "", name: "", description: "", }); - const [error, setError] = React.useState({ + const [error, setError] = useState({ code: null, message: null, }); const onChange = (evt) => { + if (error.code) { + setError({ + code: null, + message: null, + }); + } setInfo({ ...info, [evt.target.name]: evt.target.value, @@ -94,12 +100,22 @@ const ProjectInit = (props) => { bodyFormData.set("authors", info.authors); bodyFormData.set("description", info.description); - ProjectAPI.init(bodyFormData) + (props.edit + ? ProjectAPI.info(props.project_id, true, bodyFormData) + : ProjectAPI.init(bodyFormData) + ) .then((result) => { // set the project_id in the redux store props.setProjectId(result.data["id"]); - - props.handleAppState("project-page"); + // switch to project page if init + // reload project info if edit + if (!props.edit) { + props.onClose(); // set newProject state to false + props.handleAppState("project-page"); + } else { + props.onClose(); // set editing state to false + props.reloadProjectInfo(); + } }) .catch((error) => { setError({ @@ -109,9 +125,22 @@ const ProjectInit = (props) => { }); }; + useEffect(() => { + // pre-fill project info in edit mode + if (props.edit) { + setInfo({ + name: props.name, + authors: props.authors, + description: props.description, + }); + } + }, [props.edit, props.name, props.authors, props.description]); + return ( - Create a new project + + {props.edit ? "Edit project info" : "Create a new project"} + {error.code === 503 && ( @@ -182,7 +211,7 @@ const ProjectInit = (props) => { color="primary" disabled={info.name.length < 3} > - Create + {props.edit ? "Update" : "Create"} )} @@ -190,4 +219,4 @@ const ProjectInit = (props) => { ); }; -export default connect(mapStateToProps, mapDispatchToProps)(ProjectInit); +export default connect(mapStateToProps, mapDispatchToProps)(ProjectInfo); diff --git a/asreview/webapp/src/PreReviewComponents/ProjectPage.js b/asreview/webapp/src/PreReviewComponents/ProjectPage.js index de88cf8e4..9e7462516 100644 --- a/asreview/webapp/src/PreReviewComponents/ProjectPage.js +++ b/asreview/webapp/src/PreReviewComponents/ProjectPage.js @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect } from "react"; +import React, { useState, useRef, useEffect, useCallback } from "react"; import { makeStyles } from "@material-ui/core/styles"; import { @@ -11,7 +11,11 @@ import { IconButton, Tooltip, } from "@material-ui/core"; -import { StartReview, PreReviewZone } from "../PreReviewComponents"; +import { + StartReview, + PreReviewZone, + ProjectInfo, +} from "../PreReviewComponents"; import ErrorHandler from "../ErrorHandler"; import DangerZone from "../DangerZone.js"; @@ -21,6 +25,7 @@ import { ProjectAPI } from "../api/index.js"; import KeyboardVoiceIcon from "@material-ui/icons/KeyboardVoice"; import GetAppIcon from "@material-ui/icons/GetApp"; +import EditOutlinedIcon from "@material-ui/icons/EditOutlined"; import Finished from "../images/Finished.svg"; import InReview from "../images/InReview.svg"; @@ -80,6 +85,7 @@ const ProjectPage = (props) => { const [state, setState] = useState({ // info-header infoLoading: true, + infoEditing: false, info: null, // stage @@ -94,6 +100,29 @@ const ProjectPage = (props) => { message: null, }); + const editProjectInfo = () => { + setState({ + ...state, + infoEditing: true, + }); + }; + + const finishEditProjectInfo = () => { + setState({ + ...state, + infoEditing: false, + }); + }; + + const reloadProjectInfo = () => { + setState((s) => { + return { + ...s, + infoLoading: true, + }; + }); + }; + const finishProjectSetup = () => { setState({ ...state, @@ -105,8 +134,8 @@ const ProjectPage = (props) => { const finishProjectFirstTraining = () => { setState({ ...state, - info: { ...state.info, projectInitReady: true }, training: false, + info: { ...state.info, projectInitReady: true }, }); }; @@ -149,6 +178,27 @@ const ProjectPage = (props) => { } }; + const fetchProjectInfo = useCallback(async () => { + ProjectAPI.info(props.project_id) + .then((result) => { + // update the state with the fetched data + setState((s) => { + return { + ...s, + infoLoading: false, + info: result.data, + finished: result.data.reviewFinished, + }; + }); + }) + .catch((error) => { + setError({ + code: error.code, + message: error.message, + }); + }); + }, [props.project_id]); + const scrollToTop = () => { EndRef.current.scrollIntoView({ behavior: "smooth" }); }; @@ -160,29 +210,10 @@ const ProjectPage = (props) => { }, [state.setup, state.finished, state.infoLoading]); useEffect(() => { - const fetchProjectInfo = async () => { - ProjectAPI.info(props.project_id) - .then((result) => { - // update the state with the fetched data - setState((s) => { - return { - ...s, - infoLoading: false, - info: result.data, - finished: result.data.reviewFinished, - }; - }); - }) - .catch((error) => { - setError({ - code: error.code, - message: error.message, - }); - }); - }; - - fetchProjectInfo(); - }, [props.project_id, state.finished, error.message]); + if (state.infoLoading) { + fetchProjectInfo(); + } + }, [fetchProjectInfo, state.infoLoading, error.message]); return ( @@ -210,6 +241,11 @@ const ProjectPage = (props) => { className={classes.title} > {state.info.name} + + + + + {state.info.description} @@ -326,6 +362,18 @@ const ProjectPage = (props) => { )} + {/* Edit project info*/} + {error.message === null && !state.infoLoading && ( + + )} ); }; diff --git a/asreview/webapp/src/PreReviewComponents/ProjectUpload.js b/asreview/webapp/src/PreReviewComponents/ProjectUpload.js index e7507cde3..c65db6573 100644 --- a/asreview/webapp/src/PreReviewComponents/ProjectUpload.js +++ b/asreview/webapp/src/PreReviewComponents/ProjectUpload.js @@ -439,9 +439,9 @@ const ProjectUpload = ({ From file/URL: Select a file from your computer or fill in a link to a file - from the Internet. The accepted file formats are CSV, Excel, TSV, and - RIS. The selected dataset should contain the title and abstract - of each record. Read more about + from the Internet. The accepted file formats are CSV, Excel, + TSV, and RIS. The selected dataset should contain the title and + abstract of each record. Read more about { {open.newProject && ( - { }; useEffect(() => { - /** - * Get summary statistics - */ + // flag denotes mount status + let isMounted = true; + const getProgressInfo = () => { ProjectAPI.progress(props.project_id) .then((result) => { - setStatistics(result.data); + if (isMounted) setStatistics(result.data); }) .catch((error) => { setError((s) => { @@ -118,7 +118,7 @@ const StatisticsZone = (props) => { const getProgressHistory = () => { ProjectAPI.progress_history(props.project_id) .then((result) => { - setHistory(result.data); + if (isMounted) setHistory(result.data); }) .catch((error) => { setError((s) => { @@ -134,7 +134,7 @@ const StatisticsZone = (props) => { const getProgressEfficiency = () => { ProjectAPI.progress_efficiency(props.project_id) .then((result) => { - setEfficiency(result.data); + if (isMounted) setEfficiency(result.data); }) .catch((error) => { setError((s) => { @@ -152,6 +152,11 @@ const StatisticsZone = (props) => { getProgressHistory(); getProgressEfficiency(); } + + // useEffect cleanup to set flag false, if unmounted + return () => { + isMounted = false; + }; }, [props.projectInitReady, props.training, props.project_id, error.error]); return ( diff --git a/asreview/webapp/src/api/ProjectAPI.js b/asreview/webapp/src/api/ProjectAPI.js index a7c17cf67..e1efb3e47 100644 --- a/asreview/webapp/src/api/ProjectAPI.js +++ b/asreview/webapp/src/api/ProjectAPI.js @@ -57,11 +57,14 @@ class ProjectAPI { }); } - static info(project_id) { + static info(project_id, edit = false, data = null) { const url = api_url + `project/${project_id}/info`; return new Promise((resolve, reject) => { - axios - .get(url) + axios({ + method: edit ? "put" : "get", + url: url, + data: data, + }) .then((result) => { resolve(result); }) diff --git a/asreview/webapp/tests/test_api.py b/asreview/webapp/tests/test_api.py index 50e21c8e9..dd3c9e6e3 100644 --- a/asreview/webapp/tests/test_api.py +++ b/asreview/webapp/tests/test_api.py @@ -75,11 +75,23 @@ def test_get_project_data(client): assert json_data["filename"] == "Hall_2012" +def test_update_project_info(client): + """Test update project info""" + + response = client.put("/api/project/project-id/info", data={ + "name": "project_id", + "authors": "asreview team", + "description": "hello world" + }) + assert response.status_code == 200 + + def test_get_project_info(client): """Test get info on the project""" response = client.get("/api/project/project-id/info") json_data = response.get_json() + assert json_data["authors"] == "asreview team" assert json_data["dataset_path"] == "Hall_2012.csv" diff --git a/asreview/webapp/utils/project.py b/asreview/webapp/utils/project.py index fdba1c1cc..12a0d9f35 100644 --- a/asreview/webapp/utils/project.py +++ b/asreview/webapp/utils/project.py @@ -14,6 +14,7 @@ import json import os +import re import shutil import zipfile import tempfile @@ -36,6 +37,7 @@ from asreview.webapp.utils.io import read_proba from asreview.webapp.utils.io import write_label_history from asreview.webapp.utils.io import write_pool +from asreview.webapp.utils.paths import asreview_path from asreview.webapp.utils.paths import get_data_path from asreview.webapp.utils.paths import get_data_file_path from asreview.webapp.utils.paths import get_labeled_path @@ -102,6 +104,50 @@ def init_project(project_id, raise err +def update_project_info(project_id, + project_name=None, + project_description=None, + project_authors=None): + '''Update project info''' + + project_id_new = re.sub('[^A-Za-z0-9]+', '-', project_name).lower() + + if not project_id_new and not isinstance(project_id_new, str) \ + and len(project_id_new) >= 3: + raise ValueError("Project name should be at least 3 characters.") + + if (project_id != project_id_new) & is_project(project_id_new): + raise ValueError("Project name already exists.") + + try: + + # read the file with project info + with open(get_project_file_path(project_id), "r") as fp: + project_info = json.load(fp) + + project_info["id"] = project_id_new + project_info["name"] = project_name + project_info["authors"] = project_authors + project_info["description"] = project_description + + # # backwards support <0.10 + # if "projectInitReady" not in project_info: + # project_info["projectInitReady"] = True + + # update the file with project info + with open(get_project_file_path(project_id), "w") as fp: + json.dump(project_info, fp) + + # rename the folder + get_project_path(project_id) \ + .rename(Path(asreview_path(), project_id_new)) + + except Exception as err: + raise err + + return project_info["id"] + + def import_project_file(file_name): """Import .asreview project file"""