Add independent roast plan cloning from the plans table
Test and deploy / test-and-deploy (push) Successful in 1m52s

This commit is contained in:
snowspeeder committed 2026-09-06 16:58:41 -04:00
1 parent 393978195c
commit a9358048ef
5 files changed
+46 -1

No files matched your search

+14 -1
View File
@@ -60,6 +60,19 @@ async function load() {
); );
const actions = document.createElement("td"); const actions = document.createElement("td");
actions.className = "data-table-actions"; actions.className = "data-table-actions";
const clone = node("button", "Clone", "ghost-btn small");
clone.type = "button";
clone.setAttribute("aria-label", "Clone " + plan.title);
clone.onclick = async () => {
clone.disabled = true;
try {
const result = await api(`/api/plans/${plan.id}/clone`, {method:"POST"});
location.assign("/app?plan=" + encodeURIComponent(result.plan.id));
} catch (error) {
showToast(error.message, "fail");
clone.disabled = false;
}
};
const rename = node("button", "Rename", "ghost-btn small"); const rename = node("button", "Rename", "ghost-btn small");
rename.type = "button"; rename.type = "button";
rename.setAttribute("aria-label", "Rename " + plan.title); rename.setAttribute("aria-label", "Rename " + plan.title);
@@ -88,7 +101,7 @@ async function load() {
showToast(e.message, "fail"); showToast(e.message, "fail");
} }
}; };
actions.append(rename, remove); actions.append(clone, rename, remove);
tr.append(actions); tr.append(actions);
body.append(tr); body.append(tr);
} }
+16
View File
@@ -1,3 +1,4 @@
import { blankPlan } from "../shared/fields.js";
import { sanitizeObservations } from "../shared/roast-comparison.js"; import { sanitizeObservations } from "../shared/roast-comparison.js";
import express from "express"; import express from "express";
import path from "node:path"; import path from "node:path";
@@ -789,6 +790,21 @@ export function createApp({
if(!row) return res.status(404).json({ok:false,code:"not_found"}); if(!row) return res.status(404).json({ok:false,code:"not_found"});
res.json({ok:true,plan:row}); res.json({ok:true,plan:row});
} catch(error) {next(error);} } catch(error) {next(error);}
});
app.post("/api/plans/:id/clone", requireAuth, csrf, requireUuidParam("id"), async (req, res, next) => {
try {
const source = (await db.query("SELECT plan,name FROM roast_plans WHERE id=$1 AND user_id=$2", [req.params.id, req.user.id])).rows[0];
if (!source) return res.status(404).json({ok:false, code:"not_found"});
const plan = structuredClone(source.plan);
const defaults = blankPlan();
plan.planActual = defaults.planActual;
plan.afterRoast = defaults.afterRoast;
plan.inventory = {...defaults.inventory, ...plan.inventory, consumed:null};
const title = source.name || plan.fields?.["0.1"] || "Untitled plan";
const name = `${title.slice(0, 113)} (copy)`;
const copy = (await db.query("INSERT INTO roast_plans(user_id,plan,name) VALUES($1,$2,$3) RETURNING id,plan,name,created_at,updated_at", [req.user.id, plan, name])).rows[0];
res.status(201).json({ok:true, plan:copy});
} catch (error) { next(error); }
}); });
app.post("/api/plans", requireAuth, csrf, async (req, res, next) => { app.post("/api/plans", requireAuth, csrf, async (req, res, next) => {
try { try {
+1
View File
@@ -95,6 +95,7 @@ export function buildOpenApiSpec({ origin = "" } = {}) {
get: { tags: ["roast plans"], summary: "List your roast plans; view=summary returns paginated lightweight rows", parameters: ["view","q","process","level","sort","page","limit"].map(name=>({name,in:"query",schema:{type:"string"}})), responses: { 200: ok("Plans", obj({ ok: bool, plans: arr({ type: "object" }) })) } }, get: { tags: ["roast plans"], summary: "List your roast plans; view=summary returns paginated lightweight rows", parameters: ["view","q","process","level","sort","page","limit"].map(name=>({name,in:"query",schema:{type:"string"}})), responses: { 200: ok("Plans", obj({ ok: bool, plans: arr({ type: "object" }) })) } },
post: { tags: ["roast plans"], summary: "Create a roast plan", requestBody: jsonBody(obj({ plan: { type: "object" }, name: { ...str, description: "Optional custom display name (falls back to worksheet field 0.1 when blank)" } }, ["plan"])), responses: { 201: ok("Created") } }, post: { tags: ["roast plans"], summary: "Create a roast plan", requestBody: jsonBody(obj({ plan: { type: "object" }, name: { ...str, description: "Optional custom display name (falls back to worksheet field 0.1 when blank)" } }, ["plan"])), responses: { 201: ok("Created") } },
}, },
"/api/plans/{id}/clone": { post: { tags: ["roast plans"], summary: "Clone your plan for a new roast; clears recorded outcomes and inventory consumption", parameters: [idParam], responses: { 201: ok("Independent copy created"), 404: err("Not found") } } },
"/api/plans/{id}": { "/api/plans/{id}": {
get: {tags:["roast plans"],summary:"Read one of your plans",parameters:[idParam],responses:{200:ok("Plan"),404:err("Not found")}}, get: {tags:["roast plans"],summary:"Read one of your plans",parameters:[idParam],responses:{200:ok("Plan"),404:err("Not found")}},
put: { tags: ["roast plans"], summary: "Update a roast plan and/or rename it (send `plan`, `name`, or both — omitted fields are left unchanged)", parameters: [idParam], requestBody: jsonBody(obj({ plan: { type: "object" }, name: str })), responses: { 200: ok("Updated"), 404: err("Not found") } }, put: { tags: ["roast plans"], summary: "Update a roast plan and/or rename it (send `plan`, `name`, or both — omitted fields are left unchanged)", parameters: [idParam], requestBody: jsonBody(obj({ plan: { type: "object" }, name: str })), responses: { 200: ok("Updated"), 404: err("Not found") } },
+9
View File
@@ -89,3 +89,12 @@ test("summary search, filters and pagination exclude full telemetry and other ac
0, 0,
); );
}); });
test('clone creates an independent owned plan without copying roast outcomes or consumption', async () => {
const {app,agent}=await setup();const {csrf}=await signup(agent,'[email protected]');const other=request.agent(app);const {csrf:otherCsrf}=await signup(other,'[email protected]');
const plan=blankPlan();plan.fields['0.1']='Burundi';plan.fields['4.1']='light';plan.temps.fc.tempC='190';plan.inventory={lotId:'lot',lotLabel:'Burundi lot',consumed:{weightG:500}};plan.afterRoast.cupNotes='Original cup';plan.planActual.fc.actualTime='8:20';
const original=(await agent.post('/api/plans').set('x-csrf-token',csrf).send({plan,name:'Fruity'})).body.plan;
const route='/api/plans/'+original.id+'/clone';assert.equal((await request(app).post(route)).status,401);assert.equal((await agent.post(route)).status,403);assert.equal((await other.post(route).set('x-csrf-token',otherCsrf)).status,404);assert.equal((await agent.post('/api/plans/not-a-uuid/clone').set('x-csrf-token',csrf)).status,404);
const response=await agent.post(route).set('x-csrf-token',csrf);assert.equal(response.status,201);const copy=response.body.plan;assert.notEqual(copy.id,original.id);assert.equal(copy.name,'Fruity (copy)');assert.deepEqual(copy.plan.fields,plan.fields);assert.deepEqual(copy.plan.temps,plan.temps);assert.equal(copy.plan.inventory.lotId,'lot');assert.equal(copy.plan.inventory.consumed,null);assert.deepEqual(copy.plan.afterRoast,blankPlan().afterRoast);assert.deepEqual(copy.plan.planActual,blankPlan().planActual);
copy.plan.fields['4.1']='medium';assert.equal((await agent.put('/api/plans/'+copy.id).set('x-csrf-token',csrf).send({plan:copy.plan})).status,200);assert.deepEqual((await agent.get('/api/plans/'+original.id)).body.plan.plan,plan);assert.equal((await other.get('/api/plans/'+copy.id)).status,404);
});
+6
View File
@@ -286,3 +286,9 @@ test('browser: rejected sync shows the cause and keeps the edited plan open', br
await page.locator('#plan-form input[name="0.4"]').fill('550');await page.getByRole('link',{name:'▤ Your plans',exact:true}).click();await page.getByText(/Your sign-in session needs refreshing/).waitFor();assert.ok(page.url().includes('/app?plan='+saved.id));assert.equal((await db.query('SELECT COUNT(*) AS n FROM roast_plans')).rows[0].n,1); await page.locator('#plan-form input[name="0.4"]').fill('550');await page.getByRole('link',{name:'▤ Your plans',exact:true}).click();await page.getByText(/Your sign-in session needs refreshing/).waitFor();assert.ok(page.url().includes('/app?plan='+saved.id));assert.equal((await db.query('SELECT COUNT(*) AS n FROM roast_plans')).rows[0].n,1);
const draft=await page.evaluate(()=>JSON.parse(localStorage.getItem(Object.keys(localStorage).find(k=>k.startsWith('roastPlannerPlan.v2:')&&!k.endsWith(':last-user')))));assert.equal(draft.plan.fields['0.4'],'550'); const draft=await page.evaluate(()=>JSON.parse(localStorage.getItem(Object.keys(localStorage).find(k=>k.startsWith('roastPlannerPlan.v2:')&&!k.endsWith(':last-user')))));assert.equal(draft.plan.fields['0.4'],'550');
}); });
test('browser: Clone opens an independent plan whose roast level can change', browserOptions, async t => {
const {page,base,create,db,errors}=await session(t);const original=await create(warningPlan(),'Fruit original');await page.goto(base+'/plans');await page.getByRole('button',{name:'Clone Fruit original',exact:true}).click();await page.waitForURL('**/app?plan=*');await page.locator('#header-coffee-name').filter({hasText:'Fruit original (copy)'}).waitFor();
const id=new URL(page.url()).searchParams.get('plan');assert.notEqual(id,original.id);await page.locator('#plan-form label').filter({has:page.locator('input[name="4.1"][value="medium"]')}).click();await page.getByRole('link',{name:'▤ Your plans',exact:true}).click();await page.waitForURL('**/plans*');await page.getByRole('link',{name:'Fruit original (copy)',exact:true}).waitFor();
const rows=(await db.query('SELECT id,plan FROM roast_plans')).rows;assert.equal(rows.length,2);assert.equal(rows.find(r=>r.id===original.id).plan.fields['4.1'],'light');assert.equal(rows.find(r=>r.id===id).plan.fields['4.1'],'medium');assert.deepEqual(errors,[]);
});