diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a017f87..b1e6045 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import LoginPage from "./pages/Login"; import UploadPage from "./pages/Upload"; import StatPage from "./pages/Stats"; import { getDocumentTitle } from "./utils/documentTitle"; +import DatasetEditPage from "./pages/DatasetEdit"; function App() { const location = useLocation(); @@ -24,6 +25,7 @@ function App() { } /> } /> } /> + } /> ); diff --git a/frontend/src/components/ConfirmationModal.tsx b/frontend/src/components/ConfirmationModal.tsx new file mode 100644 index 0000000..2f249f3 --- /dev/null +++ b/frontend/src/components/ConfirmationModal.tsx @@ -0,0 +1,48 @@ +import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react"; +import StatsStyling from "../styles/stats_styling"; + +type Props = { + open: boolean; + title: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + loading?: boolean; + onConfirm: () => void; + onCancel: () => void; +}; + +const styles = StatsStyling; + +export default function ConfirmationModal({ + open, + title, + message, + confirmLabel = "Confirm", + cancelLabel = "Cancel", + loading = false, + onConfirm, + onCancel, +}: Props) { + return ( + + + + + + {title} + {message} + + + + {cancelLabel} + + + {loading ? "Deleting..." : confirmLabel} + + + + + + ); +} diff --git a/frontend/src/pages/DatasetEdit.tsx b/frontend/src/pages/DatasetEdit.tsx new file mode 100644 index 0000000..fb92603 --- /dev/null +++ b/frontend/src/pages/DatasetEdit.tsx @@ -0,0 +1,217 @@ +import StatsStyling from "../styles/stats_styling"; +import { useNavigate, useParams } from "react-router-dom"; +import { useEffect, useMemo, useState, type FormEvent } from "react"; +import axios from "axios"; +import ConfirmationModal from "../components/ConfirmationModal"; + +const API_BASE_URL = import.meta.env.VITE_BACKEND_URL; +const styles = StatsStyling; + +type DatasetInfoResponse = { + id: number; + name: string; + created_at: string; +}; + +const DatasetEditPage = () => { + const navigate = useNavigate(); + const { datasetId } = useParams<{ datasetId: string }>(); + const parsedDatasetId = useMemo(() => Number(datasetId), [datasetId]); + const [statusMessage, setStatusMessage] = useState(""); + const [loading, setLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [hasError, setHasError] = useState(false); + + const [datasetName, setDatasetName] = useState(""); + useEffect(() => { + if (!Number.isInteger(parsedDatasetId) || parsedDatasetId <= 0) { + setHasError(true); + setStatusMessage("Invalid dataset id."); + setLoading(false); + return; + } + + const token = localStorage.getItem("access_token"); + if (!token) { + setHasError(true); + setStatusMessage("You must be signed in to edit datasets."); + setLoading(false); + return; + } + + axios + .get(`${API_BASE_URL}/dataset/${parsedDatasetId}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + .then((response) => { + setDatasetName(response.data.name || ""); + }) + .catch((error: unknown) => { + setHasError(true); + if (axios.isAxiosError(error)) { + setStatusMessage(String(error.response?.data?.error || error.message)); + } else { + setStatusMessage("Could not get dataset info."); + } + }) + .finally(() => { + setLoading(false); + }); + }, [parsedDatasetId]); + + + const saveDatasetName = async (event: FormEvent) => { + event.preventDefault(); + + const trimmedName = datasetName.trim(); + if (!trimmedName) { + setHasError(true); + setStatusMessage("Please enter a valid dataset name."); + return; + } + + const token = localStorage.getItem("access_token"); + if (!token) { + setHasError(true); + setStatusMessage("You must be signed in to save changes."); + return; + } + + try { + setIsSaving(true); + setHasError(false); + setStatusMessage(""); + + await axios.patch( + `${API_BASE_URL}/dataset/${parsedDatasetId}`, + { name: trimmedName }, + { headers: { Authorization: `Bearer ${token}` } } + ); + + navigate("/datasets", { replace: true }); + } catch (error: unknown) { + setHasError(true); + if (axios.isAxiosError(error)) { + setStatusMessage(String(error.response?.data?.error || error.message || "Save failed.")); + } else { + setStatusMessage("Save failed due to an unexpected error."); + } + } finally { + setIsSaving(false); + } + }; + + const deleteDataset = async () => { + const deleteToken = localStorage.getItem("access_token"); + if (!deleteToken) { + setHasError(true); + setStatusMessage("You must be signed in to delete datasets."); + setIsDeleteModalOpen(false); + return; + } + + try { + setIsDeleting(true); + setHasError(false); + setStatusMessage(""); + + await axios.delete( + `${API_BASE_URL}/dataset/${parsedDatasetId}`, + { headers: { Authorization: `Bearer ${deleteToken}` } } + ); + + setIsDeleteModalOpen(false); + navigate("/datasets", { replace: true }); + } catch (error: unknown) { + setHasError(true); + if (axios.isAxiosError(error)) { + setStatusMessage(String(error.response?.data?.error || error.message || "Delete failed.")); + } else { + setStatusMessage("Delete failed due to an unexpected error."); + } + } finally { + setIsDeleting(false); + } + }; + + return ( + + + + + Edit Dataset + Update the dataset name shown in your datasets list. + + + + + + Dataset name + + + setDatasetName(event.target.value)} + disabled={loading || isSaving} + /> + + + setIsDeleteModalOpen(true)} + disabled={isSaving || isDeleting} + > + Delete Dataset + + + navigate("/datasets")} + disabled={isSaving || isDeleting} + > + Cancel + + + {isSaving ? "Saving..." : "Save"} + + + {loading + ? "Loading dataset details..." + : statusMessage} + + + + setIsDeleteModalOpen(false)} + onConfirm={deleteDataset} + /> + + + ); +}; + +export default DatasetEditPage; diff --git a/frontend/src/pages/Datasets.tsx b/frontend/src/pages/Datasets.tsx index b054420..4c79cdc 100644 --- a/frontend/src/pages/Datasets.tsx +++ b/frontend/src/pages/Datasets.tsx @@ -94,6 +94,7 @@ const DatasetsPage = () => { {datasets.map((dataset) => { const isComplete = dataset.status === "complete"; + const editPath = `/dataset/${dataset.id}/edit`; const targetPath = isComplete ? `/dataset/${dataset.id}/stats` : `/dataset/${dataset.id}/status`; @@ -117,13 +118,25 @@ const DatasetsPage = () => { )} - navigate(targetPath)} - > - {isComplete ? "Open stats" : "View status"} - + + { isComplete && + navigate(editPath)} + > + Edit Dataset + + } + + navigate(targetPath)} + > + {isComplete ? "Open stats" : "View status"} + + ); })} diff --git a/frontend/src/styles/stats/foundations.ts b/frontend/src/styles/stats/foundations.ts index 7801824..48194f3 100644 --- a/frontend/src/styles/stats/foundations.ts +++ b/frontend/src/styles/stats/foundations.ts @@ -97,6 +97,16 @@ export const foundationStyles: StyleMap = { cursor: "pointer", }, + buttonDanger: { + padding: "8px 12px", + borderRadius: 6, + border: `1px solid ${palette.borderDefault}`, + background: palette.dangerText, + color: palette.textPrimary, + fontWeight: 600, + cursor: "pointer", + }, + grid: { marginTop: 12, display: "grid", diff --git a/server/app.py b/server/app.py index 1332ad2..7cbf9d3 100644 --- a/server/app.py +++ b/server/app.py @@ -164,10 +164,55 @@ def get_dataset(dataset_id): if not dataset_manager.authorize_user_dataset(dataset_id, user_id): raise NotAuthorisedException("This user is not authorised to access this dataset") - dataset_content = dataset_manager.get_dataset_content(dataset_id) - filters = get_request_filters() - filtered_dataset = stat_gen.filter_dataset(dataset_content, filters) - return jsonify(filtered_dataset), 200 + dataset_info = dataset_manager.get_dataset_info(dataset_id) + included_cols = {"id", "name", "created_at"} + + return jsonify({k: dataset_info[k] for k in included_cols}), 200 + except NotAuthorisedException: + return jsonify({"error": "User is not authorised to access this content"}), 403 + except NonExistentDatasetException: + return jsonify({"error": "Dataset does not exist"}), 404 + except Exception: + print(traceback.format_exc()) + return jsonify({"error": "An unexpected error occured"}), 500 + +@app.route("/dataset/", methods=["PATCH"]) +@jwt_required() +def update_dataset(dataset_id): + try: + user_id = int(get_jwt_identity()) + + if not dataset_manager.authorize_user_dataset(dataset_id, user_id): + raise NotAuthorisedException("This user is not authorised to access this dataset") + + body = request.get_json() + new_name = body.get("name") + + if not new_name or not new_name.strip(): + return jsonify({"error": "A valid name must be provided"}), 400 + + dataset_manager.update_dataset_name(dataset_id, new_name.strip()) + return jsonify({"message": f"Dataset {dataset_id} renamed to '{new_name.strip()}'"}), 200 + except NotAuthorisedException: + return jsonify({"error": "User is not authorised to access this content"}), 403 + except NonExistentDatasetException: + return jsonify({"error": "Dataset does not exist"}), 404 + except Exception: + print(traceback.format_exc()) + return jsonify({"error": "An unexpected error occurred"}), 500 + +@app.route("/dataset/", methods=["DELETE"]) +@jwt_required() +def delete_dataset(dataset_id): + try: + user_id = int(get_jwt_identity()) + + if not dataset_manager.authorize_user_dataset(dataset_id, user_id): + raise NotAuthorisedException("This user is not authorised to access this dataset") + + dataset_manager.delete_dataset_info(dataset_id) + dataset_manager.delete_dataset_content(dataset_id) + return jsonify({"message": f"Dataset {dataset_id} metadata and content successfully deleted"}), 200 except NotAuthorisedException: return jsonify({"error": "User is not authorised to access this content"}), 403 except NonExistentDatasetException: @@ -208,6 +253,8 @@ def content_endpoint(dataset_id): return jsonify(stat_gen.get_content_analysis(dataset_content, filters)), 200 except NotAuthorisedException: return jsonify({"error": "User is not authorised to access this content"}), 403 + except NonExistentDatasetException: + return jsonify({"error": "Dataset does not exist"}), 404 except ValueError as e: return jsonify({"error": f"Malformed or missing data: {str(e)}"}), 400 except Exception as e: @@ -228,6 +275,8 @@ def get_summary(dataset_id): return jsonify(stat_gen.summary(dataset_content, filters)), 200 except NotAuthorisedException: return jsonify({"error": "User is not authorised to access this content"}), 403 + except NonExistentDatasetException: + return jsonify({"error": "Dataset does not exist"}), 404 except ValueError as e: return jsonify({"error": f"Malformed or missing data: {str(e)}"}), 400 except Exception as e: @@ -248,6 +297,8 @@ def get_time_analysis(dataset_id): return jsonify(stat_gen.get_time_analysis(dataset_content, filters)), 200 except NotAuthorisedException: return jsonify({"error": "User is not authorised to access this content"}), 403 + except NonExistentDatasetException: + return jsonify({"error": "Dataset does not exist"}), 404 except ValueError as e: return jsonify({"error": f"Malformed or missing data: {str(e)}"}), 400 except Exception as e: @@ -268,6 +319,8 @@ def get_user_analysis(dataset_id): return jsonify(stat_gen.get_user_analysis(dataset_content, filters)), 200 except NotAuthorisedException: return jsonify({"error": "User is not authorised to access this content"}), 403 + except NonExistentDatasetException: + return jsonify({"error": "Dataset does not exist"}), 404 except ValueError as e: return jsonify({"error": f"Malformed or missing data: {str(e)}"}), 400 except Exception as e: @@ -288,6 +341,8 @@ def get_cultural_analysis(dataset_id): return jsonify(stat_gen.get_cultural_analysis(dataset_content, filters)), 200 except NotAuthorisedException: return jsonify({"error": "User is not authorised to access this content"}), 403 + except NonExistentDatasetException: + return jsonify({"error": "Dataset does not exist"}), 404 except ValueError as e: return jsonify({"error": f"Malformed or missing data: {str(e)}"}), 400 except Exception as e: @@ -308,6 +363,8 @@ def get_interaction_analysis(dataset_id): return jsonify(stat_gen.get_interactional_analysis(dataset_content, filters)), 200 except NotAuthorisedException: return jsonify({"error": "User is not authorised to access this content"}), 403 + except NonExistentDatasetException: + return jsonify({"error": "Dataset does not exist"}), 404 except ValueError as e: return jsonify({"error": f"Malformed or missing data: {str(e)}"}), 400 except Exception as e: diff --git a/server/core/datasets.py b/server/core/datasets.py index e7ee717..5886cfc 100644 --- a/server/core/datasets.py +++ b/server/core/datasets.py @@ -143,4 +143,18 @@ class DatasetManager: print(result) raise NonExistentDatasetException(f"Dataset {dataset_id} does not exist") - return result[0] \ No newline at end of file + return result[0] + + def update_dataset_name(self, dataset_id: int, new_name: str): + query = "UPDATE datasets SET name = %s WHERE id = %s" + self.db.execute(query, (new_name, dataset_id)) + + def delete_dataset_info(self, dataset_id: int): + query = "DELETE FROM datasets WHERE id = %s" + + self.db.execute(query, (dataset_id, )) + + def delete_dataset_content(self, dataset_id: int): + query = "DELETE FROM events WHERE dataset_id = %s" + + self.db.execute(query, (dataset_id, )) \ No newline at end of file diff --git a/server/db/database.py b/server/db/database.py index 346ef8b..f56f579 100644 --- a/server/db/database.py +++ b/server/db/database.py @@ -38,9 +38,13 @@ class PostgresConnector: raise def execute_batch(self, query, values): - with self.connection.cursor(cursor_factory=RealDictCursor) as cursor: - execute_batch(cursor, query, values) + try: + with self.connection.cursor(cursor_factory=RealDictCursor) as cursor: + execute_batch(cursor, query, values) self.connection.commit() + except Exception: + self.connection.rollback() + raise def close(self): if self.connection:
{message}
Update the dataset name shown in your datasets list.