diff --git a/web/app/equipment/register/page.tsx b/web/app/equipment/register/page.tsx index 3b9ff60..f5f27c3 100644 --- a/web/app/equipment/register/page.tsx +++ b/web/app/equipment/register/page.tsx @@ -56,7 +56,10 @@ const UNIT_OPTIONS = [ { value: "ohms", label: "ohms" }, { value: "s", label: "s" }, { value: "min", label: "min" }, + { value: "h", label: "h" }, { value: "°C", label: "°C" }, + { value: "°C/min", label: "°C/min" }, + { value: "°", label: "° (degrees)" }, { value: "nm", label: "nm" }, { value: "um", label: "um" }, { value: "nm/min", label: "nm/min" }, diff --git a/web/app/experiment/new/page.tsx b/web/app/experiment/new/page.tsx index 11ba6f0..3eebc44 100644 --- a/web/app/experiment/new/page.tsx +++ b/web/app/experiment/new/page.tsx @@ -14,6 +14,7 @@ import { getEquipmentListSimple, getV2Projects, getSamples, + updateExperimentType, type EquipmentOutput, type EquipmentInfo, type EquipmentSimple, @@ -131,6 +132,10 @@ function NewExperimentContent() { const { user } = useAuth(); const searchParams = useSearchParams(); const projectRunMode = Boolean(searchParams.get("project_id") && searchParams.get("type_id")); + // Editing an existing template saves it in place as a new version instead + // of creating a copy. The backend allows only the owner or an admin. + const editTypeId = projectRunMode ? "" : searchParams.get("edit_type") || ""; + const editMode = Boolean(editTypeId); const activeSteps = projectRunMode ? [ { id: 2, label: "Run Values", icon: ClipboardList }, @@ -138,7 +143,7 @@ function NewExperimentContent() { ] : [ { id: 1, label: "Unit Experiment Definition", icon: FlaskConical }, - { id: 3, label: "Review & Publish", icon: FileCheck }, + { id: 3, label: editMode ? "Review & Save" : "Review & Publish", icon: FileCheck }, ]; const prefillApplied = useRef(false); // One idempotency key per run submission, kept until it succeeds so a @@ -148,6 +153,10 @@ function NewExperimentContent() { const [formData, setFormData] = useState(INITIAL_DATA); const [submitting, setSubmitting] = useState(false); const [submitResult, setSubmitResult] = useState<{ status: string; message: string; href?: string; linkLabel?: string } | null>(null); + const [changeSummary, setChangeSummary] = useState(""); + // The template being edited, set once prefill has applied it. Saving is + // blocked until then so a half-loaded form can't overwrite the template. + const [editTemplate, setEditTemplate] = useState(null); // Editing the form starts a new submission; only an unchanged retry // reuses the idempotency key. @@ -202,7 +211,7 @@ function NewExperimentContent() { if (prefillApplied.current || equipmentLoading || projectsLoading) return; const paramEquipmentId = searchParams.get("equipment_id"); const paramProjectId = searchParams.get("project_id"); - const paramTypeId = searchParams.get("type_id"); + const paramTypeId = editTypeId || searchParams.get("type_id"); if (!paramEquipmentId && !paramTypeId) return; prefillApplied.current = true; void (async () => { @@ -213,7 +222,15 @@ function NewExperimentContent() { templateToApply = (allTypes ?? []).find((t) => t.id === paramTypeId) || null; effectiveEquipmentId = templateToApply?.equipment_id || ""; } - if (!effectiveEquipmentId) return; + if (!effectiveEquipmentId) { + if (editMode) { + setSubmitResult({ + status: "error", + message: "This unit experiment was not found or you do not have access to edit it.", + }); + } + return; + } await selectEquipment(effectiveEquipmentId); if (paramProjectId) { setFormData((prev) => ({ ...prev, selected_project: paramProjectId })); @@ -223,9 +240,17 @@ function NewExperimentContent() { const types = await getExperimentTypes(effectiveEquipmentId); setExperimentTypes(types ?? []); const match = (types ?? []).find((t) => t.id === paramTypeId) || templateToApply; - if (match) applyExperimentType(match); + if (match) { + applyExperimentType(match); + if (editMode) setEditTemplate(match); + } else if (editMode) { + setSubmitResult({ + status: "error", + message: "This unit experiment was not found or you do not have access to edit it.", + }); + } } - if (paramProjectId && paramTypeId) { + if (paramProjectId && paramTypeId && !editMode) { setCurrentStep(2); } })(); @@ -384,7 +409,16 @@ function NewExperimentContent() { template.visibility === "private" || template.visibility === "published" ? template.visibility : "shared", - type_parameters: nextTypeParameters, + // When editing, keep equipment parameters added since the template + // was saved (left as "not used") so they can be switched on. + type_parameters: editMode + ? [ + ...nextTypeParameters, + ...prev.type_parameters.filter( + (param) => !nextTypeParameters.some((next) => next.name === param.name), + ), + ] + : nextTypeParameters, outputs: template.outputs || [], additional_inputs: (template.additional_inputs || []).map((param) => ({ name: param.name, @@ -576,31 +610,66 @@ function NewExperimentContent() { return; } + const typePayload = { + type_name: formData.type_name, + type_description: formData.type_description, + scientific_objective: formData.scientific_objective, + equipment_id: formData.selected_equipment, + type_parameters: formData.type_parameters + .filter((param) => param.name.trim() && param.mode !== "not_used") + .map((param) => ({ + ...param, + is_variable: param.recipe_settable !== false && param.mode === "variable", + mode: + param.recipe_settable === false && param.mode === "variable" + ? "fixed" + : param.mode, + })), + outputs: formData.outputs.filter((output) => output.name.trim()), + additional_inputs: formData.additional_inputs.filter((param) => param.name.trim()), + visibility: formData.visibility, + primary_target: "", + primary_objective: "", + secondary_target: "", + secondary_objective: "", + }; + + if (editMode) { + if (!editTemplate) { + setSubmitResult({ + status: "error", + message: "The unit experiment is still loading. Wait for it to load before saving.", + }); + setSubmitting(false); + return; + } + const updated = await updateExperimentType(editTypeId, { + ...typePayload, + // The backend stores these as sent, so carry them over. + author_name: editTemplate.author_name || "", + author_email: editTemplate.author_email || "", + change_summary: changeSummary.trim(), + }); + setSubmitResult( + updated.ok + ? { + status: "success", + message: `Unit Experiment "${formData.type_name}" saved as version ${updated.data?.version ?? ""}.`, + href: "/experiment", + linkLabel: "View unit experiments.", + } + : { + status: "error", + message: updated.error || "Failed to save the unit experiment.", + }, + ); + setSubmitting(false); + return; + } + const typeResult = (projectRunMode || formData.template_mode === "existing") && formData.selected_type_id ? { status: "reused", type_id: formData.selected_type_id } - : await createExperimentType({ - type_name: formData.type_name, - type_description: formData.type_description, - scientific_objective: formData.scientific_objective, - equipment_id: formData.selected_equipment, - type_parameters: formData.type_parameters - .filter((param) => param.name.trim() && param.mode !== "not_used") - .map((param) => ({ - ...param, - is_variable: param.recipe_settable !== false && param.mode === "variable", - mode: - param.recipe_settable === false && param.mode === "variable" - ? "fixed" - : param.mode, - })), - outputs: formData.outputs.filter((output) => output.name.trim()), - additional_inputs: formData.additional_inputs.filter((param) => param.name.trim()), - visibility: formData.visibility, - primary_target: "", - primary_objective: "", - secondary_target: "", - secondary_objective: "", - }); + : await createExperimentType(typePayload); if (!typeResult) { setSubmitResult({ @@ -714,7 +783,8 @@ function NewExperimentContent() { )} + {editMode ? ( +
+ + setChangeSummary(e.target.value)} + placeholder="e.g., Added composition input and XRD outputs" + className={inputClass} + /> +
+ ) : (
{!formData.selected_equipment ? ( @@ -761,6 +843,7 @@ function NewExperimentContent() { )}
+ )}

Name: {formData.type_name || "—"}

Objective: {formData.scientific_objective || "—"}

-

Source: {formData.template_mode === "existing" ? "Reused existing" : `New (${formData.visibility})`}

+

Source: {editMode ? `Edited in place (${formData.visibility})` : formData.template_mode === "existing" ? "Reused existing" : `New (${formData.visibility})`}

{formData.type_parameters.filter(p => p.name).length > 0 && (
@@ -1497,7 +1580,7 @@ function NewExperimentContent() {

- {projectRunMode ? "New Project Run" : "New Unit Experiment"} + {projectRunMode ? "New Project Run" : editMode ? "Edit Unit Experiment" : "New Unit Experiment"}

{projectRunMode @@ -1572,7 +1655,7 @@ function NewExperimentContent() { {!projectRunMode && ( diff --git a/web/app/experiment/page.tsx b/web/app/experiment/page.tsx index 3ab795e..d1b020b 100644 --- a/web/app/experiment/page.tsx +++ b/web/app/experiment/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import Link from "next/link"; import { cn } from "@/lib/utils"; +import { useAuth } from "@/lib/auth-context"; import { deleteExperimentType, forkExperimentType, @@ -19,6 +20,7 @@ import { Layers, Loader2, Lock, + Pencil, Plus, Search, Share2, @@ -43,6 +45,7 @@ function formatDate(value: string | null | undefined) { } export default function ExperimentPage() { + const { user } = useAuth(); const [templates, setTemplates] = useState([]); const [equipment, setEquipment] = useState([]); const [loading, setLoading] = useState(true); @@ -184,6 +187,8 @@ export default function ExperimentPage() { const visibility = VISIBILITY_STYLE[visibilityKey] || VISIBILITY_STYLE.private; const VisibilityIcon = visibility.icon; const equipmentName = equipmentNameById.get(template.equipment_id) || template.equipment_id; + // Mirrors the backend rule for editing a template in place. + const canEdit = Boolean(user && (user.role === "admin" || user.id === template.owner_id)); return (

+ {canEdit && ( + + + Edit + + )} - Run / Edit + Run / Copy
diff --git a/web/lib/api-client.ts b/web/lib/api-client.ts index f6a4d10..b7e309b 100644 --- a/web/lib/api-client.ts +++ b/web/lib/api-client.ts @@ -1150,7 +1150,8 @@ export function forkExperimentType(typeId: string) { } export function updateExperimentType(typeId: string, payload: CreateExperimentTypePayload) { - return apiJson<{ status: string; type_id: string; version: number; pid: string }>( + // Result form so a 403 (not the owner or an admin) surfaces its message. + return apiJsonResult( `/equipment/experiment-type/${encodeURIComponent(typeId)}`, "PATCH", payload,