From 82afaa88b224b4e93ec6e96dd097b2682dbedeaf Mon Sep 17 00:00:00 2001 From: harbassan Date: Sat, 18 Jul 2026 17:31:01 +1200 Subject: [PATCH 1/5] refactor: merge collections concept into resources --- backend/src/db/models/CollectionGroup.js | 33 ---- backend/src/db/models/resource.js | 15 +- backend/src/routes/api/collections.js | 207 ----------------------- backend/src/routes/api/index.js | 2 - backend/src/routes/api/resources.js | 88 ++++++++-- 5 files changed, 87 insertions(+), 258 deletions(-) delete mode 100644 backend/src/db/models/CollectionGroup.js delete mode 100644 backend/src/routes/api/collections.js diff --git a/backend/src/db/models/CollectionGroup.js b/backend/src/db/models/CollectionGroup.js deleted file mode 100644 index 3527c7c31..000000000 --- a/backend/src/db/models/CollectionGroup.js +++ /dev/null @@ -1,33 +0,0 @@ -import mongoose from "mongoose"; - -const { Schema, Types, model } = mongoose; - -const CollectionGroupSchema = new Schema( - { - scenarioId: { type: Types.ObjectId, required: true, index: true }, - name: { type: String, required: true, trim: true }, - order: { type: Number, default: 0 }, - stateConditionals: { - type: [ - { - stateVariableId: { type: String, required: true }, - comparator: { - type: String, - enum: ["=", "!=", "<", ">"], - required: true, - }, - value: { type: String, required: true }, - }, - ], - default: [], - }, - createdAt: { type: Date, default: Date.now }, - }, - { versionKey: false } -); - -// Helpful indexes for sorting/filtering by scenario -CollectionGroupSchema.index({ scenarioId: 1, order: 1 }); -CollectionGroupSchema.index({ scenarioId: 1, name: 1 }); - -export default model("CollectionGroup", CollectionGroupSchema); diff --git a/backend/src/db/models/resource.js b/backend/src/db/models/resource.js index 857442218..4f5a07072 100644 --- a/backend/src/db/models/resource.js +++ b/backend/src/db/models/resource.js @@ -8,17 +8,22 @@ const resourceSchema = new Schema( required: true, index: true, }, - groupId: { + parentId: { type: Schema.Types.ObjectId, - ref: "CollectionGroup", - required: true, + ref: "Resource", + required: false, index: true, }, + type: { + type: String, + enum: ["file", "collection"], + required: true, + }, name: { type: String, required: true }, fileId: { type: Schema.Types.ObjectId, ref: "UploadedFile", - required: true, + required: false, }, stateConditionals: { type: [ @@ -40,7 +45,7 @@ const resourceSchema = new Schema( resourceSchema.index({ scenarioId: 1, - groupId: 1, + parentId: 1, createdAt: -1, }); diff --git a/backend/src/routes/api/collections.js b/backend/src/routes/api/collections.js deleted file mode 100644 index 7d7c1e750..000000000 --- a/backend/src/routes/api/collections.js +++ /dev/null @@ -1,207 +0,0 @@ -import { Router } from "express"; -import mongoose from "mongoose"; -import auth from "../../middleware/firebaseAuth.js"; -import CollectionGroup from "../../db/models/CollectionGroup.js"; -import Resource from "../../db/models/resource.js"; -import { applyReferenceDeltas } from "../../db/daos/fileDao.js"; - -const router = Router(); - -// Allow ?token=ID_TOKEN for / links (copy to Authorization header) -router.use((req, _res, next) => { - if (req.query && req.query.token && !req.headers.authorization) { - req.headers.authorization = `Bearer ${req.query.token}`; - } - next(); -}); - -// Require Firebase auth for all routes below -router.use(auth); - -/** - * @route POST /api/collections/groups - * @desc Create a new group under a scenario - */ -router.post("/groups", async (req, res) => { - try { - const { scenarioId, name, order = 0 } = req.body; - if (!scenarioId || !name) { - return res - .status(400) - .json({ error: "scenarioId and name are required" }); - } - - const group = await CollectionGroup.create({ - scenarioId: new mongoose.Types.ObjectId(scenarioId), - name, - order, - }); - - return res.status(201).json(group); - } catch (err) { - return res.status(500).json({ error: err.message }); - } -}); - -/** - * @route GET /api/collections/tree/:scenarioId - * @desc Get all groups + their files for a scenario - */ -router.get("/tree/:scenarioId", async (req, res) => { - try { - const { scenarioId } = req.params; - - const [groups, files] = await Promise.all([ - CollectionGroup.find({ scenarioId }).sort({ order: 1, name: 1 }).lean(), - Resource.find({ scenarioId }) - .populate("fileId", "url type contentType size") - .sort({ groupId: 1, createdAt: -1 }) - .lean(), - ]); - - // index files by groupId - const filesByGroup = new Map(); - for (const f of files) { - const key = String(f.groupId); - if (!filesByGroup.has(key)) filesByGroup.set(key, []); - filesByGroup.get(key).push(f); - } - - // attach files directly to each group - const tree = groups.map((g) => ({ - ...g, - files: filesByGroup.get(String(g._id)) || [], - })); - - return res.json(tree); - } catch (err) { - return res.status(500).json({ error: err.message }); - } -}); - -/** - * @route DELETE /api/collections/groups/:groupId - * @desc Delete a group and all associated files - */ -router.delete("/groups/:groupId", async (req, res) => { - try { - const { groupId } = req.params; - - const group = await CollectionGroup.findById(groupId); - if (!group) return res.status(404).json({ error: "Group not found" }); - - const resources = await Resource.find({ groupId: group._id }); - - const fileRefDeltas = new Map(); - for (const resource of resources) { - const fileId = resource.fileId.toString(); - fileRefDeltas.set(fileId, (fileRefDeltas.get(fileId) ?? 0) - 1); - } - - await Resource.deleteMany({ groupId: group._id }); - await applyReferenceDeltas(fileRefDeltas); - await group.deleteOne(); - - return res.json({ - deleted: { - groups: 1, - files: resources.length, - }, - }); - } catch (err) { - return res.status(500).json({ error: err.message }); - } -}); - -/** - * @route POST /api/collections/groups/:groupId/state-conditionals - * @desc Add a state conditional to a collection group - */ -router.post("/groups/:groupId/state-conditionals", async (req, res) => { - try { - const { groupId } = req.params; - const { stateConditional } = req.body; - if ( - !stateConditional || - !stateConditional.stateVariableId || - !stateConditional.comparator || - stateConditional.value === undefined - ) { - return res - .status(400) - .json({ error: "Invalid stateConditional payload" }); - } - const group = await CollectionGroup.findById(groupId); - if (!group) return res.status(404).json({ error: "Group not found" }); - - group.stateConditionals.push(stateConditional); - await group.save(); - - return res.json(group); - } catch (err) { - return res.status(500).json({ error: err.message }); - } -}); - -/** - * `@route` PUT /api/collections/groups/:groupId/state-conditionals - * `@desc` Update a state conditional on a collection group - */ -router.put("/groups/:groupId/state-conditionals", async (req, res) => { - try { - const { groupId } = req.params; - const { stateConditional } = req.body; - const group = await CollectionGroup.findById(groupId); - if (!group) return res.status(404).json({ error: "Group not found" }); - - let found = false; - group.stateConditionals = group.stateConditionals.map((sc) => { - if (sc._id.toString() === stateConditional._id) { - found = true; - return stateConditional; - } - return sc; - }); - if (!found) { - return res.status(404).json({ error: "State conditional not found" }); - } - - await group.save(); - - return res.json(group); - } catch (err) { - return res.status(500).json({ error: err.message }); - } -}); - -/** - * @route DELETE /api/collections/groups/:groupId/state-conditionals/:stateConditionalId - * @desc Delete a state conditional from a collection group - */ -router.delete( - "/groups/:groupId/state-conditionals/:stateConditionalId", - async (req, res) => { - try { - const { groupId, stateConditionalId } = req.params; - const group = await CollectionGroup.findById(groupId); - if (!group) return res.status(404).json({ error: "Group not found" }); - - const originalLength = group.stateConditionals.length; - group.stateConditionals = group.stateConditionals.filter( - (sc) => sc._id.toString() !== stateConditionalId - ); - - if (group.stateConditionals.length === originalLength) { - return res.status(404).json({ error: "State conditional not found" }); - } - - await group.save(); - - return res.json(group); - } catch (err) { - return res.status(500).json({ error: err.message }); - } - } -); - -export default router; diff --git a/backend/src/routes/api/index.js b/backend/src/routes/api/index.js index eef7220b0..69e1b656d 100644 --- a/backend/src/routes/api/index.js +++ b/backend/src/routes/api/index.js @@ -10,7 +10,6 @@ import navigate from "./navigate/index.js"; import access from "./access.js"; import dashboard from "./dashboard.js"; import files from "./files.js"; -import collections from "./collections.js"; const router = Router(); @@ -24,6 +23,5 @@ router.use("/resources", resource); router.use("/access", access); router.use("/dashboard", dashboard); router.use("/files", files); -router.use("/collections", collections); export default router; diff --git a/backend/src/routes/api/resources.js b/backend/src/routes/api/resources.js index 18bf035a4..d613753a1 100644 --- a/backend/src/routes/api/resources.js +++ b/backend/src/routes/api/resources.js @@ -2,9 +2,11 @@ import { Router } from "express"; import auth from "../../middleware/firebaseAuth.js"; import { handle, HttpError } from "../../util/error.js"; import scenarioAuth from "../../middleware/scenarioAuth.js"; -import CollectionGroup from "../../db/models/CollectionGroup.js"; import { HttpStatusCode } from "axios"; -import { applyReferenceDelta } from "../../db/daos/fileDao.js"; +import { + applyReferenceDelta, + applyReferenceDeltas, +} from "../../db/daos/fileDao.js"; import Resource from "../../db/models/resource.js"; import { isValidObjectId } from "../../util/validation.js"; import UploadedFile from "../../db/models/uploadedFile.js"; @@ -14,36 +16,58 @@ const router = Router(); router.use(auth); router.use("/:scenarioId", scenarioAuth); +/** + * @route GET /api/resources/:scenarioId + * @desc Get all resources for a scenario + */ +router.get( + "/:scenarioId", + handle(async (req, res) => { + const { scenarioId } = req.params; + + const resources = await Resource.find({ scenarioId }) + .populate("fileId", "url type contentType size") + .sort({ parentId: 1, createdAt: -1 }) + .lean(); + + return res.json(resources); + }) +); + /** * @route POST /api/resources/:scenarioId - * @desc Upload a file to a resource group + * @desc Upload a file resource, optionally to a collection */ router.post( "/:scenarioId", handle(async (req, res) => { const { scenarioId } = req.params; - const { groupId, name, fileId } = req.body; + const { parentId, name, fileId } = req.body; - if (!groupId || !name || !fileId) + if (!name || !fileId) throw new HttpError( "missing required properties", HttpStatusCode.BadRequest ); - if (!isValidObjectId(groupId)) - throw new HttpError("invalid group id", HttpStatusCode.BadRequest); + if (parentId && !isValidObjectId(parentId)) + throw new HttpError("invalid parent id", HttpStatusCode.BadRequest); if (!isValidObjectId(fileId)) throw new HttpError("invalid file id", HttpStatusCode.BadRequest); - const group = await CollectionGroup.exists({ _id: groupId, scenarioId }); - if (!group) throw new HttpError("group not found", HttpStatusCode.NotFound); + if (parentId) { + const parent = await Resource.exists({ _id: parentId, scenarioId }); + if (!parent) + throw new HttpError("group not found", HttpStatusCode.NotFound); + } const file = await UploadedFile.exists({ _id: fileId, scenarioId }); if (!file) throw new HttpError("file not found", HttpStatusCode.NotFound); const resource = await Resource.create({ + type: "file", scenarioId, - groupId, + parentId, name, fileId, }); @@ -55,6 +79,32 @@ router.post( }) ); +/** + * @route POST /api/resources/:scenarioId/collection + * @desc Create a resource collection + */ +router.post( + "/:scenarioId/collection", + handle(async (req, res) => { + const { scenarioId } = req.params; + const { name } = req.body; + + if (!name) + throw new HttpError( + "missing required properties", + HttpStatusCode.BadRequest + ); + + const resource = await Resource.create({ + type: "collection", + scenarioId, + name, + }); + + return res.status(HttpStatusCode.Created).json(resource); + }) +); + /** * @route DELETE /api/resources/:scenarioId/:resourceId * @desc Delete a resource @@ -64,6 +114,9 @@ router.delete( handle(async (req, res) => { const { scenarioId, resourceId } = req.params; + if (!isValidObjectId(resourceId)) + throw new HttpError("invalid resource id", HttpStatusCode.BadRequest); + const resource = await Resource.findOneAndDelete({ _id: resourceId, scenarioId, @@ -71,7 +124,20 @@ router.delete( if (!resource) throw new HttpError("resource not found", HttpStatusCode.NotFound); - await applyReferenceDelta(resource.fileId, -1); + const fileRefDeltas = new Map(); + + if (resource.type === "collection") { + const children = await Resource.find({ parentId: resource._id }); + for (const child of children) { + const fileId = child.fileId.toString(); + fileRefDeltas.set(fileId, (fileRefDeltas.get(fileId) ?? 0) - 1); + } + await Resource.deleteMany({ parentId: resource._id }); + } else { + fileRefDeltas.set(resource.fileId, -1); + } + + await applyReferenceDeltas(fileRefDeltas); return res.status(HttpStatusCode.NoContent).send(); }) From dcba835a3471002d1b96ce5b0cf85a499d7ade87 Mon Sep 17 00:00:00 2001 From: harbassan Date: Sat, 18 Jul 2026 19:02:15 +1200 Subject: [PATCH 2/5] refactor: modify api communication for new contract using queries --- .../StateVariables/CreateStateConditional.jsx | 61 +- .../StateVariables/EditStateConditional.jsx | 124 ++-- .../StateVariables/StateConditionalMenu.jsx | 35 +- .../resources/ManageResourcesPage.jsx | 598 +++++++----------- .../features/resources/ResourcePreview.jsx | 77 +++ frontend/src/features/resources/util.js | 15 +- 6 files changed, 427 insertions(+), 483 deletions(-) create mode 100644 frontend/src/features/resources/ResourcePreview.jsx diff --git a/frontend/src/components/StateVariables/CreateStateConditional.jsx b/frontend/src/components/StateVariables/CreateStateConditional.jsx index a9c9cae2f..f95c61119 100644 --- a/frontend/src/components/StateVariables/CreateStateConditional.jsx +++ b/frontend/src/components/StateVariables/CreateStateConditional.jsx @@ -6,6 +6,16 @@ import ModalDialog from "../ModalDialogue"; import { api } from "../../util/api"; import AuthenticationContext from "../../context/AuthenticationContext"; import toast from "react-hot-toast"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useParams } from "react-router-dom"; + +function createStateConditional(user, scenarioId, resourceId, conditional) { + return api.post( + user, + `api/resources/${scenarioId}/${resourceId}/conditionals`, + { stateConditional: conditional } + ); +} /** * Component used for creating state conditionals @@ -13,7 +23,8 @@ import toast from "react-hot-toast"; * * @component */ -const CreateStateConditional = ({ endpoint, open, setOpen, updateTarget }) => { +const CreateStateConditional = ({ resource, open, setOpen }) => { + const { scenarioId } = useParams(); const { user } = useContext(AuthenticationContext); const { stateVariables } = useContext(ScenarioContext); @@ -21,6 +32,8 @@ const CreateStateConditional = ({ endpoint, open, setOpen, updateTarget }) => { const [comparator, setComparator] = useState(null); const [value, setValue] = useState(null); + const queryClient = useQueryClient(); + if (!stateVariables?.length) { return ( { ); } + const createConditionalMutation = useMutation({ + mutationFn: (conditional) => + createStateConditional(user, scenarioId, resource._id, conditional), + onSettled: () => queryClient.invalidateQueries(["resources", scenarioId]), + onError: (e) => { + console.error(e); + toast.error("Error creating state conditional"); + }, + onSuccess: () => { + setSelectedState(null); + setComparator(null); + setValue(null); + setOpen(false); + toast.success("State conditional created!"); + }, + }); + const handleSubmit = () => { - // Validate that all required fields are filled if (!selectedState?.id || !comparator) return; - const stateConditional = { + createConditionalMutation.mutate({ stateVariableId: selectedState.id, comparator, value: selectedState.type === stateTypes.NUMBER ? Number(value) : value, - }; - - api - .post(user, endpoint, { - stateConditional, - }) - .then((res) => { - updateTarget(res.data); - setSelectedState(null); - setComparator(null); - setValue(null); - setOpen(false); - toast.success("State conditional created!"); - }) - .catch((err) => { - console.error(err); - toast.error("Error creating state conditional"); - }); + }); }; function onVariableChange(variable) { @@ -80,7 +92,12 @@ const CreateStateConditional = ({ endpoint, open, setOpen, updateTarget }) => { setOpen(false)} + onClose={() => { + setSelectedState(null); + setComparator(null); + setValue(null); + setOpen(false); + }} >
diff --git a/frontend/src/components/StateVariables/EditStateConditional.jsx b/frontend/src/components/StateVariables/EditStateConditional.jsx index fb5c8d3ba..2da6d09ab 100644 --- a/frontend/src/components/StateVariables/EditStateConditional.jsx +++ b/frontend/src/components/StateVariables/EditStateConditional.jsx @@ -5,6 +5,23 @@ import { stateTypes, validComparators } from "./stateTypes"; import AuthenticationContext from "../../context/AuthenticationContext"; import { api } from "../../util/api"; import toast from "react-hot-toast"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useParams } from "react-router-dom"; + +function deleteStateConditional(user, scenarioId, resourceId, conditionalId) { + return api.delete( + user, + `api/resources/${scenarioId}/${resourceId}/conditionals/${conditionalId}` + ); +} + +function editStateConditional(user, scenarioId, resourceId, conditional) { + return api.put( + user, + `api/resources/${scenarioId}/${resourceId}/conditionals`, + { stateConditional: conditional } + ); +} /** * Component used for editing state operations @@ -12,20 +29,15 @@ import toast from "react-hot-toast"; * * @component */ -const EditStateConditional = ({ - endpoint, - conditional, - conditionalId, - updateTarget, -}) => { +const EditStateConditional = ({ resource, conditional }) => { + const { scenarioId } = useParams(); const { user } = useContext(AuthenticationContext); const { stateVariables } = useContext(ScenarioContext); const [comparator, setComparator] = useState(conditional.comparator); const [value, setValue] = useState(conditional.value); - const isEditing = - comparator !== conditional.comparator || value !== conditional.value; + const queryClient = useQueryClient(); useEffect(() => { if (conditional.comparator !== comparator) @@ -37,48 +49,78 @@ const EditStateConditional = ({ return null; } + const deleteConditionalMutation = useMutation({ + mutationFn: (conditionalId) => + deleteStateConditional(user, scenarioId, resource._id, conditionalId), + onMutate: (conditionalId) => { + queryClient.cancelQueries(["resources", scenarioId]); + queryClient.setQueryData(["resources", scenarioId], (prev) => { + return prev.map((r) => + r._id !== resource._id + ? r + : { + ...r, + stateConditionals: r.stateConditionals.filter( + (c) => c._id !== conditionalId + ), + } + ); + }); + }, + onSettled: () => queryClient.invalidateQueries(["resources", scenarioId]), + onError: (e) => { + console.error(e); + toast.error("Error deleting state conditional"); + }, + }); + + const updateConditionalMutation = useMutation({ + mutationFn: (conditional) => + editStateConditional(user, scenarioId, resource._id, conditional), + onMutate: (conditional) => { + queryClient.cancelQueries(["resources", scenarioId]); + queryClient.setQueryData(["resources", scenarioId], (prev) => { + return prev.map((r) => + r._id !== resource._id + ? r + : { + ...r, + stateConditionals: r.stateConditionals.map((c) => + c._id !== conditional._id ? c : conditional + ), + } + ); + }); + }, + onSettled: () => queryClient.invalidateQueries(["resources", scenarioId]), + onError: (e) => { + console.error(e); + toast.error("Error deleting state conditional"); + }, + }); + const stateVariable = stateVariables.find( (v) => v.id === conditional.stateVariableId ); if (!stateVariable) return null; - const deleteStateConditional = () => { - api - .delete(user, `${endpoint}/${conditionalId}`) - .then((res) => { - updateTarget(res.data); - }) - .catch((err) => { - console.error(err); - toast.error("Error deleting state conditional"); - }); - }; - - const editStateConditional = () => { - const stateConditional = { - ...conditional, + function constructConditional() { + return { + _id: conditional._id, + stateVariableId: conditional.stateVariableId, comparator, value: stateVariable.type === stateTypes.NUMBER ? Number(value) : value, }; + } - api - .put(user, endpoint, { - stateConditional, - }) - .then((res) => { - updateTarget(res.data); - }) - .catch((err) => { - console.error(err); - toast.error("Error editing state conditional"); - }); - }; - - const resetFields = (e) => { + function resetFields(e) { e.preventDefault(); setComparator(conditional.comparator); setValue(conditional.value); - }; + } + + const isEditing = + comparator !== conditional.comparator || value !== conditional.value; return (
@@ -128,7 +170,9 @@ const EditStateConditional = ({ diff --git a/frontend/src/components/StateVariables/StateConditionalMenu.jsx b/frontend/src/components/StateVariables/StateConditionalMenu.jsx index dd67178b3..efce9e7c7 100644 --- a/frontend/src/components/StateVariables/StateConditionalMenu.jsx +++ b/frontend/src/components/StateVariables/StateConditionalMenu.jsx @@ -2,38 +2,18 @@ import CreateStateConditional from "./CreateStateConditional"; import { PlusIcon } from "lucide-react"; import { useState } from "react"; import EditStateConditional from "./EditStateConditional"; -import { useParams } from "react-router-dom"; -/** - * Component that houses state conditional interface (methods for creating and editing) - * - * @component - */ -const StateConditionalMenu = ({ - target, - title = "State Conditionals", - endpoint, - updateTarget, -}) => { +const StateConditionalMenu = ({ resource }) => { const [createOpen, setCreateOpen] = useState(false); - const { scenarioId } = useParams(); - const subject = target; - const conditionEndpoint = - endpoint || - (subject ? `/api/resources/${scenarioId}/${subject.id}/conditionals` : ""); - const handleUpdate = updateTarget; - - if (!subject) { - return null; - } + if (!resource) return null; return ( <>
- {title} + Visibility Conditionals
- {subject.stateConditionals?.map((stateConditional) => ( + {resource.stateConditionals?.map((stateConditional) => ( ))}
); diff --git a/frontend/src/features/resources/ManageResourcesPage.jsx b/frontend/src/features/resources/ManageResourcesPage.jsx index f2c77e0a4..e8e0232c1 100644 --- a/frontend/src/features/resources/ManageResourcesPage.jsx +++ b/frontend/src/features/resources/ManageResourcesPage.jsx @@ -1,221 +1,160 @@ -import React, { useRef, useState, useEffect, useContext } from "react"; -import { getAuth } from "firebase/auth"; -import axios from "axios"; +import React, { useRef, useState, useContext } from "react"; import toast from "react-hot-toast"; import { useParams } from "react-router-dom"; import { useHistory } from "react-router-dom"; -import { - ArrowLeftIcon, - PlayIcon, - UsersIcon, - PlusIcon, - XIcon, -} from "lucide-react"; +import { ArrowLeftIcon, PlusIcon, XIcon } from "lucide-react"; import AddGroup from "./components/AddGroup"; import StateConditionalMenu from "../../components/StateVariables/StateConditionalMenu"; -import MDTextViewer from "../playScenario/components/MDTextViewer"; import { api } from "../../util/api"; import AuthenticationContext from "../../context/AuthenticationContext"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { normaliseFile } from "./util"; +import { v4 as uuid } from "uuid"; +import ResourcePreview from "./ResourcePreview"; -function normaliseGroup(g) { - return { - id: g._id || g.id, - name: g.name, - order: g.order ?? 0, - stateConditionals: g.stateConditionals || [], - files: (g.files || []).map((f) => normaliseFile(f)), - }; -} +async function uploadFileResource(user, scenarioId, parentId, file) { + const formData = new FormData(); + formData.append("file", file); -async function uploadResource(user, scenarioId, groupId, file) { - try { - const formData = new FormData(); - formData.append("file", file); + const fileResponse = await api.post( + user, + `api/files/${scenarioId}`, + formData + ); - const fileResponse = await api.post( - user, - `api/files/${scenarioId}`, - formData - ); + const resourceResponse = await api.post(user, `api/resources/${scenarioId}`, { + parentId, + name: fileResponse.data.name, + fileId: fileResponse.data._id, + }); + return resourceResponse.data; +} - const resourceResponse = await api.post( - user, - `api/resources/${scenarioId}`, - { - groupId, - name: fileResponse.data.name, - fileId: fileResponse.data._id, - } - ); - toast.success(`Resource created`); - return resourceResponse.data; - } catch (err) { - console.error(err); - toast.error("Upload failed"); - } +async function createResourceCollection(user, scenarioId, name) { + const res = await api.post(user, `api/resources/${scenarioId}/collection`, { + name, + }); + return res.data; } async function removeResource(user, scenarioId, resourceId) { - try { - await api.delete(user, `/api/resources/${scenarioId}/${resourceId}`); - toast.success("Resource deleted"); - return resourceId; - } catch (err) { - console.error(err); - toast.error("Delete failed"); - } + await api.delete(user, `/api/resources/${scenarioId}/${resourceId}`); } -// Page for managing resources (collections and files) for a scenario -export default function ManageResourcesPage() { - const { scenarioId } = useParams(); - const history = useHistory(); - function goBack() { - history.push(`/scenario/${scenarioId}`); - } - - function goToGroups() { - history.push(`/scenario/${scenarioId}/manage-groups`); - } +function buildResourceTree(resources) { + const collections = resources.filter((r) => r.type === "collection"); + const files = resources.filter((r) => r.type === "file").map(normaliseFile); - function playScenario() { - window.open(`/play/${scenarioId}`, "_blank"); - } + const grouped = collections.map((collection) => ({ + _id: collection._id, + name: collection.name, + type: "collection", + stateConditionals: collection.stateConditionals, + children: files.filter( + (f) => String(f.parentId) === String(collection._id) + ), + })); - // Groups (each with files) - const [groups, setGroups] = useState([]); - const [selectedGroup, setSelectedGroup] = useState(null); - const [selectedFile, setSelectedFile] = useState(null); + const orphanFiles = files.filter((f) => !f.parentId); - const { user } = useContext(AuthenticationContext); - - // Load groups and files - useEffect(() => { - let cancelled = false; - (async () => { - try { - const { data } = await api.get( - user, - `/api/collections/tree/${scenarioId}` - ); - const normalized = (data || []).map((g) => normaliseGroup(g)) || []; - if (!cancelled) setGroups(normalized); - } catch (err) { - console.error(err); - if (!cancelled) toast.error("Failed to load groups/files"); - } - })(); - - return () => { - cancelled = true; - }; - }, [scenarioId]); + return [...grouped, ...orphanFiles]; +} - async function addResourceToGroup(groupId, file) { - const resource = await uploadResource(user, scenarioId, groupId, file); - if (!resource) return; +async function getResources(user, scenarioId) { + const res = await api.get(user, `/api/resources/${scenarioId}`); + return res.data; +} - setGroups((prev) => - prev.map((g) => - g.id === groupId - ? { ...g, files: [normaliseFile(resource), ...(g.files || [])] } - : g - ) - ); - } +export default function ManageResourcesPage() { + const { scenarioId } = useParams(); + const history = useHistory(); - async function deleteResource(resourceId) { - const success = await removeResource(user, scenarioId, resourceId); - if (!success) return; + const { user } = useContext(AuthenticationContext); + const queryClient = useQueryClient(); - setGroups((prev) => - prev.map((g) => ({ - ...g, - files: (g.files || []).filter((f) => f.id !== resourceId), - })) - ); + const [selectedResource, setSelectedResource] = useState(null); - if (selectedFile?.id === resourceId) setSelectedFile(null); - } + const resourcesQuery = useQuery({ + queryKey: ["resources", scenarioId], + queryFn: () => getResources(user, scenarioId), + }); - async function deleteGroup(groupId) { - const ok = window.confirm( - "Delete this group and ALL of its files? This cannot be undone." - ); - if (!ok) return; - try { - const user = getAuth().currentUser; - if (!user) { - toast.error("You must be logged in to delete."); - return; + const addFileResourceMutation = useMutation({ + mutationFn: ({ parentId, file }) => + uploadFileResource(user, scenarioId, parentId, file), + onMutate: async ({ parentId, file }) => { + await queryClient.cancelQueries(["resources", scenarioId]); + const tempId = `temp.${uuid()}`; + const temp = { parentId, name: file.name, _id: tempId, type: "file" }; + queryClient.setQueryData(["resources", scenarioId], (prev) => [ + ...prev, + temp, + ]); + return { tempId }; + }, + onSuccess: () => queryClient.invalidateQueries(["resources", scenarioId]), + onError: (e, _, context) => { + const tempId = context?.tempId; + if (tempId) { + queryClient.setQueryData(["resources", scenarioId], (prev) => + prev.filter((r) => r._id !== tempId) + ); } - const idToken = await user.getIdToken(); - await axios.delete(`/api/collections/groups/${groupId}`, { - headers: { Authorization: `Bearer ${idToken}` }, - }); - - setGroups((prev) => prev.filter((g) => g.id !== groupId)); + console.error(e); + toast.error("Something went wrong uploading the resource"); + }, + }); - if (selectedFile && selectedFile.groupId === groupId) - setSelectedFile(null); - if (selectedGroup?.id === groupId) setSelectedGroup(null); + const addResourceCollectionMutation = useMutation({ + mutationFn: (name) => createResourceCollection(user, scenarioId, name), + onMutate: async (name) => { + await queryClient.cancelQueries(["resources", scenarioId]); + const tempId = `temp.${uuid()}`; + const temp = { name, _id: tempId }; + queryClient.setQueryData(["resources", scenarioId], (prev) => [ + ...prev, + temp, + ]); + return { tempId }; + }, + onSuccess: () => queryClient.invalidateQueries(["resources", scenarioId]), + onError: (e, _, context) => { + const tempId = context?.tempId; + if (tempId) { + queryClient.setQueryData(["resources", scenarioId], (prev) => + prev.filter((r) => r._id !== tempId) + ); + } + console.error(e); + toast.error("Something went wrong creating the collection"); + }, + }); - toast.success("Group deleted"); - } catch (err) { - console.error(err); - toast.error(err?.response?.data?.error || "Failed to delete group"); - } - } + const deleteResourceMutation = useMutation({ + mutationFn: (resourceId) => removeResource(user, scenarioId, resourceId), + onMutate: async (resourceId) => { + await queryClient.cancelQueries(["resources", scenarioId]); + queryClient.setQueryData(["resources", scenarioId], (prev) => + prev.filter((r) => r._id !== resourceId && r.parentId !== resourceId) + ); + }, + onError: (e) => { + console.error(e); + toast.error("Something went wrong deleting the resource"); + }, + onSettled: () => queryClient.invalidateQueries(["resources", scenarioId]), + }); - function updateFile(updatedFile) { - const normalisedFile = normaliseFile(updatedFile); - setSelectedFile(normalisedFile); - setGroups((prev) => - prev.map((g) => - g.id === normalisedFile.groupId - ? { - ...g, - files: (g.files || []).map((f) => - f.id === normalisedFile.id ? normalisedFile : f - ), - } - : g - ) - ); + function goBack() { + history.push(`/scenario/${scenarioId}`); } - function updateGroup(updatedGroup) { - const normalisedGroup = normaliseGroup(updatedGroup); - setSelectedGroup((prev) => ({ - ...normalisedGroup, - files: prev?.files || normalisedGroup.files, - })); - setGroups((prev) => - prev.map((g) => - g.id === normalisedGroup.id - ? { - ...g, - ...normalisedGroup, - files: g.files || [], - } - : g - ) - ); + // TODO: loading and error handling + if (resourcesQuery.isLoading || resourcesQuery.isError) { + return null; } - const selectedTarget = selectedFile || selectedGroup; - const selectedTargetType = selectedFile - ? "File" - : selectedGroup - ? "Collection" - : null; - const selectedTargetEndpoint = selectedFile - ? `/api/resources/${scenarioId}/${selectedFile.id}/conditionals` - : selectedGroup - ? `/api/collections/groups/${selectedGroup.id}/state-conditionals` - : ""; + const resourceTree = buildResourceTree(resourcesQuery.data); return (
@@ -224,151 +163,114 @@ export default function ManageResourcesPage() { Back - - - -
-

Uploaded Resources

+

Resources

- {/* LEFT: Groups and files */}
-

Collections

- { - try { - const user = getAuth().currentUser; - if (!user) return toast.error("You must be logged in."); - const idToken = await user.getIdToken(); - const { data } = await axios.post( - "/api/collections/groups", - { scenarioId, name }, - { headers: { Authorization: `Bearer ${idToken}` } } - ); - setGroups((g) => [ - ...g, - { - id: data._id, - name: data.name, - order: data.order ?? 0, - stateConditionals: data.stateConditionals || [], - files: [], - }, - ]); - } catch (e) { - toast.error( - e?.response?.data?.error || "Failed to create group" - ); - } - }} - /> +

Uploaded Resources

+
    - {groups.map((group) => ( -
  • -
    - { - setSelectedGroup(group); - setSelectedFile(null); - }} - > - {group.name} -
    - - addResourceToGroup(group.id, files[0]) - } - /> - -
    -
    - - -
    + {resourceTree.map((resource) => ( +
  • + {resource.type === "collection" ? ( +
    + setSelectedResource(resource)} + > + + {resource.name} + +
    + { + addFileResourceMutation.mutate({ + parentId: resource._id, + file, + }); + }} + /> + +
    +
    + + +
    + ) : ( +
    + setSelectedResource(resource)} + > + {resource.name} + + +
    + )}
  • ))}
- {/* RIGHT: File list and preview */}
- {selectedTarget ? ( -
-
- {selectedTargetType} -
-

{selectedTarget.name}

-
- ) : null} - - + + {selectedResource.type === "file" && ( + + )}
@@ -389,9 +291,8 @@ function UploadButton({ onFiles, multiple = true, className = "" }) { multiple={multiple} className="hidden" onChange={(e) => { - const files = Array.from(e.target.files || []); - if (files.length) onFiles(files); - e.target.value = ""; + const file = e.target.files[0]; + onFiles(file); }} />