mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
feat(frontend): new agent library run page (#10835)
## Changes 🏗️ This is the new **Agent Library Run** page. Sorry in advance for the massive PR 🙏🏽 . I got carried away and it has been tricky to split it ( _maybe I abused the agent too much_ 🤔 ) <img width="800" height="1085" alt="Screenshot 2025-09-04 at 13 58 33" src="https://github.com/user-attachments/assets/b709edb9-d2b5-48ad-a04d-dddf10c89af3" /> <img width="800" height="338" alt="Screenshot 2025-09-04 at 13 54 51" src="https://github.com/user-attachments/assets/efa28be2-d2dd-477f-af13-33ddd1d639dd" /> <img width="800" height="598" alt="Screenshot 2025-09-04 at 13 54 18" src="https://github.com/user-attachments/assets/806ab620-3492-4c5b-b4e2-f17b89756dd8" /> - Schedules are now on the sidebar tabbed along with runs - The whole UI has been updated to match the new designs and design system - There is no more "run draft" view as the modal is in charge of new runs now 💪🏽 - The page is responsive and mobile friendly 📱 Uploading mobile.mov… https://github.com/user-attachments/assets/0e483062-0e50-4fa6-aaad-a1f6766df931 ### Safety I understand this is a lot of changes. However is all behind a feature flag, `new-agent-runs`, when OFF it will display the old library agent view. The old library agent view can still be accessed under: `/library/legacy/{id}` for reference 👍🏽 ### Testing I haven't any tests for now... 💆🏽 I want to get this enabled on dev so we can start running our agents there through the new page and modal and start catching edge-cases. Tests will come later in the form of E2E for the happy paths, and probably I will introduce [Vitest](https://vitest.dev/) + [Testing Library](https://testing-library.com/) for the finer details... ## 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] Test the above ### For configuration changes: None, the feature flag is already configured 🙏🏽 --------- Co-authored-by: Abhimanyu Yadav <122007096+Abhi1992002@users.noreply.github.com>
This commit is contained in:
@@ -4,18 +4,27 @@ import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { useAgentRunsView } from "./useAgentRunsView";
|
||||
import { AgentRunsLoading } from "./components/AgentRunsLoading";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { RunAgentModal } from "@/app/(platform)/library/agents/[id]/components/AgentRunsView/components/RunAgentModal/RunAgentModal";
|
||||
import { PlusIcon } from "@phosphor-icons/react/dist/ssr";
|
||||
import { RunsSidebar } from "./components/RunsSidebar/RunsSidebar";
|
||||
import React from "react";
|
||||
import { RunDetails } from "./components/RunDetails/RunDetails";
|
||||
import { ScheduleDetails } from "./components/ScheduleDetails/ScheduleDetails";
|
||||
|
||||
export function AgentRunsView() {
|
||||
const { response, ready, error, agentId } = useAgentRunsView();
|
||||
const {
|
||||
response,
|
||||
ready,
|
||||
error,
|
||||
agentId,
|
||||
selectedRun,
|
||||
handleSelectRun,
|
||||
clearSelectedRun,
|
||||
} = useAgentRunsView();
|
||||
|
||||
if (!ready) {
|
||||
return <AgentRunsLoading />;
|
||||
}
|
||||
|
||||
if (error || (response && response.status !== 200)) {
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorCard
|
||||
isSuccess={false}
|
||||
@@ -34,8 +43,7 @@ export function AgentRunsView() {
|
||||
);
|
||||
}
|
||||
|
||||
// Handle missing data
|
||||
if (!response?.data) {
|
||||
if (!response?.data || response.status !== 200) {
|
||||
return (
|
||||
<ErrorCard
|
||||
isSuccess={false}
|
||||
@@ -49,19 +57,12 @@ export function AgentRunsView() {
|
||||
const agent = response.data;
|
||||
|
||||
return (
|
||||
<div className="grid h-screen grid-cols-[25%_85%] gap-4 pt-8">
|
||||
{/* Left Sidebar - 30% */}
|
||||
<div className="bg-gray-50 p-4">
|
||||
<RunAgentModal
|
||||
triggerSlot={
|
||||
<Button variant="primary" size="large" className="w-full">
|
||||
<PlusIcon size={20} /> New Run
|
||||
</Button>
|
||||
}
|
||||
agent={agent}
|
||||
agentId={agent.id.toString()}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid h-screen grid-cols-1 gap-0 pt-6 md:gap-4 lg:grid-cols-[25%_70%]">
|
||||
<RunsSidebar
|
||||
agent={agent}
|
||||
selectedRunId={selectedRun}
|
||||
onSelectRun={handleSelectRun}
|
||||
/>
|
||||
|
||||
{/* Main Content - 70% */}
|
||||
<div className="p-4">
|
||||
@@ -71,8 +72,27 @@ export function AgentRunsView() {
|
||||
{ name: agent.name, link: `/library/agents/${agentId}` },
|
||||
]}
|
||||
/>
|
||||
{/* Main content will go here */}
|
||||
<div className="mt-4 text-gray-600">Main content area</div>
|
||||
<div className="mt-1">
|
||||
{selectedRun ? (
|
||||
selectedRun.startsWith("schedule:") ? (
|
||||
<ScheduleDetails
|
||||
agent={agent}
|
||||
scheduleId={selectedRun.replace("schedule:", "")}
|
||||
/>
|
||||
) : (
|
||||
<RunDetails
|
||||
agent={agent}
|
||||
runId={selectedRun}
|
||||
onSelectRun={handleSelectRun}
|
||||
onClearSelectedRun={clearSelectedRun}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="text-gray-600">
|
||||
Select a run to view its details
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
|
||||
type Props = {
|
||||
agent: LibraryAgent;
|
||||
inputs?: Record<string, any> | null;
|
||||
};
|
||||
|
||||
function getAgentInputFields(agent: LibraryAgent): Record<string, any> {
|
||||
const schema = agent.input_schema as unknown as {
|
||||
properties?: Record<string, any>;
|
||||
} | null;
|
||||
if (!schema || !schema.properties) return {};
|
||||
const properties = schema.properties as Record<string, any>;
|
||||
const visibleEntries = Object.entries(properties).filter(
|
||||
([, sub]) => !sub?.hidden,
|
||||
);
|
||||
return Object.fromEntries(visibleEntries);
|
||||
}
|
||||
|
||||
function renderValue(value: any): string {
|
||||
if (value === undefined || value === null) return "";
|
||||
if (
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
)
|
||||
return String(value);
|
||||
try {
|
||||
return JSON.stringify(value, undefined, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function AgentInputsReadOnly({ agent, inputs }: Props) {
|
||||
const fields = getAgentInputFields(agent);
|
||||
const entries = Object.entries(fields);
|
||||
|
||||
if (!inputs || entries.length === 0) {
|
||||
return <div className="text-neutral-600">No input for this run.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{entries.map(([key, sub]) => (
|
||||
<div key={key} className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">{sub?.title || key}</label>
|
||||
<p className="whitespace-pre-wrap break-words text-sm text-neutral-700">
|
||||
{renderValue((inputs as Record<string, any>)[key])}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,3 +26,25 @@ export function validateSchedule(
|
||||
}
|
||||
return fieldErrors;
|
||||
}
|
||||
|
||||
export type ParsedCron = {
|
||||
repeat: "daily" | "weekly";
|
||||
selectedDays: string[]; // for weekly, 0-6 (0=Sun) as strings
|
||||
time: string; // HH:MM
|
||||
};
|
||||
|
||||
export function parseCronToForm(cron: string): ParsedCron | undefined {
|
||||
if (!cron) return undefined;
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return undefined;
|
||||
const [minute, hour, _dom, _mon, dow] = parts;
|
||||
const hh = String(hour ?? "0").padStart(2, "0");
|
||||
const mm = String(minute ?? "0").padStart(2, "0");
|
||||
const time = `${hh}:${mm}`; // Cron is stored in UTC; we keep raw HH:MM
|
||||
|
||||
if (dow && dow !== "*") {
|
||||
return { repeat: "weekly", selectedDays: dow.split(","), time };
|
||||
}
|
||||
|
||||
return { repeat: "daily", selectedDays: [], time };
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
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 {
|
||||
usePostV1ExecuteGraphAgent,
|
||||
getGetV1ListGraphExecutionsInfiniteQueryOptions,
|
||||
} from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import {
|
||||
usePostV1CreateExecutionSchedule as useCreateSchedule,
|
||||
getGetV1ListExecutionSchedulesForAGraphQueryKey,
|
||||
} from "@/app/api/__generated__/endpoints/schedules/schedules";
|
||||
import { usePostV2SetupTrigger } from "@/app/api/__generated__/endpoints/presets/presets";
|
||||
import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
|
||||
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
|
||||
@@ -26,6 +33,7 @@ export function useAgentRunModal(
|
||||
callbacks?: UseAgentRunModalCallbacks,
|
||||
) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showScheduleView, setShowScheduleView] = useState(false);
|
||||
const [inputValues, setInputValues] = useState<Record<string, any>>({});
|
||||
@@ -51,7 +59,13 @@ export function useAgentRunModal(
|
||||
toast({
|
||||
title: "Agent execution started",
|
||||
});
|
||||
callbacks?.onRun?.(response.data);
|
||||
callbacks?.onRun?.(response.data as unknown as GraphExecutionMeta);
|
||||
// Invalidate runs list for this graph
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV1ListGraphExecutionsInfiniteQueryOptions(
|
||||
agent.graph_id,
|
||||
).queryKey,
|
||||
});
|
||||
setIsOpen(false);
|
||||
}
|
||||
},
|
||||
@@ -73,6 +87,12 @@ export function useAgentRunModal(
|
||||
title: "Schedule created",
|
||||
});
|
||||
callbacks?.onCreateSchedule?.(response.data);
|
||||
// Invalidate schedules list for this graph
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getGetV1ListExecutionSchedulesForAGraphQueryKey(
|
||||
agent.graph_id,
|
||||
),
|
||||
});
|
||||
setIsOpen(false);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function RunDetailCard({ children }: Props) {
|
||||
return (
|
||||
<div className="min-h-20 rounded-xlarge border border-slate-50/70 bg-white p-6">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { RunStatusBadge } from "../RunDetails/components/RunStatusBadge";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import {
|
||||
PencilSimpleIcon,
|
||||
TrashIcon,
|
||||
StopIcon,
|
||||
PlayIcon,
|
||||
ArrowSquareOut,
|
||||
} from "@phosphor-icons/react";
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import moment from "moment";
|
||||
import { GraphExecution } from "@/app/api/__generated__/models/graphExecution";
|
||||
import { useRunDetailHeader } from "./useRunDetailHeader";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/molecules/DropdownMenu/DropdownMenu";
|
||||
import Link from "next/link";
|
||||
|
||||
type Props = {
|
||||
agent: LibraryAgent;
|
||||
run: GraphExecution | undefined;
|
||||
scheduleRecurrence?: string;
|
||||
onSelectRun?: (id: string) => void;
|
||||
onClearSelectedRun?: () => void;
|
||||
};
|
||||
|
||||
export function RunDetailHeader({
|
||||
agent,
|
||||
run,
|
||||
scheduleRecurrence,
|
||||
onSelectRun,
|
||||
onClearSelectedRun,
|
||||
}: Props) {
|
||||
const {
|
||||
stopRun,
|
||||
canStop,
|
||||
isStopping,
|
||||
deleteRun,
|
||||
isDeleting,
|
||||
runAgain,
|
||||
isRunningAgain,
|
||||
openInBuilderHref,
|
||||
} = useRunDetailHeader(agent.graph_id, run, onSelectRun, onClearSelectedRun);
|
||||
return (
|
||||
<div>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex w-full flex-col gap-0">
|
||||
<div className="flex w-full flex-col flex-wrap items-start justify-between gap-2 md:flex-row md:items-center">
|
||||
<div className="flex min-w-0 flex-1 flex-col items-start gap-2 md:flex-row md:items-center">
|
||||
{run?.status ? <RunStatusBadge status={run.status} /> : null}
|
||||
<Text
|
||||
variant="h3"
|
||||
className="truncate text-ellipsis !font-normal"
|
||||
>
|
||||
{agent.name}
|
||||
</Text>
|
||||
</div>
|
||||
{run ? (
|
||||
<div className="my-4 flex flex-wrap items-center gap-2 md:my-2 lg:my-0">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={runAgain}
|
||||
loading={isRunningAgain}
|
||||
>
|
||||
<PlayIcon size={16} /> Run again
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={deleteRun}
|
||||
loading={isDeleting}
|
||||
>
|
||||
<TrashIcon size={16} /> Delete run
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" size="small">
|
||||
•••
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{canStop ? (
|
||||
<DropdownMenuItem onClick={stopRun} disabled={isStopping}>
|
||||
<StopIcon size={14} className="mr-2" /> Stop run
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{openInBuilderHref ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={openInBuilderHref}
|
||||
target="_blank"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowSquareOut size={14} /> Open in builder
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/build?flowID=${agent.graph_id}&flowVersion=${agent.graph_version}`}
|
||||
target="_blank"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<PencilSimpleIcon size={16} /> Edit agent
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{run ? (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 gap-y-1 text-zinc-600">
|
||||
<Text variant="small" className="!text-zinc-600">
|
||||
Started {moment(run.started_at).fromNow()}
|
||||
</Text>
|
||||
<span className="mx-1 inline-block text-zinc-200">|</span>
|
||||
<Text variant="small" className="!text-zinc-600">
|
||||
Version: {run.graph_version}
|
||||
</Text>
|
||||
{run.stats?.node_exec_count !== undefined && (
|
||||
<>
|
||||
<span className="mx-1 inline-block text-zinc-200">|</span>
|
||||
<Text variant="small" className="!text-zinc-600">
|
||||
Steps: {run.stats.node_exec_count}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{run.stats?.duration !== undefined && (
|
||||
<>
|
||||
<span className="mx-1 inline-block text-zinc-200">|</span>
|
||||
<Text variant="small" className="!text-zinc-600">
|
||||
Duration:{" "}
|
||||
{moment.duration(run.stats.duration, "seconds").humanize()}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{run.stats?.cost !== undefined && (
|
||||
<>
|
||||
<span className="mx-1 inline-block text-zinc-200">|</span>
|
||||
<Text variant="small" className="!text-zinc-600">
|
||||
Cost: ${run.stats.cost}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{run.stats?.activity_status && (
|
||||
<>
|
||||
<span className="mx-1 inline-block text-zinc-200">|</span>
|
||||
<Text variant="small" className="!text-zinc-600">
|
||||
{String(run.stats.activity_status)}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : scheduleRecurrence ? (
|
||||
<Text variant="small" className="mt-1 !text-zinc-600">
|
||||
{scheduleRecurrence}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
usePostV1StopGraphExecution,
|
||||
getGetV1ListGraphExecutionsInfiniteQueryOptions,
|
||||
} from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import { useDeleteV1DeleteGraphExecution } from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import { usePostV1ExecuteGraphAgent } from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import type { GraphExecution } from "@/app/api/__generated__/models/graphExecution";
|
||||
|
||||
export function useRunDetailHeader(
|
||||
agentGraphId: string,
|
||||
run?: GraphExecution,
|
||||
onSelectRun?: (id: string) => void,
|
||||
onClearSelectedRun?: () => void,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
const stopMutation = usePostV1StopGraphExecution({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
toast({ title: "Run stopped" });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey:
|
||||
getGetV1ListGraphExecutionsInfiniteQueryOptions(agentGraphId)
|
||||
.queryKey,
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Failed to stop run",
|
||||
description: error?.message || "An unexpected error occurred.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function stopRun() {
|
||||
if (!run) return;
|
||||
stopMutation.mutate({ graphId: run.graph_id, graphExecId: run.id });
|
||||
}
|
||||
|
||||
const canStop = run?.status === "RUNNING" || run?.status === "QUEUED";
|
||||
|
||||
// Delete run
|
||||
const deleteMutation = useDeleteV1DeleteGraphExecution({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
toast({ title: "Run deleted" });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey:
|
||||
getGetV1ListGraphExecutionsInfiniteQueryOptions(agentGraphId)
|
||||
.queryKey,
|
||||
});
|
||||
if (onClearSelectedRun) onClearSelectedRun();
|
||||
},
|
||||
onError: (error: any) =>
|
||||
toast({
|
||||
title: "Failed to delete run",
|
||||
description: error?.message || "An unexpected error occurred.",
|
||||
variant: "destructive",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
function deleteRun() {
|
||||
if (!run) return;
|
||||
deleteMutation.mutate({ graphExecId: run.id });
|
||||
}
|
||||
|
||||
// Run again (execute agent with previous inputs/credentials)
|
||||
const executeMutation = usePostV1ExecuteGraphAgent({
|
||||
mutation: {
|
||||
onSuccess: async (res) => {
|
||||
toast({ title: "Run started" });
|
||||
const newRunId = res?.status === 200 ? (res?.data?.id ?? "") : "";
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey:
|
||||
getGetV1ListGraphExecutionsInfiniteQueryOptions(agentGraphId)
|
||||
.queryKey,
|
||||
});
|
||||
if (newRunId && onSelectRun) onSelectRun(newRunId);
|
||||
},
|
||||
onError: (error: any) =>
|
||||
toast({
|
||||
title: "Failed to start run",
|
||||
description: error?.message || "An unexpected error occurred.",
|
||||
variant: "destructive",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
function runAgain() {
|
||||
if (!run) return;
|
||||
executeMutation.mutate({
|
||||
graphId: run.graph_id,
|
||||
graphVersion: run.graph_version,
|
||||
data: {
|
||||
inputs: (run as any).inputs || {},
|
||||
credentials_inputs: (run as any).credential_inputs || {},
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
|
||||
// Open in builder URL helper
|
||||
const openInBuilderHref = run
|
||||
? `/build?flowID=${run.graph_id}&flowVersion=${run.graph_version}&flowExecutionID=${run.id}`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
stopRun,
|
||||
canStop,
|
||||
isStopping: stopMutation.isPending,
|
||||
deleteRun,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
runAgain,
|
||||
isRunningAgain: executeMutation.isPending,
|
||||
openInBuilderHref,
|
||||
} as const;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useDeleteV1DeleteExecutionSchedule,
|
||||
getGetV1ListExecutionSchedulesForAGraphQueryOptions,
|
||||
} from "@/app/api/__generated__/endpoints/schedules/schedules";
|
||||
|
||||
export function useScheduleDetailHeader(
|
||||
agentGraphId: string,
|
||||
scheduleId?: string,
|
||||
agentGraphVersion?: number | string,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
const deleteMutation = useDeleteV1DeleteExecutionSchedule({
|
||||
mutation: {
|
||||
onSuccess: () => {
|
||||
toast({ title: "Schedule deleted" });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey:
|
||||
getGetV1ListExecutionSchedulesForAGraphQueryOptions(agentGraphId)
|
||||
.queryKey,
|
||||
});
|
||||
},
|
||||
onError: (error: any) =>
|
||||
toast({
|
||||
title: "Failed to delete schedule",
|
||||
description: error?.message || "An unexpected error occurred.",
|
||||
variant: "destructive",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
function deleteSchedule() {
|
||||
if (!scheduleId) return;
|
||||
deleteMutation.mutate({ scheduleId });
|
||||
}
|
||||
|
||||
const openInBuilderHref = `/build?flowID=${agentGraphId}&flowVersion=${agentGraphVersion}`;
|
||||
|
||||
return {
|
||||
deleteSchedule,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
openInBuilderHref,
|
||||
} as const;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
TabsLine,
|
||||
TabsLineContent,
|
||||
TabsLineList,
|
||||
TabsLineTrigger,
|
||||
} from "@/components/molecules/TabsLine/TabsLine";
|
||||
import { useRunDetails } from "./useRunDetails";
|
||||
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader";
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { AgentInputsReadOnly } from "../AgentInputsReadOnly/AgentInputsReadOnly";
|
||||
import { RunDetailCard } from "../RunDetailCard/RunDetailCard";
|
||||
|
||||
interface RunDetailsProps {
|
||||
agent: LibraryAgent;
|
||||
runId: string;
|
||||
onSelectRun?: (id: string) => void;
|
||||
onClearSelectedRun?: () => void;
|
||||
}
|
||||
|
||||
export function RunDetails({
|
||||
agent,
|
||||
runId,
|
||||
onSelectRun,
|
||||
onClearSelectedRun,
|
||||
}: RunDetailsProps) {
|
||||
const { run, isLoading, responseError, httpError } = useRunDetails(
|
||||
agent.graph_id,
|
||||
runId,
|
||||
);
|
||||
|
||||
if (responseError || httpError) {
|
||||
return (
|
||||
<ErrorCard
|
||||
responseError={responseError ?? undefined}
|
||||
httpError={httpError ?? undefined}
|
||||
context="run"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading && !run) {
|
||||
return (
|
||||
<div className="flex-1 space-y-4">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<RunDetailHeader
|
||||
agent={agent}
|
||||
run={run}
|
||||
onSelectRun={onSelectRun}
|
||||
onClearSelectedRun={onClearSelectedRun}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<TabsLine defaultValue="output">
|
||||
<TabsLineList>
|
||||
<TabsLineTrigger value="output">Output</TabsLineTrigger>
|
||||
<TabsLineTrigger value="input">Your input</TabsLineTrigger>
|
||||
</TabsLineList>
|
||||
|
||||
<TabsLineContent value="output">
|
||||
<RunDetailCard>
|
||||
{isLoading ? (
|
||||
<div className="text-neutral-500">Loading…</div>
|
||||
) : !run ||
|
||||
!("outputs" in run) ||
|
||||
Object.keys(run.outputs || {}).length === 0 ? (
|
||||
<div className="text-neutral-600">No output from this run.</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{Object.entries(run.outputs).map(([key, values]) => (
|
||||
<div key={key} className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">{key}</label>
|
||||
{values.map((value, i) => (
|
||||
<p
|
||||
key={i}
|
||||
className="whitespace-pre-wrap break-words text-sm text-neutral-700"
|
||||
>
|
||||
{typeof value === "object"
|
||||
? JSON.stringify(value, undefined, 2)
|
||||
: String(value)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</RunDetailCard>
|
||||
</TabsLineContent>
|
||||
|
||||
<TabsLineContent value="input">
|
||||
<RunDetailCard>
|
||||
<AgentInputsReadOnly agent={agent} inputs={(run as any)?.inputs} />
|
||||
</RunDetailCard>
|
||||
</TabsLineContent>
|
||||
</TabsLine>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
PauseCircleIcon,
|
||||
StopCircleIcon,
|
||||
WarningCircleIcon,
|
||||
XCircleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type StatusIconMap = {
|
||||
icon: React.ReactNode;
|
||||
bgColor: string;
|
||||
textColor: string;
|
||||
};
|
||||
|
||||
const statusIconMap: Record<AgentExecutionStatus, StatusIconMap> = {
|
||||
INCOMPLETE: {
|
||||
icon: (
|
||||
<WarningCircleIcon size={16} className="text-red-700" weight="bold" />
|
||||
),
|
||||
bgColor: "bg-red-50",
|
||||
textColor: "!text-red-700",
|
||||
},
|
||||
QUEUED: {
|
||||
icon: <ClockIcon size={16} className="text-yellow-700" weight="bold" />,
|
||||
bgColor: "bg-yellow-50",
|
||||
textColor: "!text-yellow-700",
|
||||
},
|
||||
RUNNING: {
|
||||
icon: (
|
||||
<PauseCircleIcon size={16} className="text-yellow-700" weight="bold" />
|
||||
),
|
||||
bgColor: "bg-yellow-50",
|
||||
textColor: "!text-yellow-700",
|
||||
},
|
||||
COMPLETED: {
|
||||
icon: (
|
||||
<CheckCircleIcon size={16} className="text-green-700" weight="bold" />
|
||||
),
|
||||
bgColor: "bg-green-50",
|
||||
textColor: "!text-green-700",
|
||||
},
|
||||
TERMINATED: {
|
||||
icon: <StopCircleIcon size={16} className="text-slate-700" weight="bold" />,
|
||||
bgColor: "bg-slate-50",
|
||||
textColor: "!text-slate-700",
|
||||
},
|
||||
FAILED: {
|
||||
icon: <XCircleIcon size={16} className="text-red-700" weight="bold" />,
|
||||
bgColor: "bg-red-50",
|
||||
textColor: "!text-red-700",
|
||||
},
|
||||
};
|
||||
|
||||
type Props = {
|
||||
status: AgentExecutionStatus;
|
||||
};
|
||||
|
||||
export function RunStatusBadge({ status }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md p-1",
|
||||
statusIconMap[status].bgColor,
|
||||
)}
|
||||
>
|
||||
{statusIconMap[status].icon}
|
||||
<Text
|
||||
variant="small-medium"
|
||||
className={cn(statusIconMap[status].textColor, "capitalize")}
|
||||
>
|
||||
{status.toLowerCase()}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useGetV1GetExecutionDetails } from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import type { GetV1GetExecutionDetails200 } from "@/app/api/__generated__/models/getV1GetExecutionDetails200";
|
||||
|
||||
export function useRunDetails(graphId: string, runId: string) {
|
||||
const query = useGetV1GetExecutionDetails(graphId, runId);
|
||||
|
||||
const status = query.data?.status;
|
||||
|
||||
const run: GetV1GetExecutionDetails200 | undefined =
|
||||
status === 200
|
||||
? (query.data?.data as GetV1GetExecutionDetails200)
|
||||
: undefined;
|
||||
|
||||
const httpError =
|
||||
status && status !== 200
|
||||
? { status, statusText: `Request failed: ${status}` }
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
run,
|
||||
isLoading: query.isLoading,
|
||||
responseError: query.error,
|
||||
httpError,
|
||||
} as const;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
TabsLine,
|
||||
TabsLineList,
|
||||
TabsLineTrigger,
|
||||
TabsLineContent,
|
||||
} from "@/components/molecules/TabsLine/TabsLine";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { PlusIcon } from "@phosphor-icons/react/dist/ssr";
|
||||
import { RunAgentModal } from "../RunAgentModal/RunAgentModal";
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { useRunsSidebar } from "./useRunsSidebar";
|
||||
import { RunListItem } from "./components/RunListItem";
|
||||
import { ScheduleListItem } from "./components/ScheduleListItem";
|
||||
import type { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
|
||||
import { InfiniteList } from "@/components/molecules/InfiniteList/InfiniteList";
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
interface RunsSidebarProps {
|
||||
agent: LibraryAgent;
|
||||
selectedRunId?: string;
|
||||
onSelectRun: (id: string) => void;
|
||||
}
|
||||
|
||||
export function RunsSidebar({
|
||||
agent,
|
||||
selectedRunId,
|
||||
onSelectRun,
|
||||
}: RunsSidebarProps) {
|
||||
const {
|
||||
runs,
|
||||
schedules,
|
||||
runsCount,
|
||||
schedulesCount,
|
||||
error,
|
||||
loading,
|
||||
fetchMoreRuns,
|
||||
hasMoreRuns,
|
||||
isFetchingMoreRuns,
|
||||
tabValue,
|
||||
setTabValue,
|
||||
} = useRunsSidebar({ graphId: agent.graph_id, onSelectRun });
|
||||
|
||||
if (error) {
|
||||
return <ErrorCard responseError={error} />;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="ml-6 w-80 space-y-4">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0 bg-gray-50 p-4 pl-5">
|
||||
<RunAgentModal
|
||||
triggerSlot={
|
||||
<Button variant="primary" size="large" className="w-full">
|
||||
<PlusIcon size={20} /> New Run
|
||||
</Button>
|
||||
}
|
||||
agent={agent}
|
||||
agentId={agent.id.toString()}
|
||||
/>
|
||||
|
||||
<TabsLine
|
||||
value={tabValue}
|
||||
onValueChange={(v) => {
|
||||
const value = v as "runs" | "scheduled";
|
||||
setTabValue(value);
|
||||
if (value === "runs") {
|
||||
if (runs && runs.length) onSelectRun(runs[0].id);
|
||||
} else {
|
||||
if (schedules && schedules.length)
|
||||
onSelectRun(`schedule:${schedules[0].id}`);
|
||||
}
|
||||
}}
|
||||
className="mt-6 min-w-0 overflow-hidden"
|
||||
>
|
||||
<TabsLineList>
|
||||
<TabsLineTrigger value="runs">
|
||||
Runs <span className="ml-3 inline-block">{runsCount}</span>
|
||||
</TabsLineTrigger>
|
||||
<TabsLineTrigger value="scheduled">
|
||||
Scheduled{" "}
|
||||
<span className="ml-3 inline-block">{schedulesCount}</span>
|
||||
</TabsLineTrigger>
|
||||
</TabsLineList>
|
||||
|
||||
<>
|
||||
<TabsLineContent value="runs">
|
||||
<InfiniteList
|
||||
items={runs}
|
||||
hasMore={!!hasMoreRuns}
|
||||
isFetchingMore={isFetchingMoreRuns}
|
||||
onEndReached={fetchMoreRuns}
|
||||
className="flex flex-nowrap items-center justify-start gap-4 overflow-x-scroll px-1 pb-4 pt-1 lg:flex-col lg:gap-1 lg:overflow-x-hidden"
|
||||
itemWrapperClassName="w-auto lg:w-full"
|
||||
renderItem={(run) => (
|
||||
<div className="mb-3 w-[15rem] lg:w-full">
|
||||
<RunListItem
|
||||
run={run}
|
||||
title={agent.name}
|
||||
selected={selectedRunId === run.id}
|
||||
onClick={() => onSelectRun && onSelectRun(run.id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</TabsLineContent>
|
||||
<TabsLineContent value="scheduled">
|
||||
<div className="flex flex-nowrap items-center justify-start gap-4 overflow-x-scroll px-1 pb-4 pt-1 lg:flex-col lg:gap-1 lg:overflow-x-hidden">
|
||||
{schedules.map((s: GraphExecutionJobInfo) => (
|
||||
<div className="mb-3 w-[15rem] lg:w-full" key={s.id}>
|
||||
<ScheduleListItem
|
||||
schedule={s}
|
||||
selected={selectedRunId === `schedule:${s.id}`}
|
||||
onClick={() => onSelectRun(`schedule:${s.id}`)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsLineContent>
|
||||
</>
|
||||
</TabsLine>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
className: string;
|
||||
};
|
||||
|
||||
export function IconWrapper({ children, className }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-5 w-5 items-center justify-center rounded-large border",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import moment from "moment";
|
||||
import { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExecutionMeta";
|
||||
import { RunSidebarCard } from "./RunSidebarCard";
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
PauseCircleIcon,
|
||||
StopCircleIcon,
|
||||
WarningCircleIcon,
|
||||
XCircleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { IconWrapper } from "./RunIconWrapper";
|
||||
|
||||
const statusIconMap: Record<AgentExecutionStatus, React.ReactNode> = {
|
||||
INCOMPLETE: (
|
||||
<IconWrapper className="border-red-50 bg-red-50">
|
||||
<WarningCircleIcon size={16} className="text-red-700" weight="bold" />
|
||||
</IconWrapper>
|
||||
),
|
||||
QUEUED: (
|
||||
<IconWrapper className="border-yellow-50 bg-yellow-50">
|
||||
<ClockIcon size={16} className="text-yellow-700" weight="bold" />
|
||||
</IconWrapper>
|
||||
),
|
||||
RUNNING: (
|
||||
<IconWrapper className="border-yellow-50 bg-yellow-50">
|
||||
<PauseCircleIcon size={16} className="text-yellow-700" weight="bold" />
|
||||
</IconWrapper>
|
||||
),
|
||||
COMPLETED: (
|
||||
<IconWrapper className="border-green-50 bg-green-50">
|
||||
<CheckCircleIcon size={16} className="text-green-700" weight="bold" />
|
||||
</IconWrapper>
|
||||
),
|
||||
TERMINATED: (
|
||||
<IconWrapper className="border-slate-50 bg-slate-50">
|
||||
<StopCircleIcon size={16} className="text-slate-700" weight="bold" />
|
||||
</IconWrapper>
|
||||
),
|
||||
FAILED: (
|
||||
<IconWrapper className="border-red-50 bg-red-50">
|
||||
<XCircleIcon size={16} className="text-red-700" weight="bold" />
|
||||
</IconWrapper>
|
||||
),
|
||||
};
|
||||
|
||||
interface RunListItemProps {
|
||||
run: GraphExecutionMeta;
|
||||
title: string;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function RunListItem({
|
||||
run,
|
||||
title,
|
||||
selected,
|
||||
onClick,
|
||||
}: RunListItemProps) {
|
||||
return (
|
||||
<RunSidebarCard
|
||||
icon={statusIconMap[run.status]}
|
||||
title={title}
|
||||
description={moment(run.started_at).fromNow()}
|
||||
onClick={onClick}
|
||||
selected={selected}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
|
||||
interface RunListItemProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function RunSidebarCard({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
selected,
|
||||
onClick,
|
||||
}: RunListItemProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"w-full rounded-large border border-slate-50/70 bg-white p-3 text-left transition-all duration-150 hover:scale-[1.01] hover:bg-slate-50/50",
|
||||
selected ? "ring-2 ring-slate-800" : undefined,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex min-w-0 items-center justify-start gap-3">
|
||||
{icon}
|
||||
<div className="flex min-w-0 flex-1 flex-col items-start justify-between">
|
||||
<Text
|
||||
variant="body-medium"
|
||||
className="block w-full truncate text-ellipsis"
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<Text variant="small" className="!text-zinc-500">
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
|
||||
import moment from "moment";
|
||||
import { RunSidebarCard } from "./RunSidebarCard";
|
||||
import { IconWrapper } from "./RunIconWrapper";
|
||||
import { ClockClockwiseIcon } from "@phosphor-icons/react";
|
||||
|
||||
interface ScheduleListItemProps {
|
||||
schedule: GraphExecutionJobInfo;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function ScheduleListItem({
|
||||
schedule,
|
||||
selected,
|
||||
onClick,
|
||||
}: ScheduleListItemProps) {
|
||||
return (
|
||||
<RunSidebarCard
|
||||
title={schedule.name}
|
||||
description={moment(schedule.next_run_time).fromNow()}
|
||||
onClick={onClick}
|
||||
selected={selected}
|
||||
icon={
|
||||
<IconWrapper className="border-slate-50 bg-slate-50">
|
||||
<ClockClockwiseIcon
|
||||
size={16}
|
||||
className="text-slate-700"
|
||||
weight="bold"
|
||||
/>
|
||||
</IconWrapper>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { useGetV1ListGraphExecutionsInfinite } from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import { useGetV1ListExecutionSchedulesForAGraph } from "@/app/api/__generated__/endpoints/schedules/schedules";
|
||||
import { GraphExecutionsPaginated } from "@/app/api/__generated__/models/graphExecutionsPaginated";
|
||||
import type { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
type Args = {
|
||||
graphId?: string;
|
||||
onSelectRun: (runId: string) => void;
|
||||
};
|
||||
|
||||
export function useRunsSidebar({ graphId, onSelectRun }: Args) {
|
||||
const params = useSearchParams();
|
||||
const existingRunId = params.get("run") as string | undefined;
|
||||
const [tabValue, setTabValue] = useState<"runs" | "scheduled">("runs");
|
||||
|
||||
const runsQuery = useGetV1ListGraphExecutionsInfinite(
|
||||
graphId || "",
|
||||
{ page: 1, page_size: 20 },
|
||||
{
|
||||
query: {
|
||||
enabled: !!graphId,
|
||||
// Lightweight polling so statuses refresh; only poll if any run is active
|
||||
refetchInterval: (q) => {
|
||||
if (tabValue !== "runs") return false;
|
||||
const pages = q.state.data?.pages as
|
||||
| Array<{ data: unknown }>
|
||||
| undefined;
|
||||
if (!pages || pages.length === 0) return false;
|
||||
try {
|
||||
const executions = pages.flatMap((p) => {
|
||||
const response = p.data as GraphExecutionsPaginated;
|
||||
return response.executions || [];
|
||||
});
|
||||
const hasActive = executions.some(
|
||||
(e: { status?: string }) =>
|
||||
e.status === "RUNNING" || e.status === "QUEUED",
|
||||
);
|
||||
return hasActive ? 3000 : false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
refetchIntervalInBackground: true,
|
||||
refetchOnWindowFocus: false,
|
||||
getNextPageParam: (lastPage) => {
|
||||
const pagination = (lastPage.data as GraphExecutionsPaginated)
|
||||
.pagination;
|
||||
const hasMore =
|
||||
pagination.current_page * pagination.page_size <
|
||||
pagination.total_items;
|
||||
|
||||
return hasMore ? pagination.current_page + 1 : undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const schedulesQuery = useGetV1ListExecutionSchedulesForAGraph(
|
||||
graphId || "",
|
||||
{
|
||||
query: { enabled: !!graphId },
|
||||
},
|
||||
);
|
||||
|
||||
const runs = useMemo(
|
||||
() =>
|
||||
runsQuery.data?.pages.flatMap((p) => {
|
||||
const response = p.data as GraphExecutionsPaginated;
|
||||
return response.executions;
|
||||
}) || [],
|
||||
[runsQuery.data],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (runs.length > 0) {
|
||||
if (existingRunId) {
|
||||
onSelectRun(existingRunId);
|
||||
return;
|
||||
}
|
||||
onSelectRun(runs[0].id);
|
||||
}
|
||||
}, [runs, existingRunId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (existingRunId && existingRunId.startsWith("schedule:"))
|
||||
setTabValue("scheduled");
|
||||
else setTabValue("runs");
|
||||
}, [existingRunId]);
|
||||
|
||||
const schedules: GraphExecutionJobInfo[] =
|
||||
schedulesQuery.data?.status === 200 ? schedulesQuery.data.data : [];
|
||||
|
||||
return {
|
||||
runs,
|
||||
schedules,
|
||||
error: schedulesQuery.error || runsQuery.error,
|
||||
loading: !schedulesQuery.isSuccess || !runsQuery.isSuccess,
|
||||
runsQuery,
|
||||
tabValue,
|
||||
setTabValue,
|
||||
runsCount:
|
||||
(
|
||||
runsQuery.data?.pages.at(-1)?.data as
|
||||
| GraphExecutionsPaginated
|
||||
| undefined
|
||||
)?.pagination.total_items || runs.length,
|
||||
schedulesCount: schedules.length,
|
||||
fetchMoreRuns: runsQuery.fetchNextPage,
|
||||
hasMoreRuns: runsQuery.hasNextPage,
|
||||
isFetchingMoreRuns: runsQuery.isFetchingNextPage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import {
|
||||
TabsLine,
|
||||
TabsLineContent,
|
||||
TabsLineList,
|
||||
TabsLineTrigger,
|
||||
} from "@/components/molecules/TabsLine/TabsLine";
|
||||
import { useScheduleDetails } from "./useScheduleDetails";
|
||||
import { RunDetailCard } from "../RunDetailCard/RunDetailCard";
|
||||
import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/molecules/DropdownMenu/DropdownMenu";
|
||||
import { PencilSimpleIcon, ArrowSquareOut } from "@phosphor-icons/react";
|
||||
import Link from "next/link";
|
||||
import { useScheduleDetailHeader } from "../RunDetailHeader/useScheduleDetailHeader";
|
||||
import { DeleteScheduleButton } from "./components/DeleteScheduleButton/DeleteScheduleButton";
|
||||
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
|
||||
import { useGetV1GetUserTimezone } from "@/app/api/__generated__/endpoints/auth/auth";
|
||||
import { formatInTimezone } from "@/lib/timezone-utils";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { AgentInputsReadOnly } from "../AgentInputsReadOnly/AgentInputsReadOnly";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
|
||||
interface ScheduleDetailsProps {
|
||||
agent: LibraryAgent;
|
||||
scheduleId: string;
|
||||
onClearSelectedRun?: () => void;
|
||||
}
|
||||
|
||||
export function ScheduleDetails({
|
||||
agent,
|
||||
scheduleId,
|
||||
onClearSelectedRun,
|
||||
}: ScheduleDetailsProps) {
|
||||
const { schedule, isLoading, error } = useScheduleDetails(
|
||||
agent.graph_id,
|
||||
scheduleId,
|
||||
);
|
||||
const { data: userTzRes } = useGetV1GetUserTimezone({
|
||||
query: {
|
||||
select: (res) => (res.status === 200 ? res.data.timezone : undefined),
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorCard
|
||||
responseError={
|
||||
error
|
||||
? {
|
||||
message: String(
|
||||
(error as unknown as { message?: string })?.message ||
|
||||
"Failed to load schedule",
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
httpError={
|
||||
(error as any)?.status
|
||||
? {
|
||||
status: (error as any).status,
|
||||
statusText: (error as any).statusText,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
context="schedule"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading && !schedule) {
|
||||
return (
|
||||
<div className="flex-1 space-y-4">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex w-full flex-col gap-0">
|
||||
<RunDetailHeader
|
||||
agent={agent}
|
||||
run={undefined}
|
||||
scheduleRecurrence={humanizeCronExpression(
|
||||
schedule?.cron || "",
|
||||
userTzRes,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{schedule ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<DeleteScheduleButton
|
||||
agent={agent}
|
||||
scheduleId={schedule.id}
|
||||
onDeleted={onClearSelectedRun}
|
||||
/>
|
||||
<ScheduleActions agent={agent} scheduleId={schedule.id} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsLine defaultValue="input">
|
||||
<TabsLineList>
|
||||
<TabsLineTrigger value="input">Your input</TabsLineTrigger>
|
||||
<TabsLineTrigger value="schedule">Schedule</TabsLineTrigger>
|
||||
</TabsLineList>
|
||||
|
||||
<TabsLineContent value="input">
|
||||
<RunDetailCard>
|
||||
<div className="relative">
|
||||
{/* {// TODO: re-enable edit inputs modal once the API supports it */}
|
||||
{/* {schedule && Object.keys(schedule.input_data).length > 0 && (
|
||||
<EditInputsModal agent={agent} schedule={schedule} />
|
||||
)} */}
|
||||
<AgentInputsReadOnly
|
||||
agent={agent}
|
||||
inputs={schedule?.input_data}
|
||||
/>
|
||||
</div>
|
||||
</RunDetailCard>
|
||||
</TabsLineContent>
|
||||
|
||||
<TabsLineContent value="schedule">
|
||||
<RunDetailCard>
|
||||
{isLoading || !schedule ? (
|
||||
<div className="text-neutral-500">Loading…</div>
|
||||
) : (
|
||||
<div className="relative flex flex-col gap-8">
|
||||
{
|
||||
// TODO: re-enable edit schedule modal once the API supports it
|
||||
/* <EditScheduleModal
|
||||
graphId={agent.graph_id}
|
||||
schedule={schedule}
|
||||
/> */
|
||||
}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Text variant="body-medium" className="!text-black">
|
||||
Name
|
||||
</Text>
|
||||
<p className="text-sm text-zinc-600">{schedule.name}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Text variant="body-medium" className="!text-black">
|
||||
Recurrence
|
||||
</Text>
|
||||
<p className="text-sm text-zinc-600">
|
||||
{humanizeCronExpression(schedule.cron, userTzRes)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Text variant="body-medium" className="!text-black">
|
||||
Next run
|
||||
</Text>
|
||||
<p className="text-sm text-zinc-600">
|
||||
{formatInTimezone(
|
||||
schedule.next_run_time,
|
||||
userTzRes || "UTC",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</RunDetailCard>
|
||||
</TabsLineContent>
|
||||
</TabsLine>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleActions({
|
||||
agent,
|
||||
scheduleId,
|
||||
}: {
|
||||
agent: LibraryAgent;
|
||||
scheduleId: string;
|
||||
}) {
|
||||
const { openInBuilderHref } = useScheduleDetailHeader(
|
||||
agent.graph_id,
|
||||
scheduleId,
|
||||
agent.graph_version,
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" size="small">
|
||||
•••
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{openInBuilderHref ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={openInBuilderHref}
|
||||
target="_blank"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowSquareOut size={14} /> Open in builder
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/build?flowID=${agent.graph_id}&flowVersion=${agent.graph_version}`}
|
||||
target="_blank"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<PencilSimpleIcon size={16} /> Edit agent
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { TrashIcon } from "@phosphor-icons/react";
|
||||
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { useScheduleDetailHeader } from "../../../RunDetailHeader/useScheduleDetailHeader";
|
||||
|
||||
type Props = {
|
||||
agent: LibraryAgent;
|
||||
scheduleId: string;
|
||||
onDeleted?: () => void;
|
||||
};
|
||||
|
||||
export function DeleteScheduleButton({ agent, scheduleId, onDeleted }: Props) {
|
||||
const { deleteSchedule, isDeleting } = useScheduleDetailHeader(
|
||||
agent.graph_id,
|
||||
scheduleId,
|
||||
agent.graph_version,
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
deleteSchedule();
|
||||
if (onDeleted) onDeleted();
|
||||
}}
|
||||
loading={isDeleting}
|
||||
>
|
||||
<TrashIcon size={16} /> Delete schedule
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import type { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
|
||||
import { RunAgentInputs } from "../../../RunAgentInputs/RunAgentInputs";
|
||||
import { useEditInputsModal } from "./useEditInputsModal";
|
||||
import { PencilSimpleIcon } from "@phosphor-icons/react";
|
||||
|
||||
type Props = {
|
||||
agent: LibraryAgent;
|
||||
schedule: GraphExecutionJobInfo;
|
||||
};
|
||||
|
||||
export function EditInputsModal({ agent, schedule }: Props) {
|
||||
const {
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
inputFields,
|
||||
values,
|
||||
setValues,
|
||||
handleSave,
|
||||
isSaving,
|
||||
} = useEditInputsModal(agent, schedule);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
controlled={{ isOpen, set: setIsOpen }}
|
||||
styling={{ maxWidth: "32rem" }}
|
||||
>
|
||||
<Dialog.Trigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
className="absolute -right-2 -top-2"
|
||||
>
|
||||
<PencilSimpleIcon className="size-4" /> Edit inputs
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Text variant="h3">Edit inputs</Text>
|
||||
<div className="flex flex-col gap-4">
|
||||
{Object.entries(inputFields).map(([key, fieldSchema]) => (
|
||||
<div key={key} className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
{fieldSchema?.title || key}
|
||||
</label>
|
||||
<RunAgentInputs
|
||||
schema={fieldSchema as any}
|
||||
value={values[key]}
|
||||
onChange={(v) => setValues((prev) => ({ ...prev, [key]: v }))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<div className="flex w-full justify-end gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="min-w-32"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={handleSave}
|
||||
loading={isSaving}
|
||||
className="min-w-32"
|
||||
>
|
||||
{isSaving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { getGetV1ListExecutionSchedulesForAGraphQueryKey } from "@/app/api/__generated__/endpoints/schedules/schedules";
|
||||
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import type { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
|
||||
function getAgentInputFields(agent: LibraryAgent): Record<string, any> {
|
||||
const schema = agent.input_schema as unknown as {
|
||||
properties?: Record<string, any>;
|
||||
} | null;
|
||||
if (!schema || !schema.properties) return {};
|
||||
const properties = schema.properties as Record<string, any>;
|
||||
const visibleEntries = Object.entries(properties).filter(
|
||||
([, sub]) => !sub?.hidden,
|
||||
);
|
||||
return Object.fromEntries(visibleEntries);
|
||||
}
|
||||
|
||||
export function useEditInputsModal(
|
||||
agent: LibraryAgent,
|
||||
schedule: GraphExecutionJobInfo,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const inputFields = useMemo(() => getAgentInputFields(agent), [agent]);
|
||||
const [values, setValues] = useState<Record<string, any>>({
|
||||
...(schedule.input_data as Record<string, any>),
|
||||
});
|
||||
|
||||
async function handleSave() {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/schedules/${schedule.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ inputs: values }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let message = "Failed to update schedule inputs";
|
||||
const data = await res.json();
|
||||
message = data?.message || data?.detail || message;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: getGetV1ListExecutionSchedulesForAGraphQueryKey(
|
||||
schedule.graph_id,
|
||||
),
|
||||
});
|
||||
toast({
|
||||
title: "Schedule inputs updated",
|
||||
});
|
||||
setIsOpen(false);
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "Failed to update schedule inputs",
|
||||
description: error?.message || "An unexpected error occurred.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
setIsSaving(false);
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
inputFields,
|
||||
values,
|
||||
setValues,
|
||||
handleSave,
|
||||
isSaving,
|
||||
} as const;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { MultiToggle } from "@/components/molecules/MultiToggle/MultiToggle";
|
||||
import { Select } from "@/components/atoms/Select/Select";
|
||||
import { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
|
||||
import { useEditScheduleModal } from "./useEditScheduleModal";
|
||||
import { PencilSimpleIcon } from "@phosphor-icons/react";
|
||||
|
||||
type Props = {
|
||||
graphId: string;
|
||||
schedule: GraphExecutionJobInfo;
|
||||
};
|
||||
|
||||
export function EditScheduleModal({ graphId, schedule }: Props) {
|
||||
const {
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
name,
|
||||
setName,
|
||||
repeat,
|
||||
setRepeat,
|
||||
selectedDays,
|
||||
setSelectedDays,
|
||||
time,
|
||||
setTime,
|
||||
errors,
|
||||
repeatOptions,
|
||||
dayItems,
|
||||
mutateAsync,
|
||||
isPending,
|
||||
} = useEditScheduleModal(graphId, schedule);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
controlled={{ isOpen, set: setIsOpen }}
|
||||
styling={{ maxWidth: "22rem" }}
|
||||
>
|
||||
<Dialog.Trigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
className="absolute -right-2 -top-2"
|
||||
>
|
||||
<PencilSimpleIcon className="size-4" /> Edit schedule
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content>
|
||||
<div className="flex flex-col gap-6">
|
||||
<Text variant="h3">Edit schedule</Text>
|
||||
<Input
|
||||
id="schedule-name"
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
error={errors.scheduleName}
|
||||
/>
|
||||
<Select
|
||||
id="repeat"
|
||||
label="Repeats"
|
||||
value={repeat}
|
||||
onValueChange={setRepeat}
|
||||
options={repeatOptions}
|
||||
/>
|
||||
{repeat === "weekly" && (
|
||||
<MultiToggle
|
||||
items={dayItems}
|
||||
selectedValues={selectedDays}
|
||||
onChange={setSelectedDays}
|
||||
aria-label="Select days"
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
id="schedule-time"
|
||||
label="At"
|
||||
value={time}
|
||||
onChange={(e) => setTime(e.target.value.trim())}
|
||||
placeholder="00:00"
|
||||
error={errors.time}
|
||||
/>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<div className="flex w-full justify-end gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="min-w-32"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={() => mutateAsync()}
|
||||
loading={isPending}
|
||||
className="min-w-32"
|
||||
>
|
||||
{isPending ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getGetV1ListExecutionSchedulesForAGraphQueryKey } from "@/app/api/__generated__/endpoints/schedules/schedules";
|
||||
import { getGetV1ListGraphExecutionsInfiniteQueryOptions } from "@/app/api/__generated__/endpoints/graphs/graphs";
|
||||
import {
|
||||
parseCronToForm,
|
||||
validateSchedule,
|
||||
} from "../../../RunAgentModal/components/ScheduleView/helpers";
|
||||
import type { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
|
||||
export function useEditScheduleModal(
|
||||
graphId: string,
|
||||
schedule: GraphExecutionJobInfo,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [name, setName] = useState(schedule.name);
|
||||
|
||||
const parsed = useMemo(() => parseCronToForm(schedule.cron), [schedule.cron]);
|
||||
const [repeat, setRepeat] = useState<string>(parsed?.repeat || "daily");
|
||||
const [selectedDays, setSelectedDays] = useState<string[]>(
|
||||
parsed?.selectedDays || [],
|
||||
);
|
||||
const [time, setTime] = useState<string>(parsed?.time || "00:00");
|
||||
const [errors, setErrors] = useState<{
|
||||
scheduleName?: string;
|
||||
time?: string;
|
||||
}>({});
|
||||
|
||||
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" },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
function humanizeToCron(): string {
|
||||
const [hh, mm] = time.split(":");
|
||||
const minute = Number(mm || 0);
|
||||
const hour = Number(hh || 0);
|
||||
if (repeat === "weekly") {
|
||||
const dow = selectedDays.length ? selectedDays.join(",") : "*";
|
||||
return `${minute} ${hour} * * ${dow}`;
|
||||
}
|
||||
return `${minute} ${hour} * * *`;
|
||||
}
|
||||
|
||||
const { mutateAsync, isPending } = useMutation({
|
||||
mutationKey: ["patchSchedule", schedule.id],
|
||||
mutationFn: async () => {
|
||||
const errorsNow = validateSchedule({ scheduleName: name, time });
|
||||
setErrors(errorsNow);
|
||||
if (Object.keys(errorsNow).length > 0) throw new Error("Invalid form");
|
||||
|
||||
const cron = humanizeToCron();
|
||||
const res = await fetch(`/api/schedules/${schedule.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, cron }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let message = "Failed to update schedule";
|
||||
try {
|
||||
const data = await res.json();
|
||||
message = data?.message || data?.detail || message;
|
||||
} catch {
|
||||
try {
|
||||
message = await res.text();
|
||||
} catch {}
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: getGetV1ListExecutionSchedulesForAGraphQueryKey(graphId),
|
||||
});
|
||||
const runsKey = getGetV1ListGraphExecutionsInfiniteQueryOptions(graphId)
|
||||
.queryKey as any;
|
||||
await queryClient.invalidateQueries({ queryKey: runsKey });
|
||||
setIsOpen(false);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "❌ Failed to update schedule",
|
||||
description: error?.message || "An unexpected error occurred.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
name,
|
||||
setName,
|
||||
repeat,
|
||||
setRepeat,
|
||||
selectedDays,
|
||||
setSelectedDays,
|
||||
time,
|
||||
setTime,
|
||||
errors,
|
||||
repeatOptions,
|
||||
dayItems,
|
||||
mutateAsync,
|
||||
isPending,
|
||||
} as const;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useGetV1ListExecutionSchedulesForAGraph } from "@/app/api/__generated__/endpoints/schedules/schedules";
|
||||
import type { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
|
||||
|
||||
export function useScheduleDetails(graphId: string, scheduleId: string) {
|
||||
const query = useGetV1ListExecutionSchedulesForAGraph(graphId, {
|
||||
query: {
|
||||
enabled: !!graphId,
|
||||
select: (res) =>
|
||||
res.status === 200 ? (res.data as GraphExecutionJobInfo[]) : [],
|
||||
},
|
||||
});
|
||||
|
||||
const schedule = useMemo(
|
||||
() => query.data?.find((s) => s.id === scheduleId),
|
||||
[query.data, scheduleId],
|
||||
);
|
||||
|
||||
const httpError =
|
||||
query.isSuccess && !schedule
|
||||
? { status: 404, statusText: "Not found" }
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
schedule,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error || httpError,
|
||||
} as const;
|
||||
}
|
||||
@@ -1,15 +1,30 @@
|
||||
import { useGetV2GetLibraryAgent } from "@/app/api/__generated__/endpoints/library/library";
|
||||
import { useParams } from "next/navigation";
|
||||
import { parseAsString, useQueryState } from "nuqs";
|
||||
|
||||
export function useAgentRunsView() {
|
||||
const { id } = useParams();
|
||||
const agentId = id as string;
|
||||
const { data: response, isSuccess, error } = useGetV2GetLibraryAgent(agentId);
|
||||
|
||||
const [runParam, setRunParam] = useQueryState("run", parseAsString);
|
||||
const selectedRun = runParam ?? undefined;
|
||||
|
||||
function handleSelectRun(id: string) {
|
||||
setRunParam(id, { shallow: true });
|
||||
}
|
||||
|
||||
function clearSelectedRun() {
|
||||
setRunParam(null, { shallow: true });
|
||||
}
|
||||
|
||||
return {
|
||||
agentId: id,
|
||||
ready: isSuccess,
|
||||
error,
|
||||
response,
|
||||
selectedRun,
|
||||
handleSelectRun,
|
||||
clearSelectedRun,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -309,7 +309,7 @@ export function OldAgentLibraryView() {
|
||||
const newSelectedRun = agentRuns.find((run) => run.id == selectedView.id);
|
||||
if (selectedView.id !== selectedRun?.id) {
|
||||
// Pull partial data from "cache" while waiting for the rest to load
|
||||
setSelectedRun(newSelectedRun ?? null);
|
||||
setSelectedRun((newSelectedRun as GraphExecutionMeta) ?? null);
|
||||
}
|
||||
}, [api, selectedView, agentRuns, selectedRun?.id]);
|
||||
|
||||
|
||||
@@ -16,13 +16,9 @@ import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import LoadingBox, { LoadingSpinner } from "@/components/ui/loading";
|
||||
import { PlusIcon } from "@phosphor-icons/react";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
|
||||
import { RunAgentModal } from "../../AgentRunsView/components/RunAgentModal/RunAgentModal";
|
||||
import { AgentRunsQuery } from "../use-agent-runs";
|
||||
import { agentRunStatusMap } from "./agent-run-status-chip";
|
||||
import { AgentRunSummaryCard } from "./agent-run-summary-card";
|
||||
@@ -73,8 +69,6 @@ export function AgentRunsSelectorList({
|
||||
"runs",
|
||||
);
|
||||
|
||||
const isNewAgentRunsEnabled = useGetFlag(Flag.NEW_AGENT_RUNS);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedView.type === "schedule") {
|
||||
setActiveListTab("scheduled");
|
||||
@@ -87,17 +81,7 @@ export function AgentRunsSelectorList({
|
||||
|
||||
return (
|
||||
<aside className={cn("flex flex-col gap-4", className)}>
|
||||
{isNewAgentRunsEnabled ? (
|
||||
<RunAgentModal
|
||||
triggerSlot={
|
||||
<Button variant="primary" size="large" className="w-full">
|
||||
<PlusIcon size={20} /> New Run
|
||||
</Button>
|
||||
}
|
||||
agent={agent}
|
||||
agentId={agent.id.toString()}
|
||||
/>
|
||||
) : allowDraftNewRun ? (
|
||||
{allowDraftNewRun ? (
|
||||
<Button
|
||||
className={"mb-4 hidden lg:flex"}
|
||||
onClick={onSelectDraftNewRun}
|
||||
@@ -218,7 +202,7 @@ export function AgentRunsSelectorList({
|
||||
timestamp={run.started_at}
|
||||
selected={selectedView.id === run.id}
|
||||
onClick={() => onSelectRun(run.id)}
|
||||
onDelete={() => doDeleteRun(run)}
|
||||
onDelete={() => doDeleteRun(run as GraphExecutionMeta)}
|
||||
onPinAsPreset={
|
||||
doCreatePresetFromRun
|
||||
? () => doCreatePresetFromRun(run.id)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { AgentRunsView } from "./components/AgentRunsView/AgentRunsView";
|
||||
import { OldAgentLibraryView } from "./components/OldAgentLibraryView/OldAgentLibraryView";
|
||||
|
||||
export default function AgentLibraryPage() {
|
||||
return <OldAgentLibraryView />;
|
||||
const isNewLibraryPageEnabled = useGetFlag(Flag.NEW_AGENT_RUNS);
|
||||
return isNewLibraryPageEnabled ? <AgentRunsView /> : <OldAgentLibraryView />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { OldAgentLibraryView } from "../../agents/[id]/components/OldAgentLibraryView/OldAgentLibraryView";
|
||||
|
||||
export default function OldAgentLibraryPage() {
|
||||
return <OldAgentLibraryView />;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -12,9 +11,10 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { usePathname } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { IconChevronUp, IconMenu } from "../../../../ui/icons";
|
||||
import { MenuItemGroup } from "../../helpers";
|
||||
import { MobileNavbarMenuItem } from "./components/MobileNavbarMenuItem";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { CaretUpIcon, ListIcon } from "@phosphor-icons/react";
|
||||
|
||||
interface MobileNavBarProps {
|
||||
userName?: string;
|
||||
@@ -48,14 +48,15 @@ export function MobileNavBar({
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label="Open menu"
|
||||
className="fixed right-4 top-4 z-50 flex h-14 w-14 items-center justify-center rounded-lg border border-neutral-500 bg-neutral-200 hover:bg-gray-200/50 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:bg-gray-700/50 md:hidden"
|
||||
className="min-w-auto flex !w-[3.75rem] items-center justify-center md:hidden"
|
||||
data-testid="mobile-nav-bar-trigger"
|
||||
>
|
||||
{isOpen ? (
|
||||
<IconChevronUp className="h-8 w-8 stroke-black dark:stroke-white" />
|
||||
<CaretUpIcon className="size-6 stroke-slate-800" />
|
||||
) : (
|
||||
<IconMenu className="h-8 w-8 stroke-black dark:stroke-white" />
|
||||
<ListIcon className="size-6 stroke-slate-800" />
|
||||
)}
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
@@ -68,10 +69,10 @@ export function MobileNavBar({
|
||||
initial={{ opacity: 0, y: -32 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -32, transition: { duration: 0.2 } }}
|
||||
className="w-screen rounded-b-2xl bg-white dark:bg-neutral-900"
|
||||
className="w-screen rounded-b-2xl bg-white"
|
||||
>
|
||||
<div className="mb-4 inline-flex w-full items-end justify-start gap-4">
|
||||
<Avatar className="h-14 w-14 border border-[#474747] dark:border-[#cfcfcf]">
|
||||
<Avatar className="h-14 w-14">
|
||||
<AvatarImage
|
||||
src={avatarSrc}
|
||||
alt={userName || "Unknown User"}
|
||||
@@ -81,15 +82,15 @@ export function MobileNavBar({
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="relative h-14 w-full">
|
||||
<div className="absolute left-0 top-0 text-lg font-semibold leading-7 text-[#474747] dark:text-[#cfcfcf]">
|
||||
<div className="absolute left-0 top-0 text-lg font-semibold leading-7 text-[#474747]">
|
||||
{userName || "Unknown User"}
|
||||
</div>
|
||||
<div className="absolute left-0 top-6 font-sans text-base font-normal leading-7 text-[#474747] dark:text-[#cfcfcf]">
|
||||
<div className="absolute left-0 top-6 font-sans text-base font-normal leading-7 text-[#474747]">
|
||||
{userEmail || "No Email Set"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="mb-4 dark:bg-[#3a3a3a]" />
|
||||
<Separator className="mb-4" />
|
||||
{menuItemGroups.map((group, groupIndex) => (
|
||||
<React.Fragment key={groupIndex}>
|
||||
{group.items.map((item, itemIndex) => (
|
||||
@@ -103,7 +104,7 @@ export function MobileNavBar({
|
||||
/>
|
||||
))}
|
||||
{groupIndex < menuItemGroups.length - 1 && (
|
||||
<Separator className="my-4 dark:bg-[#3a3a3a]" />
|
||||
<Separator className="my-4" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
@@ -19,21 +19,19 @@ export function MobileNavbarMenuItem({
|
||||
onClick,
|
||||
}: Props) {
|
||||
const content = (
|
||||
<div className="inline-flex w-full items-center justify-start gap-4 hover:rounded hover:bg-[#e0e0e0] dark:hover:bg-[#3a3a3a]">
|
||||
<div className="inline-flex w-full items-center justify-start gap-4 hover:rounded hover:bg-[#e0e0e0]">
|
||||
{getAccountMenuOptionIcon(icon)}
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
"font-sans text-base font-normal leading-7",
|
||||
isActive
|
||||
? "font-semibold text-[#272727] dark:text-[#ffffff]"
|
||||
: "text-[#474747] dark:text-[#cfcfcf]",
|
||||
isActive ? "font-semibold text-[#272727]" : "text-[#474747]",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
{isActive && (
|
||||
<div className="absolute bottom-[-4px] left-0 h-[2px] w-full bg-[#272727] dark:bg-[#ffffff]"></div>
|
||||
<div className="absolute bottom-[-4px] left-0 h-[2px] w-full bg-[#272727]"></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,9 +26,9 @@ export function NavbarLink({ name, href }: Props) {
|
||||
<Link href={href} data-testid={`navbar-link-${name.toLowerCase()}`}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-start gap-1 p-2",
|
||||
"flex items-center justify-start gap-1 p-1 md:p-2",
|
||||
isActive &&
|
||||
"rounded-small bg-neutral-800 py-2 pl-2 pr-3 transition-all duration-300 dark:bg-neutral-200",
|
||||
"rounded-small bg-neutral-800 py-1 pl-1 pr-1.5 transition-all duration-300 dark:bg-neutral-200 md:py-2 md:pl-2 md:pr-3",
|
||||
)}
|
||||
>
|
||||
{href === "/marketplace" && (
|
||||
|
||||
@@ -27,9 +27,9 @@ export const NavbarView = ({ isLoggedIn }: NavbarViewProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="sticky top-0 z-40 hidden h-16 items-center border border-white/50 bg-[#f3f4f6]/20 p-3 backdrop-blur-[26px] md:inline-flex">
|
||||
<nav className="sticky top-0 z-40 inline-flex h-16 items-center border border-white/50 bg-[#f3f4f6]/20 p-3 backdrop-blur-[26px]">
|
||||
{/* Left section */}
|
||||
<div className="flex flex-1 items-center gap-5">
|
||||
<div className="hidden flex-1 items-center gap-3 md:flex md:gap-5">
|
||||
{isLoggedIn
|
||||
? loggedInLinks.map((link) => (
|
||||
<NavbarLink key={link.name} name={link.name} href={link.href} />
|
||||
@@ -40,12 +40,12 @@ export const NavbarView = ({ isLoggedIn }: NavbarViewProps) => {
|
||||
</div>
|
||||
|
||||
{/* Centered logo */}
|
||||
<div className="absolute left-1/2 top-1/2 h-10 w-[88.87px] -translate-x-1/2 -translate-y-1/2">
|
||||
<div className="absolute left-16 top-1/2 h-auto w-[5.5rem] -translate-x-1/2 -translate-y-1/2 md:left-1/2">
|
||||
<IconAutoGPTLogo className="h-full w-full" />
|
||||
</div>
|
||||
|
||||
{/* Right section */}
|
||||
<div className="flex flex-1 items-center justify-end gap-4">
|
||||
<div className="hidden flex-1 items-center justify-end gap-4 md:flex">
|
||||
{isLoggedIn ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<AgentActivityDropdown />
|
||||
@@ -66,7 +66,8 @@ export const NavbarView = ({ isLoggedIn }: NavbarViewProps) => {
|
||||
{/* Mobile Navbar - Adjust positioning */}
|
||||
<>
|
||||
{isLoggedIn ? (
|
||||
<div className="fixed right-4 top-4 z-50">
|
||||
<div className="fixed -right-4 top-2 z-50 flex items-center gap-0 md:hidden">
|
||||
<Wallet />
|
||||
<MobileNavBar
|
||||
userName={profile?.username}
|
||||
menuItemGroups={[
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
IconBuilder,
|
||||
IconEdit,
|
||||
IconLayoutDashboard,
|
||||
IconLibrary,
|
||||
IconLogOut,
|
||||
IconMarketplace,
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
IconType,
|
||||
IconUploadCloud,
|
||||
} from "@/components/ui/icons";
|
||||
import { StorefrontIcon } from "@phosphor-icons/react";
|
||||
|
||||
type Link = {
|
||||
name: string;
|
||||
@@ -155,10 +155,10 @@ export function getAccountMenuItems(userRole?: string): MenuItemGroup[] {
|
||||
}
|
||||
|
||||
export function getAccountMenuOptionIcon(icon: IconType) {
|
||||
const iconClass = "w-6 h-6";
|
||||
const iconClass = "w-5 h-5";
|
||||
switch (icon) {
|
||||
case IconType.LayoutDashboard:
|
||||
return <IconLayoutDashboard className={iconClass} />;
|
||||
return <StorefrontIcon className={iconClass} />;
|
||||
case IconType.UploadCloud:
|
||||
return <IconUploadCloud className={iconClass} />;
|
||||
case IconType.Edit:
|
||||
|
||||
@@ -13,24 +13,22 @@ interface Props {
|
||||
|
||||
export function Breadcrumbs({ items }: Props) {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-auto flex-wrap items-center justify-start gap-3 rounded-[5rem] dark:bg-transparent">
|
||||
{items.map((item, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<Link
|
||||
href={item.link}
|
||||
className="text-zinc-700 transition-colors hover:text-zinc-900 hover:no-underline"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
{index < items.length - 1 && (
|
||||
<Text variant="body-medium" className="text-zinc-700">
|
||||
/
|
||||
</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="mb-4 flex h-auto flex-wrap items-center justify-start gap-2 md:mb-0 md:gap-3">
|
||||
{items.map((item, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<Link
|
||||
href={item.link}
|
||||
className="text-zinc-700 transition-colors hover:text-zinc-900 hover:no-underline"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
{index < items.length - 1 && (
|
||||
<Text variant="body-medium" className="text-zinc-700">
|
||||
/
|
||||
</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from "./DropdownMenu";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Molecules/DropdownMenu",
|
||||
component: DropdownMenuContent,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DropdownMenuContent>;
|
||||
|
||||
export const Basic: Story = {
|
||||
render: () => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" size="small">
|
||||
Open menu
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => alert("Action 1")}>
|
||||
Action 1
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => alert("Action 2")}>
|
||||
Action 2
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => alert("Danger")}>
|
||||
Danger
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
DotFilledIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-neutral-100 data-[state=open]:bg-neutral-100 dark:focus:bg-neutral-800 dark:data-[state=open]:bg-neutral-800",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-neutral-200 bg-white p-1 text-neutral-950 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border border-neutral-200 bg-white p-1 text-neutral-950 shadow-md dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-neutral-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-neutral-800 dark:focus:text-neutral-50",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-neutral-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-neutral-800 dark:focus:text-neutral-50",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-neutral-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-neutral-800 dark:focus:text-neutral-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<DotFilledIcon className="h-4 w-4 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
));
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"-mx-1 my-1 h-px bg-neutral-100 dark:bg-neutral-800",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { ErrorCard } from "./ErrorCard";
|
||||
import { ArrowClockwise } from "@phosphor-icons/react";
|
||||
|
||||
const meta: Meta<typeof ErrorCard> = {
|
||||
title: "Molecules/ErrorCard",
|
||||
@@ -86,11 +85,6 @@ The component will automatically:
|
||||
defaultValue: { summary: '"data"' },
|
||||
},
|
||||
},
|
||||
loadingSlot: {
|
||||
control: false,
|
||||
description:
|
||||
"Custom loading component to show instead of default spinner",
|
||||
},
|
||||
onRetry: {
|
||||
control: false,
|
||||
description:
|
||||
@@ -190,24 +184,6 @@ export const LoadingState: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* You can provide a custom loading component via the loadingSlot prop.
|
||||
*/
|
||||
export const CustomLoadingSlot: Story = {
|
||||
args: {
|
||||
loadingSlot: (
|
||||
<div className="flex items-center gap-3">
|
||||
<ArrowClockwise
|
||||
size={20}
|
||||
weight="bold"
|
||||
className="animate-spin text-purple-500"
|
||||
/>
|
||||
<span className="text-zinc-600">Loading your awesome data...</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Response errors can also have string error details instead of arrays.
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { getErrorMessage, getHttpErrorMessage, isHttpError } from "./helpers";
|
||||
import { getErrorMessage, getHttpErrorMessage } from "./helpers";
|
||||
import { CardWrapper } from "./components/CardWrapper";
|
||||
import { ErrorHeader } from "./components/ErrorHeader";
|
||||
import { ErrorMessage } from "./components/ErrorMessage";
|
||||
@@ -17,7 +17,6 @@ export interface ErrorCardProps {
|
||||
message?: string;
|
||||
};
|
||||
context?: string;
|
||||
loadingSlot?: React.ReactNode;
|
||||
onRetry?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
@@ -34,21 +33,24 @@ export function ErrorCard({
|
||||
return null;
|
||||
}
|
||||
|
||||
const isHttp = isHttpError(httpError);
|
||||
const hasResponseDetail = !!(
|
||||
responseError &&
|
||||
((typeof responseError.detail === "string" &&
|
||||
responseError.detail.length > 0) ||
|
||||
(Array.isArray(responseError.detail) &&
|
||||
responseError.detail.length > 0) ||
|
||||
(responseError.message && responseError.message.length > 0))
|
||||
);
|
||||
|
||||
const errorMessage = isHttp
|
||||
? getHttpErrorMessage(httpError)
|
||||
: getErrorMessage(responseError);
|
||||
const errorMessage = hasResponseDetail
|
||||
? getErrorMessage(responseError)
|
||||
: getHttpErrorMessage(httpError);
|
||||
|
||||
return (
|
||||
<CardWrapper className={className}>
|
||||
<div className="relative space-y-4 p-6">
|
||||
<ErrorHeader />
|
||||
<ErrorMessage
|
||||
isHttpError={isHttp}
|
||||
errorMessage={errorMessage}
|
||||
context={context}
|
||||
/>
|
||||
<ErrorMessage errorMessage={errorMessage} context={context} />
|
||||
<ActionButtons
|
||||
onRetry={onRetry}
|
||||
responseError={responseError}
|
||||
|
||||
@@ -8,12 +8,12 @@ interface CardWrapperProps {
|
||||
|
||||
export function CardWrapper({ children, className = "" }: CardWrapperProps) {
|
||||
return (
|
||||
<div className={`relative overflow-hidden rounded-xl ${className}`}>
|
||||
<div className={`relative my-6 overflow-hidden rounded-xl ${className}`}>
|
||||
{/* Purple gradient border */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-xl p-[1px]"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${colors.zinc[500]}, ${colors.zinc[200]}, ${colors.zinc[100]})`,
|
||||
background: `linear-gradient(135deg, ${colors.zinc[100]}, ${colors.zinc[200]}, ${colors.zinc[100]})`,
|
||||
}}
|
||||
>
|
||||
<div className="h-full w-full rounded-xl bg-white" />
|
||||
|
||||
@@ -1,35 +1,22 @@
|
||||
import React from "react";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
|
||||
interface ErrorMessageProps {
|
||||
isHttpError: boolean;
|
||||
interface Props {
|
||||
errorMessage: string;
|
||||
context: string;
|
||||
}
|
||||
|
||||
export function ErrorMessage({
|
||||
isHttpError,
|
||||
errorMessage,
|
||||
context,
|
||||
}: ErrorMessageProps) {
|
||||
export function ErrorMessage({ errorMessage, context }: Props) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{isHttpError ? (
|
||||
<Text variant="body" className="text-zinc-700">
|
||||
<Text variant="body" className="text-zinc-700">
|
||||
We had the following error when retrieving {context ?? "your data"}:
|
||||
</Text>
|
||||
<div className="rounded-lg border border-zinc-100 bg-zinc-50 p-3">
|
||||
<Text variant="body" className="!text-red-700">
|
||||
{errorMessage}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text variant="body" className="text-zinc-700">
|
||||
We had the following error when retrieving {context ?? "your data"}:
|
||||
</Text>
|
||||
<div className="rounded-lg border border-zinc-100 bg-zinc-50 p-3">
|
||||
<Text variant="body" className="!text-red-700">
|
||||
{errorMessage}
|
||||
</Text>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ export function getHttpErrorMessage(
|
||||
|
||||
const status = httpError.status || 0;
|
||||
|
||||
if (httpError.message || httpError.statusText)
|
||||
return httpError.message || httpError.statusText || "Unknown error";
|
||||
|
||||
if (status >= 500) {
|
||||
return "An internal server error has occurred. Please try again in a few minutes.";
|
||||
}
|
||||
@@ -52,10 +55,6 @@ export function shouldShowError(
|
||||
return !isSuccess || !!responseError || !!httpError;
|
||||
}
|
||||
|
||||
export function isHttpError(httpError?: ErrorCardProps["httpError"]): boolean {
|
||||
return !!httpError;
|
||||
}
|
||||
|
||||
export function handleReportError(
|
||||
responseError?: ErrorCardProps["responseError"],
|
||||
httpError?: ErrorCardProps["httpError"],
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import React from "react";
|
||||
import { InfiniteList } from "./InfiniteList";
|
||||
|
||||
const meta: Meta<typeof InfiniteList> = {
|
||||
title: "Molecules/InfiniteList",
|
||||
component: InfiniteList,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof InfiniteList>;
|
||||
|
||||
function useMockInfiniteData(total: number, pageSize: number) {
|
||||
const [items, setItems] = React.useState<number[]>(
|
||||
Array.from({ length: Math.min(pageSize, total) }, (_, i) => i + 1),
|
||||
);
|
||||
const [isFetchingMore, setIsFetchingMore] = React.useState(false);
|
||||
|
||||
const hasMore = items.length < total;
|
||||
|
||||
function fetchMore() {
|
||||
if (!hasMore || isFetchingMore) return;
|
||||
setIsFetchingMore(true);
|
||||
setTimeout(() => {
|
||||
setItems((prev) => {
|
||||
const nextStart = prev.length + 1;
|
||||
const nextEnd = Math.min(prev.length + pageSize, total);
|
||||
const next = Array.from(
|
||||
{ length: nextEnd - nextStart + 1 },
|
||||
(_, i) => nextStart + i,
|
||||
);
|
||||
return [...prev, ...next];
|
||||
});
|
||||
setIsFetchingMore(false);
|
||||
}, 400);
|
||||
}
|
||||
|
||||
return { items, isFetchingMore, hasMore, fetchMore };
|
||||
}
|
||||
|
||||
export const Basic: Story = {
|
||||
render: () => {
|
||||
const { items, hasMore, isFetchingMore, fetchMore } = useMockInfiniteData(
|
||||
40,
|
||||
10,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: 320,
|
||||
overflow: "auto",
|
||||
border: "1px solid #eee",
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
<InfiniteList
|
||||
items={items}
|
||||
hasMore={hasMore}
|
||||
isFetchingMore={isFetchingMore}
|
||||
onEndReached={fetchMore}
|
||||
renderItem={(n) => (
|
||||
<div
|
||||
style={{
|
||||
padding: 8,
|
||||
marginBottom: 8,
|
||||
background: "#fff",
|
||||
border: "1px solid #e5e5e5",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
Item {n}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const LongList: Story = {
|
||||
render: () => {
|
||||
const { items, hasMore, isFetchingMore, fetchMore } = useMockInfiniteData(
|
||||
200,
|
||||
20,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: 320,
|
||||
overflow: "auto",
|
||||
border: "1px solid #eee",
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
<InfiniteList
|
||||
items={items}
|
||||
hasMore={hasMore}
|
||||
isFetchingMore={isFetchingMore}
|
||||
onEndReached={fetchMore}
|
||||
renderItem={(n) => (
|
||||
<div
|
||||
style={{
|
||||
padding: 8,
|
||||
marginBottom: 8,
|
||||
background: "#fff",
|
||||
border: "1px solid #e5e5e5",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
Row {n}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLoadingIndicator: Story = {
|
||||
render: () => {
|
||||
const { items, hasMore, isFetchingMore, fetchMore } = useMockInfiniteData(
|
||||
100,
|
||||
10,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: 320,
|
||||
overflow: "auto",
|
||||
border: "1px solid #eee",
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
<InfiniteList
|
||||
items={items}
|
||||
hasMore={hasMore}
|
||||
isFetchingMore={isFetchingMore}
|
||||
onEndReached={fetchMore}
|
||||
renderItem={(n) => (
|
||||
<div
|
||||
style={{
|
||||
padding: 8,
|
||||
marginBottom: 8,
|
||||
background: "#fff",
|
||||
border: "1px solid #e5e5e5",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
#{n}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{isFetchingMore && (
|
||||
<div style={{ padding: 8, color: "#666" }}>Loading more…</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
interface InfiniteListProps<T> {
|
||||
items: T[];
|
||||
renderItem: (item: T, index: number) => React.ReactNode;
|
||||
onEndReached: () => void;
|
||||
hasMore: boolean;
|
||||
isFetchingMore?: boolean;
|
||||
className?: string;
|
||||
itemWrapperClassName?: string;
|
||||
}
|
||||
|
||||
export function InfiniteList<T>(props: InfiniteListProps<T>) {
|
||||
const {
|
||||
items,
|
||||
renderItem,
|
||||
onEndReached,
|
||||
hasMore,
|
||||
isFetchingMore,
|
||||
className,
|
||||
itemWrapperClassName,
|
||||
} = props;
|
||||
const sentinelRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasMore) return;
|
||||
|
||||
const node = sentinelRef.current;
|
||||
if (!node) return;
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry.isIntersecting && hasMore && !isFetchingMore) onEndReached();
|
||||
});
|
||||
|
||||
observer.observe(node);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [hasMore, isFetchingMore, onEndReached]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{items.map((item, idx) => (
|
||||
<div key={idx} className={itemWrapperClassName}>
|
||||
{renderItem(item, idx)}
|
||||
</div>
|
||||
))}
|
||||
<div ref={sentinelRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -109,7 +109,7 @@ const TabsLineTrigger = React.forwardRef<
|
||||
elementRef.current = node;
|
||||
}}
|
||||
className={cn(
|
||||
"relative inline-flex items-center justify-center whitespace-nowrap px-3 py-2 font-sans text-[1rem] font-medium leading-[1.5rem] text-zinc-700 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-purple-600",
|
||||
"relative inline-flex items-center justify-center whitespace-nowrap px-3 py-3 font-sans text-[1rem] font-medium leading-[1.5rem] text-zinc-700 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-purple-600",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -6,7 +6,7 @@ function Skeleton({
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-primary/10", className)}
|
||||
className={cn("animate-pulse rounded-md bg-slate-50", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user