refactor: dedicated edit page for backups (#736)

This commit is contained in:
Nico
2026-04-02 21:36:31 +02:00
committed by GitHub
parent a6d402b915
commit d6e80b71d7
6 changed files with 235 additions and 69 deletions

View File

@@ -16,7 +16,7 @@ import type { BackupSchedule } from "~/client/lib/types";
import { BackupProgressCard } from "./backup-progress-card";
import { getBackupProgressOptions, runForgetMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { Link, useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
import { handleRepositoryError } from "~/client/lib/errors";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/client/components/ui/collapsible";
@@ -30,13 +30,12 @@ type Props = {
handleRunBackupNow: () => void;
handleStopBackup: () => void;
handleDeleteSchedule: () => void;
setIsEditMode: (isEdit: boolean) => void;
};
export const ScheduleSummary = (props: Props) => {
const { schedule, handleToggleEnabled, handleRunBackupNow, handleStopBackup, handleDeleteSchedule, setIsEditMode } =
props;
const { schedule, handleToggleEnabled, handleRunBackupNow, handleStopBackup, handleDeleteSchedule } = props;
const { formatShortDateTime } = useTimeFormat();
const navigate = useNavigate();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showForgetConfirm, setShowForgetConfirm] = useState(false);
const [showStopConfirm, setShowStopConfirm] = useState(false);
@@ -162,7 +161,12 @@ export const ScheduleSummary = (props: Props) => {
<span>Run cleanup</span>
</Button>
)}
<Button variant="outline" size="sm" onClick={() => setIsEditMode(true)} className="w-full @medium:w-auto">
<Button
variant="outline"
size="sm"
onClick={() => navigate({ to: "/backups/$backupId/edit", params: { backupId: schedule.shortId } })}
className="w-full @medium:w-auto"
>
<Pencil className="h-4 w-4 mr-2" />
<span>Edit schedule</span>
</Button>

View File

@@ -0,0 +1,87 @@
import { afterEach, expect, test, vi } from "vitest";
import { HttpResponse, http, server } from "~/test/msw/server";
import { cleanup, render, screen, userEvent } from "~/test/test-utils";
const navigateMock = vi.fn(async () => {});
vi.mock("@tanstack/react-router", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-router")>();
return {
...actual,
useNavigate: (() => navigateMock) as typeof actual.useNavigate,
};
});
import { EditBackupPage } from "../edit-backup";
afterEach(() => {
navigateMock.mockClear();
cleanup();
});
test("submits the computed cron expression when saving a daily schedule", async () => {
const submittedBody = new Promise<Record<string, unknown>>((resolve) => {
server.use(
http.get("/api/v1/backups/:shortId", () => {
return HttpResponse.json({
shortId: "backup-1",
name: "Backup 1",
repository: { shortId: "repo-1", name: "Repo 1", type: "local" },
volume: {
id: "volume-1",
shortId: "vol-1",
name: "Volume 1",
config: { backend: "directory", path: "/mnt" },
},
cronExpression: "0 2 * * *",
retentionPolicy: null,
includePaths: ["/project"],
includePatterns: [],
excludePatterns: [],
excludeIfPresent: [],
oneFileSystem: false,
customResticParams: [],
});
}),
http.get("/api/v1/repositories", () => {
return HttpResponse.json([{ shortId: "repo-1", name: "Repo 1", type: "local" }]);
}),
http.get("/api/v1/volumes/:shortId/files", () => {
return HttpResponse.json({
files: [{ name: "project", path: "/project", type: "directory" }],
path: "/",
offset: 0,
limit: 100,
total: 1,
hasMore: false,
});
}),
http.patch("/api/v1/backups/:shortId", async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
resolve(body);
return HttpResponse.json({
shortId: "backup-1",
volume: {
id: "volume-1",
shortId: "vol-1",
name: "Volume 1",
config: { backend: "directory", path: "/mnt" },
},
repository: { shortId: "repo-1", name: "Repo 1", type: "local" },
...body,
});
}),
);
});
render(<EditBackupPage backupId="backup-1" />, { withSuspense: true });
await userEvent.click(await screen.findByRole("button", { name: "Update schedule" }));
await expect(submittedBody).resolves.toMatchObject({
frequency: "daily",
cronExpression: "00 02 * * *",
});
});

View File

@@ -1,9 +1,7 @@
import { useId, useState } from "react";
import { useState } from "react";
import { useQuery, useMutation, useSuspenseQuery, useQueryClient } from "@tanstack/react-query";
import { useSearch } from "@tanstack/react-router";
import { toast } from "sonner";
import { Save, X } from "lucide-react";
import { Button } from "~/client/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
@@ -25,8 +23,6 @@ import {
deleteSnapshotMutation,
} from "~/client/api-client/@tanstack/react-query.gen";
import { parseError, handleRepositoryError } from "~/client/lib/errors";
import { getCronExpression } from "~/utils/utils";
import { CreateScheduleForm, type BackupScheduleFormValues } from "../components/create-schedule-form";
import { ScheduleSummary } from "../components/schedule-summary";
import { SnapshotFileBrowser } from "../components/snapshot-file-browser";
import { SnapshotTimeline } from "../components/snapshot-timeline";
@@ -67,8 +63,6 @@ export function ScheduleDetailsPage(props: Props) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const searchParams = useSearch({ from: "/(dashboard)/backups/$backupId/" });
const [isEditMode, setIsEditMode] = useState(false);
const formId = useId();
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string | undefined>(
initialSnapshotId ?? loaderData.snapshots?.at(-1)?.short_id,
);
@@ -93,7 +87,6 @@ export function ScheduleDetailsPage(props: Props) {
...updateBackupScheduleMutation(),
onSuccess: () => {
toast.success("Backup schedule saved successfully");
setIsEditMode(false);
},
onError: (error) => {
toast.error("Failed to save backup schedule", {
@@ -159,43 +152,6 @@ export function ScheduleDetailsPage(props: Props) {
},
});
const handleSubmit = (formValues: BackupScheduleFormValues) => {
if (!schedule) return;
const cronExpression = getCronExpression(
formValues.frequency,
formValues.dailyTime,
formValues.weeklyDay,
formValues.monthlyDays,
formValues.cronExpression,
);
const retentionPolicy: Record<string, number> = {};
if (formValues.keepLast) retentionPolicy.keepLast = formValues.keepLast;
if (formValues.keepHourly) retentionPolicy.keepHourly = formValues.keepHourly;
if (formValues.keepDaily) retentionPolicy.keepDaily = formValues.keepDaily;
if (formValues.keepWeekly) retentionPolicy.keepWeekly = formValues.keepWeekly;
if (formValues.keepMonthly) retentionPolicy.keepMonthly = formValues.keepMonthly;
if (formValues.keepYearly) retentionPolicy.keepYearly = formValues.keepYearly;
updateSchedule.mutate({
path: { shortId: schedule.shortId },
body: {
name: formValues.name,
repositoryId: formValues.repositoryId,
enabled: formValues.frequency === "manual" ? false : schedule.enabled,
cronExpression,
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
includePaths: formValues.includePaths,
includePatterns: formValues.includePatterns,
excludePatterns: formValues.excludePatterns,
excludeIfPresent: formValues.excludeIfPresent,
oneFileSystem: formValues.oneFileSystem,
customResticParams: formValues.customResticParams,
},
});
};
const handleToggleEnabled = (enabled: boolean) => {
updateSchedule.mutate({
path: { shortId: schedule.shortId },
@@ -244,24 +200,6 @@ export function ScheduleDetailsPage(props: Props) {
});
};
if (isEditMode) {
return (
<div>
<CreateScheduleForm volume={schedule.volume} initialValues={schedule} onSubmit={handleSubmit} formId={formId} />
<div className="flex justify-end mt-4 gap-2">
<Button type="submit" className="ml-auto" variant="primary" form={formId} loading={updateSchedule.isPending}>
<Save className="h-4 w-4 mr-2" />
Update schedule
</Button>
<Button variant="outline" onClick={() => setIsEditMode(false)}>
<X className="h-4 w-4 mr-2" />
Cancel
</Button>
</div>
</div>
);
}
const selectedSnapshot = snapshots?.find((s) => s.short_id === selectedSnapshotId);
return (
@@ -271,7 +209,6 @@ export function ScheduleDetailsPage(props: Props) {
handleRunBackupNow={() => runBackupNow.mutate({ path: { shortId: schedule.shortId } })}
handleStopBackup={() => stopBackup.mutate({ path: { shortId: schedule.shortId } })}
handleDeleteSchedule={() => deleteSchedule.mutate({ path: { shortId: schedule.shortId } })}
setIsEditMode={setIsEditMode}
schedule={schedule}
/>
<div className={cn({ hidden: !loaderData.notifs?.length })}>

View File

@@ -0,0 +1,75 @@
import { useId } from "react";
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { Save, X } from "lucide-react";
import { toast } from "sonner";
import { getBackupScheduleOptions, updateBackupScheduleMutation } from "~/client/api-client/@tanstack/react-query.gen";
import { Button } from "~/client/components/ui/button";
import { parseError } from "~/client/lib/errors";
import { useNavigate } from "@tanstack/react-router";
import { getCronExpression } from "~/utils/utils";
import { CreateScheduleForm, type BackupScheduleFormValues } from "../components/create-schedule-form";
export function EditBackupPage({ backupId }: { backupId: string }) {
const navigate = useNavigate();
const formId = useId();
const { data: schedule } = useSuspenseQuery({
...getBackupScheduleOptions({ path: { shortId: backupId } }),
});
const updateSchedule = useMutation({
...updateBackupScheduleMutation(),
onSuccess: () => {
toast.success("Backup schedule saved successfully");
void navigate({ to: `/backups/${schedule.shortId}` });
},
onError: (error) => {
toast.error("Failed to save backup schedule", {
description: parseError(error)?.message,
});
},
});
const handleSubmit = (formValues: BackupScheduleFormValues) => {
const cronExpression = getCronExpression(
formValues.frequency,
formValues.dailyTime,
formValues.weeklyDay,
formValues.monthlyDays,
formValues.cronExpression,
);
const retentionPolicy: Record<string, number> = {};
if (formValues.keepLast) retentionPolicy.keepLast = formValues.keepLast;
if (formValues.keepHourly) retentionPolicy.keepHourly = formValues.keepHourly;
if (formValues.keepDaily) retentionPolicy.keepDaily = formValues.keepDaily;
if (formValues.keepWeekly) retentionPolicy.keepWeekly = formValues.keepWeekly;
if (formValues.keepMonthly) retentionPolicy.keepMonthly = formValues.keepMonthly;
if (formValues.keepYearly) retentionPolicy.keepYearly = formValues.keepYearly;
updateSchedule.mutate({
path: { shortId: schedule.shortId },
body: {
...formValues,
cronExpression,
retentionPolicy: Object.keys(retentionPolicy).length > 0 ? retentionPolicy : undefined,
},
});
};
return (
<div>
<CreateScheduleForm volume={schedule.volume} initialValues={schedule} onSubmit={handleSubmit} formId={formId} />
<div className="flex justify-end mt-4 gap-2">
<Button type="submit" className="ml-auto" variant="primary" form={formId} loading={updateSchedule.isPending}>
<Save className="h-4 w-4 mr-2" />
Update schedule
</Button>
<Button variant="outline" onClick={() => navigate({ to: `/backups/${schedule.shortId}` })}>
<X className="h-4 w-4 mr-2" />
Cancel
</Button>
</div>
</div>
);
}

View File

@@ -33,6 +33,7 @@ import { Route as dashboardRepositoriesRepositoryIdIndexRouteImport } from './ro
import { Route as dashboardBackupsBackupIdIndexRouteImport } from './routes/(dashboard)/backups/$backupId/index'
import { Route as dashboardSettingsSsoNewRouteImport } from './routes/(dashboard)/settings/sso/new'
import { Route as dashboardRepositoriesRepositoryIdEditRouteImport } from './routes/(dashboard)/repositories/$repositoryId/edit'
import { Route as dashboardBackupsBackupIdEditRouteImport } from './routes/(dashboard)/backups/$backupId/edit'
import { Route as dashboardRepositoriesRepositoryIdSnapshotIdIndexRouteImport } from './routes/(dashboard)/repositories/$repositoryId/$snapshotId/index'
import { Route as dashboardRepositoriesRepositoryIdSnapshotIdRestoreRouteImport } from './routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore'
import { Route as dashboardBackupsBackupIdSnapshotIdRestoreRouteImport } from './routes/(dashboard)/backups/$backupId/$snapshotId.restore'
@@ -164,6 +165,12 @@ const dashboardRepositoriesRepositoryIdEditRoute =
path: '/repositories/$repositoryId/edit',
getParentRoute: () => dashboardRouteRoute,
} as any)
const dashboardBackupsBackupIdEditRoute =
dashboardBackupsBackupIdEditRouteImport.update({
id: '/backups/$backupId/edit',
path: '/backups/$backupId/edit',
getParentRoute: () => dashboardRouteRoute,
} as any)
const dashboardRepositoriesRepositoryIdSnapshotIdIndexRoute =
dashboardRepositoriesRepositoryIdSnapshotIdIndexRouteImport.update({
id: '/repositories/$repositoryId/$snapshotId/',
@@ -202,6 +209,7 @@ export interface FileRoutesByFullPath {
'/repositories/': typeof dashboardRepositoriesIndexRoute
'/settings/': typeof dashboardSettingsIndexRoute
'/volumes/': typeof dashboardVolumesIndexRoute
'/backups/$backupId/edit': typeof dashboardBackupsBackupIdEditRoute
'/repositories/$repositoryId/edit': typeof dashboardRepositoriesRepositoryIdEditRoute
'/settings/sso/new': typeof dashboardSettingsSsoNewRoute
'/backups/$backupId/': typeof dashboardBackupsBackupIdIndexRoute
@@ -229,6 +237,7 @@ export interface FileRoutesByTo {
'/repositories': typeof dashboardRepositoriesIndexRoute
'/settings': typeof dashboardSettingsIndexRoute
'/volumes': typeof dashboardVolumesIndexRoute
'/backups/$backupId/edit': typeof dashboardBackupsBackupIdEditRoute
'/repositories/$repositoryId/edit': typeof dashboardRepositoriesRepositoryIdEditRoute
'/settings/sso/new': typeof dashboardSettingsSsoNewRoute
'/backups/$backupId': typeof dashboardBackupsBackupIdIndexRoute
@@ -259,6 +268,7 @@ export interface FileRoutesById {
'/(dashboard)/repositories/': typeof dashboardRepositoriesIndexRoute
'/(dashboard)/settings/': typeof dashboardSettingsIndexRoute
'/(dashboard)/volumes/': typeof dashboardVolumesIndexRoute
'/(dashboard)/backups/$backupId/edit': typeof dashboardBackupsBackupIdEditRoute
'/(dashboard)/repositories/$repositoryId/edit': typeof dashboardRepositoriesRepositoryIdEditRoute
'/(dashboard)/settings/sso/new': typeof dashboardSettingsSsoNewRoute
'/(dashboard)/backups/$backupId/': typeof dashboardBackupsBackupIdIndexRoute
@@ -288,6 +298,7 @@ export interface FileRouteTypes {
| '/repositories/'
| '/settings/'
| '/volumes/'
| '/backups/$backupId/edit'
| '/repositories/$repositoryId/edit'
| '/settings/sso/new'
| '/backups/$backupId/'
@@ -315,6 +326,7 @@ export interface FileRouteTypes {
| '/repositories'
| '/settings'
| '/volumes'
| '/backups/$backupId/edit'
| '/repositories/$repositoryId/edit'
| '/settings/sso/new'
| '/backups/$backupId'
@@ -344,6 +356,7 @@ export interface FileRouteTypes {
| '/(dashboard)/repositories/'
| '/(dashboard)/settings/'
| '/(dashboard)/volumes/'
| '/(dashboard)/backups/$backupId/edit'
| '/(dashboard)/repositories/$repositoryId/edit'
| '/(dashboard)/settings/sso/new'
| '/(dashboard)/backups/$backupId/'
@@ -530,6 +543,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof dashboardRepositoriesRepositoryIdEditRouteImport
parentRoute: typeof dashboardRouteRoute
}
'/(dashboard)/backups/$backupId/edit': {
id: '/(dashboard)/backups/$backupId/edit'
path: '/backups/$backupId/edit'
fullPath: '/backups/$backupId/edit'
preLoaderRoute: typeof dashboardBackupsBackupIdEditRouteImport
parentRoute: typeof dashboardRouteRoute
}
'/(dashboard)/repositories/$repositoryId/$snapshotId/': {
id: '/(dashboard)/repositories/$repositoryId/$snapshotId/'
path: '/repositories/$repositoryId/$snapshotId'
@@ -595,6 +615,7 @@ interface dashboardRouteRouteChildren {
dashboardRepositoriesIndexRoute: typeof dashboardRepositoriesIndexRoute
dashboardSettingsIndexRoute: typeof dashboardSettingsIndexRoute
dashboardVolumesIndexRoute: typeof dashboardVolumesIndexRoute
dashboardBackupsBackupIdEditRoute: typeof dashboardBackupsBackupIdEditRoute
dashboardRepositoriesRepositoryIdEditRoute: typeof dashboardRepositoriesRepositoryIdEditRoute
dashboardSettingsSsoNewRoute: typeof dashboardSettingsSsoNewRoute
dashboardBackupsBackupIdIndexRoute: typeof dashboardBackupsBackupIdIndexRoute
@@ -618,6 +639,7 @@ const dashboardRouteRouteChildren: dashboardRouteRouteChildren = {
dashboardRepositoriesIndexRoute: dashboardRepositoriesIndexRoute,
dashboardSettingsIndexRoute: dashboardSettingsIndexRoute,
dashboardVolumesIndexRoute: dashboardVolumesIndexRoute,
dashboardBackupsBackupIdEditRoute: dashboardBackupsBackupIdEditRoute,
dashboardRepositoriesRepositoryIdEditRoute:
dashboardRepositoriesRepositoryIdEditRoute,
dashboardSettingsSsoNewRoute: dashboardSettingsSsoNewRoute,

View File

@@ -0,0 +1,41 @@
import { createFileRoute } from "@tanstack/react-router";
import { getBackupScheduleOptions, listRepositoriesOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { EditBackupPage } from "~/client/modules/backups/routes/edit-backup";
export const Route = createFileRoute("/(dashboard)/backups/$backupId/edit")({
component: RouteComponent,
errorComponent: () => <div>Failed to load backup</div>,
loader: async ({ params, context }) => {
const schedule = await context.queryClient.ensureQueryData({
...getBackupScheduleOptions({ path: { shortId: params.backupId } }),
});
await context.queryClient.ensureQueryData({
...listRepositoriesOptions(),
});
return schedule;
},
staticData: {
breadcrumb: (match) => [
{ label: "Backup Jobs", href: "/backups" },
{ label: match.loaderData?.name || "Job Details", href: `/backups/${match.params.backupId}` },
{ label: "Edit" },
],
},
head: ({ loaderData }) => ({
meta: [
{ title: `Zerobyte - Edit ${loaderData?.name || "Backup Job"}` },
{
name: "description",
content: "Edit backup job configuration and schedule.",
},
],
}),
});
function RouteComponent() {
const { backupId } = Route.useParams();
return <EditBackupPage backupId={backupId} />;
}