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
3 changes: 3 additions & 0 deletions web/app/equipment/register/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
151 changes: 117 additions & 34 deletions web/app/experiment/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
getEquipmentListSimple,
getV2Projects,
getSamples,
updateExperimentType,
type EquipmentOutput,
type EquipmentInfo,
type EquipmentSimple,
Expand Down Expand Up @@ -131,14 +132,18 @@ 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 },
{ id: 3, label: "Review & Run", icon: FileCheck },
]
: [
{ 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
Expand All @@ -148,6 +153,10 @@ function NewExperimentContent() {
const [formData, setFormData] = useState<FormData>(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<ExperimentTypeRecord | null>(null);

// Editing the form starts a new submission; only an unchanged retry
// reuses the idempotency key.
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 }));
Expand All @@ -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);
}
})();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -714,7 +783,8 @@ function NewExperimentContent() {
<select
value={formData.selected_equipment}
onChange={(e) => void selectEquipment(e.target.value)}
className={inputClass}
disabled={editMode}
className={cn(inputClass, editMode && "opacity-60")}
>
<option value="">Choose registered equipment...</option>
{equipment.map((eq) => (
Expand All @@ -725,6 +795,18 @@ function NewExperimentContent() {
</select>
)}
</div>
{editMode ? (
<div>
<label className="block text-sm font-medium mb-1.5">Change Summary</label>
<input
type="text"
value={changeSummary}
onChange={(e) => setChangeSummary(e.target.value)}
placeholder="e.g., Added composition input and XRD outputs"
className={inputClass}
/>
</div>
) : (
<div>
<label className="block text-sm font-medium mb-1.5">Existing Unit Experiment</label>
{!formData.selected_equipment ? (
Expand Down Expand Up @@ -761,6 +843,7 @@ function NewExperimentContent() {
</select>
)}
</div>
)}
<div>
<label className="block text-sm font-medium mb-1.5">Unit Experiment Name</label>
<input
Expand Down Expand Up @@ -1347,7 +1430,7 @@ function NewExperimentContent() {
<div className="space-y-1 text-sm">
<p><span className="text-[hsl(var(--muted-foreground))]">Name:</span> {formData.type_name || "—"}</p>
<p><span className="text-[hsl(var(--muted-foreground))]">Objective:</span> {formData.scientific_objective || "—"}</p>
<p><span className="text-[hsl(var(--muted-foreground))]">Source:</span> {formData.template_mode === "existing" ? "Reused existing" : `New (${formData.visibility})`}</p>
<p><span className="text-[hsl(var(--muted-foreground))]">Source:</span> {editMode ? `Edited in place (${formData.visibility})` : formData.template_mode === "existing" ? "Reused existing" : `New (${formData.visibility})`}</p>
</div>
{formData.type_parameters.filter(p => p.name).length > 0 && (
<div className="rounded border border-[hsl(var(--border))] overflow-hidden">
Expand Down Expand Up @@ -1497,7 +1580,7 @@ function NewExperimentContent() {
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold tracking-tight">
{projectRunMode ? "New Project Run" : "New Unit Experiment"}
{projectRunMode ? "New Project Run" : editMode ? "Edit Unit Experiment" : "New Unit Experiment"}
</h1>
<p className="text-[hsl(var(--muted-foreground))] mt-1">
{projectRunMode
Expand Down Expand Up @@ -1572,7 +1655,7 @@ function NewExperimentContent() {
{!projectRunMode && (
<button
onClick={() => void handleSubmit(false)}
disabled={submitting || submitResult?.status === "success"}
disabled={submitting || submitResult?.status === "success" || (editMode && !editTemplate)}
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--background))] text-sm font-medium hover:bg-[hsl(var(--accent))] transition-colors disabled:opacity-50"
>
{submitting ? (
Expand All @@ -1588,7 +1671,7 @@ function NewExperimentContent() {
) : (
<>
<Check className="w-4 h-4" />
Save Unit Experiment
{editMode ? "Save Changes" : "Save Unit Experiment"}
</>
)}
</button>
Expand Down
16 changes: 15 additions & 1 deletion web/app/experiment/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,6 +20,7 @@ import {
Layers,
Loader2,
Lock,
Pencil,
Plus,
Search,
Share2,
Expand All @@ -43,6 +45,7 @@ function formatDate(value: string | null | undefined) {
}

export default function ExperimentPage() {
const { user } = useAuth();
const [templates, setTemplates] = useState<ExperimentTypeRecord[]>([]);
const [equipment, setEquipment] = useState<EquipmentSimple[]>([]);
const [loading, setLoading] = useState(true);
Expand Down Expand Up @@ -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 (
<div
key={template.id}
Expand Down Expand Up @@ -249,12 +254,21 @@ export default function ExperimentPage() {
)}
Delete
</button>
{canEdit && (
<Link
href={`/experiment/new?edit_type=${encodeURIComponent(template.id)}`}
className="inline-flex items-center gap-1.5 rounded-md border border-[hsl(var(--border))] px-2 py-1 text-xs font-medium hover:bg-[hsl(var(--accent))]"
>
<Pencil className="h-3.5 w-3.5" />
Edit
</Link>
)}
<Link
href={`/experiment/new?equipment_id=${encodeURIComponent(template.equipment_id)}&type_id=${encodeURIComponent(template.id)}`}
className="inline-flex items-center gap-1.5 rounded-md bg-[hsl(var(--primary))] px-2 py-1 text-xs font-medium text-[hsl(var(--primary-foreground))] hover:opacity-90"
>
<FlaskConical className="h-3.5 w-3.5" />
Run / Edit
Run / Copy
</Link>
</div>
</div>
Expand Down
3 changes: 2 additions & 1 deletion web/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CreateExperimentTypePayload, { status: string; type_id: string; version: number; pid: string }>(
`/equipment/experiment-type/${encodeURIComponent(typeId)}`,
"PATCH",
payload,
Expand Down