From d6e80b71d73d876b251bc06ce53989e7b54fd4e4 Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:36:31 +0200 Subject: [PATCH] refactor: dedicated edit page for backups (#736) --- .../backups/components/schedule-summary.tsx | 14 +-- .../routes/__tests__/edit-backup.test.tsx | 87 +++++++++++++++++++ .../modules/backups/routes/backup-details.tsx | 65 +------------- .../modules/backups/routes/edit-backup.tsx | 75 ++++++++++++++++ app/routeTree.gen.ts | 22 +++++ .../(dashboard)/backups/$backupId/edit.tsx | 41 +++++++++ 6 files changed, 235 insertions(+), 69 deletions(-) create mode 100644 app/client/modules/backups/routes/__tests__/edit-backup.test.tsx create mode 100644 app/client/modules/backups/routes/edit-backup.tsx create mode 100644 app/routes/(dashboard)/backups/$backupId/edit.tsx diff --git a/app/client/modules/backups/components/schedule-summary.tsx b/app/client/modules/backups/components/schedule-summary.tsx index 30c2a12b..80b7d000 100644 --- a/app/client/modules/backups/components/schedule-summary.tsx +++ b/app/client/modules/backups/components/schedule-summary.tsx @@ -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) => { Run cleanup )} - diff --git a/app/client/modules/backups/routes/__tests__/edit-backup.test.tsx b/app/client/modules/backups/routes/__tests__/edit-backup.test.tsx new file mode 100644 index 00000000..5d46dd16 --- /dev/null +++ b/app/client/modules/backups/routes/__tests__/edit-backup.test.tsx @@ -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(); + + 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>((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; + 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(, { withSuspense: true }); + + await userEvent.click(await screen.findByRole("button", { name: "Update schedule" })); + + await expect(submittedBody).resolves.toMatchObject({ + frequency: "daily", + cronExpression: "00 02 * * *", + }); +}); diff --git a/app/client/modules/backups/routes/backup-details.tsx b/app/client/modules/backups/routes/backup-details.tsx index fc60d2e5..0e7358a1 100644 --- a/app/client/modules/backups/routes/backup-details.tsx +++ b/app/client/modules/backups/routes/backup-details.tsx @@ -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( 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 = {}; - 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 ( -
- -
- - -
-
- ); - } - 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} />
diff --git a/app/client/modules/backups/routes/edit-backup.tsx b/app/client/modules/backups/routes/edit-backup.tsx new file mode 100644 index 00000000..8aae42c7 --- /dev/null +++ b/app/client/modules/backups/routes/edit-backup.tsx @@ -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 = {}; + 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 ( +
+ +
+ + +
+
+ ); +} diff --git a/app/routeTree.gen.ts b/app/routeTree.gen.ts index e0a737d3..ca1eb70c 100644 --- a/app/routeTree.gen.ts +++ b/app/routeTree.gen.ts @@ -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, diff --git a/app/routes/(dashboard)/backups/$backupId/edit.tsx b/app/routes/(dashboard)/backups/$backupId/edit.tsx new file mode 100644 index 00000000..f74ff57d --- /dev/null +++ b/app/routes/(dashboard)/backups/$backupId/edit.tsx @@ -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: () =>
Failed to load backup
, + 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 ; +}