feat(frontend): Add cron-based scheduling functionality to new builder with input/credential support (#11312)

This PR introduces scheduling functionality to the new builder, allowing
users to create cron-based schedules for automated graph execution with
configurable inputs and credentials.


https://github.com/user-attachments/assets/20c1359f-a3d6-47bf-a881-4f22c657906c

## What's New

### 🚀 Features

#### Scheduling Infrastructure
- **CronSchedulerDialog Component**: Interactive dialog for creating
scheduled runs with:
  - Schedule name configuration
  - Cron expression builder with visual UI
  - Timezone support (displays user timezone or defaults to UTC)
  - Integration with backend scheduling API

- **ScheduleGraph Component**: New action button in builder actions
toolbar
  - Clock icon button to initiate scheduling workflow
  - Handles conditional flow based on input/credential requirements

#### Enhanced Input Management
- **Unified RunInputDialog**: Refactored to support both manual runs and
scheduled runs
- Dynamic "purpose" prop (`"run"` | `"schedule"`) for contextual
behavior
  - Seamless credential and input collection flow
  - Transitions to cron scheduler when scheduling

#### Builder Actions Improvements
- **New Action Buttons Layout**: Three primary actions in the builder
toolbar:
  1. Agent Outputs (placeholder for future implementation)
  2. Run Graph (play/stop button with gradient styling)
  3. Schedule Graph (clock icon for scheduling)

## Technical Details

### New Components
- `CronSchedulerDialog` - Main scheduling dialog component
- `useCronSchedulerDialog` - Hook managing scheduling logic and API
calls
- `ScheduleGraph` - Schedule button component
- `useScheduleGraph` - Hook for scheduling flow control
- `AgentOutputs` - Placeholder component for future outputs feature

### Modified Components
- `BuilderActions` - Added new action buttons
- `RunGraph` - Enhanced with tooltip support
- `RunInputDialog` - Made multi-purpose for run/schedule
- `useRunInputDialog` - Added scheduling dialog state management

### API Integration
- Uses `usePostV1CreateExecutionSchedule` for schedule creation
- Fetches user timezone with `useGetV1GetUserTimezone`
- Validates and passes graph ID, version, inputs, and credentials

## User Experience

1. **Without Inputs/Credentials**: 
   - Click schedule button → Opens cron scheduler directly

2. **With Inputs/Credentials**:
   - Click schedule button → Opens input dialog
   - Fill required fields → Click "Schedule Run"
   - Configure cron expression → Create schedule

3. **Timezone Awareness**:
   - Shows user's configured timezone
   - Warns if no timezone is set (defaults to UTC)
   - Provides link to timezone settings

## Testing Checklist

- [x] Create a schedule without inputs/credentials
- [x] Create a schedule with required inputs
- [x] Create a schedule with credentials
- [x] Verify timezone display (with and without user timezone)
This commit is contained in:
Abhimanyu Yadav
2025-11-04 17:07:10 +05:30
committed by GitHub
parent 69b6b732a2
commit ad3ea59d90
9 changed files with 459 additions and 86 deletions

View File

@@ -1,11 +1,13 @@
import { AgentOutputs } from "./components/AgentOutputs/AgentOutputs";
import { RunGraph } from "./components/RunGraph/RunGraph";
import { ScheduleGraph } from "./components/ScheduleGraph/ScheduleGraph";
export const BuilderActions = () => {
return (
<div className="absolute bottom-4 left-[50%] z-[100] -translate-x-1/2">
{/* TODO: Add Agent Output */}
<div className="absolute bottom-4 left-[50%] z-[100] flex -translate-x-1/2 items-center gap-2 gap-4">
<AgentOutputs />
<RunGraph />
{/* TODO: Add Schedule run button */}
<ScheduleGraph />
</div>
);
};

View File

@@ -0,0 +1,32 @@
import { Button } from "@/components/atoms/Button/Button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { LogOutIcon } from "lucide-react";
export const AgentOutputs = () => {
return (
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{/* Todo: Implement Agent Outputs */}
<Button
variant="primary"
size="large"
className={"relative min-w-0 border-none text-lg"}
>
<LogOutIcon className="size-6" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Agent Outputs</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
);
};

View File

@@ -0,0 +1,109 @@
import { Button } from "@/components/atoms/Button/Button";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { InfoIcon } from "lucide-react";
import { CronScheduler } from "@/app/(platform)/library/agents/[id]/components/AgentRunsView/components/ScheduleAgentModal/components/CronScheduler/CronScheduler";
import { Text } from "@/components/atoms/Text/Text";
import { useCronSchedulerDialog } from "./useCronSchedulerDialog";
import { Input } from "@/components/atoms/Input/Input";
type CronSchedulerDialogProps = {
open: boolean;
setOpen: (open: boolean) => void;
inputs: Record<string, any>;
credentials: Record<string, any>;
defaultCronExpression?: string;
title?: string;
};
export function CronSchedulerDialog({
open,
setOpen,
defaultCronExpression = "",
title = "Schedule Graph",
inputs,
credentials,
}: CronSchedulerDialogProps) {
const {
setCronExpression,
userTimezone,
timezoneDisplay,
handleCreateSchedule,
scheduleName,
setScheduleName,
isCreatingSchedule,
} = useCronSchedulerDialog({
open,
setOpen,
inputs,
credentials,
defaultCronExpression,
});
return (
<Dialog
controlled={{ isOpen: open, set: setOpen }}
title={title}
styling={{ maxWidth: "600px", minWidth: "600px" }}
>
<Dialog.Content>
<div className="flex flex-col gap-4">
<Input
id="schedule-name"
label="Schedule Name"
placeholder="Enter schedule name"
size="small"
className="max-w-80"
value={scheduleName}
onChange={(e) => setScheduleName(e.target.value)}
/>
<CronScheduler
onCronExpressionChange={setCronExpression}
initialCronExpression={defaultCronExpression}
key={`${open}-${defaultCronExpression}`}
/>
{/* Timezone info */}
{userTimezone === "not-set" ? (
<div className="flex items-center gap-2 rounded-xlarge border border-amber-200 bg-amber-50 p-3">
<InfoIcon className="h-4 w-4 text-amber-600" />
<Text variant="body" className="text-amber-800">
No timezone set. Schedule will run in UTC.
<a href="/profile/settings" className="ml-1 underline">
Set your timezone
</a>
</Text>
</div>
) : (
<div className="flex items-center gap-2 rounded-xlarge bg-muted/50 p-3">
<InfoIcon className="h-4 w-4 text-muted-foreground" />
<Text variant="body">
Schedule will run in your timezone:{" "}
<Text variant="body-medium" as="span">
{timezoneDisplay}
</Text>
</Text>
</div>
)}
</div>
<div className="mt-8 flex justify-end space-x-2">
<Button
variant="outline"
onClick={() => setOpen(false)}
className="h-fit"
>
Cancel
</Button>
<Button
loading={isCreatingSchedule}
disabled={isCreatingSchedule}
onClick={handleCreateSchedule}
className="h-fit"
>
{isCreatingSchedule ? "Creating schedule..." : "Done"}
</Button>
</div>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -0,0 +1,100 @@
import { useGetV1GetUserTimezone } from "@/app/api/__generated__/endpoints/auth/auth";
import { usePostV1CreateExecutionSchedule } from "@/app/api/__generated__/endpoints/schedules/schedules";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { getTimezoneDisplayName } from "@/lib/timezone-utils";
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
import { useEffect, useState } from "react";
export const useCronSchedulerDialog = ({
open,
setOpen,
inputs,
credentials,
defaultCronExpression = "",
}: {
open: boolean;
setOpen: (open: boolean) => void;
inputs: Record<string, any>;
credentials: Record<string, any>;
defaultCronExpression?: string;
}) => {
const { toast } = useToast();
const [cronExpression, setCronExpression] = useState<string>("");
const [scheduleName, setScheduleName] = useState<string>("");
const [{ flowID, flowVersion }] = useQueryStates({
flowID: parseAsString,
flowVersion: parseAsInteger,
flowExecutionID: parseAsString,
});
const { data: userTimezone } = useGetV1GetUserTimezone({
query: {
select: (res) => (res.status === 200 ? res.data.timezone : undefined),
},
});
const timezoneDisplay = getTimezoneDisplayName(userTimezone || "UTC");
const { mutateAsync: createSchedule, isPending: isCreatingSchedule } =
usePostV1CreateExecutionSchedule({
mutation: {
onSuccess: (response) => {
if (response.status === 200) {
setOpen(false);
toast({
title: "Schedule created",
description: "Schedule created successfully",
});
}
},
onError: (error) => {
toast({
variant: "destructive",
title: "Failed to create schedule",
description:
(error.detail as string) ?? "An unexpected error occurred.",
});
},
},
});
useEffect(() => {
if (open) {
setCronExpression(defaultCronExpression);
}
}, [open, defaultCronExpression]);
const handleCreateSchedule = async () => {
if (!cronExpression || cronExpression.trim() === "") {
toast({
variant: "destructive",
title: "Invalid schedule",
description: "Please enter a valid cron expression",
});
return;
}
await createSchedule({
graphId: flowID || "",
data: {
name: scheduleName,
graph_version: flowID ? flowVersion : undefined,
cron: cronExpression,
inputs: inputs,
credentials: credentials,
},
});
setOpen(false);
};
return {
cronExpression,
setCronExpression,
userTimezone,
timezoneDisplay,
handleCreateSchedule,
setScheduleName,
scheduleName,
isCreatingSchedule,
};
};

View File

@@ -6,6 +6,11 @@ import { useShallow } from "zustand/react/shallow";
import { StopIcon } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
import { RunInputDialog } from "../RunInputDialog/RunInputDialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
export const RunGraph = () => {
const {
@@ -21,24 +26,31 @@ export const RunGraph = () => {
return (
<>
<Button
variant="primary"
size="large"
className={cn(
"relative min-w-44 border-none bg-gradient-to-r from-purple-500 to-pink-500 text-lg",
)}
onClick={isGraphRunning ? handleStopGraph : handleRunGraph}
>
{!isGraphRunning && !isSaving ? (
<PlayIcon className="mr-1 size-5" />
) : (
<StopIcon className="mr-1 size-5" />
)}
{isGraphRunning || isSaving ? "Stop Agent" : "Run Agent"}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="primary"
size="large"
className={cn(
"relative min-w-0 border-none bg-gradient-to-r from-purple-500 to-pink-500 text-lg",
)}
onClick={isGraphRunning ? handleStopGraph : handleRunGraph}
>
{!isGraphRunning && !isSaving ? (
<PlayIcon className="size-6" />
) : (
<StopIcon className="size-6" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{isGraphRunning ? "Stop agent" : "Run agent"}
</TooltipContent>
</Tooltip>
<RunInputDialog
isOpen={openRunInputDialog}
setIsOpen={setOpenRunInputDialog}
purpose="run"
/>
</>
);

View File

@@ -3,17 +3,20 @@ import { RJSFSchema } from "@rjsf/utils";
import { uiSchema } from "../../../FlowEditor/nodes/uiSchema";
import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
import { Button } from "@/components/atoms/Button/Button";
import { PlayIcon } from "@phosphor-icons/react";
import { ClockIcon, PlayIcon } from "@phosphor-icons/react";
import { Text } from "@/components/atoms/Text/Text";
import { FormRenderer } from "@/components/renderers/input-renderer/FormRenderer";
import { useRunInputDialog } from "./useRunInputDialog";
import { CronSchedulerDialog } from "../CronSchedulerDialog/CronSchedulerDialog";
export const RunInputDialog = ({
isOpen,
setIsOpen,
purpose,
}: {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
purpose: "run" | "schedule";
}) => {
const hasInputs = useGraphStore((state) => state.hasInputs);
const hasCredentials = useGraphStore((state) => state.hasCredentials);
@@ -26,82 +29,107 @@ export const RunInputDialog = ({
credentialsUiSchema,
handleManualRun,
handleInputChange,
openCronSchedulerDialog,
setOpenCronSchedulerDialog,
inputValues,
credentialValues,
handleCredentialChange,
isExecutingGraph,
} = useRunInputDialog({ setIsOpen });
return (
<Dialog
title="Run Agent"
controlled={{
isOpen,
set: setIsOpen,
}}
styling={{ maxWidth: "700px" }}
>
<Dialog.Content>
<div className="space-y-6 p-1">
{/* Credentials Section */}
{hasCredentials() && (
<div>
<div className="mb-4">
<Text variant="h4" className="text-gray-900">
Credentials
</Text>
<>
<Dialog
title={purpose === "run" ? "Run Agent" : "Schedule Run"}
controlled={{
isOpen,
set: setIsOpen,
}}
styling={{ maxWidth: "600px", minWidth: "600px" }}
>
<Dialog.Content>
<div className="space-y-6 p-1">
{/* Credentials Section */}
{hasCredentials() && (
<div>
<div className="mb-4">
<Text variant="h4" className="text-gray-900">
Credentials
</Text>
</div>
<div className="px-2">
<FormRenderer
jsonSchema={credentialsSchema as RJSFSchema}
handleChange={(v) => handleCredentialChange(v.formData)}
uiSchema={credentialsUiSchema}
initialValues={{}}
formContext={{
showHandles: false,
size: "large",
}}
/>
</div>
</div>
<div className="px-2">
<FormRenderer
jsonSchema={credentialsSchema as RJSFSchema}
handleChange={(v) => handleCredentialChange(v.formData)}
uiSchema={credentialsUiSchema}
initialValues={{}}
formContext={{
showHandles: false,
size: "large",
}}
/>
</div>
</div>
)}
)}
{/* Inputs Section */}
{hasInputs() && (
<div>
<div className="mb-4">
<Text variant="h4" className="text-gray-900">
Inputs
</Text>
{/* Inputs Section */}
{hasInputs() && (
<div>
<div className="mb-4">
<Text variant="h4" className="text-gray-900">
Inputs
</Text>
</div>
<div className="px-2">
<FormRenderer
jsonSchema={inputSchema as RJSFSchema}
handleChange={(v) => handleInputChange(v.formData)}
uiSchema={uiSchema}
initialValues={{}}
formContext={{
showHandles: false,
size: "large",
}}
/>
</div>
</div>
<div className="px-2">
<FormRenderer
jsonSchema={inputSchema as RJSFSchema}
handleChange={(v) => handleInputChange(v.formData)}
uiSchema={uiSchema}
initialValues={{}}
formContext={{
showHandles: false,
size: "large",
}}
/>
</div>
</div>
)}
)}
{/* Action Button */}
<div className="flex justify-end pt-2">
<Button
variant="primary"
size="large"
className="group h-fit min-w-0 gap-2 border-none bg-gradient-to-r from-blue-600 to-purple-600 px-8 transition-all"
onClick={handleManualRun}
loading={isExecutingGraph}
>
<PlayIcon className="size-5 transition-transform group-hover:scale-110" />
<span className="font-semibold">Manual Run</span>
</Button>
{/* Action Button */}
<div className="flex justify-end pt-2">
{purpose === "run" && (
<Button
variant="primary"
size="large"
className="group h-fit min-w-0 gap-2"
onClick={handleManualRun}
loading={isExecutingGraph}
>
<PlayIcon className="size-5 transition-transform group-hover:scale-110" />
<span className="font-semibold">Manual Run</span>
</Button>
)}
{purpose === "schedule" && (
<Button
variant="primary"
size="large"
className="group h-fit min-w-0 gap-2"
onClick={() => setOpenCronSchedulerDialog(true)}
>
<ClockIcon className="size-5 transition-transform group-hover:scale-110" />
<span className="font-semibold">Schedule Run</span>
</Button>
)}
</div>
</div>
</div>
</Dialog.Content>
</Dialog>
</Dialog.Content>
</Dialog>
<CronSchedulerDialog
open={openCronSchedulerDialog}
setOpen={setOpenCronSchedulerDialog}
inputs={inputValues}
credentials={credentialValues}
/>
</>
);
};

View File

@@ -19,6 +19,8 @@ export const useRunInputDialog = ({
const credentialsSchema = useGraphStore(
(state) => state.credentialsInputSchema,
);
const [openCronSchedulerDialog, setOpenCronSchedulerDialog] = useState(false);
const [inputValues, setInputValues] = useState<Record<string, any>>({});
const [credentialValues, setCredentialValues] = useState<
Record<string, CredentialsMetaInput>
@@ -104,5 +106,7 @@ export const useRunInputDialog = ({
handleInputChange,
handleCredentialChange,
handleManualRun,
openCronSchedulerDialog,
setOpenCronSchedulerDialog,
};
};

View File

@@ -0,0 +1,53 @@
import { Button } from "@/components/atoms/Button/Button";
import { ClockIcon } from "@phosphor-icons/react";
import { RunInputDialog } from "../RunInputDialog/RunInputDialog";
import { useScheduleGraph } from "./useScheduleGraph";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { CronSchedulerDialog } from "../CronSchedulerDialog/CronSchedulerDialog";
export const ScheduleGraph = () => {
const {
openScheduleInputDialog,
setOpenScheduleInputDialog,
handleScheduleGraph,
openCronSchedulerDialog,
setOpenCronSchedulerDialog,
} = useScheduleGraph();
return (
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="primary"
size="large"
className={"relative min-w-0 border-none text-lg"}
onClick={handleScheduleGraph}
>
<ClockIcon className="size-6" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Schedule Graph</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<RunInputDialog
isOpen={openScheduleInputDialog}
setIsOpen={setOpenScheduleInputDialog}
purpose="schedule"
/>
<CronSchedulerDialog
open={openCronSchedulerDialog}
setOpen={setOpenCronSchedulerDialog}
inputs={{}}
credentials={{}}
/>
</>
);
};

View File

@@ -0,0 +1,33 @@
import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
import { useShallow } from "zustand/react/shallow";
import { useNewSaveControl } from "../../../NewControlPanel/NewSaveControl/useNewSaveControl";
import { useState } from "react";
export const useScheduleGraph = () => {
const { onSubmit: onSaveGraph } = useNewSaveControl({
showToast: false,
});
const hasInputs = useGraphStore(useShallow((state) => state.hasInputs));
const hasCredentials = useGraphStore(
useShallow((state) => state.hasCredentials),
);
const [openScheduleInputDialog, setOpenScheduleInputDialog] = useState(false);
const [openCronSchedulerDialog, setOpenCronSchedulerDialog] = useState(false);
const handleScheduleGraph = async () => {
await onSaveGraph(undefined);
if (hasInputs() || hasCredentials()) {
setOpenScheduleInputDialog(true);
} else {
setOpenCronSchedulerDialog(true);
}
};
return {
openScheduleInputDialog,
setOpenScheduleInputDialog,
handleScheduleGraph,
openCronSchedulerDialog,
setOpenCronSchedulerDialog,
};
};