From 89d64e0e6bf2326a5f8f2ea6b3c1b5ebd42acbb4 Mon Sep 17 00:00:00 2001 From: Juan Pablo Vega Date: Thu, 18 Jun 2026 18:25:29 +0200 Subject: [PATCH] feat(web): add triggers catalog browse and shared-connection list Adds a Triggers settings surface mirroring the tools UI: browse a connected integration's providers/integrations/events and view each event's trigger_config schema. Connections are read via /triggers/connections and the same shared gateway_connections row appears under both tools and triggers without a second connect. The gatewayTrigger API layer validates at the boundary with zod, mirroring the backend DTOs until the Fern client is regenerated. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/Sidebar/SettingsSidebar.tsx | 14 + .../pages/settings/Triggers/Triggers.tsx | 9 + .../components/GatewayTriggersSection.tsx | 134 ++++++++++ .../p/[project_id]/settings/index.tsx | 10 + web/packages/agenta-entities/package.json | 1 + .../src/gatewayTrigger/api/api.ts | 119 +++++++++ .../src/gatewayTrigger/api/client.ts | 30 +++ .../src/gatewayTrigger/api/index.ts | 8 + .../src/gatewayTrigger/core/index.ts | 1 + .../src/gatewayTrigger/core/types.ts | 131 +++++++++ .../src/gatewayTrigger/hooks/index.ts | 8 + .../gatewayTrigger/hooks/useCatalogEvents.ts | 84 ++++++ .../hooks/useTriggerConnections.ts | 66 +++++ .../gatewayTrigger/hooks/useTriggerEvent.ts | 37 +++ .../src/gatewayTrigger/index.ts | 64 +++++ .../src/gatewayTrigger/state/atoms.ts | 17 ++ .../src/gatewayTrigger/state/index.ts | 2 + .../tests/unit/gatewayTriggerApi.test.ts | 173 ++++++++++++ web/packages/agenta-entity-ui/package.json | 1 + .../drawers/TriggerEventsDrawer.tsx | 250 ++++++++++++++++++ .../src/gatewayTrigger/index.ts | 10 + 21 files changed, 1169 insertions(+) create mode 100644 web/oss/src/components/pages/settings/Triggers/Triggers.tsx create mode 100644 web/oss/src/components/pages/settings/Triggers/components/GatewayTriggersSection.tsx create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/api/api.ts create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/api/client.ts create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/api/index.ts create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/core/index.ts create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/core/types.ts create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/hooks/index.ts create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/hooks/useCatalogEvents.ts create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerConnections.ts create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerEvent.ts create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/index.ts create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/state/atoms.ts create mode 100644 web/packages/agenta-entities/src/gatewayTrigger/state/index.ts create mode 100644 web/packages/agenta-entities/tests/unit/gatewayTriggerApi.test.ts create mode 100644 web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerEventsDrawer.tsx create mode 100644 web/packages/agenta-entity-ui/src/gatewayTrigger/index.ts diff --git a/web/oss/src/components/Sidebar/SettingsSidebar.tsx b/web/oss/src/components/Sidebar/SettingsSidebar.tsx index bdb3fe25ec..93529955c6 100644 --- a/web/oss/src/components/Sidebar/SettingsSidebar.tsx +++ b/web/oss/src/components/Sidebar/SettingsSidebar.tsx @@ -5,6 +5,7 @@ import { Buildings, ClockCounterClockwise, Key, + Lightning, Link, Receipt, Sparkle, @@ -46,6 +47,7 @@ const SettingsSidebar: FC = ({lastPath}) => { const canShowUsageBilling = isEE() && isOwner const billingEnabled = isBillingEnabled() const canShowTools = isToolsEnabled() + const canShowTriggers = isToolsEnabled() // Audit Log is an EE feature. Within EE the tab is gated by `view_events`; // the page content is gated separately by the `Flag.AUDIT` entitlement. const canShowAuditLog = isEE() && canViewEvents @@ -57,6 +59,7 @@ const SettingsSidebar: FC = ({lastPath}) => { (requestedTab === "organization" && !canShowOrganization) || (requestedTab === "billing" && !canShowUsageBilling) || (requestedTab === "tools" && !canShowTools) || + (requestedTab === "triggers" && !canShowTriggers) || (requestedTab === "apiKeys" && !canViewApiKeys) || (requestedTab === "auditLog" && !canShowAuditLog) || (requestedTab === "account" && !canShowAccount) @@ -69,6 +72,7 @@ const SettingsSidebar: FC = ({lastPath}) => { canShowUsageBilling, canShowOrganization, canShowTools, + canShowTriggers, canViewApiKeys, canShowAuditLog, canShowAccount, @@ -107,6 +111,15 @@ const SettingsSidebar: FC = ({lastPath}) => { }, ] : []), + ...(canShowTriggers + ? [ + { + key: "triggers", + title: "Triggers", + icon: , + }, + ] + : []), { key: "automations", title: "Automations", @@ -156,6 +169,7 @@ const SettingsSidebar: FC = ({lastPath}) => { billingEnabled, canShowOrganization, canShowTools, + canShowTriggers, canViewApiKeys, canShowAuditLog, canShowAccount, diff --git a/web/oss/src/components/pages/settings/Triggers/Triggers.tsx b/web/oss/src/components/pages/settings/Triggers/Triggers.tsx new file mode 100644 index 0000000000..0c65e99f53 --- /dev/null +++ b/web/oss/src/components/pages/settings/Triggers/Triggers.tsx @@ -0,0 +1,9 @@ +import GatewayTriggersSection from "./components/GatewayTriggersSection" + +export default function Triggers() { + return ( +
+ +
+ ) +} diff --git a/web/oss/src/components/pages/settings/Triggers/components/GatewayTriggersSection.tsx b/web/oss/src/components/pages/settings/Triggers/components/GatewayTriggersSection.tsx new file mode 100644 index 0000000000..f853ef2eb8 --- /dev/null +++ b/web/oss/src/components/pages/settings/Triggers/components/GatewayTriggersSection.tsx @@ -0,0 +1,134 @@ +import {useCallback, useMemo} from "react" + +import { + eventsDrawerAtom, + useTriggerConnectionsQuery, + type TriggerConnection, +} from "@agenta/entities/gatewayTrigger" +import {ConnectionStatusBadge} from "@agenta/entity-ui/gatewayTool" +import {TriggerEventsDrawer} from "@agenta/entity-ui/gatewayTrigger" +import {Lightning} from "@phosphor-icons/react" +import {Button, Empty, Table, Tag, Tooltip, Typography} from "antd" +import type {ColumnsType} from "antd/es/table" +import {useSetAtom} from "jotai" + +import {formatDay} from "@/oss/lib/helpers/dateTimeHelper" + +const DEFAULT_PROVIDER = "composio" + +export default function GatewayTriggersSection() { + const {connections, isLoading} = useTriggerConnectionsQuery() + const setEventsDrawer = useSetAtom(eventsDrawerAtom) + + const openEvents = useCallback( + (record: TriggerConnection) => { + setEventsDrawer({ + providerKey: record.provider_key ?? DEFAULT_PROVIDER, + integrationKey: record.integration_key, + integrationName: record.name ?? record.slug ?? record.integration_key, + connectionId: record.id ?? undefined, + }) + }, + [setEventsDrawer], + ) + + const columns: ColumnsType = useMemo( + () => [ + { + title: "Integration", + key: "integration", + onHeaderCell: () => ({style: {minWidth: 160}}), + render: (_, record) => ( + + {record.integration_key} + + ), + }, + { + title: "Name", + key: "name", + onHeaderCell: () => ({style: {minWidth: 160}}), + render: (_, record) => ( + {record.name || record.slug} + ), + }, + { + title: "Status", + key: "status", + onHeaderCell: () => ({style: {minWidth: 120}}), + render: (_, record) => , + }, + { + title: "Created at", + dataIndex: "created_at", + key: "created_at", + onHeaderCell: () => ({style: {minWidth: 160}}), + render: (value: string) => + value ? formatDay({date: value, outputFormat: "YYYY-MM-DD HH:mm"}) : "-", + }, + { + title: "", + key: "actions", + width: 120, + fixed: "right", + align: "right", + render: (_, record) => ( + + ), + }, + ], + [openEvents], + ) + + return ( + <> +
+
+ + Trigger integrations + + + + +
+ + + Triggers reuse the same connections as tools. Connect an integration under + Tools, then browse its events here. + + + + className="ph-no-capture" + columns={columns} + dataSource={connections} + rowKey={(record) => record.id ?? record.slug ?? record.integration_key} + bordered + pagination={false} + loading={isLoading} + locale={{ + emptyText: , + }} + onRow={(record) => ({ + onClick: () => openEvents(record), + className: "cursor-pointer", + })} + /> +
+ + + + ) +} diff --git a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/settings/index.tsx b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/settings/index.tsx index 3ab8b8929a..4d758005d9 100644 --- a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/settings/index.tsx +++ b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/settings/index.tsx @@ -39,6 +39,10 @@ const Tools = dynamic(() => import("@/oss/components/pages/settings/Tools/Tools" ssr: false, }) +const Triggers = dynamic(() => import("@/oss/components/pages/settings/Triggers/Triggers"), { + ssr: false, +}) + const Organization = dynamic(() => import("@/oss/components/pages/settings/Organization"), { ssr: false, }) @@ -71,12 +75,14 @@ export const Settings: React.FC = ({AuditLogComponent}) => { const canShowBilling = isEE() && isOwner const billingEnabled = isBillingEnabled() const canShowTools = isToolsEnabled() + const canShowTriggers = isToolsEnabled() const canShowAuditLog = isEE() && canViewEvents const canShowAccount = isEE() const resolvedTab = (tab === "organization" && !canShowOrganization) || (tab === "billing" && !canShowBilling) || (tab === "tools" && !canShowTools) || + (tab === "triggers" && !canShowTriggers) || (tab === "apiKeys" && !canViewApiKeys) || (tab === "auditLog" && !canShowAuditLog) || (tab === "account" && !canShowAccount) @@ -124,6 +130,8 @@ export const Settings: React.FC = ({AuditLogComponent}) => { return "Providers & Models" case "tools": return "Tools" + case "triggers": + return "Triggers" case "apiKeys": return "API Keys" case "automations": @@ -177,6 +185,8 @@ export const Settings: React.FC = ({AuditLogComponent}) => { return {content: , title: "Providers & Models"} case "tools": return {content: , title: "Tools"} + case "triggers": + return {content: , title: "Triggers"} case "apiKeys": return {content: , title: "API Keys"} case "billing": diff --git a/web/packages/agenta-entities/package.json b/web/packages/agenta-entities/package.json index 2d9b25ba74..5ace2985e9 100644 --- a/web/packages/agenta-entities/package.json +++ b/web/packages/agenta-entities/package.json @@ -50,6 +50,7 @@ "./event/state": "./src/event/state/index.ts", "./secret": "./src/secret/index.ts", "./gatewayTool": "./src/gatewayTool/index.ts", + "./gatewayTrigger": "./src/gatewayTrigger/index.ts", "./environment": "./src/environment/index.ts", "./simpleQueue": "./src/simpleQueue/index.ts", "./simpleQueue/etl": "./src/simpleQueue/etl/index.ts", diff --git a/web/packages/agenta-entities/src/gatewayTrigger/api/api.ts b/web/packages/agenta-entities/src/gatewayTrigger/api/api.ts new file mode 100644 index 0000000000..9de7f2d628 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/api/api.ts @@ -0,0 +1,119 @@ +/** + * Gateway-trigger API functions. + * + * Catalog browse + connection list over the `/triggers/*` endpoints. Each + * response is validated against the frozen zod schema at the boundary + * (`safeParseWithLogging`), so a backend drift surfaces as a logged parse + * failure rather than a downstream crash. + * + * `/triggers/connections/query` reads the same shared `gateway_connections` + * rows as `/tools/connections/query` (WP0); the connection shape is reused + * from gatewayTool so the two lists stay byte-compatible (F2). + */ + +import {safeParseWithLogging} from "../../shared" +import { + triggerCatalogEventResponseSchema, + triggerCatalogEventsResponseSchema, + triggerCatalogProviderResponseSchema, + triggerCatalogProvidersResponseSchema, + triggerConnectionsResponseSchema, + type TriggerCatalogEventResponse, + type TriggerCatalogEventsResponse, + type TriggerCatalogProviderResponse, + type TriggerCatalogProvidersResponse, + type TriggerConnectionsResponse, +} from "../core/types" + +import {axios, projectScopedParams, triggersBaseUrl} from "./client" + +// --- Catalog browse --- + +export const fetchTriggerProviders = async (): Promise => { + const {data} = await axios.get(`${triggersBaseUrl()}/catalog/providers/`, projectScopedParams()) + return ( + safeParseWithLogging( + triggerCatalogProvidersResponseSchema, + data, + "[fetchTriggerProviders]", + ) ?? {count: 0, providers: []} + ) +} + +export const fetchTriggerProvider = async ( + providerKey: string, +): Promise => { + const {data} = await axios.get( + `${triggersBaseUrl()}/catalog/providers/${providerKey}`, + projectScopedParams(), + ) + return ( + safeParseWithLogging( + triggerCatalogProviderResponseSchema, + data, + "[fetchTriggerProvider]", + ) ?? {count: 0, provider: null} + ) +} + +export const fetchTriggerEvents = async ( + providerKey: string, + integrationKey: string, + params?: {query?: string; limit?: number; cursor?: string}, +): Promise => { + const {data} = await axios.get( + `${triggersBaseUrl()}/catalog/providers/${providerKey}/integrations/${integrationKey}/events/`, + projectScopedParams({ + query: params?.query, + limit: params?.limit, + cursor: params?.cursor, + }), + ) + return ( + safeParseWithLogging(triggerCatalogEventsResponseSchema, data, "[fetchTriggerEvents]") ?? { + count: 0, + total: 0, + cursor: null, + events: [], + } + ) +} + +export const fetchTriggerEvent = async ( + providerKey: string, + integrationKey: string, + eventKey: string, +): Promise => { + const {data} = await axios.get( + `${triggersBaseUrl()}/catalog/providers/${providerKey}/integrations/${integrationKey}/events/${eventKey}`, + projectScopedParams(), + ) + return ( + safeParseWithLogging(triggerCatalogEventResponseSchema, data, "[fetchTriggerEvent]") ?? { + count: 0, + event: null, + } + ) +} + +// --- Connections (shared rows, WP0 view; F2) --- + +export const queryTriggerConnections = async (params?: { + provider_key?: string + integration_key?: string +}): Promise => { + const {data} = await axios.post( + `${triggersBaseUrl()}/connections/query`, + { + provider_key: params?.provider_key, + integration_key: params?.integration_key, + }, + projectScopedParams(), + ) + const validated = safeParseWithLogging( + triggerConnectionsResponseSchema, + data, + "[queryTriggerConnections]", + ) + return (validated as TriggerConnectionsResponse | null) ?? {count: 0, connections: []} +} diff --git a/web/packages/agenta-entities/src/gatewayTrigger/api/client.ts b/web/packages/agenta-entities/src/gatewayTrigger/api/client.ts new file mode 100644 index 0000000000..ef1785b52b --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/api/client.ts @@ -0,0 +1,30 @@ +import {axios, getAgentaApiUrl} from "@agenta/shared/api" +import {projectIdAtom} from "@agenta/shared/state" +import {getDefaultStore} from "jotai" + +/** + * HTTP client for the `/triggers/*` API. + * + * The triggers catalog isn't in the Fern client yet (WP1 hasn't been + * regenerated into `@agentaai/api-client`), so we use the shared axios + * instance. Once the client gains a `triggers` resource this module collapses + * onto `getAgentaSdkClient().triggers` like `gatewayTool/api/client.ts`. + */ +export const triggersBaseUrl = () => `${getAgentaApiUrl()}/triggers` + +/** + * Scope a request to the current project. The shared axios interceptor does + * not inject `project_id`, so we mirror `gatewayTool`'s `projectScopedRequest` + * and read it from the shared atom. + */ +export function projectScopedParams(extra?: Record) { + const projectId = getDefaultStore().get(projectIdAtom) + return { + params: { + ...(projectId ? {project_id: projectId} : {}), + ...(extra ?? {}), + }, + } +} + +export {axios} diff --git a/web/packages/agenta-entities/src/gatewayTrigger/api/index.ts b/web/packages/agenta-entities/src/gatewayTrigger/api/index.ts new file mode 100644 index 0000000000..c307965ae8 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/api/index.ts @@ -0,0 +1,8 @@ +export { + fetchTriggerEvent, + fetchTriggerEvents, + fetchTriggerProvider, + fetchTriggerProviders, + queryTriggerConnections, +} from "./api" +export {triggersBaseUrl, projectScopedParams} from "./client" diff --git a/web/packages/agenta-entities/src/gatewayTrigger/core/index.ts b/web/packages/agenta-entities/src/gatewayTrigger/core/index.ts new file mode 100644 index 0000000000..51f739d012 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/core/index.ts @@ -0,0 +1 @@ +export * from "./types" diff --git a/web/packages/agenta-entities/src/gatewayTrigger/core/types.ts b/web/packages/agenta-entities/src/gatewayTrigger/core/types.ts new file mode 100644 index 0000000000..16b6d8ea26 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/core/types.ts @@ -0,0 +1,131 @@ +/** + * Gateway-trigger domain types. + * + * The triggers catalog API (WP1) is not yet in the Fern-generated client, so + * the wire shapes are declared here as zod schemas mirroring the frozen + * backend DTOs (`api/oss/src/core/triggers/dtos.py`, + * `api/oss/src/apis/fastapi/triggers/models.py`). Validation runs at the API + * boundary, exactly as `web/AGENTS.md` prescribes for the Fern path. When the + * client is regenerated with a `triggers` resource these aliases swap to + * `AgentaApi.*` mechanically. + * + * Connections are shared rows (WP0): the same `gateway_connections` surface + * both `/tools/connections` and `/triggers/connections`. We reuse the + * gatewayTool connection type so the two lists are byte-compatible (F2). + */ + +import {z} from "zod" + +import type {ToolConnection, ToolConnectionsResponse} from "../../gatewayTool/core/types" + +// --------------------------------------------------------------------------- +// Catalog +// --------------------------------------------------------------------------- + +export const triggerProviderKindSchema = z.enum(["composio"]) +export type TriggerProviderKind = z.infer + +export const triggerCatalogProviderSchema = z + .object({ + key: triggerProviderKindSchema, + name: z.string(), + description: z.string().nullish(), + }) + .passthrough() +export type TriggerCatalogProvider = z.infer + +export const triggerCatalogEventSchema = z + .object({ + key: z.string(), + name: z.string(), + description: z.string().nullish(), + provider: z.string().nullish(), + integration: z.string().nullish(), + categories: z.array(z.string()).default([]), + logo: z.string().nullish(), + }) + .passthrough() +export type TriggerCatalogEvent = z.infer + +export const triggerCatalogEventDetailsSchema = triggerCatalogEventSchema.extend({ + trigger_config: z.record(z.string(), z.unknown()).nullish(), + payload: z.record(z.string(), z.unknown()).nullish(), +}) +export type TriggerCatalogEventDetails = z.infer + +export const triggerCatalogProvidersResponseSchema = z + .object({ + count: z.number().default(0), + providers: z.array(triggerCatalogProviderSchema).default([]), + }) + .passthrough() +export type TriggerCatalogProvidersResponse = z.infer + +export const triggerCatalogProviderResponseSchema = z + .object({ + count: z.number().default(0), + provider: triggerCatalogProviderSchema.nullish(), + }) + .passthrough() +export type TriggerCatalogProviderResponse = z.infer + +export const triggerCatalogEventsResponseSchema = z + .object({ + count: z.number().default(0), + total: z.number().default(0), + cursor: z.string().nullish(), + events: z.array(triggerCatalogEventSchema).default([]), + }) + .passthrough() +export type TriggerCatalogEventsResponse = z.infer + +export const triggerCatalogEventResponseSchema = z + .object({ + count: z.number().default(0), + event: triggerCatalogEventDetailsSchema.nullish(), + }) + .passthrough() +export type TriggerCatalogEventResponse = z.infer + +// --------------------------------------------------------------------------- +// Connections — shared `gateway_connections` rows (WP0). Same shape as +// `/tools/connections`; the FE treats both lists as the same rows (F2). The TS +// type aliases the gatewayTool Fern type so the two lists are byte-compatible; +// the schema validates the axios boundary (the triggers client isn't Fern yet). +// --------------------------------------------------------------------------- + +const jsonRecordSchema = z.record(z.string(), z.unknown()).nullish() + +export const triggerConnectionSchema = z + .object({ + flags: jsonRecordSchema, + tags: jsonRecordSchema, + meta: jsonRecordSchema, + created_at: z.string().nullish(), + updated_at: z.string().nullish(), + deleted_at: z.string().nullish(), + created_by_id: z.string().nullish(), + updated_by_id: z.string().nullish(), + deleted_by_id: z.string().nullish(), + name: z.string().nullish(), + description: z.string().nullish(), + slug: z.string().nullish(), + id: z.string().nullish(), + provider_key: z.string(), + integration_key: z.string(), + data: jsonRecordSchema, + status: z.unknown().nullish(), + }) + .passthrough() + +export const triggerConnectionsResponseSchema = z + .object({ + count: z.number().default(0), + connections: z.array(triggerConnectionSchema).default([]), + }) + .passthrough() + +export type TriggerConnection = ToolConnection +export type TriggerConnectionsResponse = ToolConnectionsResponse + +export {isConnectionActive, isConnectionValid} from "../../gatewayTool/core/types" diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/index.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/index.ts new file mode 100644 index 0000000000..3cbfeb2535 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/index.ts @@ -0,0 +1,8 @@ +export {catalogEventsInfiniteFamily, eventsSearchAtom, useCatalogEvents} from "./useCatalogEvents" +export {triggerEventDetailQueryFamily, useTriggerEvent} from "./useTriggerEvent" +export { + triggerConnectionsQueryAtom, + triggerIntegrationConnectionsAtomFamily, + useTriggerConnectionsQuery, + useTriggerIntegrationConnections, +} from "./useTriggerConnections" diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useCatalogEvents.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useCatalogEvents.ts new file mode 100644 index 0000000000..b5cc548b58 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useCatalogEvents.ts @@ -0,0 +1,84 @@ +import {useCallback, useEffect, useMemo, useRef, useState} from "react" + +import {atom, useAtomValue, useSetAtom} from "jotai" +import {atomFamily} from "jotai/utils" +import {atomWithInfiniteQuery} from "jotai-tanstack-query" + +import {fetchTriggerEvents} from "../api" +import type {TriggerCatalogEvent, TriggerCatalogEventsResponse} from "../core/types" + +const DEFAULT_PROVIDER = "composio" +const CHUNK_SIZE = 10 +const PREFETCH = 2 + +// Server-side search atom — set by the drawer, drives the query +export const eventsSearchAtom = atom("") + +export const catalogEventsInfiniteFamily = atomFamily((integrationKey: string) => + atomWithInfiniteQuery((get) => { + const search = get(eventsSearchAtom) + + return { + queryKey: ["triggers", "catalog", "events", DEFAULT_PROVIDER, integrationKey, search], + queryFn: async ({pageParam}) => + fetchTriggerEvents(DEFAULT_PROVIDER, integrationKey, { + query: search || undefined, + limit: CHUNK_SIZE, + cursor: (pageParam as string) || undefined, + }), + initialPageParam: "", + getNextPageParam: (lastPage) => lastPage.cursor ?? undefined, + staleTime: 5 * 60_000, + refetchOnWindowFocus: false, + enabled: !!integrationKey, + } + }), +) + +export const useCatalogEvents = (integrationKey: string) => { + const query = useAtomValue(catalogEventsInfiniteFamily(integrationKey)) + const setSearch = useSetAtom(eventsSearchAtom) + + const events = useMemo(() => { + const pages = query.data?.pages ?? [] + return pages.flatMap((p) => p.events ?? []) + }, [query.data?.pages]) + + const total = useMemo(() => { + const pages = query.data?.pages ?? [] + return pages.length > 0 ? (pages[0].total ?? 0) : 0 + }, [query.data?.pages]) + + const [targetPages, setTargetPages] = useState(1 + PREFETCH) + const loadedPages = query.data?.pages?.length ?? 0 + + const prevLoadedRef = useRef(loadedPages) + useEffect(() => { + if (loadedPages === 0 && prevLoadedRef.current > 0) { + setTargetPages(1 + PREFETCH) + } + prevLoadedRef.current = loadedPages + }, [loadedPages]) + + const requestMore = useCallback(() => { + setTargetPages((t) => t + PREFETCH) + }, []) + + useEffect(() => { + if (loadedPages < targetPages && query.hasNextPage && !query.isFetchingNextPage) { + query.fetchNextPage() + } + }, [loadedPages, targetPages, query.hasNextPage, query.isFetchingNextPage, query.fetchNextPage]) + + return { + events, + total, + prefetchThreshold: PREFETCH * CHUNK_SIZE, + isLoading: query.isPending, + isFetchingNextPage: query.isFetchingNextPage, + hasNextPage: query.hasNextPage ?? false, + error: query.error, + requestMore, + setSearch, + } +} diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerConnections.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerConnections.ts new file mode 100644 index 0000000000..ed5c3aff98 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerConnections.ts @@ -0,0 +1,66 @@ +import {useMemo} from "react" + +import {useAtomValue} from "jotai" +import {atomFamily} from "jotai/utils" +import {atomWithQuery} from "jotai-tanstack-query" + +import {queryTriggerConnections} from "../api" +import type {TriggerConnection, TriggerConnectionsResponse} from "../core/types" + +const DEFAULT_PROVIDER = "composio" + +// Full list of trigger connections (shared `gateway_connections` rows, F2). +export const triggerConnectionsQueryAtom = atomWithQuery(() => ({ + queryKey: ["triggers", "connections"], + queryFn: () => queryTriggerConnections(), + staleTime: 30_000, + refetchOnWindowFocus: false, +})) + +export const useTriggerConnectionsQuery = () => { + const query = useAtomValue(triggerConnectionsQueryAtom) + + const connections = useMemo( + () => query.data?.connections ?? [], + [query.data?.connections], + ) + + return { + connections, + count: query.data?.count ?? 0, + isLoading: query.isPending, + error: query.error, + refetch: query.refetch, + } +} + +// Connections scoped to a single integration. +export const triggerIntegrationConnectionsAtomFamily = atomFamily((integrationKey: string) => + atomWithQuery(() => ({ + queryKey: ["triggers", "connections", DEFAULT_PROVIDER, integrationKey], + queryFn: () => + queryTriggerConnections({ + provider_key: DEFAULT_PROVIDER, + integration_key: integrationKey, + }), + staleTime: 30_000, + refetchOnWindowFocus: false, + enabled: !!integrationKey, + })), +) + +export const useTriggerIntegrationConnections = (integrationKey: string) => { + const query = useAtomValue(triggerIntegrationConnectionsAtomFamily(integrationKey)) + + const connections = useMemo( + () => query.data?.connections ?? [], + [query.data?.connections], + ) + + return { + connections, + count: query.data?.count ?? 0, + isLoading: query.isPending, + error: query.error, + } +} diff --git a/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerEvent.ts b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerEvent.ts new file mode 100644 index 0000000000..912cef0700 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/hooks/useTriggerEvent.ts @@ -0,0 +1,37 @@ +import {useAtomValue} from "jotai" +import {atomFamily} from "jotai/utils" +import {atomWithQuery} from "jotai-tanstack-query" + +import {fetchTriggerEvent} from "../api" +import type {TriggerCatalogEventResponse} from "../core/types" + +const DEFAULT_PROVIDER = "composio" + +export const triggerEventDetailQueryFamily = atomFamily( + ({integrationKey, eventKey}: {integrationKey: string; eventKey: string}) => + atomWithQuery(() => ({ + queryKey: [ + "triggers", + "catalog", + "eventDetail", + DEFAULT_PROVIDER, + integrationKey, + eventKey, + ], + queryFn: () => fetchTriggerEvent(DEFAULT_PROVIDER, integrationKey, eventKey), + staleTime: 5 * 60_000, + refetchOnWindowFocus: false, + enabled: !!integrationKey && !!eventKey, + })), + (a, b) => a.integrationKey === b.integrationKey && a.eventKey === b.eventKey, +) + +export const useTriggerEvent = (integrationKey: string, eventKey: string) => { + const query = useAtomValue(triggerEventDetailQueryFamily({integrationKey, eventKey})) + + return { + event: query.data?.event ?? null, + isLoading: query.isPending, + error: query.error, + } +} diff --git a/web/packages/agenta-entities/src/gatewayTrigger/index.ts b/web/packages/agenta-entities/src/gatewayTrigger/index.ts new file mode 100644 index 0000000000..1177cb2375 --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/index.ts @@ -0,0 +1,64 @@ +/** + * Gateway-trigger entity module. + * + * Browser-side state and queries for the `/triggers/*` endpoint family: + * the read-only events catalog (WP1) and the shared connection list (WP0). + * + * Mirrors `gatewayTool`. The catalog isn't in the Fern client yet, so the API + * layer uses the shared axios instance with zod validation at the boundary + * (see `api/api.ts`); it collapses onto the Fern `triggers` resource once the + * client is regenerated. + */ + +// --------------------------------------------------------------------------- +// CORE — domain types +// --------------------------------------------------------------------------- + +export type { + TriggerCatalogEvent, + TriggerCatalogEventDetails, + TriggerCatalogEventResponse, + TriggerCatalogEventsResponse, + TriggerCatalogProvider, + TriggerCatalogProviderResponse, + TriggerCatalogProvidersResponse, + TriggerConnection, + TriggerConnectionsResponse, + TriggerProviderKind, +} from "./core" +export {isConnectionActive, isConnectionValid} from "./core" + +// --------------------------------------------------------------------------- +// API — HTTP wrappers (axios + zod boundary validation) +// --------------------------------------------------------------------------- + +export { + fetchTriggerEvent, + fetchTriggerEvents, + fetchTriggerProvider, + fetchTriggerProviders, + queryTriggerConnections, +} from "./api" + +// --------------------------------------------------------------------------- +// STATE — drawer + selection atoms +// --------------------------------------------------------------------------- + +export {eventsDrawerAtom, eventSearchAtom, selectedCatalogEventAtom} from "./state" +export type {EventsDrawerState} from "./state" + +// --------------------------------------------------------------------------- +// HOOKS — query hooks for React consumers +// --------------------------------------------------------------------------- + +export { + catalogEventsInfiniteFamily, + eventsSearchAtom, + triggerConnectionsQueryAtom, + triggerEventDetailQueryFamily, + triggerIntegrationConnectionsAtomFamily, + useCatalogEvents, + useTriggerConnectionsQuery, + useTriggerEvent, + useTriggerIntegrationConnections, +} from "./hooks" diff --git a/web/packages/agenta-entities/src/gatewayTrigger/state/atoms.ts b/web/packages/agenta-entities/src/gatewayTrigger/state/atoms.ts new file mode 100644 index 0000000000..975e2f378a --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/state/atoms.ts @@ -0,0 +1,17 @@ +import {atom} from "jotai" + +// --------------------------------------------------------------------------- +// Events drawer state — opened against a connected integration +// --------------------------------------------------------------------------- + +export interface EventsDrawerState { + providerKey: string + integrationKey: string + integrationName?: string + connectionId?: string +} +export const eventsDrawerAtom = atom(null) + +// Drawer-local browsing state (reset on close) +export const eventSearchAtom = atom("") +export const selectedCatalogEventAtom = atom(null) diff --git a/web/packages/agenta-entities/src/gatewayTrigger/state/index.ts b/web/packages/agenta-entities/src/gatewayTrigger/state/index.ts new file mode 100644 index 0000000000..6d177ea88d --- /dev/null +++ b/web/packages/agenta-entities/src/gatewayTrigger/state/index.ts @@ -0,0 +1,2 @@ +export {eventsDrawerAtom, eventSearchAtom, selectedCatalogEventAtom} from "./atoms" +export type {EventsDrawerState} from "./atoms" diff --git a/web/packages/agenta-entities/tests/unit/gatewayTriggerApi.test.ts b/web/packages/agenta-entities/tests/unit/gatewayTriggerApi.test.ts new file mode 100644 index 0000000000..50034bec1c --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/gatewayTriggerApi.test.ts @@ -0,0 +1,173 @@ +/** + * Unit tests for the gateway-trigger API layer. + * + * The triggers catalog isn't in the Fern client yet, so these functions call + * the shared axios instance and validate the response against the frozen zod + * schema at the boundary. Tests stub `@agenta/shared/api` (axios + URL) and the + * project store so we can introspect the request shape and confirm boundary + * validation without hitting the network. + * + * AC coverage: + * - Catalog browse: events are fetched against the WP1 API shape. + * - F2: `/triggers/connections/query` reads the same shared connection rows + * that `/tools/connections/query` returns, with no second connect. + */ + +import {beforeEach, describe, expect, it, vi} from "vitest" + +const {get, post} = vi.hoisted(() => ({get: vi.fn(), post: vi.fn()})) + +vi.mock("@agenta/shared/api", () => ({ + axios: {get, post}, + getAgentaApiUrl: () => "https://api.test", +})) + +vi.mock("@agenta/shared/state", () => ({ + projectIdAtom: {__type: "projectIdAtom"}, +})) + +vi.mock("jotai", async (importOriginal) => { + const actual = await importOriginal() + return {...actual, getDefaultStore: () => ({get: () => "proj-42"})} +}) + +import { + fetchTriggerEvent, + fetchTriggerEvents, + fetchTriggerProviders, + queryTriggerConnections, +} from "../../src/gatewayTrigger/api/api" + +beforeEach(() => { + get.mockReset() + post.mockReset() +}) + +describe("catalog browse", () => { + it("lists providers and scopes the request to the project", async () => { + get.mockResolvedValueOnce({ + data: {count: 1, providers: [{key: "composio", name: "Composio"}]}, + }) + + const res = await fetchTriggerProviders() + + const [url, opts] = get.mock.calls[0] + expect(url).toBe("https://api.test/triggers/catalog/providers/") + expect(opts.params).toMatchObject({project_id: "proj-42"}) + expect(res.providers[0].key).toBe("composio") + }) + + it("fetches an integration's events against the WP1 path with cursor params", async () => { + get.mockResolvedValueOnce({ + data: { + count: 1, + total: 1, + cursor: "next", + events: [{key: "github_star", name: "Repo starred", categories: []}], + }, + }) + + const res = await fetchTriggerEvents("composio", "github", { + query: "star", + limit: 10, + cursor: "c1", + }) + + const [url, opts] = get.mock.calls[0] + expect(url).toBe( + "https://api.test/triggers/catalog/providers/composio/integrations/github/events/", + ) + expect(opts.params).toMatchObject({ + project_id: "proj-42", + query: "star", + limit: 10, + cursor: "c1", + }) + expect(res.events).toHaveLength(1) + expect(res.cursor).toBe("next") + }) + + it("returns an event's trigger_config schema", async () => { + const triggerConfig = { + type: "object", + properties: {owner: {type: "string"}, repo: {type: "string"}}, + required: ["owner", "repo"], + } + get.mockResolvedValueOnce({ + data: { + count: 1, + event: { + key: "github_star", + name: "Repo starred", + categories: [], + trigger_config: triggerConfig, + }, + }, + }) + + const res = await fetchTriggerEvent("composio", "github", "github_star") + + const [url] = get.mock.calls[0] + expect(url).toBe( + "https://api.test/triggers/catalog/providers/composio/integrations/github/events/github_star", + ) + expect(res.event?.trigger_config).toEqual(triggerConfig) + }) + + it("falls back to an empty response when the payload fails validation", async () => { + get.mockResolvedValueOnce({data: {events: "not-an-array"}}) + + const res = await fetchTriggerEvents("composio", "github") + + expect(res).toEqual({count: 0, total: 0, cursor: null, events: []}) + }) +}) + +describe("connections (F2 — shared rows)", () => { + it("queries the same shared connection rows surfaced by /tools/connections", async () => { + // A row created via /tools/connections; it appears verbatim under + // /triggers/connections without a second connect. + const sharedRow = { + id: "conn-1", + slug: "github-prod", + name: "GitHub prod", + provider_key: "composio", + integration_key: "github", + flags: {is_active: true, is_valid: true}, + } + post.mockResolvedValueOnce({data: {count: 1, connections: [sharedRow]}}) + + const res = await queryTriggerConnections({ + provider_key: "composio", + integration_key: "github", + }) + + const [url, body, opts] = post.mock.calls[0] + expect(url).toBe("https://api.test/triggers/connections/query") + expect(body).toEqual({provider_key: "composio", integration_key: "github"}) + expect(opts.params).toMatchObject({project_id: "proj-42"}) + expect(res.connections[0]).toMatchObject({id: "conn-1", integration_key: "github"}) + }) + + it("tolerates a connection with no flags (no crash, no second connect path)", async () => { + post.mockResolvedValueOnce({ + data: { + count: 1, + connections: [{id: "conn-2", provider_key: "composio", integration_key: "slack"}], + }, + }) + + const res = await queryTriggerConnections() + + expect(res.connections).toHaveLength(1) + expect(res.connections[0].integration_key).toBe("slack") + }) + + it("falls back to an empty list when the payload fails validation", async () => { + post.mockResolvedValueOnce({data: {connections: 42}}) + + const res = await queryTriggerConnections() + + expect(res).toEqual({count: 0, connections: []}) + }) +}) diff --git a/web/packages/agenta-entity-ui/package.json b/web/packages/agenta-entity-ui/package.json index fe30c9997a..1b94855f70 100644 --- a/web/packages/agenta-entity-ui/package.json +++ b/web/packages/agenta-entity-ui/package.json @@ -18,6 +18,7 @@ "./adapters": "./src/adapters/index.ts", "./drill-in": "./src/DrillInView/index.ts", "./gatewayTool": "./src/gatewayTool/index.ts", + "./gatewayTrigger": "./src/gatewayTrigger/index.ts", "./modals": "./src/modals/index.ts", "./selection": "./src/selection/index.ts", "./template-format": "./src/template-format/index.ts", diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerEventsDrawer.tsx b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerEventsDrawer.tsx new file mode 100644 index 0000000000..2887a3c0ab --- /dev/null +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/drawers/TriggerEventsDrawer.tsx @@ -0,0 +1,250 @@ +import React, {useCallback, useMemo, useRef, useState} from "react" + +import { + eventsDrawerAtom, + eventsSearchAtom, + useCatalogEvents, + useTriggerEvent, + type TriggerCatalogEvent, +} from "@agenta/entities/gatewayTrigger" +import {useDebouncedAtomSearch} from "@agenta/shared/hooks" +import {ScrollSentinel, ScrollToTopButton} from "@agenta/ui" +import {ArrowLeft, MagnifyingGlass} from "@phosphor-icons/react" +import {Card, Divider, Drawer, Empty, Form, Input, Spin, Tag, Typography} from "antd" +import {useAtom, useSetAtom} from "jotai" + +import SchemaForm from "../../gatewayTool/components/SchemaForm" + +// --------------------------------------------------------------------------- +// TriggerEventsDrawer (root) — opened against a connected integration +// --------------------------------------------------------------------------- + +export default function TriggerEventsDrawer() { + const [state, setState] = useAtom(eventsDrawerAtom) + const [selectedEvent, setSelectedEvent] = useState(null) + const setEventsSearch = useSetAtom(eventsSearchAtom) + + const open = !!state + + const handleClose = useCallback(() => { + setState(null) + setSelectedEvent(null) + setEventsSearch("") + }, [setState, setEventsSearch]) + + const handleBack = useCallback(() => { + setSelectedEvent(null) + }, []) + + return ( + + {state && + (selectedEvent ? ( + + ) : ( + + ))} + + ) +} + +// --------------------------------------------------------------------------- +// Events view (sticky header + scrollable content) +// --------------------------------------------------------------------------- + +function EventsView({ + integrationKey, + onSelect, +}: { + integrationKey: string + onSelect: (event: TriggerCatalogEvent) => void +}) { + const setAtom = useSetAtom(eventsSearchAtom) + const search = useDebouncedAtomSearch(setAtom) + const scrollRef = useRef(null) + + const { + events, + total, + prefetchThreshold, + isLoading, + hasNextPage, + isFetchingNextPage, + requestMore, + } = useCatalogEvents(integrationKey) + + const sentinelIndex = useMemo( + () => Math.max(0, events.length - prefetchThreshold), + [events.length, prefetchThreshold], + ) + + return ( +
+
+ } + value={search.value} + onChange={(e) => search.onChange(e.target.value)} + allowClear + onClear={() => search.onChange("")} + /> + + {total} event{total !== 1 ? "s" : ""} + +
+ + + +
+ {isLoading && events.length === 0 ? ( +
+ +
+ ) : events.length === 0 ? ( + + ) : ( +
+ {events.map((event, i) => ( + + {i === sentinelIndex && ( + + )} + onSelect(event)} + className="cursor-pointer" + size="small" + > +
+
+ + {event.name} + + {event.categories?.slice(0, 2).map((c) => ( + + {c} + + ))} +
+ {event.description && ( + + {event.description} + + )} +
+
+
+ ))} + + + + {isFetchingNextPage && ( +
+ +
+ )} +
+ )} + + +
+
+ ) +} + +// --------------------------------------------------------------------------- +// Event detail — read-only `trigger_config` schema +// --------------------------------------------------------------------------- + +function EventDetailView({ + integrationKey, + event, + onBack, +}: { + integrationKey: string + event: TriggerCatalogEvent + onBack: () => void +}) { + const [form] = Form.useForm() + const {event: detail, isLoading} = useTriggerEvent(integrationKey, event.key) + + const schema = (detail?.trigger_config ?? null) as Record | null + + return ( +
+
+
+ + + {event.name} + +
+ {event.description && ( + + {event.description} + + )} +
+ + + +
+ + Trigger configuration + +
+ {isLoading ? ( +
+ +
+ ) : schema && Object.keys(schema).length > 0 ? ( + + ) : ( + + )} +
+
+
+ ) +} diff --git a/web/packages/agenta-entity-ui/src/gatewayTrigger/index.ts b/web/packages/agenta-entity-ui/src/gatewayTrigger/index.ts new file mode 100644 index 0000000000..e7b4348ef1 --- /dev/null +++ b/web/packages/agenta-entity-ui/src/gatewayTrigger/index.ts @@ -0,0 +1,10 @@ +/** + * Gateway-trigger entity UI. + * + * Atom-driven drawer for browsing a connected integration's events and viewing + * each event's `trigger_config` schema. State and data come from + * `@agenta/entities/gatewayTrigger`; this layer is purely the UI. Mirrors + * `gatewayTool`. + */ + +export {default as TriggerEventsDrawer} from "./drawers/TriggerEventsDrawer"