From ec37354aea3a3d901fc6f1cbeac4f3f6c0eceed7 Mon Sep 17 00:00:00 2001 From: harrydawson Date: Fri, 5 Jun 2026 12:41:29 +1000 Subject: [PATCH] feat: Todo Panel item JIRA links + context menu + delete (#52) - Add setItemJiraLink and deleteItem to todoStore - Add SetItemJiraLinkMutation and DeleteItemMutation to TodoMutation union - Items inherit category JIRA link for display if own link not set - Explicit item JIRA link overrides inherited category link - Right-click context menu on items: Edit JIRA Link, Delete - Delete shows confirmation dialog - 15 new tests for setItemJiraLink, deleteItem, and applyMutation --- apps/web/src/components/TodoPanel.tsx | 72 +++++++++++++ packages/contracts/src/rpc.ts | 15 +++ packages/shared/src/todoStore.test.ts | 150 ++++++++++++++++++++++++++ packages/shared/src/todoStore.ts | 32 ++++++ 4 files changed, 269 insertions(+) diff --git a/apps/web/src/components/TodoPanel.tsx b/apps/web/src/components/TodoPanel.tsx index 32857264fa0c..ac4bf988b857 100644 --- a/apps/web/src/components/TodoPanel.tsx +++ b/apps/web/src/components/TodoPanel.tsx @@ -378,12 +378,16 @@ function SortableCategoryHeader({ function SortableTodoItem({ item, + categoryJiraLink, onCycleItem, onSelectItem, + mutate, }: { item: TodoItem; + categoryJiraLink: string | undefined; onCycleItem: (itemId: string) => void; onSelectItem: (itemId: string) => void; + mutate: (mutations: TodoMutation[]) => Promise; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id, @@ -396,11 +400,62 @@ function SortableTodoItem({ opacity: isDragging ? 0.4 : 1, }; + const handleContextMenu = (event: React.MouseEvent) => { + event.preventDefault(); + + const api = ensureLocalApi(); + + void api.contextMenu + .show( + [ + { + id: `jira:${item.id}`, + label: item.jiraLink ? "Change JIRA Link" : "Edit JIRA Link", + }, + ...(item.jiraLink + ? [{ id: `clear-jira:${item.id}` as string, label: "Clear JIRA Link" }] + : []), + { + id: `delete:${item.id}`, + label: "Delete", + destructive: true, + }, + ], + { x: event.clientX, y: event.clientY }, + ) + .then((clicked) => { + if (!clicked) return; + + if (clicked === `jira:${item.id}`) { + const url = window.prompt("Enter JIRA issue URL:", item.jiraLink ?? ""); + if (url !== null) { + const trimmed = url.trim(); + if (trimmed) { + mutate([{ type: "setItemJiraLink", itemId: item.id, jiraLink: trimmed }]); + } else { + mutate([{ type: "setItemJiraLink", itemId: item.id, jiraLink: "" }]); + } + } + } else if (clicked === `clear-jira:${item.id}`) { + mutate([{ type: "setItemJiraLink", itemId: item.id, jiraLink: "" }]); + } else if (clicked === `delete:${item.id}`) { + if (window.confirm(`Delete "${item.title}"? This cannot be undone.`)) { + mutate([{ type: "deleteItem", itemId: item.id }]); + } + } + }); + }; + + const effectiveJiraLink = item.jiraLink ?? categoryJiraLink; + const jiraKey = effectiveJiraLink ? extractJiraKey(effectiveJiraLink) : null; + const isInherited = !item.jiraLink && !!categoryJiraLink; + return (
@@ -415,6 +470,21 @@ function SortableTodoItem({ onSelectItem(item.id)}> {item.title} + {jiraKey && ( + e.stopPropagation()} + title={isInherited ? `Inherited from category: ${jiraKey}` : jiraKey} + > + + {jiraKey} + + )}
); } @@ -612,8 +682,10 @@ function TodoCategoryRow({ ))} diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index e0c494505e28..1cba0a6f3c10 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -450,6 +450,8 @@ export const TodoMutationType = Schema.Literals([ "reorderItems", "reorderCategories", "updateItemDescription", + "setItemJiraLink", + "deleteItem", ]); export type TodoMutationType = typeof TodoMutationType.Type; @@ -511,6 +513,17 @@ export const UpdateItemDescriptionMutation = Schema.Struct({ description: Schema.String, }); +export const SetItemJiraLinkMutation = Schema.Struct({ + type: Schema.Literal("setItemJiraLink"), + itemId: Schema.String, + jiraLink: Schema.String, +}); + +export const DeleteItemMutation = Schema.Struct({ + type: Schema.Literal("deleteItem"), + itemId: Schema.String, +}); + export const TodoMutation = Schema.Union([ CreateCategoryMutation, RenameCategoryMutation, @@ -522,6 +535,8 @@ export const TodoMutation = Schema.Union([ ReorderItemsMutation, ReorderCategoriesMutation, UpdateItemDescriptionMutation, + SetItemJiraLinkMutation, + DeleteItemMutation, ]); export type TodoMutation = typeof TodoMutation.Type; diff --git a/packages/shared/src/todoStore.test.ts b/packages/shared/src/todoStore.test.ts index 18f7a61325a9..027c96386791 100644 --- a/packages/shared/src/todoStore.test.ts +++ b/packages/shared/src/todoStore.test.ts @@ -9,12 +9,14 @@ import { createItem, cycleItemStatus, deleteCategory, + deleteItem, loadTodos, renameCategory, reorderCategories, reorderItems, setCategoryColor, setCategoryJiraLink, + setItemJiraLink, toggleCategory, updateDescription, } from "./todoStore.ts"; @@ -796,4 +798,152 @@ describe("todoStore", () => { expect(state.items[0]!.description).toBeUndefined(); }); }); + + describe("setItemJiraLink", () => { + it("sets a JIRA link on an item", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = setItemJiraLink(state, "item-1", "https://jira.example.com/browse/PROJ-42"); + + expect(next.items[0]!.jiraLink).toBe("https://jira.example.com/browse/PROJ-42"); + expect(next.items[0]!.updatedAt).not.toBe(sampleItems[0]!.updatedAt); + }); + + it("clears a JIRA link when given null", () => { + const items: TodoItem[] = [ + { + ...sampleItems[0]!, + jiraLink: "https://jira.example.com/browse/PROJ-42", + }, + sampleItems[1]!, + ]; + const state = loadTodos(sampleCategories, items); + const next = setItemJiraLink(state, "item-1", null); + + expect(next.items[0]!.jiraLink).toBeUndefined(); + }); + + it("clears a JIRA link when given empty string", () => { + const items: TodoItem[] = [ + { + ...sampleItems[0]!, + jiraLink: "https://jira.example.com/browse/PROJ-42", + }, + sampleItems[1]!, + ]; + const state = loadTodos(sampleCategories, items); + const next = setItemJiraLink(state, "item-1", ""); + + expect(next.items[0]!.jiraLink).toBeUndefined(); + }); + + it("updates the JIRA link on an item that already had one", () => { + const items: TodoItem[] = [ + { + ...sampleItems[0]!, + jiraLink: "https://jira.example.com/browse/PROJ-1", + }, + sampleItems[1]!, + ]; + const state = loadTodos(sampleCategories, items); + const next = setItemJiraLink(state, "item-1", "https://jira.example.com/browse/PROJ-99"); + + expect(next.items[0]!.jiraLink).toBe("https://jira.example.com/browse/PROJ-99"); + }); + + it("returns unchanged state for unknown item ID", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = setItemJiraLink(state, "nonexistent", "https://jira.example.com/browse/PROJ-1"); + + expect(next.items).toEqual(state.items); + }); + + it("does not mutate the original state", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = setItemJiraLink(state, "item-1", "https://jira.example.com/browse/PROJ-1"); + + expect(next).not.toBe(state); + expect(next.items).not.toBe(state.items); + expect(state.items[0]!.jiraLink).toBeUndefined(); + }); + + it("does not affect other items", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = setItemJiraLink(state, "item-1", "https://jira.example.com/browse/PROJ-1"); + + expect(next.items[1]!.jiraLink).toBeUndefined(); + expect(next.items[1]).toBe(state.items[1]); + }); + }); + + describe("deleteItem", () => { + it("removes an existing item from the state", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = deleteItem(state, "item-1"); + + expect(next.items).toHaveLength(1); + expect(next.items.find((i) => i.id === "item-1")).toBeUndefined(); + }); + + it("returns unchanged state for unknown item ID", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = deleteItem(state, "nonexistent"); + + expect(next.items).toEqual(state.items); + }); + + it("keeps remaining items intact", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = deleteItem(state, "item-1"); + + expect(next.items[0]).toBe(state.items[1]); + expect(next.items[0]!.id).toBe("item-2"); + }); + + it("does not mutate the original state", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = deleteItem(state, "item-1"); + + expect(next).not.toBe(state); + expect(next.items).not.toBe(state.items); + expect(state.items).toHaveLength(2); + }); + + it("does not affect categories", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = deleteItem(state, "item-1"); + + expect(next.categories).toEqual(state.categories); + }); + + it("deletes the last remaining item", () => { + const state = loadTodos(sampleCategories, [sampleItems[0]!]); + const next = deleteItem(state, "item-1"); + + expect(next.items).toHaveLength(0); + }); + }); + + describe("applyMutation with setItemJiraLink and deleteItem", () => { + it("applies setItemJiraLink mutation", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = applyMutation(state, { + type: "setItemJiraLink", + itemId: "item-1", + jiraLink: "https://jira.example.com/browse/PROJ-42", + }); + + expect(next.items[0]!.jiraLink).toBe("https://jira.example.com/browse/PROJ-42"); + }); + + it("applies deleteItem mutation", () => { + const state = loadTodos(sampleCategories, sampleItems); + const next = applyMutation(state, { + type: "deleteItem", + itemId: "item-1", + }); + + expect(next.items).toHaveLength(1); + expect(next.items.find((i) => i.id === "item-1")).toBeUndefined(); + }); + }); }); diff --git a/packages/shared/src/todoStore.ts b/packages/shared/src/todoStore.ts index 2a3ff529e477..91cb9d9faf35 100644 --- a/packages/shared/src/todoStore.ts +++ b/packages/shared/src/todoStore.ts @@ -172,6 +172,10 @@ export function applyMutation(state: TodoState, mutation: TodoMutation): TodoSta return reorderCategories(state, mutation.orderedIds); case "updateItemDescription": return updateDescription(state, mutation.itemId, mutation.description); + case "setItemJiraLink": + return setItemJiraLink(state, mutation.itemId, mutation.jiraLink); + case "deleteItem": + return deleteItem(state, mutation.itemId); default: return state; } @@ -217,6 +221,34 @@ export function updateDescription( }; } +export function setItemJiraLink( + state: TodoState, + itemId: string, + jiraLink: string | null, +): TodoState { + const value = jiraLink || null; + return { + ...state, + items: state.items.map((item) => + item.id === itemId + ? { + ...item, + jiraLink: value ?? undefined, + // @effect-diagnostics-next-line globalDate:off + updatedAt: new Date().toISOString(), + } + : item, + ), + }; +} + +export function deleteItem(state: TodoState, itemId: string): TodoState { + return { + ...state, + items: state.items.filter((item) => item.id !== itemId), + }; +} + export function archiveDoneItems( state: TodoState, maxDoneItems = 10,