From 3f3f1868caa9f13e143a711aae5f40b6694e5a3b Mon Sep 17 00:00:00 2001 From: navidgh67 Date: Mon, 5 Oct 2026 10:30:55 -0400 Subject: [PATCH 1/2] Let owners and admins edit unit experiment templates in place The library's "Run / Edit" opened the template in the create form, so any edit saved a copy. A new Edit button (owner or admin, matching the backend rule) opens /experiment/new?edit_type=, which saves through the existing PATCH endpoint as a new version with an optional change summary. The equipment is locked, saving waits until the template has loaded, author fields are carried over, and equipment parameters added since the template was saved appear as "not used". The old button is renamed "Run / Copy". Co-Authored-By: Claude Opus 5.5 (1M context) --- web/app/experiment/new/page.tsx | 151 +++++++++++++++++++++++++------- web/app/experiment/page.tsx | 16 +++- web/lib/api-client.ts | 3 +- 3 files changed, 134 insertions(+), 36 deletions(-) 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, From 9647f143b8b3ebf70f80308223463cf9b6297dd0 Mon Sep 17 00:00:00 2001 From: navidgh67 Date: Mon, 5 Oct 2026 10:30:55 -0400 Subject: [PATCH 2/2] =?UTF-8?q?Add=20h,=20=C2=B0C/min=20and=20degree=20uni?= =?UTF-8?q?ts=20to=20equipment=20registration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Furnace heating and cooling rates had to be registered as "N/A" because the unit list had no °C/min; hours and degrees (XRD 2-theta and step size) were also missing. Co-Authored-By: Claude Opus 5.5 (1M context) --- web/app/equipment/register/page.tsx | 3 +++ 1 file changed, 3 insertions(+) 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" },