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
17 changes: 14 additions & 3 deletions src/features/database/components/cron-advanced-select.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
Expand All @@ -12,6 +12,7 @@ export const AdvancedCronSelect = ({
value,
defaultValue,
onValueChange,
onValidityChange,
}: {
id: string;
label: string;
Expand All @@ -20,11 +21,21 @@ export const AdvancedCronSelect = ({
value: string;
defaultValue: string;
onValueChange: (value: string) => void;
onValidityChange?: (valid: boolean) => void;
}) => {
const [isAdvanced, setIsAdvanced] = useState(false);
const [customValue, setCustomValue] = useState(defaultValue || value);
const [customValue, setCustomValue] = useState(value || defaultValue);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
setCustomValue(value || defaultValue);
}, [value, defaultValue]);

useEffect(() => {
const valid = !isAdvanced || isValidCronPart(type, customValue);
onValidityChange?.(valid);
}, [isAdvanced, customValue, type, onValidityChange]);

const handleBlur = () => {
if (customValue.trim() === "") {
setIsAdvanced(false);
Expand All @@ -46,7 +57,7 @@ export const AdvancedCronSelect = ({
// @ts-ignore
id={id}
className="col-span-4"
value={defaultValue}
value={value}
onValueChange={(value: string) => {
if (value === "advanced") {
setIsAdvanced(true);
Expand Down
25 changes: 19 additions & 6 deletions src/features/database/components/cron-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {AdvancedCronSelect} from "@/features/database/components/cron-advanced-s
import {updateDatabaseBackupPolicyAction} from "@/features/database/actions/cron.action";
import {useMutation, useQueryClient} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {useState} from "react";
import {useCallback, useState} from "react";
import {toast} from "sonner";
import {Button} from "@/components/ui/button";
import {Separator} from "@/components/ui/separator";
Expand All @@ -14,10 +14,17 @@ export type CronInputProps = {
};

export const CronInput = ({database, onSuccess}: CronInputProps) => {
const [cron, setCron] = useState<string>(database.backupPolicy ?? "* * * * *");
const [cron, setCron] = useState<string>(database.backupPolicy ?? "0 0 * * *");
const [fieldValidity, setFieldValidity] = useState<Record<string, boolean>>({});
const queryClient = useQueryClient();
const router = useRouter();

const setFieldValid = useCallback((id: string) => (valid: boolean) => {
setFieldValidity((prev) => (prev[id] === valid ? prev : {...prev, [id]: valid}));
}, []);

const hasInvalidField = Object.values(fieldValidity).some((valid) => !valid);

const updateBackupPolicy = useMutation({
mutationFn: (value: string) => updateDatabaseBackupPolicyAction({databaseId: database.id, backupPolicy: value}),
onSuccess: () => {
Expand Down Expand Up @@ -48,38 +55,42 @@ export const CronInput = ({database, onSuccess}: CronInputProps) => {
<AdvancedCronSelect
id="minute"
label="Minute"
options={Array.from({length: 60}, (_, i) => String(i).padStart(2, "0"))}
options={Array.from({length: 60}, (_, i) => String(i))}
type="minute"
value={cron.split(" ")[0]}
defaultValue={cron.split(" ")[0]}
onValueChange={(value) => handleChangeCron("minute", value)}
onValidityChange={setFieldValid("minute")}
/>
<AdvancedCronSelect
id="hour"
label="Hour"
options={Array.from({length: 24}, (_, i) => String(i).padStart(2, "0"))}
options={Array.from({length: 24}, (_, i) => String(i))}
type="hour"
value={cron.split(" ")[1]}
defaultValue={cron.split(" ")[1]}
onValueChange={(value) => handleChangeCron("hour", value)}
onValidityChange={setFieldValid("hour")}
/>
<AdvancedCronSelect
id="day-of-month"
label="Day of Month"
options={Array.from({length: 31}, (_, i) => String(i + 1).padStart(2, "0"))}
options={Array.from({length: 31}, (_, i) => String(i + 1))}
type="day-of-month"
value={cron.split(" ")[2]}
defaultValue={cron.split(" ")[2]}
onValueChange={(value) => handleChangeCron("day-of-month", value)}
onValidityChange={setFieldValid("day-of-month")}
/>
<AdvancedCronSelect
id="month"
label="Month"
options={["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"]}
options={Array.from({length: 12}, (_, i) => String(i + 1))}
type="month"
value={cron.split(" ")[3]}
defaultValue={cron.split(" ")[3]}
onValueChange={(value) => handleChangeCron("month", value)}
onValidityChange={setFieldValid("month")}
/>
<AdvancedCronSelect
id="day-of-week"
Expand All @@ -89,6 +100,7 @@ export const CronInput = ({database, onSuccess}: CronInputProps) => {
value={cron.split(" ")[4]}
defaultValue={cron.split(" ")[4]}
onValueChange={(value) => handleChangeCron("day-of-week", value)}
onValidityChange={setFieldValid("day-of-week")}
/>
<Separator/>
<div className="grid gap-2">
Expand All @@ -113,6 +125,7 @@ export const CronInput = ({database, onSuccess}: CronInputProps) => {
onClick={async () => {
await handleUpdateCron(cron);
}}
disabled={hasInvalidField || updateBackupPolicy.isPending}
>
Save cron
</Button>
Expand Down
55 changes: 46 additions & 9 deletions src/utils/cron.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,47 @@
const cronBounds: Record<string, [number, number]> = {
minute: [0, 59],
hour: [0, 23],
"day-of-month": [1, 31],
month: [1, 12],
"day-of-week": [0, 6],
};

const isNumberInRange = (value: string, min: number, max: number): boolean => {
if (!/^\d+$/.test(value)) return false;
const n = Number(value);
return n >= min && n <= max;
};

const isRangeOrNumber = (value: string, min: number, max: number): boolean => {
const range = value.match(/^(\d+)-(\d+)$/);
if (range) {
const start = Number(range[1]);
const end = Number(range[2]);
return isNumberInRange(range[1], min, max) && isNumberInRange(range[2], min, max) && start <= end;
}
return isNumberInRange(value, min, max);
};

export const isValidCronPart = (type: string, value: string): boolean => {
const regexMap: Record<string, RegExp> = {
minute: /^(\*|([0-5]?\d)|(\d+(,\d+)*|(\d+-\d+)|(\*\/\d+)))$/,
hour: /^(\*|([01]?\d|2[0-3])|(\d+(,\d+)*|(\d+-\d+)|(\*\/\d+)))$/,
"day-of-month": /^(\*|([1-9]|[12]\d|3[01])|(\d+(,\d+)*|(\d+-\d+)|(\*\/\d+)))$/,
month: /^(\*|([1-9]|1[0-2])|(\d+(,\d+)*|(\d+-\d+)|(\*\/\d+)))$/,
"day-of-week": /^(\*|[0-6]|(\d+(,\d+)*|(\d+-\d+)|(\*\/\d+)))$/,
};
return regexMap[type]?.test(value) ?? false;
};
const bounds = cronBounds[type];
if (!bounds) return false;
const [min, max] = bounds;

return value.split(",").every((rawPart) => {
const part = rawPart.trim();
if (part === "") return false;

const step = part.match(/^(.+)\/(\d+)$/);
if (step) {
const base = step[1];
const stepValue = Number(step[2]);
if (!/^\d+$/.test(step[2]) || stepValue < 1 || stepValue > max) return false;
if (base === "*") return true;
return isRangeOrNumber(base, min, max);
}

if (part === "*") return true;

return isRangeOrNumber(part, min, max);
});
};
Comment thread
RambokDev marked this conversation as resolved.
Loading