fix(ui): select cached files and browse retained trash in tabs
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
f65d0b841f
commit
630b002c75
|
|
@ -140,8 +140,11 @@ assigned agent, and responsible user's saved collections. Missing bindings are
|
|||
empty; private user files are available only to that user. Existing server
|
||||
ownership checks apply independently of the visibility setting.
|
||||
|
||||
The inspector is read-only and clearly identifies saved copies that can lag
|
||||
behind agent edits. It does not list repositories or the live sandbox disk.
|
||||
The inspector clearly identifies saved copies that can lag behind agent edits.
|
||||
Checkboxes select files; the move-to-trash action appears only for a nonempty
|
||||
selection. Each scope has Files and Trash tabs, with restoration from retained
|
||||
trash. Upload, folder creation, sandbox refresh, and permanent purge controls
|
||||
remain outside this inspector. It does not list repositories or the live sandbox disk.
|
||||
Agent, project, and profile pages have no standalone stored-file entry points.
|
||||
Live sandbox filesystem inspection remains a separate future feature. The
|
||||
editable stored-file browser remains in Storybook for design reference.
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||
import { CachedTaskFilesButton } from "./CachedTaskFilesButton";
|
||||
|
||||
vi.mock("@/components/WorkFolderBrowser", () => ({
|
||||
WorkFolderBrowser: ({ owner, readOnly }: { owner: unknown; readOnly: boolean }) =>
|
||||
<div data-testid="browser">{JSON.stringify({ owner, readOnly })}</div>,
|
||||
WorkFolderBrowser: ({ owner, readOnly, allowTrashActions }: { owner: unknown; readOnly: boolean; allowTrashActions: boolean }) =>
|
||||
<div data-testid="browser">{JSON.stringify({ owner, readOnly, allowTrashActions })}</div>,
|
||||
}));
|
||||
|
||||
const issue = { id: "task-1", companyId: "company-1", projectId: "project-1", assigneeAgentId: "agent-1", responsibleUserId: "user-1" };
|
||||
|
|
@ -29,11 +29,11 @@ async function select(label: string) {
|
|||
await act(async () => { tab.focus(); });
|
||||
}
|
||||
describe("cached task context inspector", () => {
|
||||
it("uses each task binding and exposes only inspection mode", async () => {
|
||||
it("uses each task binding and allows trash actions without upload or refresh controls", async () => {
|
||||
await open();
|
||||
for (const [label, scope, ownerId] of [["Task", "task", "task-1"], ["Project", "project", "project-1"], ["Agent", "agent", "agent-1"], ["Responsible user", "user", "user-1"]]) {
|
||||
await select(label!);
|
||||
expect(JSON.parse(document.querySelector('[data-testid="browser"]')!.textContent!)).toEqual({ owner: { companyId: "company-1", scope, ownerId }, readOnly: true });
|
||||
expect(JSON.parse(document.querySelector('[data-testid="browser"]')!.textContent!)).toEqual({ owner: { companyId: "company-1", scope, ownerId }, readOnly: true, allowTrashActions: true });
|
||||
}
|
||||
expect(document.body.textContent).toContain("not the live sandbox filesystem");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,13 +30,13 @@ export function CachedTaskFilesButton({ issue, currentUserId }: { issue: TaskCon
|
|||
</TabsList>
|
||||
{folders.map(({ scope, label, ownerId }) => (
|
||||
<TabsContent key={scope} value={scope} className="flex min-h-0 flex-col gap-3 overflow-auto">
|
||||
<p className="text-xs text-muted-foreground">Cached copy of $HOME/{scope}/ · Preview and download only</p>
|
||||
<p className="text-xs text-muted-foreground">Cached copy of $HOME/{scope}/ · Select files to move to trash; restore them from the Trash tab</p>
|
||||
{!ownerId ? (
|
||||
<p className="text-sm text-muted-foreground">No {label.toLowerCase()} is bound to this task. This folder is empty and unbound.</p>
|
||||
) : scope === "user" && ownerId !== currentUserId ? (
|
||||
<p className="text-sm text-muted-foreground">These cached files are private to the responsible user.</p>
|
||||
) : (
|
||||
<WorkFolderBrowser key={`${issue.companyId}:${scope}:${ownerId}`} owner={{ companyId: issue.companyId, scope, ownerId }} readOnly fillHeight />
|
||||
<WorkFolderBrowser key={`${issue.companyId}:${scope}:${ownerId}`} owner={{ companyId: issue.companyId, scope, ownerId }} readOnly allowTrashActions fillHeight />
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { WorkFolderBrowser } from "./WorkFolderBrowser";
|
||||
|
||||
const api = vi.hoisted(() => ({ list: vi.fn(), sync: vi.fn(), operation: vi.fn() }));
|
||||
vi.mock("@/api/work-folders", () => ({ workFoldersApi: api }));
|
||||
const owner = { companyId: "company", scope: "task" as const, ownerId: "task" };
|
||||
const a = { id: "a", path: "a.txt", kind: "file" };
|
||||
const b = { id: "b", path: "b.txt", kind: "file" };
|
||||
let active = [a, b];
|
||||
let deleted: typeof active = [];
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
let client: QueryClient;
|
||||
async function settle() {
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); });
|
||||
}
|
||||
async function click(element: HTMLElement) {
|
||||
await act(async () => { element.focus(); element.click(); });
|
||||
await settle();
|
||||
}
|
||||
const button = (text: string) => [...container.querySelectorAll<HTMLButtonElement>("button")].find((node) => node.textContent?.includes(text));
|
||||
const checkbox = (path: string) => container.querySelector<HTMLInputElement>(`[data-file-tree-path="${path}"] input`)!;
|
||||
beforeEach(async () => {
|
||||
active = [a, b]; deleted = [];
|
||||
api.list.mockImplementation(async (_owner, trash) => ({ files: [...(trash ? deleted : active)] }));
|
||||
api.sync.mockResolvedValue([]);
|
||||
api.operation.mockImplementation(async (_owner, operation) => {
|
||||
if (operation.action === "delete") {
|
||||
deleted.push(...active.filter((file) => file.path === operation.path));
|
||||
active = active.filter((file) => file.path !== operation.path);
|
||||
} else {
|
||||
active.push(...deleted.filter((file) => file.id === operation.fileId));
|
||||
deleted = deleted.filter((file) => file.id !== operation.fileId);
|
||||
}
|
||||
return { applied: true };
|
||||
});
|
||||
container = document.createElement("div"); document.body.append(container);
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
root = createRoot(container);
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><TooltipProvider><WorkFolderBrowser owner={owner} readOnly allowTrashActions /></TooltipProvider></QueryClientProvider>));
|
||||
await settle();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount()); client.clear(); container.remove(); vi.clearAllMocks();
|
||||
});
|
||||
describe("cached file selection and retained trash", () => {
|
||||
it("only shows trash action for checked files and restores from the Trash tab", async () => {
|
||||
expect(button("to trash")).toBeUndefined();
|
||||
await click(checkbox("a.txt"));
|
||||
expect(checkbox("a.txt").checked).toBe(true);
|
||||
expect(button("Move 1 file to trash")).toBeDefined();
|
||||
await click(button("Move 1 file to trash")!);
|
||||
expect(active).toEqual([b]);
|
||||
expect(button("to trash")).toBeUndefined();
|
||||
await click(button("Trash")!);
|
||||
expect(container.textContent).toContain("a.txt");
|
||||
expect(button("Purge")).toBeUndefined();
|
||||
await click(button("Restore")!);
|
||||
await click(button("Files")!);
|
||||
expect(checkbox("a.txt")).not.toBeNull();
|
||||
expect(deleted).toEqual([]);
|
||||
});
|
||||
it("refreshes partial successes and leaves only failed files selected for retry", async () => {
|
||||
const operate = api.operation.getMockImplementation()!;
|
||||
api.operation.mockImplementation(async (target, operation) => {
|
||||
if (operation.path === "b.txt") throw new Error("Storage unavailable");
|
||||
return operate(target, operation);
|
||||
});
|
||||
await click(checkbox("a.txt")); await click(checkbox("b.txt"));
|
||||
await click(button("Move 2 files to trash")!);
|
||||
expect(checkbox("a.txt")).toBeNull();
|
||||
expect(checkbox("b.txt").checked).toBe(true);
|
||||
expect(container.textContent).toContain("Storage unavailable");
|
||||
expect(button("Move 1 file to trash")).toBeDefined();
|
||||
expect(deleted).toEqual([a]);
|
||||
});
|
||||
});
|
||||
|
|
@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
function tree(files: WorkFile[]) {
|
||||
|
|
@ -30,9 +31,11 @@ function tree(files: WorkFile[]) {
|
|||
sort(root.children); return root.children;
|
||||
}
|
||||
|
||||
export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillHeight = false }: { owner: WorkFolderOwner; exampleFiles?: WorkFile[]; readOnly?: boolean; fillHeight?: boolean }) {
|
||||
export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillHeight = false, allowTrashActions = false }: { owner: WorkFolderOwner; exampleFiles?: WorkFile[]; readOnly?: boolean; fillHeight?: boolean; allowTrashActions?: boolean }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [trash, setTrash] = useState(false);
|
||||
const [checkedFiles, setCheckedFiles] = useState(new Set<string>());
|
||||
const canManageTrash = !readOnly || allowTrashActions;
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState(new Set<string>());
|
||||
const [directory, setDirectory] = useState("");
|
||||
|
|
@ -44,21 +47,27 @@ export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillH
|
|||
enabled: !exampleFiles, refetchInterval: 15_000, retry: false });
|
||||
const syncQuery = useQuery({ queryKey: [...key, "sync"], queryFn: () => workFoldersApi.sync(owner), enabled: !exampleFiles, refetchInterval: 5000, retry: false });
|
||||
const files = exampleFiles ?? filesQuery.data?.files ?? [];
|
||||
const checkedPaths = files.filter((file) => file.kind === "file" && checkedFiles.has(file.path)).map((file) => file.path);
|
||||
const selected = files.find((file) => file.path === selectedPath);
|
||||
const nodes = useMemo(() => tree(files), [files]);
|
||||
const preview = useQuery({ queryKey: [...key, "preview", selected?.path, selected?.sha256],
|
||||
queryFn: () => workFoldersApi.preview(owner, selected!), enabled: !exampleFiles && !trash && selected?.kind === "file", retry: false });
|
||||
const mutation = useMutation({ mutationFn: async (action: { type: "upload"; files: File[] } | { type: "mkdir" } | { type: "delete"; path: string } | { type: "restore" | "purge"; fileId: string } | { type: "refresh" }) => {
|
||||
if (readOnly) throw new Error("Cached file inspection is read-only.");
|
||||
const mutation = useMutation({ mutationFn: async (action: { type: "upload"; files: File[] } | { type: "mkdir" } | { type: "deleteSelected"; paths: string[] } | { type: "restore" | "purge"; fileId: string } | { type: "refresh" }) => {
|
||||
if (readOnly && !(allowTrashActions && (action.type === "deleteSelected" || action.type === "restore"))) throw new Error("This cached file action is unavailable.");
|
||||
if (action.type === "upload") for (const file of action.files) await workFoldersApi.upload(owner, file, directory ? `${directory}/${file.name}` : file.name, crypto.randomUUID());
|
||||
else if (action.type === "mkdir") { await workFoldersApi.operation(owner, { action: "mkdir", path: directory }, crypto.randomUUID()); setExpanded((before) => new Set([...before, directory])); }
|
||||
else if (action.type === "delete") await workFoldersApi.operation(owner, { action: "delete", path: action.path }, crypto.randomUUID());
|
||||
else if (action.type === "deleteSelected") {
|
||||
for (const path of action.paths) {
|
||||
await workFoldersApi.operation(owner, { action: "delete", path }, crypto.randomUUID());
|
||||
setCheckedFiles((before) => { const next = new Set(before); next.delete(path); return next; });
|
||||
setSelectedPath((before) => before === path ? null : before);
|
||||
}
|
||||
}
|
||||
else if (action.type === "restore" || action.type === "purge") await workFoldersApi.operation(owner, { action: action.type, fileId: action.fileId }, crypto.randomUUID());
|
||||
else for (const run of (syncQuery.data ?? []).filter((run) => run.active)) await workFoldersApi.refresh(owner, run.runId);
|
||||
}, onSuccess: async (_data, action) => {
|
||||
setAnnouncement(action.type === "refresh" ? "Refresh requested for the next safe run boundary." : "Files saved.");
|
||||
await queryClient.invalidateQueries({ queryKey: key });
|
||||
} });
|
||||
}, onSettled: () => queryClient.invalidateQueries({ queryKey: key }) });
|
||||
const statuses = syncQuery.data ?? [];
|
||||
const failed = statuses.find((status) => status.state === "failed");
|
||||
const saving = mutation.isPending || statuses.some((status) => status.state === "saving");
|
||||
|
|
@ -67,14 +76,18 @@ export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillH
|
|||
const lastOperation = filesQuery.data?.lastOperationAt;
|
||||
const saveFailed = Boolean(failed) || mutation.isError;
|
||||
const disabled = mutation.isPending || Boolean(exampleFiles);
|
||||
return <div className={cn("flex min-h-0 flex-col gap-3", fillHeight && "flex-1")}>
|
||||
return <Tabs value={trash ? "trash" : "files"} onValueChange={(value) => { setTrash(value === "trash"); setSelectedPath(null); setCheckedFiles(new Set()); }} className={cn("flex min-h-0 flex-col gap-3", fillHeight && "flex-1")}>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
{!readOnly && <><Button variant="outline" size="sm" disabled={disabled || trash} onClick={() => fileInput.current?.click()}><Upload aria-hidden />Upload</Button>
|
||||
<input ref={fileInput} className="hidden" aria-label="Upload work files" type="file" multiple onChange={(event) => {
|
||||
const chosen = Array.from(event.currentTarget.files ?? []); event.currentTarget.value = "";
|
||||
if (chosen.length) mutation.mutate({ type: "upload", files: chosen });
|
||||
}} /></>}
|
||||
<Button variant={trash ? "secondary" : "outline"} size="sm" onClick={() => { setTrash(!trash); setSelectedPath(null); }}><Trash2 aria-hidden />{trash ? "Back to files" : "Trash"}</Button>
|
||||
<TabsList aria-label="Cached folder contents">
|
||||
<TabsTrigger value="files">Files</TabsTrigger>
|
||||
<TabsTrigger value="trash">Trash</TabsTrigger>
|
||||
</TabsList>
|
||||
{!trash && canManageTrash && checkedPaths.length > 0 && <Button variant="outline" size="sm" disabled={disabled} onClick={() => mutation.mutate({ type: "deleteSelected", paths: checkedPaths })}><Trash2 aria-hidden />Move {checkedPaths.length} {checkedPaths.length === 1 ? "file" : "files"} to trash</Button>}
|
||||
{!readOnly && <Button variant="outline" size="sm" disabled={disabled || !statuses.some((status) => status.active)} onClick={() => mutation.mutate({ type: "refresh" })}><RefreshCw aria-hidden />Refresh sandbox</Button>}
|
||||
<span className="text-xs text-muted-foreground" role="status">{saving ? "Saving…" : saveFailed ? "Save failed" : "Saved"}</span>
|
||||
{lastSaved && <span className="text-xs text-muted-foreground">Last agent save {new Date(lastSaved).toLocaleTimeString()}</span>}
|
||||
|
|
@ -87,24 +100,35 @@ export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillH
|
|||
{[filesQuery.error, syncQuery.error, mutation.error].filter(Boolean).map((error, index) => <p key={index} role="alert" className="text-sm text-destructive">{(error as Error).message}</p>)}
|
||||
{failed && <p role="alert" className="text-sm text-destructive">{failed.error}</p>}
|
||||
<p className="sr-only" aria-live="polite">{announcement}</p>
|
||||
{trash ? <div className={cn("overflow-auto", fillHeight ? "min-h-0 flex-1" : "max-h-96")}>{files.length === 0 ? <p className="text-sm text-muted-foreground">Trash is empty.</p> : files.map((file) => <div key={file.id} className="flex items-center gap-2 border-b py-2">
|
||||
<span className="min-w-0 flex-1 truncate text-sm">{file.path}</span>{!readOnly && <><Button size="sm" variant="outline" disabled={disabled} onClick={() => mutation.mutate({ type: "restore", fileId: file.id })}><RotateCcw aria-hidden />Restore</Button>
|
||||
<AlertDialog><AlertDialogTrigger asChild><Button size="sm" variant="ghost" disabled={disabled}>Purge…</Button></AlertDialogTrigger>
|
||||
{trash ? <TabsContent value="trash" className={cn("overflow-auto", fillHeight ? "min-h-0 flex-1" : "max-h-96")}><p className="mb-3 text-sm text-muted-foreground">Deleted cached files are retained here. Restore them to return them to Files.</p>{files.length === 0 ? <p className="text-sm text-muted-foreground">Trash is empty.</p> : files.map((file) => <div key={file.id} className="flex items-center gap-2 border-b py-2">
|
||||
<span className="min-w-0 flex-1 truncate text-sm">{file.path}</span>{canManageTrash && <Button size="sm" variant="outline" disabled={disabled} onClick={() => mutation.mutate({ type: "restore", fileId: file.id })}><RotateCcw aria-hidden />Restore</Button>}
|
||||
{!readOnly && <AlertDialog><AlertDialogTrigger asChild><Button size="sm" variant="ghost" disabled={disabled}>Purge…</Button></AlertDialogTrigger>
|
||||
<AlertDialogContent><AlertDialogHeader><AlertDialogTitle>Permanently delete {file.path}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>This deleted copy and its deleted children will no longer be recoverable.</AlertDialogDescription></AlertDialogHeader>
|
||||
<AlertDialogFooter><AlertDialogCancel>Cancel</AlertDialogCancel><AlertDialogAction onClick={() => mutation.mutate({ type: "purge", fileId: file.id })}>Permanently delete</AlertDialogAction></AlertDialogFooter>
|
||||
</AlertDialogContent></AlertDialog></>}
|
||||
</div>)}</div> : <div className={cn("grid min-h-0 gap-3 md:grid-cols-3", fillHeight && "flex-1 grid-rows-2 md:grid-rows-1")}>
|
||||
</AlertDialogContent></AlertDialog>}
|
||||
</div>)}</TabsContent> : <TabsContent value="files" className={cn("grid min-h-0 gap-3 md:grid-cols-3", fillHeight && "flex-1 grid-rows-2 md:grid-rows-1")}>
|
||||
<div className={cn("min-h-0 overflow-auto rounded-md border", !fillHeight && "max-h-96")}><FileTree nodes={nodes} selectedFile={selectedPath} expandedDirs={expanded}
|
||||
onToggleDir={(filePath) => { setSelectedPath(filePath); setExpanded((before) => { const next = new Set(before); if (next.has(filePath)) next.delete(filePath); else next.add(filePath); return next; }); }}
|
||||
showCheckboxes={canManageTrash && !exampleFiles} checkedFiles={new Set(checkedPaths)}
|
||||
onToggleCheck={(path, kind) => {
|
||||
if (disabled) return;
|
||||
const paths = kind === "file" ? [path] : files.filter((file) => file.kind === "file" && file.path.startsWith(`${path}/`)).map((file) => file.path);
|
||||
setCheckedFiles((before) => {
|
||||
const next = new Set(before);
|
||||
const remove = paths.every((candidate) => before.has(candidate));
|
||||
for (const candidate of paths) { if (remove) next.delete(candidate); else next.add(candidate); }
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onSelectFile={setSelectedPath} loading={!exampleFiles && filesQuery.isLoading} empty={{ title: "No files yet", description: readOnly ? "No cached files have been saved for this scope." : "Upload files here, or create them during a sandbox run." }} ariaLabel={`${owner.scope} files`} /></div>
|
||||
<div className="flex min-h-0 flex-col gap-2 md:col-span-2">
|
||||
{selected && <div className="flex items-center gap-2"><span className="min-w-0 flex-1 truncate text-sm">{selected.path}</span>
|
||||
{selected.kind === "file" && !exampleFiles && <Button asChild size="sm" variant="outline"><a href={workFoldersApi.downloadUrl(owner, selected.path)} download><Download aria-hidden />Download</a></Button>}
|
||||
{!readOnly && <Button size="sm" variant="outline" disabled={disabled} onClick={() => mutation.mutate({ type: "delete", path: selected.path })}><Trash2 aria-hidden />Delete</Button>}</div>}
|
||||
</div>}
|
||||
{preview.isLoading ? <p className="text-sm text-muted-foreground">Loading preview…</p> : preview.error ? <p role="alert" className="text-sm text-muted-foreground">{preview.error.message}</p> : preview.data ?
|
||||
<div className={cn("flex min-h-0 flex-col overflow-auto rounded-md border", fillHeight ? "flex-1" : "max-h-96")}><FileContentViewer content={preview.data} highlightedLine={null} /></div> : <p className="text-sm text-muted-foreground">Select a file to preview it.</p>}
|
||||
</div>
|
||||
</div>}
|
||||
</div>;
|
||||
</TabsContent>}
|
||||
</Tabs>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@ The default URL is `http://localhost:6006`.
|
|||
viewing cached task files** toggle under Paperclip Developer Mode. It defaults
|
||||
to off. **Task Page Cached Files** shows the enabled task properties: choose
|
||||
**View cached files**, then Task, Project, Agent, or Responsible user to preview
|
||||
and download their saved collections. The inspector is read-only and identifies
|
||||
the data as cached, potentially behind the sandbox disk. User scope follows the
|
||||
and download their saved collections. The inspector identifies the data as cached, potentially behind the sandbox disk.
|
||||
Select checkboxes to move files to retained trash; restore them from the Trash
|
||||
tab for that scope. User scope follows the
|
||||
task's responsible user and remains private to that user.
|
||||
|
||||
Task Page and Mobile Task Page show the default, disabled state. Agent Page,
|
||||
|
|
@ -19,7 +20,7 @@ Live sandbox filesystem browsing remains a future feature.
|
|||
|
||||
**Work folders / Stored-file prototype** retains the reusable browser for design
|
||||
reference only. Its editing controls are explicitly labeled as unshipped; the task inspector
|
||||
uses the same browser in read-only mode. It includes Markdown, code, image and empty-file
|
||||
uses the same browser with selection, trash, and restore controls. It includes Markdown, code, image and empty-file
|
||||
previews, unsupported/large-file messages, loading, empty, saving, failed-save,
|
||||
unavailable-storage, failed-upload, trash, and permanent-deletion states.
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const meta = {
|
|||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Editable stored-file browser prototype using disposable in-memory data. The experimental task inspector reuses this browser in read-only mode. Saved copies are not the live sandbox filesystem.",
|
||||
"Editable stored-file browser prototype using disposable in-memory data. The experimental task inspector reuses this browser with selection, trash, and restore controls. Saved copies are not the live sandbox filesystem.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -113,14 +113,14 @@ export const UploadFailed: Story = {
|
|||
export const Trash: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByRole("button", { name: "Trash" }),
|
||||
await within(canvasElement).findByRole("tab", { name: "Trash" }),
|
||||
);
|
||||
},
|
||||
};
|
||||
export const PurgeConfirmation: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const c = within(canvasElement);
|
||||
await userEvent.click(await c.findByRole("button", { name: "Trash" }));
|
||||
await userEvent.click(await c.findByRole("tab", { name: "Trash" }));
|
||||
await userEvent.click(await c.findByRole("button", { name: "Purge…" }));
|
||||
},
|
||||
};
|
||||
|
|
@ -142,11 +142,12 @@ export const UploadDeleteRestore: Story = {
|
|||
}),
|
||||
);
|
||||
await userEvent.click(await c.findByText("review.md"));
|
||||
await userEvent.click(await c.findByRole("button", { name: "Delete" }));
|
||||
await userEvent.click(c.getByRole("button", { name: "Trash" }));
|
||||
await userEvent.click((await c.findByRole("treeitem", { name: "review.md" })).querySelector("input")!);
|
||||
await userEvent.click(await c.findByRole("button", { name: "Move 1 file to trash" }));
|
||||
await userEvent.click(c.getByRole("tab", { name: "Trash" }));
|
||||
const row = (await c.findByText("review.md")).parentElement!;
|
||||
await userEvent.click(within(row).getByRole("button", { name: "Restore" }));
|
||||
await userEvent.click(c.getByRole("button", { name: "Back to files" }));
|
||||
await userEvent.click(c.getByRole("tab", { name: "Files" }));
|
||||
await expect(await c.findByText("review.md")).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue