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:
DottaandPaperclip committed 2026-07-15 17:13:24 +00:00
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);
});
});
+154 -62
View File
@@ -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,
+40 -5
View File
@@ -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
+94 -1
View File
@@ -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" }),
+15 -2
View File
@@ -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])}