mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
Close ux-spec gaps in folder UI: undo move, all-unfiled nudge, search folder badge
- Extract FolderRailItem as a named shared component (nesting-ready slot per §3.1/3.2) - Undo action on single-item move toasts for routines and skills (§5.1) - ToastAction now supports onClick handlers alongside href links - Dismissible AllUnfiledBanner on both surfaces when items exist but no folders (§6.3) - Skills search spans all folders and result cards show their folder (user story 5) - Component tests: move flow via MoveToMenu, inline rename, dialogs, mobile sheet, banner dismissal persistence, Unfiled filtering + empty-folder page states Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
b309aa0fc5
commit
e82e8c6b15
5 files changed
+583
-70
No files matched your search
@@ -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<void>) {
|
||||
let result: void | Promise<void> | 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(
|
||||
<FolderRail
|
||||
result={folderResult}
|
||||
selection="unfiled"
|
||||
itemLabelPlural="routines"
|
||||
allLabel="All routines"
|
||||
onSelect={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<FolderRail
|
||||
result={folderResult}
|
||||
selection="all"
|
||||
itemLabelPlural="routines"
|
||||
allLabel="All routines"
|
||||
onSelect={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={onRename}
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
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<HTMLInputElement>("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(
|
||||
<DropdownMenu open modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button">Row actions</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<MoveToMenu
|
||||
folders={folderResult.folders}
|
||||
currentFolderId={null}
|
||||
onMove={onMove}
|
||||
onCreateAndMove={onCreateAndMove}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<FolderFormDialog
|
||||
open
|
||||
kind="routine"
|
||||
folder={null}
|
||||
onOpenChange={vi.fn()}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
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<HTMLInputElement>("#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(
|
||||
<DeleteFolderDialog
|
||||
open
|
||||
folder={folderResult.folders[0]!}
|
||||
itemLabelPlural="routines"
|
||||
onOpenChange={vi.fn()}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<MobileFolderSheet
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
result={folderResult}
|
||||
selection="all"
|
||||
allLabel="All routines"
|
||||
itemLabelPlural="Routines"
|
||||
onSelect={onSelect}
|
||||
onCreate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<AllUnfiledBanner
|
||||
storageKey={storageKey}
|
||||
itemLabelPlural="routines"
|
||||
onCreateFolder={onCreateFolder}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Create your first folder");
|
||||
|
||||
const dismissButton = container.querySelector<HTMLButtonElement>(
|
||||
'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(
|
||||
<AllUnfiledBanner
|
||||
storageKey={storageKey}
|
||||
itemLabelPlural="routines"
|
||||
onCreateFolder={onCreateFolder}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(container.textContent).not.toContain("Create your first folder");
|
||||
window.localStorage.removeItem(storageKey);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{renderVirtualRow("all", allLabel, result?.allCount ?? 0, <FolderIcon className="h-3.5 w-3.5" />)}
|
||||
{folders.map((folder) => {
|
||||
const active = selection === folder.id;
|
||||
const isRenaming = renamingFolderId === folder.id;
|
||||
return (
|
||||
<div
|
||||
key={folder.id}
|
||||
className={cn(
|
||||
"group grid grid-cols-[1rem_minmax(0,1fr)_auto_auto] items-center gap-2 rounded-md px-2 py-1 text-sm transition-colors hover:bg-accent/40",
|
||||
active ? "bg-accent/60 text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="h-4 w-4" />
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 items-center gap-2 text-left"
|
||||
aria-current={active ? "page" : undefined}
|
||||
onClick={() => onSelect(folder.id)}
|
||||
onDoubleClick={() => startRename(folder)}
|
||||
>
|
||||
<FolderSwatch color={folder.color} />
|
||||
{isRenaming ? (
|
||||
<input
|
||||
value={renameDraft}
|
||||
onChange={(event) => setRenameDraft(event.target.value)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") commitRename(folder);
|
||||
if (event.key === "Escape") setRenamingFolderId(null);
|
||||
}}
|
||||
onBlur={() => commitRename(folder)}
|
||||
className="h-6 min-w-0 flex-1 rounded-sm border border-border bg-background px-1 text-sm outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate">{folder.name}</span>
|
||||
)}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground">{folder.itemCount}</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100"
|
||||
aria-label={`Folder actions for ${folder.name}`}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => startRename(folder)}>Rename</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onEdit(folder)}>Edit color</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onSelect={() => onDelete(folder)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{folders.map((folder) => (
|
||||
<FolderRailItem
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
active={selection === folder.id}
|
||||
renaming={renamingFolderId === folder.id}
|
||||
renameDraft={renameDraft}
|
||||
onRenameDraftChange={setRenameDraft}
|
||||
onRenameCommit={() => commitRename(folder)}
|
||||
onRenameCancel={() => setRenamingFolderId(null)}
|
||||
onSelect={() => onSelect(folder.id)}
|
||||
onStartRename={() => startRename(folder)}
|
||||
onEdit={() => onEdit(folder)}
|
||||
onDelete={() => onDelete(folder)}
|
||||
/>
|
||||
))}
|
||||
<div className="px-2 pb-1 pt-3 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
System
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
"group grid grid-cols-[1rem_minmax(0,1fr)_auto_auto] items-center gap-2 rounded-md px-2 py-1 text-sm transition-colors hover:bg-accent/40",
|
||||
active ? "bg-accent/60 text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="h-4 w-4" />
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 items-center gap-2 text-left"
|
||||
aria-current={active ? "page" : undefined}
|
||||
onClick={onSelect}
|
||||
onDoubleClick={onStartRename}
|
||||
>
|
||||
<FolderSwatch color={folder.color} />
|
||||
{renaming ? (
|
||||
<input
|
||||
value={renameDraft}
|
||||
onChange={(event) => onRenameDraftChange(event.target.value)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") onRenameCommit();
|
||||
if (event.key === "Escape") onRenameCancel();
|
||||
}}
|
||||
onBlur={onRenameCommit}
|
||||
className="h-6 min-w-0 flex-1 rounded-sm border border-border bg-background px-1 text-sm outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate">{folder.name}</span>
|
||||
)}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground">{folder.itemCount}</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100"
|
||||
aria-label={`Folder actions for ${folder.name}`}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={onStartRename}>Rename</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onEdit}>Edit color</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onSelect={onDelete}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm">
|
||||
<FolderIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 text-muted-foreground">
|
||||
Group these {itemLabelPlural} into folders to keep things tidy.
|
||||
</span>
|
||||
<Button size="sm" variant="outline" onClick={onCreateFolder}>
|
||||
Create your first folder
|
||||
</Button>
|
||||
<Button size="icon-sm" variant="ghost" aria-label="Dismiss folder suggestion" onClick={dismiss}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileFolderSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
onClick={() => onOpen(card)}
|
||||
@@ -802,6 +810,12 @@ function SkillCard({
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
by {card.author}{card.version ? ` · ${card.version}` : ""}
|
||||
</div>
|
||||
{badgeFolder !== undefined ? (
|
||||
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<FolderSwatch color={badgeFolder?.color} className="h-2 w-2" />
|
||||
<span className="truncate">{badgeFolder ? badgeFolder.name : "Unfiled"}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{/* 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<DiscoveryTab, number>;
|
||||
@@ -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 */}
|
||||
<div className="min-h-0 flex-1 overflow-auto p-4">
|
||||
{scanStatus ? <p className="mb-3 text-xs text-muted-foreground">{scanStatus}</p> : null}
|
||||
{folderNudgeStorageKey && onCreateFolder && folderResult && folderResult.folders.length === 0 && !loading && cards.length > 0 ? (
|
||||
<AllUnfiledBanner
|
||||
storageKey={folderNudgeStorageKey}
|
||||
itemLabelPlural="skills"
|
||||
onCreateFolder={onCreateFolder}
|
||||
/>
|
||||
) : null}
|
||||
{selectMode && onMoveSelected && onCreateFolderAndMoveSelected && onClearSelected ? (
|
||||
<div className="mb-3">
|
||||
<BulkBar
|
||||
@@ -1317,6 +1341,7 @@ export function DiscoveryGrid({
|
||||
folders={folderResult?.folders}
|
||||
selected={selectedSkillIds.includes(card.skillId ?? "")}
|
||||
selectMode={selectMode}
|
||||
showFolderBadge={Boolean(folderResult && search.trim())}
|
||||
onOpen={onOpenCard}
|
||||
onSelectChange={onSelectCard}
|
||||
onMove={onMoveCard}
|
||||
@@ -4227,17 +4252,20 @@ export function CompanySkills() {
|
||||
.map(([slug, count]) => ({ 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 ? (
|
||||
<SkillDetailPage
|
||||
|
||||
@@ -286,6 +286,7 @@ function createRoutine(overrides: Partial<RoutineListItem>): 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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routines />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routines />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
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" }),
|
||||
|
||||
@@ -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() {
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{routineViewState.groupBy === "folder" && !hasRoutineFolders && !foldersLoading && visibleRoutines.length > 0 ? (
|
||||
<AllUnfiledBanner
|
||||
storageKey={`paperclip:routines-folder-nudge:${selectedCompanyId ?? "none"}`}
|
||||
itemLabelPlural="routines"
|
||||
onCreateFolder={() => openCreateFolder()}
|
||||
/>
|
||||
) : null}
|
||||
{selectMode ? (
|
||||
<BulkBar
|
||||
selectedCount={selectedRoutineIds.length}
|
||||
@@ -1257,13 +1265,18 @@ export function Routines() {
|
||||
folders={routineFolders?.folders ?? []}
|
||||
currentFolderId={routine.folderId ?? null}
|
||||
onMove={(folderId) => {
|
||||
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])}
|
||||
|
||||
Reference in new issue
Block a user