Compare commits
10 Commits
a9001c79e1
...
738af5415b
| Author | SHA1 | Date | |
|---|---|---|---|
| 738af5415b | |||
| 2b14a8a417 | |||
| a154b25415 | |||
| eb273efe61 | |||
| eec8f2417e | |||
| f5835b5a97 | |||
| 64e3f9eea8 | |||
| 4f01bf0419 | |||
| 6948891677 | |||
| f1f33e2fe4 |
@@ -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() {
|
||||
<Route path="/datasets" element={<DatasetsPage />} />
|
||||
<Route path="/dataset/:datasetId/status" element={<DatasetStatusPage />} />
|
||||
<Route path="/dataset/:datasetId/stats" element={<StatPage />} />
|
||||
<Route path="/dataset/:datasetId/edit" element={<DatasetEditPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
|
||||
48
frontend/src/components/ConfirmationModal.tsx
Normal file
48
frontend/src/components/ConfirmationModal.tsx
Normal file
@@ -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 (
|
||||
<Dialog open={open} onClose={onCancel} style={styles.modalRoot}>
|
||||
<div style={styles.modalBackdrop} />
|
||||
|
||||
<div style={styles.modalContainer}>
|
||||
<DialogPanel style={{ ...styles.card, ...styles.modalPanel }}>
|
||||
<DialogTitle style={styles.sectionTitle}>{title}</DialogTitle>
|
||||
<p style={styles.sectionSubtitle}>{message}</p>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
||||
<button type="button" onClick={onCancel} style={styles.buttonSecondary} disabled={loading}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button type="button" onClick={onConfirm} style={styles.buttonDanger} disabled={loading}>
|
||||
{loading ? "Deleting..." : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
217
frontend/src/pages/DatasetEdit.tsx
Normal file
217
frontend/src/pages/DatasetEdit.tsx
Normal file
@@ -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<DatasetInfoResponse>(`${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<HTMLFormElement>) => {
|
||||
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 (
|
||||
<div style={styles.page}>
|
||||
<div style={styles.containerNarrow}>
|
||||
<div style={{ ...styles.card, ...styles.headerBar }}>
|
||||
<div>
|
||||
<h1 style={styles.sectionHeaderTitle}>Edit Dataset</h1>
|
||||
<p style={styles.sectionHeaderSubtitle}>Update the dataset name shown in your datasets list.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={saveDatasetName}
|
||||
style={{ ...styles.card, marginTop: 14, display: "grid", gap: 12 }}
|
||||
>
|
||||
<label
|
||||
htmlFor="dataset-name"
|
||||
style={{ fontSize: 13, color: "#374151", fontWeight: 600 }}
|
||||
>
|
||||
Dataset name
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="dataset-name"
|
||||
style={{ ...styles.input, ...styles.inputFullWidth }}
|
||||
type="text"
|
||||
placeholder="Example: Cork Discussions - Jan 2026"
|
||||
value={datasetName}
|
||||
onChange={(event) => setDatasetName(event.target.value)}
|
||||
disabled={loading || isSaving}
|
||||
/>
|
||||
|
||||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||
<button
|
||||
type="button"
|
||||
style={styles.buttonDanger}
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
disabled={isSaving || isDeleting}
|
||||
>
|
||||
Delete Dataset
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
style={styles.buttonSecondary}
|
||||
onClick={() => navigate("/datasets")}
|
||||
disabled={isSaving || isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
style={{ ...styles.buttonPrimary, opacity: loading || isSaving ? 0.75 : 1 }}
|
||||
disabled={loading || isSaving || isDeleting}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
|
||||
{loading
|
||||
? "Loading dataset details..."
|
||||
: statusMessage}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ConfirmationModal
|
||||
open={isDeleteModalOpen}
|
||||
title="Delete Dataset"
|
||||
message={`Are you sure you want to delete "${datasetName || "this dataset"}"? This action cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
cancelLabel="Keep Dataset"
|
||||
loading={isDeleting}
|
||||
onCancel={() => setIsDeleteModalOpen(false)}
|
||||
onConfirm={deleteDataset}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DatasetEditPage;
|
||||
@@ -94,6 +94,7 @@ const DatasetsPage = () => {
|
||||
<ul style={styles.listNoBullets}>
|
||||
{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 = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
style={isComplete ? styles.buttonPrimary : styles.buttonSecondary}
|
||||
onClick={() => navigate(targetPath)}
|
||||
>
|
||||
{isComplete ? "Open stats" : "View status"}
|
||||
</button>
|
||||
<div>
|
||||
{ isComplete &&
|
||||
<button
|
||||
type="button"
|
||||
style={{...styles.buttonSecondary, "margin": "5px"}}
|
||||
onClick={() => navigate(editPath)}
|
||||
>
|
||||
Edit Dataset
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
style={isComplete ? styles.buttonPrimary : styles.buttonSecondary}
|
||||
onClick={() => navigate(targetPath)}
|
||||
>
|
||||
{isComplete ? "Open stats" : "View status"}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/<int:dataset_id>", 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/<int:dataset_id>", 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:
|
||||
|
||||
@@ -143,4 +143,18 @@ class DatasetManager:
|
||||
print(result)
|
||||
raise NonExistentDatasetException(f"Dataset {dataset_id} does not exist")
|
||||
|
||||
return result[0]
|
||||
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, ))
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user