mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
feat(frontend): new run agent modal (1/2) (#10696)
## Changes 🏗️ <img width="600" height="624" alt="Screenshot 2025-08-25 at 23 22 24" src="https://github.com/user-attachments/assets/a66b0a02-cb7a-47f3-8759-e955fb76f865" /> <img width="600" height="748" alt="Screenshot 2025-08-25 at 23 22 40" src="https://github.com/user-attachments/assets/0357bd0b-9875-41a4-8752-d7dbc7a82ff6" /> The new **Agent Run Modal**, to be used when running agents. This is PR 1/2 ( _as I learned there is so much into running agents_ 🔮 ). The first part sets up "the easy things": - the run view - the schedule run view - the switch between them - the agent details On the next PR, I will add support for the current agent run inputs ( [and all their types...](https://github.com/Significant-Gravitas/AutoGPT/blob/dev/autogpt_platform/frontend/src/components/type-based-input.tsx) 😆 ) + webhook triggers... ## Checklist 📋 ### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] with the flag ON ( is now OFF in dev but ON local ) - [x] clicking `New Run` on the new library page shows the new modal - [x] Details are shown on the modal header - [x] Agent details are shown - [x] You can schedule runs ### For configuration changes: None --------- Co-authored-by: Abhimanyu Yadav <122007096+Abhi1992002@users.noreply.github.com>
This commit is contained in:
@@ -5,17 +5,16 @@ import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { useAgentRunsView } from "./useAgentRunsView";
|
||||
import { AgentRunsLoading } from "./components/AgentRunsLoading";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Plus } from "@phosphor-icons/react";
|
||||
import { RunAgentModal } from "@/app/(platform)/library/agents/[id]/components/AgentRunsView/components/RunAgentModal/RunAgentModal";
|
||||
import { PlusIcon } from "@phosphor-icons/react/dist/ssr";
|
||||
|
||||
export function AgentRunsView() {
|
||||
const { response, ready, error, agentId } = useAgentRunsView();
|
||||
|
||||
// Handle loading state
|
||||
if (!ready) {
|
||||
return <AgentRunsLoading />;
|
||||
}
|
||||
|
||||
// Handle errors - check for query error first, then response errors
|
||||
if (error || (response && response.status !== 200)) {
|
||||
return (
|
||||
<ErrorCard
|
||||
@@ -53,9 +52,15 @@ export function AgentRunsView() {
|
||||
<div className="grid h-screen grid-cols-[25%_85%] gap-4 pt-8">
|
||||
{/* Left Sidebar - 30% */}
|
||||
<div className="bg-gray-50 p-4">
|
||||
<Button variant="primary" size="large" className="w-full">
|
||||
<Plus size={20} /> New Run
|
||||
</Button>
|
||||
<RunAgentModal
|
||||
triggerSlot={
|
||||
<Button variant="primary" size="large" className="w-full">
|
||||
<PlusIcon size={20} /> New Run
|
||||
</Button>
|
||||
}
|
||||
agent={agent}
|
||||
agentId={agent.id.toString()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Content - 70% */}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { useState } from "react";
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { useAgentRunModal } from "./useAgentRunModal";
|
||||
import { ModalHeader } from "./components/ModalHeader/ModalHeader";
|
||||
import { AgentCostSection } from "./components/AgentCostSection/AgentCostSection";
|
||||
import { AgentSectionHeader } from "./components/AgentSectionHeader/AgentSectionHeader";
|
||||
import { DefaultRunView } from "./components/DefaultRunView/DefaultRunView";
|
||||
import { ScheduleView } from "./components/ScheduleView/ScheduleView";
|
||||
import { AgentDetails } from "./components/AgentDetails/AgentDetails";
|
||||
import { RunActions } from "./components/RunActions/RunActions";
|
||||
import { ScheduleActions } from "./components/ScheduleActions/ScheduleActions";
|
||||
|
||||
interface Props {
|
||||
triggerSlot: React.ReactNode;
|
||||
agent: LibraryAgent;
|
||||
agentId: string;
|
||||
agentVersion?: number;
|
||||
}
|
||||
|
||||
export function RunAgentModal({ triggerSlot, agent }: Props) {
|
||||
const {
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
showScheduleView,
|
||||
defaultRunType,
|
||||
inputValues,
|
||||
setInputValues,
|
||||
scheduleName,
|
||||
cronExpression,
|
||||
allRequiredInputsAreSet,
|
||||
// agentInputFields, // Available if needed for future use
|
||||
hasInputFields,
|
||||
isExecuting,
|
||||
isCreatingSchedule,
|
||||
isSettingUpTrigger,
|
||||
handleRun,
|
||||
handleSchedule,
|
||||
handleShowSchedule,
|
||||
handleGoBack,
|
||||
handleSetScheduleName,
|
||||
handleSetCronExpression,
|
||||
} = useAgentRunModal(agent);
|
||||
|
||||
const [isScheduleFormValid, setIsScheduleFormValid] = useState(true);
|
||||
|
||||
function handleInputChange(key: string, value: string) {
|
||||
setInputValues((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
}
|
||||
|
||||
function handleSetOpen(open: boolean) {
|
||||
setIsOpen(open);
|
||||
// Always reset to Run view when opening/closing
|
||||
if (open || !open) handleGoBack();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
controlled={{ isOpen, set: handleSetOpen }}
|
||||
styling={{ maxWidth: "600px", maxHeight: "90vh" }}
|
||||
>
|
||||
<Dialog.Trigger>{triggerSlot}</Dialog.Trigger>
|
||||
<Dialog.Content>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0">
|
||||
<ModalHeader agent={agent} />
|
||||
<AgentCostSection flowId={agent.graph_id} />
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden pr-1"
|
||||
style={{ scrollbarGutter: "stable" }}
|
||||
>
|
||||
{/* Setup Section */}
|
||||
<div className="mt-10">
|
||||
{showScheduleView ? (
|
||||
<>
|
||||
<AgentSectionHeader title="Schedule Setup" />
|
||||
<div>
|
||||
<ScheduleView
|
||||
agent={agent}
|
||||
scheduleName={scheduleName}
|
||||
cronExpression={cronExpression}
|
||||
inputValues={inputValues}
|
||||
onScheduleNameChange={handleSetScheduleName}
|
||||
onCronExpressionChange={handleSetCronExpression}
|
||||
onInputChange={handleInputChange}
|
||||
onValidityChange={setIsScheduleFormValid}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : hasInputFields ? (
|
||||
<>
|
||||
<AgentSectionHeader
|
||||
title={
|
||||
defaultRunType === "automatic-trigger"
|
||||
? "Trigger Setup"
|
||||
: "Agent Setup"
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<DefaultRunView
|
||||
agent={agent}
|
||||
defaultRunType={defaultRunType}
|
||||
inputValues={inputValues}
|
||||
onInputChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Agent Details Section */}
|
||||
<div className="mt-8">
|
||||
<AgentSectionHeader title="Agent Details" />
|
||||
<AgentDetails agent={agent} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fixed Actions - sticky inside dialog scroll */}
|
||||
<Dialog.Footer className="sticky bottom-0 z-10 bg-white">
|
||||
{!showScheduleView ? (
|
||||
<RunActions
|
||||
hasExternalTrigger={agent.has_external_trigger}
|
||||
defaultRunType={defaultRunType}
|
||||
onShowSchedule={handleShowSchedule}
|
||||
onRun={handleRun}
|
||||
isExecuting={isExecuting}
|
||||
isSettingUpTrigger={isSettingUpTrigger}
|
||||
allRequiredInputsAreSet={allRequiredInputsAreSet}
|
||||
/>
|
||||
) : (
|
||||
<ScheduleActions
|
||||
onGoBack={handleGoBack}
|
||||
onSchedule={handleSchedule}
|
||||
isCreatingSchedule={isCreatingSchedule}
|
||||
allRequiredInputsAreSet={
|
||||
allRequiredInputsAreSet &&
|
||||
!!scheduleName.trim() &&
|
||||
isScheduleFormValid
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Dialog.Footer>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
|
||||
interface Props {
|
||||
flowId: string;
|
||||
}
|
||||
|
||||
export function AgentCostSection({ flowId }: Props) {
|
||||
return (
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
{/* TODO: enable once we have an API to show estimated cost for an agent run */}
|
||||
{/* <div className="flex items-center gap-2">
|
||||
<Text variant="body-medium">Cost</Text>
|
||||
<Text variant="body">{cost}</Text>
|
||||
</div> */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="small"
|
||||
as="NextLink"
|
||||
href={`/build?flowID=${flowId}`}
|
||||
>
|
||||
Open in builder
|
||||
</Button>
|
||||
{/* TODO: enable once we can easily link to the agent listing page from the library agent response */}
|
||||
{/* <Button variant="outline" size="small">
|
||||
View listing <ArrowSquareOutIcon size={16} />
|
||||
</Button> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Badge } from "@/components/atoms/Badge/Badge";
|
||||
import { formatAgentStatus, getStatusColor } from "./helpers";
|
||||
import { formatDate } from "@/lib/utils/time";
|
||||
|
||||
interface Props {
|
||||
agent: LibraryAgent;
|
||||
}
|
||||
|
||||
export function AgentDetails({ agent }: Props) {
|
||||
return (
|
||||
<div className="mt-4 flex flex-col gap-5">
|
||||
<div>
|
||||
<Text variant="body-medium" className="mb-1 !text-black">
|
||||
Current Status
|
||||
</Text>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${getStatusColor(agent.status)}`}
|
||||
/>
|
||||
<Text variant="body" className="!text-zinc-700">
|
||||
{formatAgentStatus(agent.status)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text variant="body-medium" className="mb-1 !text-black">
|
||||
Version
|
||||
</Text>
|
||||
<div className="flex items-center gap-2">
|
||||
<Text variant="body" className="!text-zinc-700">
|
||||
v{agent.graph_version}
|
||||
</Text>
|
||||
{agent.is_latest_version && (
|
||||
<Badge variant="success" size="small">
|
||||
Latest
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text variant="body-medium" className="mb-1 !text-black">
|
||||
Last Updated
|
||||
</Text>
|
||||
<Text variant="body" className="!text-zinc-700">
|
||||
{formatDate(agent.updated_at)}
|
||||
</Text>
|
||||
</div>
|
||||
{agent.has_external_trigger && (
|
||||
<div>
|
||||
<Text variant="body-medium" className="mb-1">
|
||||
Trigger Type
|
||||
</Text>
|
||||
<Text variant="body" className="!text-neutral-700">
|
||||
External Webhook
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { LibraryAgentStatus } from "@/app/api/__generated__/models/libraryAgentStatus";
|
||||
|
||||
export function formatAgentStatus(status: LibraryAgentStatus) {
|
||||
const statusMap: Record<string, string> = {
|
||||
COMPLETED: "Ready",
|
||||
HEALTHY: "Running",
|
||||
WAITING: "Run Queued",
|
||||
ERROR: "Failed Run",
|
||||
};
|
||||
|
||||
return statusMap[status];
|
||||
}
|
||||
|
||||
export function getStatusColor(status: LibraryAgentStatus): string {
|
||||
const colorMap: Record<LibraryAgentStatus, string> = {
|
||||
COMPLETED: "bg-blue-300",
|
||||
HEALTHY: "bg-green-300",
|
||||
WAITING: "bg-amber-300",
|
||||
ERROR: "bg-red-300",
|
||||
};
|
||||
|
||||
return colorMap[status] || "bg-gray-300";
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
|
||||
interface Props {
|
||||
agent: LibraryAgent;
|
||||
inputValues: Record<string, any>;
|
||||
onInputChange: (key: string, value: string) => void;
|
||||
variant?: "default" | "schedule";
|
||||
}
|
||||
|
||||
export function AgentInputFields({
|
||||
agent,
|
||||
inputValues,
|
||||
onInputChange,
|
||||
variant = "default",
|
||||
}: Props) {
|
||||
const hasInputFields =
|
||||
agent.input_schema &&
|
||||
typeof agent.input_schema === "object" &&
|
||||
"properties" in agent.input_schema;
|
||||
|
||||
if (!hasInputFields) {
|
||||
const emptyStateClass =
|
||||
variant === "schedule"
|
||||
? "rounded-lg bg-neutral-50 p-4 text-sm text-neutral-500"
|
||||
: "p-4 text-sm text-neutral-500";
|
||||
|
||||
return (
|
||||
<div className={emptyStateClass}>
|
||||
No input fields required for this agent
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.entries((agent.input_schema as any).properties || {}).map(
|
||||
([key, schema]: [string, any]) => (
|
||||
<Input
|
||||
key={key}
|
||||
id={key}
|
||||
label={schema.title || key}
|
||||
value={inputValues[key] || ""}
|
||||
onChange={(e) => onInputChange(key, e.target.value)}
|
||||
placeholder={schema.description}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function AgentSectionHeader({ title }: Props) {
|
||||
return (
|
||||
<div className="border-t border-zinc-400 px-0 py-2">
|
||||
<Text variant="label" className="!text-zinc-700">
|
||||
{title}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { RunVariant } from "../../useAgentRunModal";
|
||||
import { WebhookTriggerBanner } from "../WebhookTriggerBanner/WebhookTriggerBanner";
|
||||
import { AgentInputFields } from "../AgentInputFields/AgentInputFields";
|
||||
|
||||
interface Props {
|
||||
agent: LibraryAgent;
|
||||
defaultRunType: RunVariant;
|
||||
inputValues: Record<string, any>;
|
||||
onInputChange: (key: string, value: string) => void;
|
||||
}
|
||||
|
||||
export function DefaultRunView({
|
||||
agent,
|
||||
defaultRunType,
|
||||
inputValues,
|
||||
onInputChange,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="mt-6">
|
||||
{defaultRunType === "automatic-trigger" && <WebhookTriggerBanner />}
|
||||
|
||||
<AgentInputFields
|
||||
agent={agent}
|
||||
inputValues={inputValues}
|
||||
onInputChange={onInputChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Badge } from "@/components/atoms/Badge/Badge";
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { ShowMoreText } from "@/components/molecules/ShowMoreText/ShowMoreText";
|
||||
|
||||
interface ModalHeaderProps {
|
||||
agent: LibraryAgent;
|
||||
}
|
||||
|
||||
export function ModalHeader({ agent }: ModalHeaderProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="info">New Run</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<Text variant="h3">{agent.name}</Text>
|
||||
<Text variant="body-medium">
|
||||
by {agent.creator_name === "Unknown" ? "–" : agent.creator_name}
|
||||
</Text>
|
||||
<ShowMoreText
|
||||
previewLimit={80}
|
||||
variant="small"
|
||||
className="mt-4 !text-zinc-700"
|
||||
>
|
||||
{agent.description}
|
||||
</ShowMoreText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { RunVariant } from "../../useAgentRunModal";
|
||||
|
||||
interface Props {
|
||||
hasExternalTrigger: boolean;
|
||||
defaultRunType: RunVariant;
|
||||
onShowSchedule: () => void;
|
||||
onRun: () => void;
|
||||
isExecuting?: boolean;
|
||||
isSettingUpTrigger?: boolean;
|
||||
allRequiredInputsAreSet?: boolean;
|
||||
}
|
||||
|
||||
export function RunActions({
|
||||
hasExternalTrigger,
|
||||
defaultRunType,
|
||||
onShowSchedule,
|
||||
onRun,
|
||||
isExecuting = false,
|
||||
isSettingUpTrigger = false,
|
||||
allRequiredInputsAreSet = true,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="flex justify-end gap-3">
|
||||
{!hasExternalTrigger && (
|
||||
<Button variant="secondary" onClick={onShowSchedule}>
|
||||
Schedule Run
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onRun}
|
||||
disabled={!allRequiredInputsAreSet || isExecuting || isSettingUpTrigger}
|
||||
loading={isExecuting || isSettingUpTrigger}
|
||||
>
|
||||
{defaultRunType === "automatic-trigger"
|
||||
? "Set up Trigger"
|
||||
: "Run Agent"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
|
||||
interface Props {
|
||||
onGoBack: () => void;
|
||||
onSchedule: () => void;
|
||||
isCreatingSchedule?: boolean;
|
||||
allRequiredInputsAreSet?: boolean;
|
||||
}
|
||||
|
||||
export function ScheduleActions({
|
||||
onGoBack,
|
||||
onSchedule,
|
||||
isCreatingSchedule = false,
|
||||
allRequiredInputsAreSet = true,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="ghost" onClick={onGoBack}>
|
||||
Go Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onSchedule}
|
||||
disabled={!allRequiredInputsAreSet || isCreatingSchedule}
|
||||
loading={isCreatingSchedule}
|
||||
>
|
||||
Create Schedule
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { MultiToggle } from "@/components/molecules/MultiToggle/MultiToggle";
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { AgentInputFields } from "../AgentInputFields/AgentInputFields";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Select } from "@/components/atoms/Select/Select";
|
||||
import { useScheduleView } from "./useScheduleView";
|
||||
import { useCallback, useState } from "react";
|
||||
import { validateSchedule } from "./helpers";
|
||||
|
||||
interface Props {
|
||||
agent: LibraryAgent;
|
||||
scheduleName: string;
|
||||
cronExpression: string;
|
||||
inputValues: Record<string, any>;
|
||||
onScheduleNameChange: (name: string) => void;
|
||||
onCronExpressionChange: (expression: string) => void;
|
||||
onInputChange: (key: string, value: string) => void;
|
||||
onValidityChange?: (valid: boolean) => void;
|
||||
}
|
||||
|
||||
export function ScheduleView({
|
||||
agent,
|
||||
scheduleName,
|
||||
cronExpression: _cronExpression,
|
||||
inputValues,
|
||||
onScheduleNameChange,
|
||||
onCronExpressionChange,
|
||||
onInputChange,
|
||||
onValidityChange,
|
||||
}: Props) {
|
||||
const {
|
||||
repeat,
|
||||
selectedDays,
|
||||
time,
|
||||
repeatOptions,
|
||||
dayItems,
|
||||
setSelectedDays,
|
||||
handleRepeatChange,
|
||||
handleTimeChange,
|
||||
handleSelectAll,
|
||||
handleWeekdays,
|
||||
handleWeekends,
|
||||
} = useScheduleView({ onCronExpressionChange });
|
||||
|
||||
function handleScheduleNameChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
onScheduleNameChange(e.target.value);
|
||||
}
|
||||
|
||||
const [errors, setErrors] = useState<{
|
||||
scheduleName?: string;
|
||||
time?: string;
|
||||
}>({});
|
||||
|
||||
const validateNow = useCallback(
|
||||
(partial: { scheduleName?: string; time?: string }) => {
|
||||
const fieldErrors = validateSchedule({
|
||||
scheduleName,
|
||||
time,
|
||||
...partial,
|
||||
});
|
||||
setErrors(fieldErrors);
|
||||
if (onValidityChange)
|
||||
onValidityChange(Object.keys(fieldErrors).length === 0);
|
||||
},
|
||||
[scheduleName, time, onValidityChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<Input
|
||||
id="schedule-name"
|
||||
label="Schedule Name"
|
||||
value={scheduleName}
|
||||
onChange={(e) => {
|
||||
handleScheduleNameChange(e);
|
||||
validateNow({ scheduleName: e.target.value });
|
||||
}}
|
||||
placeholder="Enter a name for this schedule"
|
||||
error={errors.scheduleName}
|
||||
/>
|
||||
|
||||
<Select
|
||||
id="repeat"
|
||||
label="Repeats"
|
||||
value={repeat}
|
||||
onValueChange={handleRepeatChange}
|
||||
options={repeatOptions}
|
||||
/>
|
||||
|
||||
{repeat === "weekly" && (
|
||||
<div className="mb-8 space-y-3">
|
||||
<Text variant="body-medium" as="span" className="text-black">
|
||||
Repeats on
|
||||
</Text>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="h-[2.25rem] rounded-full border border-zinc-700 px-4 py-2 text-sm font-medium leading-[16px] text-black hover:bg-zinc-100"
|
||||
onClick={handleSelectAll}
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="h-[2.25rem] rounded-full border border-zinc-700 px-4 py-2 text-sm font-medium leading-[16px] text-black hover:bg-zinc-100"
|
||||
onClick={handleWeekdays}
|
||||
>
|
||||
Weekdays
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="h-[2.25rem] rounded-full border border-zinc-700 px-4 py-2 text-sm font-medium leading-[16px] text-black hover:bg-zinc-100"
|
||||
onClick={handleWeekends}
|
||||
>
|
||||
Weekends
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<MultiToggle
|
||||
items={dayItems}
|
||||
selectedValues={selectedDays}
|
||||
onChange={setSelectedDays}
|
||||
aria-label="Select days of week"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
id="schedule-time"
|
||||
label="At"
|
||||
value={time}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.trim();
|
||||
handleTimeChange({ ...e, target: { ...e.target, value } } as any);
|
||||
validateNow({ time: value });
|
||||
}}
|
||||
placeholder="00:00"
|
||||
error={errors.time}
|
||||
/>
|
||||
|
||||
<AgentInputFields
|
||||
agent={agent}
|
||||
inputValues={inputValues}
|
||||
onInputChange={onInputChange}
|
||||
variant="schedule"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const timeRegex = /^([01]?\d|2[0-3]):([0-5]\d)$/;
|
||||
|
||||
export const scheduleFormSchema = z.object({
|
||||
scheduleName: z.string().trim().min(1, "Schedule name is required"),
|
||||
time: z.string().trim().regex(timeRegex, "Use HH:MM (24h)"),
|
||||
});
|
||||
|
||||
export type ScheduleFormValues = z.infer<typeof scheduleFormSchema>;
|
||||
|
||||
export function validateSchedule(
|
||||
values: Partial<ScheduleFormValues>,
|
||||
): Partial<Record<keyof ScheduleFormValues, string>> {
|
||||
const result = scheduleFormSchema.safeParse({
|
||||
scheduleName: values.scheduleName ?? "",
|
||||
time: values.time ?? "",
|
||||
});
|
||||
|
||||
if (result.success) return {};
|
||||
|
||||
const fieldErrors: Partial<Record<keyof ScheduleFormValues, string>> = {};
|
||||
for (const issue of result.error.issues) {
|
||||
const path = issue.path[0] as keyof ScheduleFormValues | undefined;
|
||||
if (path && !fieldErrors[path]) fieldErrors[path] = issue.message;
|
||||
}
|
||||
return fieldErrors;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
interface UseScheduleViewOptions {
|
||||
onCronExpressionChange: (expression: string) => void;
|
||||
}
|
||||
|
||||
export function useScheduleView({
|
||||
onCronExpressionChange,
|
||||
}: UseScheduleViewOptions) {
|
||||
const repeatOptions = useMemo(
|
||||
() => [
|
||||
{ value: "daily", label: "Daily" },
|
||||
{ value: "weekly", label: "Weekly" },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const dayItems = useMemo(
|
||||
() => [
|
||||
{ value: "0", label: "Su" },
|
||||
{ value: "1", label: "Mo" },
|
||||
{ value: "2", label: "Tu" },
|
||||
{ value: "3", label: "We" },
|
||||
{ value: "4", label: "Th" },
|
||||
{ value: "5", label: "Fr" },
|
||||
{ value: "6", label: "Sa" },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const [repeat, setRepeat] = useState<string>("weekly");
|
||||
const [selectedDays, setSelectedDays] = useState<string[]>([]);
|
||||
const [time, setTime] = useState<string>("00:00");
|
||||
|
||||
function handleRepeatChange(value: string) {
|
||||
setRepeat(value);
|
||||
}
|
||||
|
||||
function handleTimeChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
setTime(e.target.value.trim());
|
||||
}
|
||||
|
||||
function parseTimeToHM(value: string): { h: number; m: number } {
|
||||
const match = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(value || "");
|
||||
if (!match) return { h: 0, m: 0 };
|
||||
return { h: Number(match[1]), m: Number(match[2]) };
|
||||
}
|
||||
|
||||
// Helpful default: when switching to Weekly with no days picked, preselect Monday
|
||||
useEffect(() => {
|
||||
if (repeat === "weekly" && selectedDays.length === 0)
|
||||
setSelectedDays(["1"]);
|
||||
}, [repeat, selectedDays]);
|
||||
|
||||
// Build cron string any time repeat/days/time change
|
||||
useEffect(() => {
|
||||
const { h, m } = parseTimeToHM(time);
|
||||
const minute = String(m);
|
||||
const hour = String(h);
|
||||
if (repeat === "daily") {
|
||||
onCronExpressionChange(`${minute} ${hour} * * *`);
|
||||
return;
|
||||
}
|
||||
|
||||
const dow = selectedDays.length ? selectedDays.join(",") : "*";
|
||||
onCronExpressionChange(`${minute} ${hour} * * ${dow}`);
|
||||
}, [repeat, selectedDays, time, onCronExpressionChange]);
|
||||
|
||||
function handleSelectAll() {
|
||||
setSelectedDays(["0", "1", "2", "3", "4", "5", "6"]);
|
||||
}
|
||||
|
||||
function handleWeekdays() {
|
||||
setSelectedDays(["1", "2", "3", "4", "5"]);
|
||||
}
|
||||
|
||||
function handleWeekends() {
|
||||
setSelectedDays(["0", "6"]);
|
||||
}
|
||||
|
||||
return {
|
||||
// state
|
||||
repeat,
|
||||
selectedDays,
|
||||
time,
|
||||
// derived/static
|
||||
repeatOptions,
|
||||
dayItems,
|
||||
// handlers
|
||||
setSelectedDays,
|
||||
handleRepeatChange,
|
||||
handleTimeChange,
|
||||
handleSelectAll,
|
||||
handleWeekdays,
|
||||
handleWeekends,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export function WebhookTriggerBanner() {
|
||||
return (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<svg
|
||||
className="h-5 w-5 text-blue-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-blue-800">Webhook Trigger</h3>
|
||||
<div className="mt-2 text-sm text-blue-700">
|
||||
<p>
|
||||
This will create a webhook endpoint that automatically runs your
|
||||
agent when triggered by external events.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { CredentialsMetaInput } from "@/app/api/__generated__/models/credentialsMetaInput";
|
||||
import { isEmpty } from "@/lib/utils";
|
||||
|
||||
export function validateInputs(
|
||||
inputSchema: any,
|
||||
values: Record<string, any>,
|
||||
): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!inputSchema?.properties) return errors;
|
||||
|
||||
const requiredFields = inputSchema.required || [];
|
||||
|
||||
for (const fieldName of requiredFields) {
|
||||
const fieldSchema = inputSchema.properties[fieldName];
|
||||
if (!fieldSchema?.hidden && isEmpty(values[fieldName])) {
|
||||
errors[fieldName] = `${fieldSchema?.title || fieldName} is required`;
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateCredentials(
|
||||
credentialsSchema: any,
|
||||
values: Record<string, CredentialsMetaInput>,
|
||||
): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!credentialsSchema?.properties) return errors;
|
||||
|
||||
const credentialFields = Object.keys(credentialsSchema.properties);
|
||||
|
||||
for (const fieldName of credentialFields) {
|
||||
if (!values[fieldName]) {
|
||||
errors[fieldName] = `${fieldName} credentials are required`;
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function formatCronExpression(cron: string): string {
|
||||
// Basic cron expression formatting/validation
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length !== 5) {
|
||||
throw new Error(
|
||||
"Cron expression must have exactly 5 parts: minute hour day month weekday",
|
||||
);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
export function parseCronDescription(cron: string): string {
|
||||
// Simple cron description parser
|
||||
const parts = cron.split(" ");
|
||||
if (parts.length !== 5) return cron;
|
||||
|
||||
// Handle some common patterns
|
||||
if (cron === "0 * * * *") return "Every hour";
|
||||
if (cron === "0 9 * * *") return "Daily at 9:00 AM";
|
||||
if (cron === "0 9 * * 1") return "Every Monday at 9:00 AM";
|
||||
if (cron === "0 9 * * 1-5") return "Weekdays at 9:00 AM";
|
||||
if (cron === "0 9 1 * *") return "Monthly on the 1st at 9:00 AM";
|
||||
|
||||
return cron; // Fallback to showing the raw cron
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { isEmpty } from "@/lib/utils";
|
||||
import { usePostV1ExecuteGraphAgent } from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import { usePostV1CreateExecutionSchedule as useCreateSchedule } from "@/app/api/__generated__/endpoints/schedules/schedules";
|
||||
import { usePostV2SetupTrigger } from "@/app/api/__generated__/endpoints/presets/presets";
|
||||
import { ExecuteGraphResponse } from "@/app/api/__generated__/models/executeGraphResponse";
|
||||
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
|
||||
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
|
||||
|
||||
export type RunVariant =
|
||||
| "manual"
|
||||
| "schedule"
|
||||
| "automatic-trigger"
|
||||
| "manual-trigger";
|
||||
|
||||
interface UseAgentRunModalCallbacks {
|
||||
onRun?: (execution: ExecuteGraphResponse) => void;
|
||||
onCreateSchedule?: (schedule: GraphExecutionJobInfo) => void;
|
||||
onSetupTrigger?: (preset: LibraryAgentPreset) => void;
|
||||
}
|
||||
|
||||
export function useAgentRunModal(
|
||||
agent: LibraryAgent,
|
||||
callbacks?: UseAgentRunModalCallbacks,
|
||||
) {
|
||||
const { toast } = useToast();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showScheduleView, setShowScheduleView] = useState(false);
|
||||
const [inputValues, setInputValues] = useState<Record<string, any>>({});
|
||||
const defaultScheduleName = useMemo(() => `Run ${agent.name}`, [agent.name]);
|
||||
const [scheduleName, setScheduleName] = useState(defaultScheduleName);
|
||||
const [cronExpression, setCronExpression] = useState("0 9 * * 1");
|
||||
|
||||
// Determine the default run type based on agent capabilities
|
||||
const defaultRunType: RunVariant = agent.has_external_trigger
|
||||
? "automatic-trigger"
|
||||
: "manual";
|
||||
|
||||
// API mutations
|
||||
const executeGraphMutation = usePostV1ExecuteGraphAgent({
|
||||
mutation: {
|
||||
onSuccess: (response) => {
|
||||
if (response.status === 200) {
|
||||
toast({
|
||||
title: "✅ Agent execution started",
|
||||
description: "Your agent is now running.",
|
||||
});
|
||||
callbacks?.onRun?.(response.data);
|
||||
setIsOpen(false);
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "❌ Failed to execute agent",
|
||||
description: error.message || "An unexpected error occurred.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const createScheduleMutation = useCreateSchedule({
|
||||
mutation: {
|
||||
onSuccess: (response) => {
|
||||
if (response.status === 200) {
|
||||
toast({
|
||||
title: "✅ Schedule created",
|
||||
description: `Agent scheduled to run: ${scheduleName}`,
|
||||
});
|
||||
callbacks?.onCreateSchedule?.(response.data);
|
||||
setIsOpen(false);
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "❌ Failed to create schedule",
|
||||
description: error.message || "An unexpected error occurred.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const setupTriggerMutation = usePostV2SetupTrigger({
|
||||
mutation: {
|
||||
onSuccess: (response: any) => {
|
||||
if (response.status === 200) {
|
||||
toast({
|
||||
title: "✅ Trigger setup complete",
|
||||
description: "Your webhook trigger is now active.",
|
||||
});
|
||||
callbacks?.onSetupTrigger?.(response.data);
|
||||
setIsOpen(false);
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "❌ Failed to setup trigger",
|
||||
description: error.message || "An unexpected error occurred.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Input schema validation
|
||||
const agentInputSchema = useMemo(
|
||||
() => agent.input_schema || { properties: {}, required: [] },
|
||||
[agent.input_schema],
|
||||
);
|
||||
|
||||
const agentInputFields = useMemo(() => {
|
||||
if (
|
||||
!agentInputSchema ||
|
||||
typeof agentInputSchema !== "object" ||
|
||||
!("properties" in agentInputSchema) ||
|
||||
!agentInputSchema.properties
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
const properties = agentInputSchema.properties as Record<string, any>;
|
||||
return Object.fromEntries(
|
||||
Object.entries(properties).filter(
|
||||
([_, subSchema]: [string, any]) => !subSchema.hidden,
|
||||
),
|
||||
);
|
||||
}, [agentInputSchema]);
|
||||
|
||||
// Validation logic
|
||||
const [allRequiredInputsAreSet, missingInputs] = useMemo(() => {
|
||||
const nonEmptyInputs = new Set(
|
||||
Object.keys(inputValues).filter((k) => !isEmpty(inputValues[k])),
|
||||
);
|
||||
const requiredInputs = new Set(
|
||||
(agentInputSchema.required as string[]) || [],
|
||||
);
|
||||
const missing = [...requiredInputs].filter(
|
||||
(input) => !nonEmptyInputs.has(input),
|
||||
);
|
||||
return [missing.length === 0, missing];
|
||||
}, [agentInputSchema.required, inputValues]);
|
||||
|
||||
const notifyMissingInputs = useCallback(
|
||||
(needScheduleName: boolean = false) => {
|
||||
const allMissingFields = (
|
||||
needScheduleName && !scheduleName ? ["schedule_name"] : []
|
||||
).concat(missingInputs);
|
||||
|
||||
toast({
|
||||
title: "⚠️ Missing required inputs",
|
||||
description: `Please provide: ${allMissingFields.map((k) => `"${k}"`).join(", ")}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
[missingInputs, scheduleName, toast],
|
||||
);
|
||||
|
||||
// Action handlers
|
||||
const handleRun = useCallback(() => {
|
||||
if (!allRequiredInputsAreSet) {
|
||||
notifyMissingInputs();
|
||||
return;
|
||||
}
|
||||
|
||||
if (defaultRunType === "automatic-trigger") {
|
||||
// Setup trigger
|
||||
if (!scheduleName.trim()) {
|
||||
toast({
|
||||
title: "⚠️ Trigger name required",
|
||||
description: "Please provide a name for your trigger.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setupTriggerMutation.mutate({
|
||||
data: {
|
||||
name: scheduleName,
|
||||
description: `Trigger for ${agent.name}`,
|
||||
graph_id: agent.graph_id,
|
||||
graph_version: agent.graph_version,
|
||||
trigger_config: inputValues,
|
||||
agent_credentials: {}, // TODO: Add credentials handling if needed
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Manual execution
|
||||
executeGraphMutation.mutate({
|
||||
graphId: agent.graph_id,
|
||||
graphVersion: agent.graph_version,
|
||||
data: {
|
||||
inputs: inputValues,
|
||||
credentials_inputs: {}, // TODO: Add credentials handling if needed
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [
|
||||
allRequiredInputsAreSet,
|
||||
defaultRunType,
|
||||
scheduleName,
|
||||
inputValues,
|
||||
agent,
|
||||
notifyMissingInputs,
|
||||
setupTriggerMutation,
|
||||
executeGraphMutation,
|
||||
toast,
|
||||
]);
|
||||
|
||||
const handleSchedule = useCallback(() => {
|
||||
if (!allRequiredInputsAreSet) {
|
||||
notifyMissingInputs(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scheduleName.trim()) {
|
||||
toast({
|
||||
title: "⚠️ Schedule name required",
|
||||
description: "Please provide a name for your schedule.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
createScheduleMutation.mutate({
|
||||
graphId: agent.graph_id,
|
||||
data: {
|
||||
name: scheduleName,
|
||||
cron: cronExpression,
|
||||
inputs: inputValues,
|
||||
graph_version: agent.graph_version,
|
||||
credentials: {}, // TODO: Add credentials handling if needed
|
||||
},
|
||||
});
|
||||
}, [
|
||||
allRequiredInputsAreSet,
|
||||
scheduleName,
|
||||
cronExpression,
|
||||
inputValues,
|
||||
agent,
|
||||
notifyMissingInputs,
|
||||
createScheduleMutation,
|
||||
toast,
|
||||
]);
|
||||
|
||||
function handleShowSchedule() {
|
||||
// Initialize with sensible defaults when entering schedule view
|
||||
setScheduleName((prev) => prev || defaultScheduleName);
|
||||
setCronExpression((prev) => prev || "0 9 * * 1");
|
||||
setShowScheduleView(true);
|
||||
}
|
||||
|
||||
function handleGoBack() {
|
||||
setShowScheduleView(false);
|
||||
// Reset schedule fields on exit
|
||||
setScheduleName(defaultScheduleName);
|
||||
setCronExpression("0 9 * * 1");
|
||||
}
|
||||
|
||||
function handleSetScheduleName(name: string) {
|
||||
setScheduleName(name);
|
||||
}
|
||||
|
||||
function handleSetCronExpression(expression: string) {
|
||||
setCronExpression(expression);
|
||||
}
|
||||
|
||||
const hasInputFields = useMemo(() => {
|
||||
return Object.keys(agentInputFields).length > 0;
|
||||
}, [agentInputFields]);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
showScheduleView,
|
||||
defaultRunType,
|
||||
inputValues,
|
||||
setInputValues,
|
||||
scheduleName,
|
||||
cronExpression,
|
||||
allRequiredInputsAreSet,
|
||||
missingInputs,
|
||||
agentInputFields,
|
||||
hasInputFields,
|
||||
isExecuting: executeGraphMutation.isPending,
|
||||
isCreatingSchedule: createScheduleMutation.isPending,
|
||||
isSettingUpTrigger: setupTriggerMutation.isPending,
|
||||
handleRun,
|
||||
handleSchedule,
|
||||
handleShowSchedule,
|
||||
handleGoBack,
|
||||
handleSetScheduleName,
|
||||
handleSetCronExpression,
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type BadgeVariant = "success" | "error" | "info";
|
||||
type BadgeSize = "small" | "medium";
|
||||
|
||||
interface BadgeProps {
|
||||
variant: BadgeVariant;
|
||||
size?: BadgeSize;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
@@ -14,16 +16,28 @@ const badgeVariants: Record<BadgeVariant, string> = {
|
||||
info: "bg-slate-50 text-black",
|
||||
};
|
||||
|
||||
export function Badge({ variant, children, className }: BadgeProps) {
|
||||
const badgeSizes: Record<BadgeSize, string> = {
|
||||
small: "px-[6px] py-[3px] text-[0.55rem] leading-4 tracking-widest",
|
||||
medium: "px-[9px] py-[3px] text-[0.6785rem] leading-5 tracking-wider",
|
||||
};
|
||||
|
||||
export function Badge({
|
||||
variant,
|
||||
size = "medium",
|
||||
children,
|
||||
className,
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
// Base styles from Figma
|
||||
"inline-flex items-center gap-2 rounded-[45px] px-[9px] py-[3px]",
|
||||
"inline-flex items-center gap-2 rounded-[45px]",
|
||||
// Text styles
|
||||
"font-sans text-[0.6785rem] font-medium uppercase leading-5 tracking-wider",
|
||||
"font-sans font-medium uppercase",
|
||||
// Text overflow handling
|
||||
"overflow-hidden text-ellipsis",
|
||||
// Size styles
|
||||
badgeSizes[size],
|
||||
// Variant styles
|
||||
badgeVariants[variant],
|
||||
className,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 759 B |
Binary file not shown.
|
After Width: | Height: | Size: 576 B |
@@ -32,6 +32,10 @@ export const variants = {
|
||||
"font-sans text-[0.75rem] font-normal leading-[1.125rem] text-zinc-800",
|
||||
"small-medium":
|
||||
"font-sans text-[0.75rem] font-medium leading-[1.125rem] text-zinc-800",
|
||||
|
||||
// Label Text
|
||||
label:
|
||||
"font-sans text-[0.6785rem] font-medium uppercase leading-[1.25rem] tracking-[0.06785rem] text-zinc-800",
|
||||
} as const;
|
||||
|
||||
export type Variant = keyof typeof variants;
|
||||
@@ -49,4 +53,5 @@ export const variantElementMap: Record<Variant, As> = {
|
||||
"body-medium": "p",
|
||||
small: "p",
|
||||
"small-medium": "p",
|
||||
label: "span",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { ShowMore } from "./ShowMore";
|
||||
|
||||
const meta: Meta<typeof ShowMore> = {
|
||||
title: "Molecules/ShowMore",
|
||||
component: ShowMore,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
## ShowMore Component
|
||||
|
||||
A simplified text truncation component that shows a preview of text content with an expand/collapse toggle functionality.
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- **String content only** - Simplified to only accept string content
|
||||
- **Text variant integration** - Uses Text component variants for consistent styling
|
||||
- **Adaptive toggle sizing** - Toggle icons automatically size to match text variant
|
||||
- **Smart truncation** - Automatically truncates text based on character limit
|
||||
- **No heading variants** - Only supports body text variants (lead, large, body, small)
|
||||
- **Inline toggle** - Toggle appears inline at the end of the text
|
||||
- **TypeScript support** - Full TypeScript interface support
|
||||
|
||||
### 🎯 Usage
|
||||
|
||||
\`\`\`tsx
|
||||
<ShowMore
|
||||
variant="body"
|
||||
previewLimit={150}
|
||||
>
|
||||
This is a long piece of text that will be truncated at the specified
|
||||
character limit and show a "more" button to expand the full content.
|
||||
</ShowMore>
|
||||
\`\`\`
|
||||
|
||||
### Props
|
||||
|
||||
- **children**: String content to show/truncate
|
||||
- **previewLimit**: Character limit for preview (default: 100)
|
||||
- **variant**: Text variant to use (excludes heading variants)
|
||||
- **className**: Additional classes for root container
|
||||
- **previewClassName**: Additional classes applied in preview mode
|
||||
- **expandedClassName**: Additional classes applied in expanded mode
|
||||
- **toggleClassName**: Additional classes for the toggle button
|
||||
- **defaultExpanded**: Whether to start in expanded state (default: false)
|
||||
|
||||
### Supported Text Variants
|
||||
|
||||
- **lead** - Large leading text
|
||||
- **large**, **large-medium**, **large-semibold** - Large body text variants
|
||||
- **body**, **body-medium** - Standard body text variants
|
||||
- **small**, **small-medium** - Small text variants
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
children: {
|
||||
control: "text",
|
||||
description: "String content to show with truncation",
|
||||
},
|
||||
previewLimit: {
|
||||
control: "number",
|
||||
description: "Character limit for preview text",
|
||||
table: {
|
||||
defaultValue: { summary: "100" },
|
||||
},
|
||||
},
|
||||
variant: {
|
||||
control: "select",
|
||||
options: [
|
||||
"lead",
|
||||
"large",
|
||||
"large-medium",
|
||||
"large-semibold",
|
||||
"body",
|
||||
"body-medium",
|
||||
"small",
|
||||
"small-medium",
|
||||
],
|
||||
description: "Text variant to use for styling",
|
||||
table: {
|
||||
defaultValue: { summary: "body" },
|
||||
},
|
||||
},
|
||||
className: {
|
||||
control: "text",
|
||||
description: "Additional CSS classes for root container",
|
||||
},
|
||||
toggleClassName: {
|
||||
control: "text",
|
||||
description: "Additional CSS classes for the toggle button",
|
||||
},
|
||||
defaultExpanded: {
|
||||
control: "boolean",
|
||||
description: "Whether to start in expanded state",
|
||||
table: {
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/**
|
||||
* Basic text truncation with default body variant and 100 character limit.
|
||||
*/
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children:
|
||||
"This is a longer piece of text that will be truncated at the preview limit. When you click 'more', you'll see the full content. This demonstrates the basic functionality of the ShowMore component with plain text content.",
|
||||
variant: "body",
|
||||
previewLimit: 100,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Short text that doesn't need truncation - toggle won't appear.
|
||||
*/
|
||||
export const ShortText: Story = {
|
||||
args: {
|
||||
children: "This text is short enough that no truncation is needed.",
|
||||
variant: "body",
|
||||
previewLimit: 100,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Large leading text variant with custom preview limit.
|
||||
*/
|
||||
export const LeadVariant: Story = {
|
||||
args: {
|
||||
children:
|
||||
"This example uses the lead text variant which is larger and more prominent. The toggle icons automatically scale to match the text size for a cohesive design.",
|
||||
variant: "lead",
|
||||
previewLimit: 80,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Large text variants demonstration.
|
||||
*/
|
||||
export const LargeVariants: Story = {
|
||||
render: () => (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<ShowMore variant="large" previewLimit={60}>
|
||||
Large variant: This demonstrates how the ShowMore component works with
|
||||
the large text variant and how the toggle scales appropriately.
|
||||
</ShowMore>
|
||||
<ShowMore variant="large-medium" previewLimit={60}>
|
||||
Large medium variant: This shows the medium weight version of the large
|
||||
text variant with proper toggle sizing.
|
||||
</ShowMore>
|
||||
<ShowMore variant="large-semibold" previewLimit={60}>
|
||||
Large semibold variant: This demonstrates the semibold version with
|
||||
heavier font weight and matching toggle.
|
||||
</ShowMore>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Body text variants demonstration.
|
||||
*/
|
||||
export const BodyVariants: Story = {
|
||||
render: () => (
|
||||
<div className="max-w-xl space-y-4">
|
||||
<ShowMore variant="body" previewLimit={70}>
|
||||
Body variant: This is the default text variant used for most content. It
|
||||
provides good readability and spacing.
|
||||
</ShowMore>
|
||||
<ShowMore variant="body-medium" previewLimit={70}>
|
||||
Body medium variant: This uses medium font weight for slightly more
|
||||
emphasis while maintaining readability.
|
||||
</ShowMore>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Small text variants demonstration.
|
||||
*/
|
||||
export const SmallVariants: Story = {
|
||||
render: () => (
|
||||
<div className="max-w-lg space-y-4">
|
||||
<ShowMore variant="small" previewLimit={80}>
|
||||
Small variant: This demonstrates the small text variant which is useful
|
||||
for secondary information, captions, or footnotes where space is
|
||||
limited.
|
||||
</ShowMore>
|
||||
<ShowMore variant="small-medium" previewLimit={80}>
|
||||
Small medium variant: This uses the small size with medium font weight
|
||||
for small text that needs slightly more emphasis.
|
||||
</ShowMore>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom preview limit of 50 characters.
|
||||
*/
|
||||
export const CustomLimit: Story = {
|
||||
args: {
|
||||
children:
|
||||
"This example shows how you can customize the preview limit to show more or less text in the initial preview before truncation occurs.",
|
||||
variant: "body",
|
||||
previewLimit: 50,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Component that starts in expanded state.
|
||||
*/
|
||||
export const DefaultExpanded: Story = {
|
||||
args: {
|
||||
children:
|
||||
"This ShowMore component starts in the expanded state by default. You can click 'less' to collapse it to the preview mode. This is useful when you want to show the full content initially but still provide the option to collapse it.",
|
||||
variant: "body",
|
||||
previewLimit: 80,
|
||||
defaultExpanded: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom styling for different states.
|
||||
*/
|
||||
export const CustomStyling: Story = {
|
||||
args: {
|
||||
children:
|
||||
"This example demonstrates custom styling options. The preview state has a different background color, the expanded state has different padding, and the toggle button has custom styling to match your design system.",
|
||||
variant: "body-medium",
|
||||
previewLimit: 80,
|
||||
className: "max-w-md",
|
||||
toggleClassName: "text-blue-600 hover:text-blue-800",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Very long content to demonstrate with different text sizes.
|
||||
*/
|
||||
export const LongContent: Story = {
|
||||
render: () => (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium text-gray-500">Lead Text</h3>
|
||||
<ShowMore variant="lead" previewLimit={120}>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
|
||||
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad
|
||||
minim veniam, quis nostrud exercitation ullamco laboris nisi ut
|
||||
aliquip ex ea commodo consequat. Duis aute irure dolor in
|
||||
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
|
||||
pariatur.
|
||||
</ShowMore>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium text-gray-500">Body Text</h3>
|
||||
<ShowMore variant="body" previewLimit={120}>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
|
||||
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad
|
||||
minim veniam, quis nostrud exercitation ullamco laboris nisi ut
|
||||
aliquip ex ea commodo consequat. Duis aute irure dolor in
|
||||
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
|
||||
pariatur.
|
||||
</ShowMore>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium text-gray-500">Small Text</h3>
|
||||
<ShowMore variant="small" previewLimit={120}>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
|
||||
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad
|
||||
minim veniam, quis nostrud exercitation ullamco laboris nisi ut
|
||||
aliquip ex ea commodo consequat. Duis aute irure dolor in
|
||||
reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
|
||||
pariatur.
|
||||
</ShowMore>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CaretDownIcon, CaretUpIcon } from "@phosphor-icons/react";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { getIconSize, ShowMoreTextVariant } from "./helpers";
|
||||
|
||||
interface ShowMoreProps {
|
||||
children: string;
|
||||
previewLimit?: number;
|
||||
variant?: ShowMoreTextVariant;
|
||||
className?: string;
|
||||
toggleClassName?: string;
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
export function ShowMore({
|
||||
children,
|
||||
previewLimit = 100,
|
||||
variant = "body",
|
||||
className,
|
||||
toggleClassName,
|
||||
defaultExpanded = false,
|
||||
}: ShowMoreProps) {
|
||||
const [isExpanded, setIsExpanded] = React.useState(defaultExpanded);
|
||||
|
||||
const shouldTruncate = children.length > previewLimit;
|
||||
const previewText = shouldTruncate
|
||||
? children.slice(0, previewLimit)
|
||||
: children;
|
||||
const displayText = isExpanded ? children : previewText;
|
||||
const iconSize = getIconSize(variant);
|
||||
|
||||
if (!shouldTruncate) {
|
||||
return (
|
||||
<Text variant={variant} className={cn(className)}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text
|
||||
variant={variant}
|
||||
className={cn(
|
||||
isExpanded
|
||||
? "flex-end flex flex-wrap items-center"
|
||||
: "flex-start flex flex-wrap items-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{displayText}
|
||||
{!isExpanded && "..."}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className={cn(
|
||||
"ml-1 inline-flex items-center gap-1 font-medium text-black",
|
||||
toggleClassName,
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<CaretUpIcon size={iconSize} weight="bold" />
|
||||
<span>less</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CaretDownIcon size={iconSize} weight="bold" />
|
||||
<span>more</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export default ShowMore;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { TextVariant } from "@/components/atoms/Text/Text";
|
||||
|
||||
export type ShowMoreTextVariant = Exclude<
|
||||
TextVariant,
|
||||
"h1" | "h2" | "h3" | "h4"
|
||||
>;
|
||||
|
||||
export function getIconSize(variant: ShowMoreTextVariant): number {
|
||||
switch (variant) {
|
||||
case "lead":
|
||||
return 20;
|
||||
case "large":
|
||||
case "large-medium":
|
||||
case "large-semibold":
|
||||
return 16;
|
||||
case "body":
|
||||
case "body-medium":
|
||||
return 14;
|
||||
case "small":
|
||||
case "small-medium":
|
||||
return 12;
|
||||
default:
|
||||
return 14;
|
||||
}
|
||||
}
|
||||
@@ -17,3 +17,13 @@ export function formatTimeAgo(dateStr: string): string {
|
||||
const diffDays = Math.floor(diffHours / HOURS_PER_DAY);
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
export function formatDate(date: Date) {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(date));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user