Skip to content
Open
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
11 changes: 11 additions & 0 deletions frontend/app/(library)/playbooks/playbook-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { Chip, type ChipProps } from "@/components/base/badges/chip";
import { Button } from "@/components/base/buttons/button";
import * as Modal from "@/components/base/modal/modal";
import { SkillDeleteAction } from "@/app/(library)/skills/skill-delete-action";
import { tagChipColor, usageCaption, type Skill } from "@/app/(library)/skills/skills-data";
import type { ChipColor } from "@/app/(library)/knowledge/knowledge-data";
import { CHIP_COLOR } from "@/components/foundations/form-recipes";
Expand Down Expand Up @@ -92,12 +93,15 @@ export function PlaybookDetail({
playbook,
onEdit,
onRun,
onDeleted,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
playbook: Skill | null;
onEdit: (playbook: Skill) => void;
onRun: (playbook: Skill) => void;
/** Deleted for good: the parent closes this dialog and refetches the list. */
onDeleted: () => void | Promise<void>;
}) {
return (
<Modal.Root open={open} onOpenChange={onOpenChange}>
Expand Down Expand Up @@ -178,6 +182,13 @@ export function PlaybookDetail({
</Button>
</div>
</div>

<SkillDeleteAction
skillId={playbook.id}
question="Delete this playbook?"
consequence="It leaves your playbooks. Runs that already used it keep their recorded version."
onDeleted={onDeleted}
/>
</div>
)}
</Modal.Content>
Expand Down
4 changes: 4 additions & 0 deletions frontend/app/(library)/playbooks/playbooks-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,10 @@ export function PlaybooksView({
playbook={detail}
onEdit={openEdit}
onRun={runPlaybook}
onDeleted={() => {
setDetailOpen(false);
void refetch();
}}
/>
<PlaybookEditor
open={editorOpen}
Expand Down
148 changes: 148 additions & 0 deletions frontend/app/(library)/skills/skill-delete-action.interaction.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// The shared delete flow used by the skill detail dialog and the playbook
// detail. Mounted directly (no Modal, so no portal) under happy-dom: confirming
// calls deleteSkill and then onDeleted (the caller's list refresh + close),
// Cancel leaves it untouched, and a failed delete shows a plain error line.
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { GlobalRegistrator } from "@happy-dom/global-registrator";

if (typeof window === "undefined") GlobalRegistrator.register();

(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

let deleteCalls: string[] = [];
let deleteShouldFail = false;

// Dispatch through a per-file globalThis hook: bun shares one module registry
// across test files, so the last mock of this module wins for every importer.
type SkillsApiHook = { deleteSkill: (id: string) => Promise<void> };
const hookHolder = globalThis as unknown as { __skillsApiHook?: SkillsApiHook };

mock.module("@/app/(library)/skills/skills-api", () => ({
deleteSkill: (id: string) => hookHolder.__skillsApiHook?.deleteSkill(id),
}));

const { act } = await import("react");
const { createRoot } = await import("react-dom/client");
const { SkillDeleteAction } = await import("./skill-delete-action");

let root: ReturnType<typeof createRoot> | null = null;
let container: HTMLElement | null = null;

async function flush() {
// Let the delete promise and the state updates it schedules settle.
await act(async () => {
await Promise.resolve();
});
}

async function mount(onDeleted: () => void | Promise<void>) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<SkillDeleteAction
skillId="k1"
question="Delete this skill?"
consequence="It leaves your library."
onDeleted={onDeleted}
/>,
);
});
}

/** First button whose label contains `label`, scoped to the confirm strip when
* asked so the trigger Delete and the strip's Delete don't collide. */
function button(label: string, inConfirm = false): HTMLButtonElement {
const scope = inConfirm
? (container?.querySelector('[data-testid="inline-delete-confirm"]') as HTMLElement | null)
: container;
const match = Array.from(scope?.querySelectorAll("button") ?? []).find((el) =>
(el.textContent ?? "").includes(label),
);
if (!match) throw new Error(`no button labelled ${label}`);
return match as HTMLButtonElement;
}

function confirmStrip(): HTMLElement | null {
return container?.querySelector('[data-testid="inline-delete-confirm"]') ?? null;
}

async function click(el: HTMLButtonElement) {
await act(async () => {
el.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
}

beforeEach(() => {
deleteCalls = [];
deleteShouldFail = false;
hookHolder.__skillsApiHook = {
deleteSkill: async (id: string) => {
deleteCalls.push(id);
if (deleteShouldFail) throw new Error("delete skill 500");
},
};
});

afterEach(() => {
if (root) act(() => root?.unmount());
container?.remove();
root = null;
container = null;
});

describe("SkillDeleteAction", () => {
test("confirming deletes the skill and asks the caller to refresh", async () => {
let refreshed = 0;
await mount(() => {
refreshed += 1;
});

// The confirm strip only appears after the Delete trigger is pressed.
expect(confirmStrip()).toBeNull();
await click(button("Delete"));
expect(confirmStrip()).not.toBeNull();

await click(button("Delete", true));
await flush();

expect(deleteCalls).toEqual(["k1"]);
expect(refreshed).toBe(1);
// The flow finished, so the confirm strip is gone again.
expect(confirmStrip()).toBeNull();
});

test("Cancel leaves the skill untouched", async () => {
let refreshed = 0;
await mount(() => {
refreshed += 1;
});

await click(button("Delete"));
await click(button("Cancel", true));

expect(deleteCalls).toEqual([]);
expect(refreshed).toBe(0);
expect(confirmStrip()).toBeNull();
});

test("a failed delete shows a plain error line and does not refresh", async () => {
deleteShouldFail = true;
let refreshed = 0;
await mount(() => {
refreshed += 1;
});

await click(button("Delete"));
await click(button("Delete", true));
await flush();

expect(deleteCalls).toEqual(["k1"]);
expect(refreshed).toBe(0);
const alert = container?.querySelector('[role="alert"]');
expect(alert?.textContent ?? "").toContain("Delete failed");
// Still confirming, so the user can retry after the error.
expect(confirmStrip()).not.toBeNull();
});
});
77 changes: 77 additions & 0 deletions frontend/app/(library)/skills/skill-delete-action.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"use client";

import { useState } from "react";
import { RiDeleteBinLine } from "@remixicon/react";

import { Button } from "@/components/base/buttons/button";
import { InlineDeleteConfirm } from "@/components/shared/inline-delete-confirm";
import { deleteSkill } from "./skills-api";

/**
* The whole delete flow for one skill or playbook (both are skills, one DELETE
* endpoint): a Delete button that opens the shared confirm strip, the backend
* call, and a plain error line if it fails. On success the caller refreshes its
* list (and closes the dialog) via `onDeleted`. Reused by the skill detail
* dialog and the playbook detail so delete asks and behaves the same in both.
*/
export function SkillDeleteAction({
skillId,
question,
consequence,
onDeleted,
}: {
skillId: string;
question: string;
consequence: string;
/** Deleted for good - the caller refetches its list and closes if needed. */
onDeleted: () => void | Promise<void>;
}) {
const [confirming, setConfirming] = useState(false);
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);

const onConfirm = async () => {
setDeleting(true);
setError(null);
try {
await deleteSkill(skillId);
setConfirming(false);
await onDeleted();
} catch {
setError("Delete failed. Check the backend and try again.");
} finally {
setDeleting(false);
}
};

return (
<div className="flex flex-col gap-2">
<Button
variant="secondary"
size="small"
leadingIcon={RiDeleteBinLine}
disabled={deleting}
onClick={() => setConfirming(true)}
className="self-start"
>
Delete
</Button>
{confirming && (
<>
<InlineDeleteConfirm
question={question}
consequence={consequence}
busy={deleting}
onCancel={() => setConfirming(false)}
onConfirm={onConfirm}
/>
{error && (
<p role="alert" className="text-caption-1-regular text-text-error-primary">
{error}
</p>
)}
</>
)}
</div>
);
}
12 changes: 12 additions & 0 deletions frontend/app/(library)/skills/skill-detail-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { RiPlayMiniLine, RiRefreshLine } from "@remixicon/react";
import { Chip, type ChipProps } from "@/components/base/badges/chip";
import { Button } from "@/components/base/buttons/button";
import * as Modal from "@/components/base/modal/modal";
import { SkillDeleteAction } from "./skill-delete-action";
import { fetchSkill, importSkillPaths } from "./skills-api";
import {
sourceRepoLabel,
Expand Down Expand Up @@ -113,13 +114,16 @@ function VariantBlock({
onRun,
resync,
onResync,
onDeleted,
}: {
skill: Skill;
detail: VariantDetail;
multi: boolean;
onRun: (skill: Skill) => void;
resync: ResyncState | undefined;
onResync: (skill: Skill) => void;
/** A variant was deleted - refetch the library (and close if it was last). */
onDeleted: () => void | Promise<void>;
}) {
const imported = Boolean(skill.sourceRepo && skill.sourcePath);
const syncing = resync?.status === "syncing";
Expand Down Expand Up @@ -207,6 +211,13 @@ function VariantBlock({
</span>
)}
</div>

<SkillDeleteAction
skillId={skill.id}
question="Delete this skill?"
consequence="It leaves your library. Runs that already used it keep their recorded version."
onDeleted={onDeleted}
/>
</section>
);
}
Expand Down Expand Up @@ -328,6 +339,7 @@ export function SkillDetailDialog({
onRun={onRun}
resync={resyncById[skill.id]}
onResync={onResync}
onDeleted={onChanged}
/>
</div>
))}
Expand Down
Loading