diff --git a/.gitignore b/.gitignore index 043bfa26..809f71d2 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ qa-output .worktrees/ .turbo +.superset diff --git a/app/client/components/file-browsers/__tests__/snapshot-tree-browser.test.tsx b/app/client/components/file-browsers/__tests__/snapshot-tree-browser.test.tsx new file mode 100644 index 00000000..772f5089 --- /dev/null +++ b/app/client/components/file-browsers/__tests__/snapshot-tree-browser.test.tsx @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; + +const snapshotFiles = { + files: [ + { name: "project", path: "/mnt/project", type: "dir" }, + { name: "a.txt", path: "/mnt/project/a.txt", type: "file" }, + ], +}; + +await mock.module("@tanstack/react-query", () => ({ + useQuery: () => ({ data: snapshotFiles, isLoading: false, error: null }), + useQueryClient: () => ({ + ensureQueryData: async () => snapshotFiles, + prefetchQuery: async () => undefined, + }), +})); + +import { SnapshotTreeBrowser } from "../snapshot-tree-browser"; + +afterEach(() => { + cleanup(); +}); + +describe("SnapshotTreeBrowser", () => { + test("renders the query root folder when display base path is broader than query base path", () => { + render( + , + ); + + screen.getByRole("button", { name: "project" }); + }); + + test("shows selected folder state when full paths are provided from the parent", () => { + render( + {}} + />, + ); + + const row = screen.getByRole("button", { name: "project" }); + const checkbox = within(row).getByRole("checkbox"); + + expect(checkbox.getAttribute("aria-checked")).toBe("true"); + }); + + test("returns the full snapshot path and kind when selecting a displayed folder", () => { + let selectedPaths: Set | undefined; + let selectedKind: "file" | "dir" | null = null; + + render( + { + selectedPaths = paths; + }} + onSingleSelectionKindChange={(kind) => { + selectedKind = kind; + }} + />, + ); + + const row = screen.getByRole("button", { name: "project" }); + const checkbox = within(row).getByRole("checkbox"); + + fireEvent.click(checkbox); + + expect(selectedPaths ? Array.from(selectedPaths) : []).toEqual(["/mnt/project"]); + expect(selectedKind === "dir").toBe(true); + }); +}); diff --git a/app/client/components/file-browsers/snapshot-tree-browser.tsx b/app/client/components/file-browsers/snapshot-tree-browser.tsx index 993b935f..f6879888 100644 --- a/app/client/components/file-browsers/snapshot-tree-browser.tsx +++ b/app/client/components/file-browsers/snapshot-tree-browser.tsx @@ -7,92 +7,91 @@ import { parseError } from "~/client/lib/errors"; import { normalizeAbsolutePath } from "@zerobyte/core/utils"; import { logger } from "~/client/lib/logger"; +function createPathPrefixFns(basePath: string) { + return { + strip(path: string) { + if (basePath === "/") return path; + if (path === basePath) return "/"; + if (path.startsWith(`${basePath}/`)) return path.slice(basePath.length); + return path; + }, + add(displayPath: string) { + if (basePath === "/") return displayPath; + if (displayPath === "/") return basePath; + return `${basePath}${displayPath}`; + }, + }; +} + type SnapshotTreeBrowserProps = FileBrowserUiProps & { repositoryId: string; snapshotId: string; - basePath?: string; + queryBasePath?: string; + displayBasePath?: string; pageSize?: number; enabled?: boolean; onSingleSelectionKindChange?: (kind: "file" | "dir" | null) => void; }; -export const SnapshotTreeBrowser = ({ - repositoryId, - snapshotId, - basePath = "/", - pageSize = 500, - enabled = true, - ...uiProps -}: SnapshotTreeBrowserProps) => { +export const SnapshotTreeBrowser = (props: SnapshotTreeBrowserProps) => { + const { + repositoryId, + snapshotId, + queryBasePath = "/", + displayBasePath, + pageSize = 500, + enabled = true, + ...uiProps + } = props; + const { selectedPaths, onSelectionChange, onSingleSelectionKindChange, ...fileBrowserUiProps } = uiProps; const queryClient = useQueryClient(); - const normalizedBasePath = normalizeAbsolutePath(basePath); + const normalizedQueryBasePath = normalizeAbsolutePath(queryBasePath); + const normalizedDisplayBasePath = normalizeAbsolutePath(displayBasePath ?? normalizedQueryBasePath); const { data, isLoading, error } = useQuery({ ...listSnapshotFilesOptions({ path: { shortId: repositoryId, snapshotId }, - query: { path: normalizedBasePath }, + query: { path: normalizedQueryBasePath }, }), enabled, }); - const stripBasePath = useCallback( - (path: string): string => { - if (normalizedBasePath === "/") return path; - if (path === normalizedBasePath) return "/"; - if (path.startsWith(`${normalizedBasePath}/`)) { - return path.slice(normalizedBasePath.length); - } - return path; - }, - [normalizedBasePath], - ); - - const addBasePath = useCallback( - (displayPath: string): string => { - if (normalizedBasePath === "/") return displayPath; - if (displayPath === "/") return normalizedBasePath; - return `${normalizedBasePath}${displayPath}`; - }, - [normalizedBasePath], - ); + const displayPathFns = useMemo(() => createPathPrefixFns(normalizedDisplayBasePath), [normalizedDisplayBasePath]); const displaySelectedPaths = useMemo(() => { if (!selectedPaths) return undefined; const displayPaths = new Set(); for (const fullPath of selectedPaths) { - displayPaths.add(stripBasePath(fullPath)); + displayPaths.add(displayPathFns.strip(fullPath)); } return displayPaths; - }, [selectedPaths, stripBasePath]); + }, [displayPathFns, selectedPaths]); const fileBrowser = useFileBrowser({ initialData: data, isLoading, - fetchFolder: async (path, offset = 0) => { + fetchFolder: async (displayPath, offset = 0) => { return await queryClient.ensureQueryData( listSnapshotFilesOptions({ path: { shortId: repositoryId, snapshotId }, - query: { path, offset: offset, limit: pageSize }, + query: { path: displayPath, offset: offset, limit: pageSize }, }), ); }, - prefetchFolder: (path) => { + prefetchFolder: (displayPath) => { void queryClient .prefetchQuery( listSnapshotFilesOptions({ path: { shortId: repositoryId, snapshotId }, - query: { path, offset: 0, limit: pageSize }, + query: { path: displayPath, offset: 0, limit: pageSize }, }), ) .catch((e) => logger.error(e)); }, - pathTransform: { - strip: stripBasePath, - add: addBasePath, - }, + pathTransform: displayPathFns, }); const displayPathKinds = useMemo(() => { @@ -109,7 +108,7 @@ export const SnapshotTreeBrowser = ({ const nextFullPaths = new Set(); for (const displayPath of nextDisplayPaths) { - nextFullPaths.add(addBasePath(displayPath)); + nextFullPaths.add(displayPathFns.add(displayPath)); } if (onSingleSelectionKindChange) { @@ -127,7 +126,7 @@ export const SnapshotTreeBrowser = ({ onSelectionChange(nextFullPaths); }, - [onSelectionChange, addBasePath, onSingleSelectionKindChange, displayPathKinds], + [displayPathFns, displayPathKinds, onSelectionChange, onSingleSelectionKindChange], ); return ( diff --git a/app/client/components/restore-form.tsx b/app/client/components/restore-form.tsx index 56d75322..467e3601 100644 --- a/app/client/components/restore-form.tsx +++ b/app/client/components/restore-form.tsx @@ -33,14 +33,15 @@ interface RestoreFormProps { repository: Repository; snapshotId: string; returnPath: string; - basePath?: string; + queryBasePath?: string; + displayBasePath?: string; } -export function RestoreForm({ repository, snapshotId, returnPath, basePath }: RestoreFormProps) { +export function RestoreForm({ repository, snapshotId, returnPath, queryBasePath, displayBasePath }: RestoreFormProps) { const navigate = useNavigate(); const { addEventListener } = useServerEvents(); - const volumeBasePath = basePath ?? "/"; + const snapshotBasePath = queryBasePath ?? "/"; const [restoreLocation, setRestoreLocation] = useState("original"); const [customTargetPath, setCustomTargetPath] = useState(""); @@ -346,7 +347,8 @@ export function RestoreForm({ repository, snapshotId, returnPath, basePath }: Re void; isDeletingSnapshot?: boolean; } @@ -28,43 +26,11 @@ const treeProps = { stateClassName: "flex-1 min-h-0", } as const; -interface ScheduleAwareTreeBrowserProps { - scheduleShortId: string; - repositoryId: string; - snapshotId: string; -} - -const ScheduleAwareTreeBrowser = ({ scheduleShortId, repositoryId, snapshotId }: ScheduleAwareTreeBrowserProps) => { - const { data: schedule, isPending } = useQuery({ - ...getBackupScheduleOptions({ path: { shortId: scheduleShortId } }), - retry: false, - }); - - if (isPending) { - return ; - } - - return ( - - ); -}; - -const TreeBrowserFallback = () => ( -
-

Loading volume info...

-
-); - export const SnapshotFileBrowser = (props: Props) => { - const { snapshot, repositoryId, backupId, basePath, onDeleteSnapshot, isDeletingSnapshot } = props; + const { snapshot, repositoryId, backupId, displayBasePath, onDeleteSnapshot, isDeletingSnapshot } = props; const { formatDateTime } = useTimeFormat(); - const scheduleShortId = !basePath ? backupId || snapshot.tags?.[0] : undefined; + const queryBasePath = findCommonAncestor(snapshot.paths); return (
@@ -110,27 +76,13 @@ export const SnapshotFileBrowser = (props: Props) => {
- {basePath ? ( - - ) : scheduleShortId ? ( - - ) : ( - - )} + diff --git a/app/client/modules/backups/routes/backup-details.tsx b/app/client/modules/backups/routes/backup-details.tsx index a581a7b3..48b6bca6 100644 --- a/app/client/modules/backups/routes/backup-details.tsx +++ b/app/client/modules/backups/routes/backup-details.tsx @@ -34,6 +34,7 @@ import { ScheduleNotificationsConfig } from "../components/schedule-notification import { ScheduleMirrorsConfig } from "../components/schedule-mirrors-config"; import { BackupSummaryCard } from "~/client/components/backup-summary-card"; import { cn } from "~/client/lib/utils"; +import { getVolumeMountPath } from "~/client/lib/volume-path"; import type { BackupSchedule, NotificationDestination, @@ -303,6 +304,7 @@ export function ScheduleDetailsPage(props: Props) { snapshot={selectedSnapshot} repositoryId={schedule.repository.shortId} backupId={schedule.shortId} + displayBasePath={getVolumeMountPath(schedule.volume)} onDeleteSnapshot={handleDeleteSnapshot} isDeletingSnapshot={deleteSnapshot.isPending} /> diff --git a/app/client/modules/repositories/routes/restore-snapshot.tsx b/app/client/modules/repositories/routes/restore-snapshot.tsx index 2459aa21..24761b42 100644 --- a/app/client/modules/repositories/routes/restore-snapshot.tsx +++ b/app/client/modules/repositories/routes/restore-snapshot.tsx @@ -5,11 +5,20 @@ type Props = { repository: Repository; snapshotId: string; returnPath: string; - basePath?: string; + queryBasePath?: string; + displayBasePath?: string; }; export function RestoreSnapshotPage(props: Props) { - const { returnPath, snapshotId, repository, basePath } = props; + const { returnPath, snapshotId, repository, queryBasePath, displayBasePath } = props; - return ; + return ( + + ); } diff --git a/app/client/modules/repositories/routes/snapshot-details.tsx b/app/client/modules/repositories/routes/snapshot-details.tsx index 43a6f301..832a3db4 100644 --- a/app/client/modules/repositories/routes/snapshot-details.tsx +++ b/app/client/modules/repositories/routes/snapshot-details.tsx @@ -101,7 +101,7 @@ export function SnapshotDetailsPage({ repositoryId, snapshotId, initialSnapshot ) : ( diff --git a/app/routes/(dashboard)/backups/$backupId/$snapshotId.restore.tsx b/app/routes/(dashboard)/backups/$backupId/$snapshotId.restore.tsx index e9f80b4f..2082f56f 100644 --- a/app/routes/(dashboard)/backups/$backupId/$snapshotId.restore.tsx +++ b/app/routes/(dashboard)/backups/$backupId/$snapshotId.restore.tsx @@ -3,6 +3,7 @@ import { getBackupSchedule } from "~/client/api-client"; import { getRepositoryOptions, getSnapshotDetailsOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { RestoreSnapshotPage } from "~/client/modules/repositories/routes/restore-snapshot"; import { getVolumeMountPath } from "~/client/lib/volume-path"; +import { findCommonAncestor } from "@zerobyte/core/utils"; export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId/restore")({ component: RouteComponent, @@ -29,7 +30,8 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId snapshot, repository, schedule: schedule.data, - basePath: getVolumeMountPath(schedule.data.volume), + queryBasePath: findCommonAncestor(snapshot.paths), + displayBasePath: getVolumeMountPath(schedule.data.volume), }; }, head: ({ params }) => ({ @@ -53,14 +55,15 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId function RouteComponent() { const { backupId, snapshotId } = Route.useParams(); - const { repository, basePath } = Route.useLoaderData(); + const { repository, queryBasePath, displayBasePath } = Route.useLoaderData(); return ( ); } diff --git a/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore.tsx b/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore.tsx index cbf4f488..f5c298c1 100644 --- a/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore.tsx +++ b/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore.tsx @@ -3,6 +3,7 @@ import { getBackupSchedule } from "~/client/api-client"; import { getRepositoryOptions, getSnapshotDetailsOptions } from "~/client/api-client/@tanstack/react-query.gen"; import { RestoreSnapshotPage } from "~/client/modules/repositories/routes/restore-snapshot"; import { getVolumeMountPath } from "~/client/lib/volume-path"; +import { findCommonAncestor } from "@zerobyte/core/utils"; export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$snapshotId/restore")({ component: RouteComponent, @@ -15,16 +16,16 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$s context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { shortId: params.repositoryId } }) }), ]); - let basePath: string | undefined; + let displayBasePath: string | undefined; const scheduleShortId = snapshot.tags?.[0]; if (scheduleShortId) { const scheduleRes = await getBackupSchedule({ path: { shortId: scheduleShortId } }); if (scheduleRes.data) { - basePath = getVolumeMountPath(scheduleRes.data.volume); + displayBasePath = getVolumeMountPath(scheduleRes.data.volume); } } - return { snapshot, repository, basePath }; + return { snapshot, repository, queryBasePath: findCommonAncestor(snapshot.paths), displayBasePath }; }, staticData: { breadcrumb: (match) => [ @@ -47,14 +48,15 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$s function RouteComponent() { const { repositoryId, snapshotId } = Route.useParams(); - const { repository, basePath } = Route.useLoaderData(); + const { repository, queryBasePath, displayBasePath } = Route.useLoaderData(); return ( ); } diff --git a/e2e/0002-backup-restore.spec.ts b/e2e/0002-backup-restore.spec.ts index be71ef20..efd01a4e 100644 --- a/e2e/0002-backup-restore.spec.ts +++ b/e2e/0002-backup-restore.spec.ts @@ -35,7 +35,7 @@ function getRunId(testInfo: TestInfo) { return `${testInfo.parallelIndex}-${testInfo.retry}-${randomUUID().slice(0, 8)}`; } -function getWorkerTestDataPath(runId: string) { +function getWorkerTestDataPath() { fs.mkdirSync(testDataPath, { recursive: true }); return testDataPath; } @@ -49,7 +49,7 @@ function getScenarioNames(runId: string): ScenarioNames { } function prepareTestFile(runId: string, fileName = "test.json"): string { - const runPath = path.join(getWorkerTestDataPath(runId), runId); + const runPath = path.join(getWorkerTestDataPath(), runId); fs.mkdirSync(runPath, { recursive: true }); const filePath = path.join(runPath, fileName); @@ -58,8 +58,8 @@ function prepareTestFile(runId: string, fileName = "test.json"): string { return filePath; } -async function createBackupScenario(page: Page, names: ScenarioNames, runId: string, options: ScenarioOptions = {}) { - getWorkerTestDataPath(runId); +async function createBackupScenario(page: Page, names: ScenarioNames, options: ScenarioOptions = {}) { + getWorkerTestDataPath(); const volumeNameInput = page.getByRole("textbox", { name: "Name" }); await expect(async () => { @@ -153,7 +153,7 @@ test("can backup & restore a file", async ({ page }, testInfo) => { await gotoAndWaitForAppReady(page, "/"); await expect(page).toHaveURL("/volumes"); - await createBackupScenario(page, names, runId); + await createBackupScenario(page, names); await page.getByRole("button", { name: "Backup now" }).click(); await expect(page.getByText("Backup started successfully")).toBeVisible(); @@ -177,7 +177,7 @@ test("can backup & restore a file", async ({ page }, testInfo) => { test("can restore a single selected file to a custom location", async ({ page }, testInfo) => { const runId = getRunId(testInfo); const names = getScenarioNames(runId); - const workerTestDataPath = getWorkerTestDataPath(runId); + const workerTestDataPath = getWorkerTestDataPath(); const fileName = `single-file-${runId}.json`; const filePath = prepareTestFile(runId, fileName); const restoreTargetPath = path.join(workerTestDataPath, fileName); @@ -188,7 +188,7 @@ test("can restore a single selected file to a custom location", async ({ page }, await gotoAndWaitForAppReady(page, "/"); await expect(page).toHaveURL("/volumes"); - await createBackupScenario(page, names, runId); + await createBackupScenario(page, names); await page.getByRole("button", { name: "Backup now" }).click(); await expect(page.getByText("Backup started successfully")).toBeVisible(); @@ -234,7 +234,7 @@ test("can re-tag a snapshot to another backup schedule", async ({ page }, testIn await gotoAndWaitForAppReady(page, "/"); await expect(page).toHaveURL("/volumes"); - await createBackupScenario(page, names, runId); + await createBackupScenario(page, names); await page.getByRole("button", { name: "Backup now" }).click(); await expect(page.getByText("Backup started successfully")).toBeVisible(); @@ -277,7 +277,7 @@ test("can delete a snapshot from the repository snapshots tab", async ({ page }, await gotoAndWaitForAppReady(page, "/"); await expect(page).toHaveURL("/volumes"); - await createBackupScenario(page, names, runId); + await createBackupScenario(page, names); await page.getByRole("button", { name: "Backup now" }).click(); await expect(page.getByText("Backup started successfully")).toBeVisible(); @@ -302,7 +302,7 @@ test("can delete a snapshot from the repository snapshots tab", async ({ page }, test("can download a selected snapshot directory as a tar archive", async ({ page }, testInfo) => { const runId = getRunId(testInfo); const names = getScenarioNames(runId); - const workerTestDataPath = getWorkerTestDataPath(runId); + const workerTestDataPath = getWorkerTestDataPath(); const fileName = `download-${runId}.json`; const filePath = prepareTestFile(runId, fileName); const downloadedPath = path.join(workerTestDataPath, `downloaded-${runId}.tar`); @@ -312,7 +312,7 @@ test("can download a selected snapshot directory as a tar archive", async ({ pag await gotoAndWaitForAppReady(page, "/"); await expect(page).toHaveURL("/volumes"); - await createBackupScenario(page, names, runId); + await createBackupScenario(page, names); await page.getByRole("button", { name: "Backup now" }).click(); await expect(page.getByText("Backup started successfully")).toBeVisible(); @@ -351,7 +351,7 @@ test("deleting a volume cascades and removes its backup schedule", async ({ page await gotoAndWaitForAppReady(page, "/"); await expect(page).toHaveURL("/volumes"); - await createBackupScenario(page, names, runId); + await createBackupScenario(page, names); await gotoAndWaitForAppReady(page, "/backups"); await page.getByText(names.backupName, { exact: true }).first().click(); @@ -378,7 +378,7 @@ test("deleting a volume cascades and removes its backup schedule", async ({ page test("backup respects include globs, exclusion patterns, and exclude-if-present", async ({ page }, testInfo) => { const runId = getRunId(testInfo); const names = getScenarioNames(runId); - const workerTestDataPath = getWorkerTestDataPath(runId); + const workerTestDataPath = getWorkerTestDataPath(); const keptDir = `kept-${runId}`; const secondKeptDir = `second-kept-${runId}`; @@ -433,7 +433,7 @@ test("backup respects include globs, exclusion patterns, and exclude-if-present" await gotoAndWaitForAppReady(page, "/"); await expect(page).toHaveURL("/volumes"); - await createBackupScenario(page, names, runId, { + await createBackupScenario(page, names, { includePatterns: [ `/${keptDir}`, `/${secondKeptDir}`, @@ -492,7 +492,7 @@ test("backup respects include globs, exclusion patterns, and exclude-if-present" test("backup can include a selected folder whose name contains brackets", async ({ page }, testInfo) => { const runId = getRunId(testInfo); const names = getScenarioNames(runId); - const workerTestDataPath = getWorkerTestDataPath(runId); + const workerTestDataPath = getWorkerTestDataPath(); const bracketDir = `movies [${runId}]`; const bracketPath = path.join(workerTestDataPath, bracketDir); const fileName = `inside-${runId}.txt`; @@ -503,7 +503,7 @@ test("backup can include a selected folder whose name contains brackets", async await gotoAndWaitForAppReady(page, "/"); await expect(page).toHaveURL("/volumes"); - await createBackupScenario(page, names, runId, { + await createBackupScenario(page, names, { selectedPaths: [`/${bracketDir}`], }); diff --git a/tsconfig.json b/tsconfig.json index bfd4ee03..6956e49b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,5 @@ { - "include": ["**/*"], + "include": ["app/**/*"], "compilerOptions": { "lib": ["DOM", "DOM.Iterable", "ES2022"], "types": ["node", "vite/client"],