Files
dokploy/apps/dokploy/components/dashboard/application/schedules/handle-schedules.tsx
T
Mauricio Siu 8db1250487 fix(ui): improve select component behavior and styling across various providers
- Added a check to prevent empty values from being processed in the onValueChange handler for Bitbucket, Gitea, GitHub, and GitLab providers.
- Removed unnecessary defaultValue prop from Select components to streamline the code.
- Updated button styles to remove background color for better consistency across the UI.
- Enhanced volume backup selection to display a message when no volumes are found.

This update enhances user experience by ensuring that empty selections are handled gracefully and improves the overall visual consistency of the UI components.
2026-07-06 03:27:52 -06:00

694 lines
18 KiB
TypeScript

import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import {
CheckIcon,
ChevronsUpDown,
DatabaseZap,
Info,
PenBoxIcon,
PlusCircle,
RefreshCw,
} from "lucide-react";
import { useEffect, useState } from "react";
import { type Control, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import type { CacheType } from "../domains/handle-domain";
import { getTimezoneLabel, TIMEZONES } from "./timezones";
export const commonCronExpressions = [
{ label: "Every minute", value: "* * * * *" },
{ label: "Every hour", value: "0 * * * *" },
{ label: "Every day at midnight", value: "0 0 * * *" },
{ label: "Every Sunday at midnight", value: "0 0 * * 0" },
{ label: "Every month on the 1st at midnight", value: "0 0 1 * *" },
{ label: "Every 15 minutes", value: "*/15 * * * *" },
{ label: "Every weekday at midnight", value: "0 0 * * 1-5" },
{ label: "Custom", value: "custom" },
];
const formSchema = z
.object({
name: z.string().min(1, "Name is required"),
description: z.string().optional(),
cronExpression: z.string().min(1, "Cron expression is required"),
shellType: z.enum(["bash", "sh"]).default("bash"),
command: z.string(),
enabled: z.boolean().default(true),
serviceName: z.string(),
scheduleType: z.enum([
"application",
"compose",
"server",
"dokploy-server",
]),
script: z.string(),
timezone: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.scheduleType === "compose" && !data.serviceName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Service name is required",
path: ["serviceName"],
});
}
if (
(data.scheduleType === "dokploy-server" ||
data.scheduleType === "server") &&
!data.script
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Script is required",
path: ["script"],
});
}
if (
(data.scheduleType === "application" ||
data.scheduleType === "compose") &&
!data.command
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Command is required",
path: ["command"],
});
}
});
interface Props {
id?: string;
scheduleId?: string;
scheduleType?: "application" | "compose" | "server" | "dokploy-server";
}
export const ScheduleFormField = ({
name,
formControl,
}: {
name: string;
formControl: Control<any>;
}) => {
const [selectedOption, setSelectedOption] = useState("");
return (
<FormField
control={formControl}
name={name}
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
Schedule
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>Cron expression format: minute hour day month weekday</p>
<p>Example: 0 0 * * * (daily at midnight)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</FormLabel>
<div className="flex flex-col gap-2">
<Select
value={selectedOption}
onValueChange={(value) => {
setSelectedOption(value);
field.onChange(value === "custom" ? "" : value);
}}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a predefined schedule" />
</SelectTrigger>
</FormControl>
<SelectContent>
{commonCronExpressions.map((expr) => (
<SelectItem key={expr.value} value={expr.value}>
{expr.label}
{expr.value !== "custom" && ` (${expr.value})`}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<FormControl>
<Input
placeholder="Custom cron expression (e.g., 0 0 * * *)"
{...field}
onChange={(e) => {
const value = e.target.value;
const commonExpression = commonCronExpressions.find(
(expression) => expression.value === value,
);
if (commonExpression) {
setSelectedOption(commonExpression.value);
} else {
setSelectedOption("custom");
}
field.onChange(e);
}}
/>
</FormControl>
</div>
</div>
<FormDescription>
Choose a predefined schedule or enter a custom cron expression
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);
};
export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [cacheType, setCacheType] = useState<CacheType>("cache");
const utils = api.useUtils();
const form = useForm({
resolver: standardSchemaResolver(formSchema),
defaultValues: {
name: "",
description: "",
cronExpression: "",
shellType: "bash",
command: "",
enabled: true,
serviceName: "",
scheduleType: scheduleType || "application",
script: "",
timezone: undefined,
},
});
const scheduleTypeForm = form.watch("scheduleType");
const { data: schedule } = api.schedule.one.useQuery(
{ scheduleId: scheduleId || "" },
{ enabled: !!scheduleId },
);
const {
data: services,
isFetching: isLoadingServices,
error: errorServices,
refetch: refetchServices,
} = api.compose.loadServices.useQuery(
{
composeId: id || "",
type: cacheType,
},
{
retry: false,
refetchOnWindowFocus: false,
enabled: !!id && scheduleType === "compose",
},
);
useEffect(() => {
if (scheduleId && schedule) {
form.reset({
name: schedule.name,
description: schedule.description || "",
cronExpression: schedule.cronExpression,
shellType: schedule.shellType,
command: schedule.command,
enabled: schedule.enabled,
serviceName: schedule.serviceName || "",
scheduleType: schedule.scheduleType,
script: schedule.script || "",
timezone: schedule.timezone || undefined,
});
}
}, [form, schedule, scheduleId]);
const { mutateAsync, isPending } = scheduleId
? api.schedule.update.useMutation()
: api.schedule.create.useMutation();
const onSubmit = async (values: z.output<typeof formSchema>) => {
if (!id && !scheduleId) return;
await mutateAsync({
...values,
scheduleId: scheduleId || "",
...(scheduleType === "application" && {
applicationId: id || "",
}),
...(scheduleType === "compose" && {
composeId: id || "",
}),
...(scheduleType === "server" && {
serverId: id || "",
}),
...(scheduleType === "dokploy-server" && {
userId: id || "",
}),
})
.then(() => {
toast.success(
`Schedule ${scheduleId ? "updated" : "created"} successfully`,
);
utils.schedule.list.invalidate({
id,
scheduleType,
});
setIsOpen(false);
})
.catch((error) => {
toast.error(
error instanceof Error ? error.message : "An unknown error occurred",
);
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{scheduleId ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button>
<PlusCircle className="w-4 h-4 mr-2" />
Add Schedule
</Button>
)}
</DialogTrigger>
<DialogContent
className={cn(
scheduleTypeForm === "dokploy-server" || scheduleTypeForm === "server"
? "sm:max-w-2xl"
: "sm:max-w-lg",
)}
>
<DialogHeader>
<DialogTitle>{scheduleId ? "Edit" : "Create"} Schedule</DialogTitle>
<DialogDescription>
{scheduleId ? "Manage" : "Create"} a schedule to run a task at a
specific time or interval.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{scheduleTypeForm === "compose" && (
<div className="flex flex-col w-full gap-4">
{errorServices && (
<AlertBlock type="warning" className="wrap-anywhere">
{errorServices?.message}
</AlertBlock>
)}
<FormField
control={form.control}
name="serviceName"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Service Name</FormLabel>
<div className="flex gap-2">
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a service name" />
</SelectTrigger>
</FormControl>
<SelectContent>
{services?.map((service, index) => (
<SelectItem
value={service}
key={`${service}-${index}`}
>
{service}
</SelectItem>
))}
<SelectItem value="none" disabled>
Empty
</SelectItem>
</SelectContent>
</Select>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "fetch") {
refetchServices();
} else {
setCacheType("fetch");
}
}}
>
<RefreshCw className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-40"
>
<p>
Fetch: Will clone the repository and load the
services
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "cache") {
refetchServices();
} else {
setCacheType("cache");
}
}}
>
<DatabaseZap className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-40"
>
<p>
Cache: If you previously deployed this compose,
it will read the services from the last
deployment/fetch from the repository
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
Task Name
</FormLabel>
<FormControl>
<Input placeholder="Daily Database Backup" {...field} />
</FormControl>
<FormDescription>
A descriptive name for your scheduled task
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input
placeholder="Backs up the database every day at midnight"
{...field}
/>
</FormControl>
<FormDescription>
Optional description of what this schedule does
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<ScheduleFormField
name="cronExpression"
formControl={form.control}
/>
<FormField
control={form.control}
name="timezone"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
Timezone
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>
Select a timezone for the schedule. If not
specified, UTC will be used.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
{getTimezoneLabel(field.value)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
<CommandInput
placeholder="Search timezone..."
className="h-9"
/>
<CommandList>
<CommandEmpty>No timezone found.</CommandEmpty>
<ScrollArea className="h-72">
{Object.entries(TIMEZONES).map(
([region, zones]) => (
<CommandGroup key={region} heading={region}>
{zones.map((tz) => (
<CommandItem
key={tz.value}
value={`${region} ${tz.label} ${tz.value}`}
onSelect={() => {
field.onChange(tz.value);
}}
>
{tz.value}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
field.value === tz.value
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
),
)}
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
Optional: Choose a timezone for the schedule execution time
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{(scheduleTypeForm === "application" ||
scheduleTypeForm === "compose") && (
<>
<FormField
control={form.control}
name="shellType"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
Shell Type
</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select shell type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="bash">Bash</SelectItem>
<SelectItem value="sh">Sh</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose the shell to execute your command
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="command"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
Command
</FormLabel>
<FormControl>
<Input placeholder="npm run backup" {...field} />
</FormControl>
<FormDescription>
The command to execute in your container
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{(scheduleTypeForm === "dokploy-server" ||
scheduleTypeForm === "server") && (
<FormField
control={form.control}
name="script"
render={({ field }) => (
<FormItem>
<FormLabel>Script</FormLabel>
<FormControl>
<FormControl>
<CodeEditor
language="shell"
placeholder={`# This is a comment
echo "Hello, world!"
`}
className="h-96 font-mono"
{...field}
/>
</FormControl>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
Enabled
</FormLabel>
</FormItem>
)}
/>
<Button type="submit" isLoading={isPending} className="w-full">
{scheduleId ? "Update" : "Create"} Schedule
</Button>
</form>
</Form>
</DialogContent>
</Dialog>
);
};