Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion backend/src/db/daos/scenarioDao.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import User from "../models/user.js";
* Creates a scenario in the database with an initial scene
* @param {String} name name of scenario
* @param {String} uid ID of authoring user
* @param {{description: String, estimatedTime: String}} [details] optional additional metadata
* @returns database scenario object
*/
const createScenario = async (name, uid) => {
const createScenario = async (name, uid, details = {}) => {
const firstScene = new Scene({
name: "Scene 1",
});
Expand All @@ -23,6 +24,8 @@ const createScenario = async (name, uid) => {
name,
uid,
scenes: [firstScene._id],
description: details.description,
estimatedTime: details.estimatedTime,
});
await dbScenario.save();
} catch (err) {
Expand Down
43 changes: 32 additions & 11 deletions backend/src/routes/api/scenario.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ import { retrieveAssignedScenarioList } from "../../db/daos/userDao.js";

import scene from "./scene.js";
import { deleteAccessList } from "../../db/daos/accessDao.js";
import { handle, HttpError } from "../../util/error.js";

const router = Router();

const HTTP_OK = 200;
const HTTP_BAD_REQUEST = 400;
const HTTP_NOT_FOUND = 404;

router.use("/:scenarioId/scene", scene);
Expand Down Expand Up @@ -61,13 +63,22 @@ router.get("/all", async (req, res) => {
});

// Create a scenario for a user
router.post("/", async (req, res) => {
const { name, uid } = req.body;
router.post(
"/",
handle(async (req, res) => {
const { name, uid, description, estimatedTime } = req.body;

const scenario = await createScenario(name, uid);
if (typeof name !== "string" || !name.trim())
throw new HttpError("scenario name is required", HTTP_BAD_REQUEST);

res.status(HTTP_OK).json(scenario);
});
const scenario = await createScenario(name.trim(), uid, {
description,
estimatedTime,
});

res.status(HTTP_OK).json(scenario);
})
);

// Apply scenario auth middleware
router.use("/:scenarioId", validScenarioId);
Expand Down Expand Up @@ -95,14 +106,24 @@ router.put("/:scenarioId", async (req, res) => {
res.status(HTTP_OK).json(scenario);
});

router.patch("/:scenarioId", async (req, res) => {
const { name, description, estimatedTime } = req.body;
router.patch(
"/:scenarioId",
handle(async (req, res) => {
const { name, description, estimatedTime } = req.body;

const updates = { name, description, estimatedTime };
if (name !== undefined && (typeof name !== "string" || !name.trim()))
throw new HttpError("scenario name cannot be empty", HTTP_BAD_REQUEST);

const scenario = await updateScenario(req.params.scenarioId, updates);
res.status(HTTP_OK).json(scenario);
});
const updates = {
name: name?.trim(),
description,
estimatedTime,
};

const scenario = await updateScenario(req.params.scenarioId, updates);
res.status(HTTP_OK).json(scenario);
})
);

// Delete a scenario of a user
router.delete("/:scenarioId", async (req, res) => {
Expand Down
42 changes: 0 additions & 42 deletions frontend/src/components/CreateScenarioCard/CreateScenarioCard.jsx

This file was deleted.

8 changes: 4 additions & 4 deletions frontend/src/context/ScenarioContextProvider.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ async function getAllScenarios(user) {
return res.data;
}

async function createScenario(user, name) {
const { data: scenario } = await api.post(user, `/api/scenario`, { name });
async function createScenario(user, details) {
const { data: scenario } = await api.post(user, `/api/scenario`, details);
return scenario._id;
}

Expand Down Expand Up @@ -48,7 +48,7 @@ export default function ScenarioContextProvider({ children }) {
});

const createMutation = useMutation({
mutationFn: (name) => createScenario(user, name),
mutationFn: (details) => createScenario(user, details),
onSuccess: () => {
return queryClient.invalidateQueries(["scenarios"]);
},
Expand Down Expand Up @@ -125,7 +125,7 @@ export default function ScenarioContextProvider({ children }) {
allScenarios: scenarioQuery.data,

deleteScenario: deleteMutation.mutate,
updateScenarioDetails: updateDetailsMutation.mutate,
updateScenarioDetails: updateDetailsMutation.mutateAsync,
createScenario: createMutation.mutateAsync,

roleList,
Expand Down
30 changes: 29 additions & 1 deletion frontend/src/features/authoring/AuthoringToolPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { replace, replaceComponent } from "./scene/operations/modifiers";
import {
ArrowLeftIcon,
FilesIcon,
PencilIcon,
PlayIcon,
UserPlusIcon,
UsersIcon,
Expand All @@ -24,6 +25,8 @@ import { debounce } from "../../util/debounce";
import { getScene } from "./scene/scene";
import ShareModal from "./components/ShareModal";
import ScenarioContext from "../../context/ScenarioContext";
import ModalDialog from "../../components/ModalDialogue";
import DetailEditModal from "../scenarioInfo/components/DetailEditModal";

const listeners = [
["copy", copy],
Expand All @@ -40,7 +43,7 @@ const listeners = [
*/
export default function AuthoringToolPage() {
const { scenes, modifyScene, switchScene } = useContext(SceneContext);
const { allScenarios } = useContext(ScenarioContext);
const { allScenarios, updateScenarioDetails } = useContext(ScenarioContext);
const { scenarioId } = useParams();

const sceneId = useVisualScene((scene) => scene.id);
Expand All @@ -50,6 +53,7 @@ export default function AuthoringToolPage() {

const [saving, setSaving] = useState(false);
const [shareModalOpen, setShareModalOpen] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);

const pendingSavesRef = useRef(0);

Expand Down Expand Up @@ -149,6 +153,15 @@ export default function AuthoringToolPage() {
<ArrowLeftIcon size={20} />
Back
</button>
{isScenarioOwner && (
<button
onClick={() => setShowEditModal(true)}
className="btn btn-phantom text-m"
>
<PencilIcon size={20} />
Details
</button>
)}
<button
onClick={goToResources}
className="btn btn-phantom text-m ml-auto"
Expand Down Expand Up @@ -186,6 +199,21 @@ export default function AuthoringToolPage() {
{isScenarioOwner && (
<ShareModal open={shareModalOpen} setOpen={setShareModalOpen} />
)}
{isScenarioOwner && (
<ModalDialog
title="Edit Scenario Details"
open={showEditModal}
onClose={() => setShowEditModal(false)}
>
<DetailEditModal
scenario={isScenarioOwner}
onSave={(details) =>
updateScenarioDetails({ id: scenarioId, details })
}
onClose={() => setShowEditModal(false)}
/>
</ModalDialog>
)}
</>
);
}
24 changes: 15 additions & 9 deletions frontend/src/features/create/CreateLandingPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { useState, useContext } from "react";
import { useHistory } from "react-router-dom";
import ScenarioContext from "../../context/ScenarioContext";
import Thumbnail from "../authoring/components/Thumbnail";
import CreateScenarioCard from "../../components/CreateScenarioCard/CreateScenarioCard";
import ModalDialog from "../../components/ModalDialogue";
import DetailEditModal from "../scenarioInfo/components/DetailEditModal";
import TopNavBar from "../../features/TopNavBar/TopNavBar";
import FabMenu from "../../components/FabMenu";
import { PlusIcon, SearchIcon, Trash2Icon } from "lucide-react";
Expand Down Expand Up @@ -45,9 +46,8 @@ export default function CreateLandingPage() {
scenario.name.toLowerCase().includes(search.toLowerCase())
);

async function handleCreate(name) {
setShowCreateModal(false);
const scenarioId = await createScenario(name);
async function handleCreate(details) {
const scenarioId = await createScenario(details);
history.push(`/scenario/${scenarioId}`);
}

Expand Down Expand Up @@ -124,12 +124,18 @@ export default function CreateLandingPage() {
/>

{/* Create Scenario Modal */}
{showCreateModal && (
<CreateScenarioCard
onCreate={handleCreate}
onClose={() => setShowCreateModal(false)}
<ModalDialog
title="Create Scenario"
open={showCreateModal}
onClose={() => setShowCreateModal(false)}
>
<DetailEditModal
scenario={null}
submitLabel="Create"
pendingLabel="Creating..."
onSave={handleCreate}
/>
)}
</ModalDialog>

<FabMenu />
</div>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/features/scenarioInfo/ScenarioInfo.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ function ScenarioInfo() {
onSave={(details) =>
updateScenarioDetails({ id: selectedScenarioId, details })
}
onClose={() => setShowEditModal(false)}
/>
</ModalDialog>

Expand Down
65 changes: 54 additions & 11 deletions frontend/src/features/scenarioInfo/components/DetailEditModal.jsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,44 @@
import { useState } from "react";

function DetailEditModal({ scenario, onSave }) {
const [description, setDescription] = useState(scenario.description);
const [name, setName] = useState(scenario.name);
const [estimatedTime, setEstimatedTime] = useState(scenario.estimatedTime);
function DetailEditModal({
scenario,
onSave,
onClose,
submitLabel = "Save Changes",
pendingLabel = "Saving...",
}) {
const [description, setDescription] = useState(scenario?.description ?? "");
const [name, setName] = useState(scenario?.name ?? "");
const [estimatedTime, setEstimatedTime] = useState(
scenario?.estimatedTime ?? ""
);
const [isSubmitting, setIsSubmitting] = useState(false);
const [showNameError, setShowNameError] = useState(false);

const isNameBlank = !name.trim();

function handleEstimatedTimeChange(e) {
const value = e.target.value.replace(/\D/g, "");
setEstimatedTime(value);
}

async function handleSave() {
if (isNameBlank) {
setShowNameError(true);
return;
}

setIsSubmitting(true);
try {
await onSave({ name: name.trim(), description, estimatedTime });
onClose?.();
} catch {
// the mutation itself surfaces an error toast; keep the modal open to retry
} finally {
setIsSubmitting(false);
}
}

return (
<>
{/* Name Field */}
Expand All @@ -24,12 +53,20 @@ function DetailEditModal({ scenario, onSave }) {
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter scenario name..."
className="input input-bordered border-primary/30 bg-base-100 text-base-content font-dm text-base w-full focus:border-primary focus:outline-none placeholder:text-base-content/40"
className={`input input-bordered bg-base-100 text-base-content font-dm text-base w-full focus:outline-none placeholder:text-base-content/40 ${
showNameError && isNameBlank
? "border-error focus:border-error"
: "border-primary/30 focus:border-primary"
}`}
maxLength={100}
/>
<label className="label">
<span className="label-text-alt text-base-content/50 font-ibm">
{name.length}/100 characters
<span
className={`label-text-alt font-ibm ${showNameError && isNameBlank ? "text-error" : "text-base-content/50"}`}
>
{showNameError && isNameBlank
? "Scenario name is required"
: `${name.length}/100 characters`}
</span>
</label>
</div>
Expand Down Expand Up @@ -79,15 +116,21 @@ function DetailEditModal({ scenario, onSave }) {

{/* Modal Actions */}
<div className="modal-action">
{/* buttons will automatically close the dialog */}
<button className="btn btn-ghost text-primary hover:text-base-content hover:bg-primary/10 font-dm">
{/* Cancel keeps the native dialog auto-close behaviour */}
<button
disabled={isSubmitting}
className="btn btn-ghost text-primary hover:text-base-content hover:bg-primary/10 font-dm"
>
Cancel
</button>
{/* type="button" so a click doesn't submit-close the dialog before the save resolves */}
<button
onClick={() => onSave({ name, description, estimatedTime })}
type="button"
onClick={handleSave}
disabled={isSubmitting}
className={`btn btn-ghost text-base-content border border-base-content/20 hover:bg-base-content/10 hover:border-base-content/40 font-dm`}
>
Save Changes
{isSubmitting ? pendingLabel : submitLabel}
</button>
</div>
</>
Expand Down
Loading