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
72 changes: 72 additions & 0 deletions apps/web/src/components/TodoPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: item.id,
Expand All @@ -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 (
<div
ref={setNodeRef}
style={style}
className="flex items-center gap-2 pl-7 pr-3 py-0.5 text-xs hover:bg-accent/50"
onContextMenu={handleContextMenu}
>
<div className="shrink-0 cursor-grab active:cursor-grabbing" {...attributes} {...listeners}>
<GripVerticalIcon size={10} className="text-muted-foreground/50" />
Expand All @@ -415,6 +470,21 @@ function SortableTodoItem({
<span className="truncate cursor-pointer" onClick={() => onSelectItem(item.id)}>
{item.title}
</span>
{jiraKey && (
<a
href={effectiveJiraLink}
target="_blank"
rel="noopener noreferrer"
className={`shrink-0 text-[10px] hover:underline flex items-center gap-0.5 ml-auto ${
isInherited ? "text-muted-foreground/50 italic" : "text-muted-foreground"
}`}
onClick={(e) => e.stopPropagation()}
title={isInherited ? `Inherited from category: ${jiraKey}` : jiraKey}
>
<ExternalLinkIcon size={10} />
{jiraKey}
</a>
)}
</div>
);
}
Expand Down Expand Up @@ -612,8 +682,10 @@ function TodoCategoryRow({
<SortableTodoItem
key={item.id}
item={item}
categoryJiraLink={category.jiraLink}
onCycleItem={onCycleItem}
onSelectItem={onSelectItem}
mutate={mutate}
/>
))}
</SortableContext>
Expand Down
15 changes: 15 additions & 0 deletions packages/contracts/src/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,8 @@ export const TodoMutationType = Schema.Literals([
"reorderItems",
"reorderCategories",
"updateItemDescription",
"setItemJiraLink",
"deleteItem",
]);
export type TodoMutationType = typeof TodoMutationType.Type;

Expand Down Expand Up @@ -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,
Expand All @@ -522,6 +535,8 @@ export const TodoMutation = Schema.Union([
ReorderItemsMutation,
ReorderCategoriesMutation,
UpdateItemDescriptionMutation,
SetItemJiraLinkMutation,
DeleteItemMutation,
]);
export type TodoMutation = typeof TodoMutation.Type;

Expand Down
150 changes: 150 additions & 0 deletions packages/shared/src/todoStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ import {
createItem,
cycleItemStatus,
deleteCategory,
deleteItem,
loadTodos,
renameCategory,
reorderCategories,
reorderItems,
setCategoryColor,
setCategoryJiraLink,
setItemJiraLink,
toggleCategory,
updateDescription,
} from "./todoStore.ts";
Expand Down Expand Up @@ -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();
});
});
});
32 changes: 32 additions & 0 deletions packages/shared/src/todoStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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,
Expand Down