Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions frontend/src/components/dataset/DeleteDatasetDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, test, vi } from "vitest";

import DeleteDatasetDialog from "./DeleteDatasetDialog";

const fetchWithHeaders = vi.fn(async (_url: string, _init: { body: string }) => ({ ok: true, json: async () => ({ success: true }) }));

vi.mock("@/contexts/ApiContext", () => ({
useApi: () => ({ baseUrl: "http://api", fetchWithHeaders }),
}));
vi.mock("@/hooks/use-toast", () => ({ useToast: () => ({ toast: () => {} }) }));

const render = async (onHub: boolean, onDeleted = () => {}) => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
await act(async () => {
root.render(
<DeleteDatasetDialog
repoId="me/demo"
onHub={onHub}
open
onOpenChange={() => {}}
onDeleted={onDeleted}
/>,
);
});
return root;
};

const deleteButton = () =>
[...document.querySelectorAll("button")].find((b) => b.textContent === "Delete")!;

test("a local-only dataset offers no Hub checkbox and deletes locally", async () => {
fetchWithHeaders.mockClear();
const onDeleted = vi.fn();
const root = await render(false, onDeleted);
expect(document.body.textContent).not.toContain("Also delete this dataset from the HuggingFace Hub");

await act(async () => deleteButton().click());

expect(JSON.parse(fetchWithHeaders.mock.calls[0][1].body)).toEqual({
dataset_repo_id: "me/demo",
delete_from_hub: false,
});
expect(onDeleted).toHaveBeenCalled();
act(() => root.unmount());
});

test("deleting from the Hub stays disabled until the repo id is typed", async () => {
const root = await render(true);
const checkbox = document.querySelector<HTMLButtonElement>("#delete-from-hub")!;
await act(async () => checkbox.click());
expect(deleteButton().disabled).toBe(true);

const input = document.querySelector<HTMLInputElement>("input")!;
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "me/demo");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(deleteButton().disabled).toBe(false);
act(() => root.unmount());
});
139 changes: 139 additions & 0 deletions frontend/src/components/dataset/DeleteDatasetDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import React, { useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useApi } from "@/contexts/ApiContext";
import { useToast } from "@/hooks/use-toast";

interface DeleteDatasetDialogProps {
repoId: string;
onHub: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
onDeleted: () => void;
}

/** Confirms and performs the removal of a local dataset, optionally from the Hub too. */
const DeleteDatasetDialog: React.FC<DeleteDatasetDialogProps> = ({
repoId,
onHub,
open,
onOpenChange,
onDeleted,
}) => {
const { baseUrl, fetchWithHeaders } = useApi();
const { toast } = useToast();
const [isDeleting, setIsDeleting] = useState(false);
const [deleteFromHub, setDeleteFromHub] = useState(false);
const [confirmText, setConfirmText] = useState("");

const reset = () => {
setDeleteFromHub(false);
setConfirmText("");
};

const handleDelete = async () => {
setIsDeleting(true);
try {
const response = await fetchWithHeaders(`${baseUrl}/delete-dataset`, {
method: "POST",
body: JSON.stringify({ dataset_repo_id: repoId, delete_from_hub: deleteFromHub }),
});
const data = await response.json();
if (response.ok && data.success) {
toast({
title: "Dataset Deleted",
description: deleteFromHub
? `${repoId} has been removed from disk and the Hub.`
: `${repoId} has been removed from disk.`,
});
onDeleted();
} else {
toast({
title: "Delete Failed",
description: data.message || "Could not delete the dataset.",
variant: "destructive",
});
}
} catch {
toast({
title: "Connection Error",
description: "Could not connect to the backend server.",
variant: "destructive",
});
} finally {
setIsDeleting(false);
onOpenChange(false);
reset();
}
};

return (
<AlertDialog
open={open}
onOpenChange={(next) => {
onOpenChange(next);
if (!next) reset();
}}
>
<AlertDialogContent className="bg-gray-900 border-gray-700 text-white">
<AlertDialogHeader>
<AlertDialogTitle>Delete dataset from disk?</AlertDialogTitle>
<AlertDialogDescription className="text-gray-400">
This permanently removes <span className="font-mono text-white">{repoId}</span> from your local cache. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
{onHub && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Checkbox
id="delete-from-hub"
checked={deleteFromHub}
onCheckedChange={(checked) => setDeleteFromHub(checked === true)}
/>
<Label htmlFor="delete-from-hub" className="text-sm text-gray-300">
Also delete this dataset from the HuggingFace Hub
</Label>
</div>
{deleteFromHub && (
<div className="space-y-1">
<Label className="text-xs text-gray-400">
Type <span className="font-mono text-white">{repoId}</span> to confirm
</Label>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
className="bg-gray-800 border-gray-700 text-white"
/>
</div>
)}
</div>
)}
<AlertDialogFooter>
<AlertDialogCancel className="bg-gray-800 border-gray-700 text-white hover:bg-gray-700">
Keep dataset
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting || (deleteFromHub && confirmText !== repoId)}
className="bg-red-500 hover:bg-red-600 text-white"
>
{isDeleting ? "Deleting…" : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

export default DeleteDatasetDialog;
8 changes: 8 additions & 0 deletions frontend/src/lib/hubViewer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/** Open a Hub dataset in the visualize_dataset Space (private ones go through the login redirect). */
export const openHubViewer = (repoId: string, isPrivate: boolean) => {
const spacePath = `/spaces/lerobot/visualize_dataset?path=${encodeURIComponent(`/${repoId}`)}`;
const target = isPrivate
? `https://huggingface.co/login?next=${encodeURIComponent(spacePath)}`
: `https://huggingface.co${spacePath}`;
window.open(target, "_blank", "noopener,noreferrer");
};
63 changes: 63 additions & 0 deletions frontend/src/pages/EditDataset.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";
import { expect, test, vi } from "vitest";

import EditDataset from "./EditDataset";

const datasets = [
{ repo_id: "me/local", source: "local", last_modified: "", private: false },
{ repo_id: "me/pushed", source: "both", last_modified: "", private: false },
];

vi.mock("@/hooks/useDatasets", () => ({
useDatasets: () => ({ datasets, loading: false, refresh: () => {} }),
}));
vi.mock("@/hooks/useEpisodes", () => ({
useEpisodes: () => ({ data: null, loading: false, error: null }),
useEpisodeDetail: () => ({ detail: null, loading: false, error: null }),
}));
vi.mock("@/hooks/useRobots", () => ({ useRobots: () => ({ selectedRecord: null }) }));
vi.mock("@/hooks/useRecording", () => ({
useRecording: () => ({ openForNew: () => {}, openToAppend: () => {}, modalProps: {} }),
}));
vi.mock("@/hooks/use-toast", () => ({ useToast: () => ({ toast: () => {} }) }));
vi.mock("@/contexts/ApiContext", () => ({
useApi: () => ({ baseUrl: "http://api", fetchWithHeaders: vi.fn() }),
}));
vi.mock("@/components/landing/RecordingModal", () => ({ default: () => null }));

const renderAt = async (repoId: string) => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
await act(async () => {
root.render(
<MemoryRouter initialEntries={[`/edit-dataset?dataset=${encodeURIComponent(repoId)}`]}>
<EditDataset />
</MemoryRouter>,
);
});
return { host, root };
};

const buttonTexts = (host: HTMLElement) =>
[...host.querySelectorAll("button, a")].map((e) => e.textContent?.trim());

test("a dataset not on the Hub offers Upload and Delete", async () => {
const { host, root } = await renderAt("me/local");
expect(buttonTexts(host)).toContain("Upload");
expect(host.querySelector('[aria-label="Delete dataset from disk"]')).not.toBeNull();
expect(host.querySelector('a[href^="https://huggingface.co"]')).toBeNull();
act(() => root.unmount());
});

test("a dataset already on the Hub links to its Hub page instead of Upload", async () => {
const { host, root } = await renderAt("me/pushed");
expect(buttonTexts(host)).not.toContain("Upload");
expect(host.querySelector("a")?.getAttribute("href")).toBe("https://huggingface.co/datasets/me/pushed");
expect(host.querySelector('[aria-label="Delete dataset from disk"]')).not.toBeNull();
act(() => root.unmount());
});
Loading
Loading