zerobyte/app/client/modules/backups/components/create-schedule-form/index.tsx
Nico a039bb478e
fix: separate raw include paths and patterns (#683)
Separate include patters and included path cleanly to avoid path with special characters to be expanded. Closes https://github.com/nicotsx/zerobyte/discussions/680

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

* **New Features**
  * Added ability to select specific directories and paths for inclusion in backup schedules, separate from pattern-based rules.

* **Bug Fixes & Improvements**
  * Automatically migrates existing backup configurations to work with the new path selection system.
  * Enhanced backup restoration to properly handle both selected paths and pattern-based inclusions.

* **Chores**
  * Updated database schema to support path selections in backup schedules.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-03-20 19:27:54 +01:00

173 lines
5.8 KiB
TypeScript

import { zodResolver } from "@hookform/resolvers/zod";
import { useCallback, useState } from "react";
import { useForm } from "react-hook-form";
import { useScrollToFormError } from "~/client/hooks/use-scroll-to-form-error";
import { Form } from "~/client/components/ui/form";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "~/client/components/ui/collapsible";
import type { BackupSchedule, Volume } from "~/client/lib/types";
import { AdvancedSection } from "./advanced-section";
import { BasicInfoSection } from "./basic-info-section";
import { ExcludeSection } from "./exclude-section";
import { FrequencySection } from "./frequency-section";
import { PathsSection } from "./paths-section";
import { RetentionSection } from "./retention-section";
import { SummarySection } from "./summary-section";
import { internalFormSchema, type BackupScheduleFormValues, type InternalFormValues } from "./types";
import { backupScheduleToFormValues, parseMultilineEntries } from "./utils";
export type { BackupScheduleFormValues };
type Props = {
volume: Volume;
initialValues?: BackupSchedule;
onSubmit: (data: BackupScheduleFormValues) => void;
loading?: boolean;
summaryContent?: React.ReactNode;
formId: string;
};
export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }: Props) => {
const form = useForm<InternalFormValues>({
resolver: zodResolver(internalFormSchema),
defaultValues: backupScheduleToFormValues(initialValues),
});
const scrollToFirstError = useScrollToFormError();
const handleSubmit = useCallback(
(data: InternalFormValues) => {
const {
excludePatternsText,
excludeIfPresentText,
includePatterns,
customResticParamsText,
includePaths,
cronExpression,
...rest
} = data;
const excludePatterns = parseMultilineEntries(excludePatternsText);
const excludeIfPresent = parseMultilineEntries(excludeIfPresentText);
const parsedIncludePatterns = parseMultilineEntries(includePatterns);
const customResticParams = parseMultilineEntries(customResticParamsText);
onSubmit({
...rest,
cronExpression,
includePaths: includePaths?.length ? includePaths : [],
includePatterns: parsedIncludePatterns.length > 0 ? parsedIncludePatterns : [],
excludePatterns,
excludeIfPresent,
customResticParams,
});
},
[onSubmit],
);
const frequency = form.watch("frequency");
const formValues = form.watch();
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set(initialValues?.includePaths || []));
const [showAllSelectedPaths, setShowAllSelectedPaths] = useState(false);
const handleSelectionChange = useCallback(
(paths: Set<string>) => {
setSelectedPaths(paths);
form.setValue("includePaths", Array.from(paths));
},
[form],
);
const handleRemovePath = useCallback(
(pathToRemove: string) => {
const newPaths = new Set(selectedPaths);
newPaths.delete(pathToRemove);
setSelectedPaths(newPaths);
form.setValue("includePaths", Array.from(newPaths));
},
[selectedPaths, form],
);
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit, scrollToFirstError)}
className="grid gap-4 xl:grid-cols-[minmax(0,2.3fr)_minmax(320px,1fr)]"
id={formId}
>
<div className="grid gap-4 min-w-0">
<Card className="min-w-0 @container">
<CardHeader>
<CardTitle>Backup automation</CardTitle>
<CardDescription className="mt-1">
Schedule automated backups of <strong>{volume.name}</strong> to a secure repository.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-6 @medium:grid-cols-2">
<BasicInfoSection form={form} volume={volume} />
<FrequencySection form={form} frequency={frequency} />
</CardContent>
</Card>
<Card className="min-w-0">
<CardHeader>
<CardTitle>Backup paths</CardTitle>
<CardDescription>
Select which folders or files to include in the backup. If no paths are selected, the entire volume will
be backed up.
</CardDescription>
</CardHeader>
<CardContent>
<PathsSection
form={form}
volume={volume}
selectedPaths={selectedPaths}
onSelectionChange={handleSelectionChange}
onRemovePath={handleRemovePath}
showAllSelectedPaths={showAllSelectedPaths}
onToggleShowAllPaths={() => setShowAllSelectedPaths(!showAllSelectedPaths)}
/>
</CardContent>
</Card>
<Card className="min-w-0">
<CardHeader>
<CardTitle>Exclude patterns</CardTitle>
<CardDescription>
Optionally specify patterns to exclude from backups. Enter one pattern per line (e.g., *.tmp,
node_modules/**, .cache/).
</CardDescription>
</CardHeader>
<CardContent>
<ExcludeSection form={form} />
</CardContent>
</Card>
<Card className="min-w-0 @container">
<CardHeader>
<CardTitle>Retention policy</CardTitle>
<CardDescription>Define how many snapshots to keep. Leave empty to keep all.</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 @medium:grid-cols-2">
<RetentionSection form={form} />
</CardContent>
</Card>
<Card className="min-w-0 @container">
<CardContent>
<Collapsible>
<CollapsibleTrigger>Advanced</CollapsibleTrigger>
<CollapsibleContent className="pb-4 space-y-4">
<AdvancedSection form={form} />
</CollapsibleContent>
</Collapsible>
</CardContent>
</Card>
</div>
<div className="xl:sticky xl:top-6 xl:self-start">
<SummarySection volume={volume} frequency={frequency} formValues={formValues} />
</div>
</form>
</Form>
);
};