diff --git a/ui/src/components/folders/FolderControls.test.tsx b/ui/src/components/folders/FolderControls.test.tsx index 0e9a7b3fd08..f2ba064da97 100644 --- a/ui/src/components/folders/FolderControls.test.tsx +++ b/ui/src/components/folders/FolderControls.test.tsx @@ -5,14 +5,35 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { FolderListResult } from "@paperclipai/shared"; import { + AllUnfiledBanner, + DeleteFolderDialog, + FolderFormDialog, FolderRail, + MobileFolderSheet, + MoveToMenu, folderSearchValue, normalizeFolderSelection, } from "./FolderControls"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +if (!globalThis.PointerEvent) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).PointerEvent = MouseEvent; +} +if (!Element.prototype.hasPointerCapture) { + Element.prototype.hasPointerCapture = () => false; +} +if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => undefined; +} + function act(callback: () => void | Promise) { let result: void | Promise | undefined; flushSync(() => { @@ -107,4 +128,263 @@ describe("FolderControls", () => { expect(onSelect).toHaveBeenCalledWith("folder-reporting"); }); + + it("marks the active row with aria-current, including virtual Unfiled", () => { + root = createRoot(container); + act(() => { + root?.render( + , + ); + }); + + const current = container.querySelector('[aria-current="page"]'); + expect(current?.textContent).toContain("Unfiled"); + }); + + it("renames a folder inline via double-click and Enter", () => { + const onRename = vi.fn(); + root = createRoot(container); + act(() => { + root?.render( + , + ); + }); + + const nameButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Reporting"), + ); + act(() => { + nameButton?.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true })); + }); + + const input = container.querySelector("input"); + expect(input).toBeTruthy(); + expect(input?.value).toBe("Reporting"); + + act(() => { + if (!input) return; + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(input, "Monthly reports"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + act(() => { + input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + }); + + expect(onRename).toHaveBeenCalledWith(folderResult.folders[0], "Monthly reports"); + }); + + it("moves via MoveToMenu: Unfiled, a folder, and new-folder chaining", () => { + const onMove = vi.fn(); + const onCreateAndMove = vi.fn(); + root = createRoot(container); + act(() => { + root?.render( + + + + + + + + , + ); + }); + + const subTrigger = Array.from(document.querySelectorAll("[data-radix-collection-item]")).find( + (element) => element.textContent?.includes("Move to"), + ); + expect(subTrigger).toBeTruthy(); + act(() => { + subTrigger?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + }); + + const menuItems = Array.from(document.querySelectorAll('[role="menuitem"]')); + const unfiledItem = menuItems.find((element) => element.textContent?.includes("Unfiled")); + const folderItem = menuItems.find((element) => element.textContent?.includes("Reporting")); + const newFolderItem = menuItems.find((element) => element.textContent?.includes("New folder")); + expect(unfiledItem).toBeTruthy(); + expect(folderItem).toBeTruthy(); + expect(newFolderItem).toBeTruthy(); + + act(() => { + unfiledItem?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + expect(onMove).toHaveBeenCalledWith(null); + + act(() => { + folderItem?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + expect(onMove).toHaveBeenCalledWith("folder-reporting"); + + act(() => { + newFolderItem?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + expect(onCreateAndMove).toHaveBeenCalled(); + }); + + it("creates a folder through FolderFormDialog and disables submit on empty name", () => { + const onSubmit = vi.fn(); + root = createRoot(container); + act(() => { + root?.render( + , + ); + }); + + const submit = Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent === "Create folder", + ) as HTMLButtonElement | undefined; + expect(submit).toBeTruthy(); + expect(submit?.disabled).toBe(true); + + const nameInput = document.querySelector("#folder-name"); + act(() => { + if (!nameInput) return; + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(nameInput, "Reporting"); + nameInput.dispatchEvent(new Event("input", { bubbles: true })); + }); + + expect(submit?.disabled).toBe(false); + act(() => { + submit?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + expect(onSubmit).toHaveBeenCalledWith({ name: "Reporting", color: expect.any(String) }); + }); + + it("states the forgiving delete behavior and confirms", () => { + const onConfirm = vi.fn(); + root = createRoot(container); + act(() => { + root?.render( + , + ); + }); + + expect(document.body.textContent).toContain("3 routines in this folder won't be deleted"); + expect(document.body.textContent).toContain("They'll move to Unfiled"); + + const confirmButton = Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent === "Delete folder", + ); + act(() => { + confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + expect(onConfirm).toHaveBeenCalled(); + }); + + it("selects a folder from the mobile sheet and dismisses", () => { + const onSelect = vi.fn(); + const onOpenChange = vi.fn(); + root = createRoot(container); + act(() => { + root?.render( + , + ); + }); + + expect(document.body.textContent).toContain("All routines"); + expect(document.body.textContent).toContain("Unfiled"); + + const reportingRow = Array.from(document.querySelectorAll("button")).find( + (button) => button.textContent?.includes("Reporting"), + ); + act(() => { + reportingRow?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + expect(onSelect).toHaveBeenCalledWith("folder-reporting"); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("persists AllUnfiledBanner dismissal across mounts", () => { + const storageKey = "paperclip:test-folder-nudge"; + window.localStorage.removeItem(storageKey); + const onCreateFolder = vi.fn(); + root = createRoot(container); + act(() => { + root?.render( + , + ); + }); + + expect(container.textContent).toContain("Create your first folder"); + + const dismissButton = container.querySelector( + 'button[aria-label="Dismiss folder suggestion"]', + ); + act(() => { + dismissButton?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + expect(container.textContent).not.toContain("Create your first folder"); + expect(window.localStorage.getItem(storageKey)).toBe("1"); + + act(() => { + root?.unmount(); + }); + root = createRoot(container); + act(() => { + root?.render( + , + ); + }); + expect(container.textContent).not.toContain("Create your first folder"); + window.localStorage.removeItem(storageKey); + }); }); diff --git a/ui/src/components/folders/FolderControls.tsx b/ui/src/components/folders/FolderControls.tsx index 4360f9c1ea6..55614de093f 100644 --- a/ui/src/components/folders/FolderControls.tsx +++ b/ui/src/components/folders/FolderControls.tsx @@ -7,6 +7,7 @@ import { Plus, Search, Trash2, + X, } from "lucide-react"; import type { FolderKind, FolderListItem, FolderListResult } from "@paperclipai/shared"; import { Button } from "@/components/ui/button"; @@ -220,68 +221,22 @@ export function FolderRail({ ) : (
{renderVirtualRow("all", allLabel, result?.allCount ?? 0, )} - {folders.map((folder) => { - const active = selection === folder.id; - const isRenaming = renamingFolderId === folder.id; - return ( -
- - - {folder.itemCount} - - - - - - startRename(folder)}>Rename - onEdit(folder)}>Edit color - - onDelete(folder)}> - - Delete - - - -
- ); - })} + {folders.map((folder) => ( + commitRename(folder)} + onRenameCancel={() => setRenamingFolderId(null)} + onSelect={() => onSelect(folder.id)} + onStartRename={() => startRename(folder)} + onEdit={() => onEdit(folder)} + onDelete={() => onDelete(folder)} + /> + ))}
System
@@ -292,6 +247,143 @@ export function FolderRail({ ); } +/** + * One selectable rail row. The leading 1rem grid column is the reserved + * disclosure/indent slot so nested folders can slot in later without relayout + * (ux-spec §3.1/§3.2 "nesting-ready"). + */ +export function FolderRailItem({ + folder, + active, + renaming, + renameDraft, + onRenameDraftChange, + onRenameCommit, + onRenameCancel, + onSelect, + onStartRename, + onEdit, + onDelete, +}: { + folder: FolderListItem; + active: boolean; + renaming: boolean; + renameDraft: string; + onRenameDraftChange: (value: string) => void; + onRenameCommit: () => void; + onRenameCancel: () => void; + onSelect: () => void; + onStartRename: () => void; + onEdit: () => void; + onDelete: () => void; +}) { + return ( +
+ + + {folder.itemCount} + + + + + + Rename + Edit color + + + + Delete + + + +
+ ); +} + +/** + * Dismissible nudge shown when items exist but no folders do (ux-spec §6.3). + * Dismissal persists per storage key. + */ +export function AllUnfiledBanner({ + storageKey, + itemLabelPlural, + onCreateFolder, +}: { + storageKey: string; + itemLabelPlural: string; + onCreateFolder: () => void; +}) { + const [dismissed, setDismissed] = useState(() => { + try { + return window.localStorage.getItem(storageKey) === "1"; + } catch { + return false; + } + }); + + if (dismissed) return null; + + function dismiss() { + setDismissed(true); + try { + window.localStorage.setItem(storageKey, "1"); + } catch { + // Ignore storage failures; the banner just reappears next visit. + } + } + + return ( +
+ + + Group these {itemLabelPlural} into folders to keep things tidy. + + + +
+ ); +} + export function MobileFolderSheet({ open, onOpenChange, diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index e4b22031d73..7acd7c61a17 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -91,11 +91,13 @@ import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Badge } from "@/components/ui/badge"; import { + AllUnfiledBanner, BulkBar, DeleteFolderDialog, FolderChip, FolderFormDialog, FolderRail, + FolderSwatch, MobileFolderSheet, MoveToMenu, folderSearchValue, @@ -753,6 +755,7 @@ function SkillCard({ folders, selected = false, selectMode = false, + showFolderBadge = false, onOpen, onSelectChange, onMove, @@ -762,11 +765,16 @@ function SkillCard({ folders?: FolderListItem[]; selected?: boolean; selectMode?: boolean; + /** Show the card's folder so search results reveal where an item lives (user story 5). */ + showFolderBadge?: boolean; onOpen: (card: DiscoveryCard) => void; onSelectChange?: (card: DiscoveryCard, selected: boolean) => void; onMove?: (card: DiscoveryCard, folderId: string | null) => void; onCreateFolderAndMove?: (card: DiscoveryCard) => void; }) { + const badgeFolder = showFolderBadge && card.installed + ? (card.folderId ? folders?.find((folder) => folder.id === card.folderId) ?? null : null) + : undefined; return (
onOpen(card)} @@ -802,6 +810,12 @@ function SkillCard({
by {card.author}{card.version ? ` · ${card.version}` : ""}
+ {badgeFolder !== undefined ? ( +
+ + {badgeFolder ? badgeFolder.name : "Unfiled"} +
+ ) : null}
{/* Where the skill came from (PAP-10907 E); native title gives a hover hint. */} {(() => { @@ -978,6 +992,7 @@ export function DiscoveryGrid({ onCreateFolderAndMoveSelected, onClearSelected, onOpenMobileFolders, + folderNudgeStorageKey, }: { tab: DiscoveryTab; tabCounts: Record; @@ -1019,6 +1034,8 @@ export function DiscoveryGrid({ onCreateFolderAndMoveSelected?: () => void; onClearSelected?: () => void; onOpenMobileFolders?: () => void; + /** When set and no folders exist yet, show the dismissible all-unfiled nudge (ux-spec §6.3). */ + folderNudgeStorageKey?: string; }) { // Source filter (github / skills.sh / local / …) lives in the grid so it // narrows whatever the parent already filtered by tab/category/search (PAP-10907 E). @@ -1250,6 +1267,13 @@ export function DiscoveryGrid({ {/* Grid body */}
{scanStatus ?

{scanStatus}

: null} + {folderNudgeStorageKey && onCreateFolder && folderResult && folderResult.folders.length === 0 && !loading && cards.length > 0 ? ( + + ) : null} {selectMode && onMoveSelected && onCreateFolderAndMoveSelected && onClearSelected ? (
({ slug, count })) .sort((a, b) => b.count - a.count || a.slug.localeCompare(b.slug)); }, [discoveryTabCards]); + const discoverySearchActive = discoverySearch.trim().length > 0; const visibleDiscoveryCards = useMemo(() => { const filtered = discoveryTabCards.filter((card) => { if (discoveryCategory && !card.categories.includes(discoveryCategory)) return false; - if (discoveryTab === "installed") { + // Search spans all folders (user story 5): the folder filter only + // narrows when the user is browsing, never when searching. + if (discoveryTab === "installed" && !discoverySearchActive) { if (folderSelection === "unfiled" && card.folderId) return false; if (folderSelection !== "all" && folderSelection !== "unfiled" && card.folderId !== folderSelection) return false; } return discoveryMatchesSearch(card, discoverySearch.trim()); }); return sortDiscoveryCards(filtered, discoverySort, discoveryTab !== "bundled"); - }, [discoveryTabCards, discoveryCategory, discoverySearch, discoverySort, discoveryTab, folderSelection]); + }, [discoveryTabCards, discoveryCategory, discoverySearch, discoverySearchActive, discoverySort, discoveryTab, folderSelection]); const selectedCatalogSkill = catalogDetailQuery.data ?? (catalogListQuery.data ?? []).find((entry) => entry.id === selectedCatalogRef || entry.key === selectedCatalogRef) @@ -4840,13 +4868,19 @@ export function CompanySkills() { } : undefined} onMoveCard={showInstalledFolders ? (card, folderId) => { if (!card.skillId) return; - moveSkillToFolder.mutate({ itemId: card.skillId, folderId }); + const skillId = card.skillId; + const previousFolderId = card.folderId ?? null; + moveSkillToFolder.mutate({ itemId: skillId, folderId }); pushToast({ tone: "success", title: "Skill moved", body: folderId - ? `Moved to ${skillFolderResult?.folders.find((folder) => folder.id === folderId)?.name ?? "folder"}.` - : "Moved to Unfiled.", + ? `Moved "${card.name}" to ${skillFolderResult?.folders.find((folder) => folder.id === folderId)?.name ?? "folder"}.` + : `Moved "${card.name}" to Unfiled.`, + action: { + label: "Undo", + onClick: () => moveSkillToFolder.mutate({ itemId: skillId, folderId: previousFolderId }), + }, }); } : undefined} onCreateFolderAndMoveCard={showInstalledFolders ? (card) => { @@ -4855,6 +4889,7 @@ export function CompanySkills() { onMoveSelected={showInstalledFolders ? (folderId) => void moveSelectedSkills(folderId) : undefined} onCreateFolderAndMoveSelected={showInstalledFolders ? () => openCreateFolder(selectedSkillIds) : undefined} onClearSelected={showInstalledFolders ? () => setSelectedSkillIds([]) : undefined} + folderNudgeStorageKey={showInstalledFolders ? `paperclip:skills-folder-nudge:${selectedCompanyId ?? "none"}` : undefined} /> ) : activeView === "installed" && selectedSkillId ? ( ): RoutineListItem { triggers: [], lastRun: null, activeIssue: null, + folderId: null, ...overrides, }; } @@ -578,7 +579,6 @@ describe("Routines page", () => { }), ]); issuesListMock.mockResolvedValue([]); - const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { @@ -611,6 +611,99 @@ describe("Routines page", () => { }); }); + it("filters to Unfiled and shows the empty-folder state with a create CTA", async () => { + foldersListMock.mockResolvedValue({ + kind: "routine", + allCount: 2, + unfiledCount: 1, + folders: [ + { + id: "folder-reporting", + companyId: "company-1", + kind: "routine", + name: "Reporting", + color: "#6366f1", + position: 0, + itemCount: 1, + createdAt: new Date("2026-07-01T00:00:00.000Z"), + updatedAt: new Date("2026-07-01T00:00:00.000Z"), + }, + { + id: "folder-empty", + companyId: "company-1", + kind: "routine", + name: "Empty folder", + color: null, + position: 1, + itemCount: 0, + createdAt: new Date("2026-07-01T00:00:00.000Z"), + updatedAt: new Date("2026-07-01T00:00:00.000Z"), + }, + ], + }); + routinesListMock.mockResolvedValue([ + createRoutine({ id: "routine-1", title: "Filed digest", folderId: "folder-reporting" }), + createRoutine({ id: "routine-2", title: "Loose routine", folderId: null }), + ]); + issuesListMock.mockResolvedValue([]); + + currentSearch = "folder=unfiled"; + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + + await act(async () => { + root.render( + + + , + ); + await flush(); + }); + for (let attempts = 0; attempts < 5 && !container.textContent?.includes("Loose routine"); attempts += 1) { + await act(async () => { + await flush(); + }); + } + + // Unfiled filter: only the folderless routine renders; the rail still lists folders. + expect(container.textContent).toContain("Loose routine"); + expect(container.textContent).not.toContain("Filed digest"); + expect(container.textContent).toContain("Reporting"); + expect(container.textContent).toContain("Empty folder"); + + await act(async () => { + root.unmount(); + }); + + // Remount filtered to the empty folder: empty state + create-into-folder CTA. + currentSearch = "folder=folder-empty"; + const secondRoot = createRoot(container); + await act(async () => { + secondRoot.render( + + + , + ); + await flush(); + }); + for (let attempts = 0; attempts < 5 && !container.textContent?.includes("This folder is empty"); attempts += 1) { + await act(async () => { + await flush(); + }); + } + + expect(container.textContent).toContain("This folder is empty"); + expect(container.textContent).toContain("New routine in this folder"); + + await act(async () => { + secondRoot.unmount(); + }); + }); + it("hides archived routines from the routines list", async () => { routinesListMock.mockResolvedValue([ createRoutine({ id: "routine-1", title: "Morning sync", status: "active" }), diff --git a/ui/src/pages/Routines.tsx b/ui/src/pages/Routines.tsx index 407b7c7b0f1..f368073dba1 100644 --- a/ui/src/pages/Routines.tsx +++ b/ui/src/pages/Routines.tsx @@ -50,6 +50,7 @@ import { Tabs, TabsContent } from "@/components/ui/tabs"; import type { RoutineListItem, RoutineVariable } from "@paperclipai/shared"; import type { FolderListItem } from "@paperclipai/shared"; import { + AllUnfiledBanner, BulkBar, DeleteFolderDialog, FolderChip, @@ -1170,6 +1171,13 @@ export function Routines() { )}
) : null} + {routineViewState.groupBy === "folder" && !hasRoutineFolders && !foldersLoading && visibleRoutines.length > 0 ? ( + openCreateFolder()} + /> + ) : null} {selectMode ? ( { + const previousFolderId = routine.folderId ?? null; moveRoutineToFolder.mutate({ itemId: routine.id, folderId }); pushToast({ title: "Routine moved", body: folderId - ? `Moved to ${routineFolders?.folders.find((folder) => folder.id === folderId)?.name ?? "folder"}.` - : "Moved to Unfiled.", + ? `Moved "${routine.title}" to ${routineFolders?.folders.find((folder) => folder.id === folderId)?.name ?? "folder"}.` + : `Moved "${routine.title}" to Unfiled.`, tone: "success", + action: { + label: "Undo", + onClick: () => moveRoutineToFolder.mutate({ itemId: routine.id, folderId: previousFolderId }), + }, }); }} onCreateAndMove={() => openCreateFolder([routine.id])}