diff --git a/apps/api/src/app/agents/dtos/agent-integration-summary.dto.ts b/apps/api/src/app/agents/dtos/agent-integration-summary.dto.ts new file mode 100644 index 00000000000..304f7590902 --- /dev/null +++ b/apps/api/src/app/agents/dtos/agent-integration-summary.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ChannelTypeEnum } from '@novu/shared'; + +export class AgentIntegrationSummaryDto { + @ApiProperty({ description: 'Integration document id.' }) + integrationId: string; + + @ApiProperty() + providerId: string; + + @ApiProperty() + name: string; + + @ApiProperty() + identifier: string; + + @ApiProperty({ enum: ChannelTypeEnum, enumName: 'ChannelTypeEnum' }) + channel: ChannelTypeEnum; + + @ApiProperty() + active: boolean; +} diff --git a/apps/api/src/app/agents/dtos/agent-response.dto.ts b/apps/api/src/app/agents/dtos/agent-response.dto.ts index d5a4b0310d2..685f0310ebc 100644 --- a/apps/api/src/app/agents/dtos/agent-response.dto.ts +++ b/apps/api/src/app/agents/dtos/agent-response.dto.ts @@ -1,5 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { AgentIntegrationSummaryDto } from './agent-integration-summary.dto'; + export class AgentResponseDto { @ApiProperty() _id: string; @@ -24,4 +26,7 @@ export class AgentResponseDto { @ApiProperty() updatedAt: string; + + @ApiPropertyOptional({ type: [AgentIntegrationSummaryDto] }) + integrations?: AgentIntegrationSummaryDto[]; } diff --git a/apps/api/src/app/agents/dtos/create-agent-request.dto.ts b/apps/api/src/app/agents/dtos/create-agent-request.dto.ts index 4da94f1ad64..f07f376b507 100644 --- a/apps/api/src/app/agents/dtos/create-agent-request.dto.ts +++ b/apps/api/src/app/agents/dtos/create-agent-request.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { SLUG_IDENTIFIER_REGEX, slugIdentifierFormatMessage } from '@novu/shared'; +import { IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator'; export class CreateAgentRequestDto { @ApiProperty() @@ -10,6 +11,9 @@ export class CreateAgentRequestDto { @ApiProperty() @IsString() @IsNotEmpty() + @Matches(SLUG_IDENTIFIER_REGEX, { + message: slugIdentifierFormatMessage('identifier'), + }) identifier: string; @ApiPropertyOptional() diff --git a/apps/api/src/app/agents/dtos/index.ts b/apps/api/src/app/agents/dtos/index.ts index b63d9531dac..89ef735ab40 100644 --- a/apps/api/src/app/agents/dtos/index.ts +++ b/apps/api/src/app/agents/dtos/index.ts @@ -1,4 +1,5 @@ export * from './add-agent-integration-request.dto'; +export * from './agent-integration-summary.dto'; export * from './agent-integration-response.dto'; export * from './agent-response.dto'; export * from './create-agent-request.dto'; diff --git a/apps/api/src/app/agents/e2e/agents.e2e.ts b/apps/api/src/app/agents/e2e/agents.e2e.ts index a0dad59613b..d4fa1a01e50 100644 --- a/apps/api/src/app/agents/e2e/agents.e2e.ts +++ b/apps/api/src/app/agents/e2e/agents.e2e.ts @@ -61,6 +61,20 @@ describe('Agents API - /agents #novu-v2', () => { expect(afterDelete.status).to.equal(404); }); + it('should return 422 when identifier is not a valid slug', async () => { + const res = await session.testAgent.post('/v1/agents').send({ + name: 'Invalid Slug Agent', + identifier: 'bad id with spaces', + }); + + expect(res.status).to.equal(422); + const messages = res.body?.errors?.general?.messages; + const text = Array.isArray(messages) ? messages.join(' ') : String(messages ?? ''); + + expect(text.toLowerCase()).to.contain('identifier'); + expect(text.toLowerCase()).to.match(/slug|valid/); + }); + it('should return 404 when agent identifier does not exist', async () => { const res = await session.testAgent.get('/v1/agents/nonexistent-agent-id-xyz'); @@ -153,9 +167,7 @@ describe('Agents API - /agents #novu-v2', () => { expect(removeRes.status).to.equal(204); - const listAfterRemove = await session.testAgent.get( - `/v1/agents/${encodeURIComponent(identifier)}/integrations` - ); + const listAfterRemove = await session.testAgent.get(`/v1/agents/${encodeURIComponent(identifier)}/integrations`); expect(listAfterRemove.body.data.length).to.equal(0); diff --git a/apps/api/src/app/agents/mappers/agent-response.mapper.ts b/apps/api/src/app/agents/mappers/agent-response.mapper.ts index 9dac3fd0055..f649bb4426a 100644 --- a/apps/api/src/app/agents/mappers/agent-response.mapper.ts +++ b/apps/api/src/app/agents/mappers/agent-response.mapper.ts @@ -1,6 +1,6 @@ -import type { AgentEntity, AgentIntegrationEntity } from '@novu/dal'; +import type { AgentEntity, AgentIntegrationEntity, IntegrationEntity } from '@novu/dal'; -import type { AgentIntegrationResponseDto, AgentResponseDto } from '../dtos'; +import type { AgentIntegrationResponseDto, AgentIntegrationSummaryDto, AgentResponseDto } from '../dtos'; export function toAgentResponse(agent: AgentEntity): AgentResponseDto { return { @@ -15,6 +15,19 @@ export function toAgentResponse(agent: AgentEntity): AgentResponseDto { }; } +export function toAgentIntegrationSummary( + integration: Pick +): AgentIntegrationSummaryDto { + return { + integrationId: integration._id, + providerId: integration.providerId, + name: integration.name, + identifier: integration.identifier, + channel: integration.channel, + active: integration.active, + }; +} + export function toAgentIntegrationResponse( link: AgentIntegrationEntity, integrationIdentifier: string diff --git a/apps/api/src/app/agents/usecases/list-agents/list-agents.usecase.ts b/apps/api/src/app/agents/usecases/list-agents/list-agents.usecase.ts index 94365257a61..264bdb8372b 100644 --- a/apps/api/src/app/agents/usecases/list-agents/list-agents.usecase.ts +++ b/apps/api/src/app/agents/usecases/list-agents/list-agents.usecase.ts @@ -1,14 +1,19 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { InstrumentUsecase } from '@novu/application-generic'; -import { AgentRepository } from '@novu/dal'; +import { AgentIntegrationRepository, AgentRepository, IntegrationRepository } from '@novu/dal'; import { DirectionEnum } from '@novu/shared'; +import type { AgentIntegrationSummaryDto } from '../../dtos/agent-integration-summary.dto'; import { ListAgentsResponseDto } from '../../dtos/list-agents-response.dto'; -import { toAgentResponse } from '../../mappers/agent-response.mapper'; +import { toAgentIntegrationSummary, toAgentResponse } from '../../mappers/agent-response.mapper'; import { ListAgentsCommand } from './list-agents.command'; @Injectable() export class ListAgents { - constructor(private readonly agentRepository: AgentRepository) {} + constructor( + private readonly agentRepository: AgentRepository, + private readonly agentIntegrationRepository: AgentIntegrationRepository, + private readonly integrationRepository: IntegrationRepository + ) {} @InstrumentUsecase() async execute(command: ListAgentsCommand): Promise { @@ -28,12 +33,101 @@ export class ListAgents { identifier: command.identifier, }); + const integrationsByAgentId = await this.loadIntegrationsForAgents( + command.environmentId, + command.organizationId, + pagination.agents + ); + return { - data: pagination.agents.map((agent) => toAgentResponse(agent)), + data: pagination.agents.map((agent) => ({ + ...toAgentResponse(agent), + integrations: integrationsByAgentId.get(agent._id) ?? [], + })), next: pagination.next, previous: pagination.previous, totalCount: pagination.totalCount, totalCountCapped: pagination.totalCountCapped, }; } + + private async loadIntegrationsForAgents( + environmentId: string, + organizationId: string, + agents: { _id: string }[] + ): Promise> { + const result = new Map(); + + if (agents.length === 0) { + return result; + } + + const agentIds = agents.map((a) => a._id); + const links = await this.agentIntegrationRepository.findLinksForAgents({ + environmentId, + organizationId, + agentIds, + }); + + const integrationIds = [...new Set(links.map((l) => l._integrationId))]; + + if (integrationIds.length === 0) { + for (const id of agentIds) { + result.set(id, []); + } + + return result; + } + + const integrations = await this.integrationRepository.find( + { + _id: { $in: integrationIds }, + _environmentId: environmentId, + _organizationId: organizationId, + }, + '_id identifier name providerId channel active' + ); + + const summaryByIntegrationId = new Map(integrations.map((i) => [i._id, toAgentIntegrationSummary(i)] as const)); + + const seen = new Map>(); + + for (const link of links) { + const summary = summaryByIntegrationId.get(link._integrationId); + + if (!summary) { + continue; + } + + let dedupe = seen.get(link._agentId); + + if (!dedupe) { + dedupe = new Set(); + seen.set(link._agentId, dedupe); + } + + if (dedupe.has(summary.integrationId)) { + continue; + } + + dedupe.add(summary.integrationId); + const list = result.get(link._agentId) ?? []; + list.push(summary); + + result.set(link._agentId, list); + } + + for (const id of agentIds) { + if (!result.has(id)) { + result.set(id, []); + } else { + const list = result.get(id) ?? []; + const sorted = [...list].sort((a, b) => a.name.localeCompare(b.name)); + + result.set(id, sorted); + } + } + + return result; + } } diff --git a/apps/api/src/app/workflows-v2/dtos/create-step.dto.ts b/apps/api/src/app/workflows-v2/dtos/create-step.dto.ts index 6d227845f55..d2bf42a1bc7 100644 --- a/apps/api/src/app/workflows-v2/dtos/create-step.dto.ts +++ b/apps/api/src/app/workflows-v2/dtos/create-step.dto.ts @@ -11,7 +11,7 @@ import { SmsControlDto, ThrottleControlDto, } from '@novu/application-generic'; -import { StepTypeEnum } from '@novu/shared'; +import { SLUG_IDENTIFIER_REGEX, StepTypeEnum, slugIdentifierFormatMessage } from '@novu/shared'; import { IsEnum, IsObject, IsOptional, IsString, Matches } from 'class-validator'; // Base DTO for common properties @@ -27,8 +27,8 @@ export class BaseStepConfigDto { @ApiPropertyOptional({ description: 'Unique identifier for the step' }) @IsString() - @Matches(/^[a-zA-Z0-9]+(?:[-_.][a-zA-Z0-9]+)*$/, { - message: 'stepId must be a valid slug format (letters, numbers, hyphens, dot and underscores only)', + @Matches(SLUG_IDENTIFIER_REGEX, { + message: slugIdentifierFormatMessage('stepId'), }) @IsOptional() stepId?: string; diff --git a/apps/api/src/app/workflows-v2/dtos/create-workflow.dto.ts b/apps/api/src/app/workflows-v2/dtos/create-workflow.dto.ts index 248ab42eead..a7660f6e69a 100644 --- a/apps/api/src/app/workflows-v2/dtos/create-workflow.dto.ts +++ b/apps/api/src/app/workflows-v2/dtos/create-workflow.dto.ts @@ -12,7 +12,13 @@ import { ThrottleControlDto, WorkflowCommonsFields, } from '@novu/application-generic'; -import { SeverityLevelEnum, StepTypeEnum, WorkflowCreationSourceEnum } from '@novu/shared'; +import { + SeverityLevelEnum, + SLUG_IDENTIFIER_REGEX, + StepTypeEnum, + slugIdentifierFormatMessage, + WorkflowCreationSourceEnum, +} from '@novu/shared'; import { Type } from 'class-transformer'; import { IsArray, IsEnum, IsOptional, IsString, Matches, ValidateNested } from 'class-validator'; import { @@ -67,8 +73,8 @@ export type StepCreateDto = export class CreateWorkflowDto extends WorkflowCommonsFields { @ApiProperty({ description: 'Unique identifier for the workflow' }) @IsString() - @Matches(/^[a-zA-Z0-9]+(?:[-_.][a-zA-Z0-9]+)*$/, { - message: 'workflowId must be a valid slug format (letters, numbers, hyphens, dot and underscores only)', + @Matches(SLUG_IDENTIFIER_REGEX, { + message: slugIdentifierFormatMessage('workflowId'), }) workflowId: string; diff --git a/apps/api/src/app/workflows-v2/dtos/duplicate-workflow.dto.ts b/apps/api/src/app/workflows-v2/dtos/duplicate-workflow.dto.ts index 2a9152af78c..0123a4fcf0b 100644 --- a/apps/api/src/app/workflows-v2/dtos/duplicate-workflow.dto.ts +++ b/apps/api/src/app/workflows-v2/dtos/duplicate-workflow.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { SLUG_IDENTIFIER_REGEX, slugIdentifierFormatMessage } from '@novu/shared'; import { IsArray, IsBoolean, IsOptional, IsString, Matches } from 'class-validator'; export class DuplicateWorkflowDto { @@ -16,8 +17,8 @@ export class DuplicateWorkflowDto { }) @IsOptional() @IsString() - @Matches(/^[a-zA-Z0-9]+(?:[-_.][a-zA-Z0-9]+)*$/, { - message: 'workflowId must be a valid slug format (letters, numbers, hyphens, dot and underscores only)', + @Matches(SLUG_IDENTIFIER_REGEX, { + message: slugIdentifierFormatMessage('workflowId'), }) workflowId?: string; diff --git a/apps/dashboard/src/api/agents.ts b/apps/dashboard/src/api/agents.ts new file mode 100644 index 00000000000..3d588edca25 --- /dev/null +++ b/apps/dashboard/src/api/agents.ts @@ -0,0 +1,107 @@ +import type { ChannelTypeEnum, DirectionEnum, IEnvironment } from '@novu/shared'; +import { del, get, post } from '@/api/api.client'; + +/** Root segment for TanStack Query keys; use with {@link getAgentsListQueryKey}. */ +export const AGENTS_LIST_QUERY_KEY = 'fetchAgents' as const; + +export function getAgentsListQueryKey( + environmentId: string | undefined, + params: { after?: string; before?: string; limit: number; identifier: string } +) { + return [AGENTS_LIST_QUERY_KEY, environmentId, params] as const; +} + +export type AgentIntegrationSummary = { + integrationId: string; + providerId: string; + name: string; + identifier: string; + channel: ChannelTypeEnum; + active: boolean; +}; + +export type AgentResponse = { + _id: string; + name: string; + identifier: string; + description?: string; + _environmentId: string; + _organizationId: string; + createdAt: string; + updatedAt: string; + integrations?: AgentIntegrationSummary[]; +}; + +export type ListAgentsResponse = { + data: AgentResponse[]; + next: string | null; + previous: string | null; + totalCount: number; + totalCountCapped: boolean; +}; + +export type CreateAgentBody = { + name: string; + identifier: string; + description?: string; +}; + +export type ListAgentsParams = { + environment: IEnvironment; + limit?: number; + after?: string; + before?: string; + orderBy?: 'createdAt' | 'updatedAt' | '_id'; + orderDirection?: DirectionEnum; + identifier?: string; + signal?: AbortSignal; +}; + +function buildAgentsQuery(params: ListAgentsParams): string { + const searchParams = new URLSearchParams(); + + if (params.limit != null) { + searchParams.set('limit', String(params.limit)); + } + + if (params.after) { + searchParams.set('after', params.after); + } + + if (params.before) { + searchParams.set('before', params.before); + } + + if (params.orderBy) { + searchParams.set('orderBy', params.orderBy); + } + + if (params.orderDirection) { + searchParams.set('orderDirection', params.orderDirection); + } + + if (params.identifier) { + searchParams.set('identifier', params.identifier); + } + + const qs = searchParams.toString(); + + return qs ? `?${qs}` : ''; +} + +export function listAgents(params: ListAgentsParams): Promise { + const query = buildAgentsQuery(params); + + return get(`/agents${query}`, { + environment: params.environment, + signal: params.signal, + }); +} + +export function createAgent(environment: IEnvironment, body: CreateAgentBody): Promise { + return post('/agents', { environment, body }); +} + +export function deleteAgent(environment: IEnvironment, identifier: string): Promise { + return del(`/agents/${encodeURIComponent(identifier)}`, { environment }); +} diff --git a/apps/dashboard/src/components/agents/agents-list.tsx b/apps/dashboard/src/components/agents/agents-list.tsx new file mode 100644 index 00000000000..f5f0b8bc6b7 --- /dev/null +++ b/apps/dashboard/src/components/agents/agents-list.tsx @@ -0,0 +1,287 @@ +import { DirectionEnum, PermissionsEnum } from '@novu/shared'; +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback, useEffect, useState } from 'react'; +import { RiRobot2Line } from 'react-icons/ri'; +import { + AGENTS_LIST_QUERY_KEY, + type AgentResponse, + type CreateAgentBody, + createAgent, + deleteAgent, + getAgentsListQueryKey, + listAgents, +} from '@/api/agents'; +import { NovuApiError } from '@/api/api.client'; +import { AgentsTable } from '@/components/agents/agents-table'; +import { CreateAgentDialog } from '@/components/agents/create-agent-dialog'; +import { DeleteAgentDialog } from '@/components/agents/delete-agent-dialog'; +import { ListNoResults } from '@/components/list-no-results'; +import { FacetedFormFilter } from '@/components/primitives/form/faceted-filter/facated-form-filter'; +import { PermissionButton } from '@/components/primitives/permission-button'; +import { showErrorToast, showSuccessToast } from '@/components/primitives/sonner-helpers'; +import { requireEnvironment, useEnvironment } from '@/context/environment/hooks'; +import { useHasPermission } from '@/hooks/use-has-permission'; + +const PAGE_SIZE_OPTIONS = [10, 12, 20, 50]; + +export function AgentsList() { + const queryClient = useQueryClient(); + const { currentEnvironment } = useEnvironment(); + const has = useHasPermission(); + const canReadAgents = has({ permission: PermissionsEnum.AGENT_READ }); + + const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [after, setAfter] = useState(); + const [before, setBefore] = useState(); + const [limit, setLimit] = useState(12); + const [createOpen, setCreateOpen] = useState(false); + const [agentToDelete, setAgentToDelete] = useState(null); + + useEffect(() => { + const t = setTimeout(() => { + setDebouncedSearch((prev) => { + const next = search.trim(); + + if (prev !== next) { + setAfter(undefined); + setBefore(undefined); + } + + return next; + }); + }, 400); + + return () => clearTimeout(t); + }, [search]); + + const listQuery = useQuery({ + queryKey: getAgentsListQueryKey(currentEnvironment?._id, { + after, + before, + limit, + identifier: debouncedSearch, + }), + queryFn: () => + listAgents({ + environment: requireEnvironment(currentEnvironment, 'No environment selected'), + limit, + after, + before, + orderBy: 'updatedAt', + orderDirection: DirectionEnum.DESC, + identifier: debouncedSearch || undefined, + }), + enabled: Boolean(currentEnvironment) && canReadAgents, + placeholderData: keepPreviousData, + }); + + const createMutation = useMutation({ + mutationFn: (body: CreateAgentBody) => + createAgent(requireEnvironment(currentEnvironment, 'No environment selected'), body), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [AGENTS_LIST_QUERY_KEY] }); + showSuccessToast('Agent created', 'Your agent is ready to use.'); + }, + onError: (err: Error) => { + const message = err instanceof NovuApiError ? err.message : 'Could not create agent.'; + + showErrorToast(message, 'Create failed'); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (identifier: string) => + deleteAgent(requireEnvironment(currentEnvironment, 'No environment selected'), identifier), + onSuccess: async () => { + setAgentToDelete(null); + showSuccessToast('Agent deleted', 'The agent was removed.'); + + const environment = requireEnvironment(currentEnvironment, 'No environment selected'); + const listKey = getAgentsListQueryKey(environment._id, { + after, + before, + limit, + identifier: debouncedSearch, + }); + + await queryClient.invalidateQueries({ queryKey: [AGENTS_LIST_QUERY_KEY] }); + + const refreshed = await queryClient.fetchQuery({ + queryKey: listKey, + queryFn: () => + listAgents({ + environment, + limit, + after, + before, + orderBy: 'updatedAt', + orderDirection: DirectionEnum.DESC, + identifier: debouncedSearch || undefined, + }), + }); + + if (refreshed.data.length === 0 && refreshed.previous) { + setBefore(refreshed.previous); + setAfter(undefined); + } + }, + onError: (err: Error) => { + const message = err instanceof NovuApiError ? err.message : 'Could not delete agent.'; + + showErrorToast(message, 'Delete failed'); + }, + }); + + const handleNextPage = useCallback(() => { + const next = listQuery.data?.next; + + if (!next) { + return; + } + + setAfter(next); + setBefore(undefined); + }, [listQuery.data?.next]); + + const handlePreviousPage = useCallback(() => { + const previous = listQuery.data?.previous; + + if (!previous) { + return; + } + + setBefore(previous); + setAfter(undefined); + }, [listQuery.data?.previous]); + + const handlePageSizeChange = useCallback((nextLimit: number) => { + setLimit(nextLimit); + setAfter(undefined); + setBefore(undefined); + }, []); + + const handleCreateSubmit = useCallback( + async (body: CreateAgentBody) => { + await createMutation.mutateAsync(body); + }, + [createMutation] + ); + + if (!canReadAgents) { + return ( +
+ You don't have permission to view agents for this organization. +
+ ); + } + + const data = listQuery.data; + const isLoading = listQuery.isPending; + const agents: AgentResponse[] = data?.data ?? []; + const hasFilters = debouncedSearch.length > 0; + const showEmptyBlank = !listQuery.isError && !isLoading && !hasFilters && agents.length === 0; + const showNoResults = !listQuery.isError && !isLoading && hasFilters && agents.length === 0; + + return ( +
+
+ + setCreateOpen(true)} + > + Add Agent + +
+ + {listQuery.isError ? ( +
Could not load agents. Try again later.
+ ) : null} + + {showEmptyBlank ? ( +
+

No agents yet

+

+ Create an agent to connect your brain to channels with a unified API. +

+ setCreateOpen(true)} + > + Add Agent + +
+ ) : null} + + {showNoResults ? ( + setSearch('')} + /> + ) : null} + + {!listQuery.isError && !showEmptyBlank && !showNoResults ? ( + + ) : null} + + + + { + if (!open) { + setAgentToDelete(null); + } + }} + onConfirm={() => { + if (agentToDelete) { + deleteMutation.mutate(agentToDelete.identifier); + } + }} + agentName={agentToDelete?.name ?? ''} + agentIdentifier={agentToDelete?.identifier ?? ''} + isDeleting={deleteMutation.isPending} + /> +
+ ); +} diff --git a/apps/dashboard/src/components/agents/agents-table.tsx b/apps/dashboard/src/components/agents/agents-table.tsx new file mode 100644 index 00000000000..db08a27139d --- /dev/null +++ b/apps/dashboard/src/components/agents/agents-table.tsx @@ -0,0 +1,220 @@ +import { providers as novuProviders, PermissionsEnum } from '@novu/shared'; +import { RiMore2Fill, RiRobot2Line } from 'react-icons/ri'; +import type { AgentResponse } from '@/api/agents'; +import { ProviderIcon } from '@/components/integrations/components/provider-icon'; +import { CompactButton } from '@/components/primitives/button-compact'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/primitives/dropdown-menu'; +import { Skeleton } from '@/components/primitives/skeleton'; +import { + Table, + TableBody, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +} from '@/components/primitives/table'; +import { TablePaginationFooter } from '@/components/primitives/table-pagination-footer'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/primitives/tooltip'; +import { useHasPermission } from '@/hooks/use-has-permission'; +import { formatDateSimple } from '@/utils/format-date'; +import { cn } from '@/utils/ui'; + +type AgentsTableProps = { + agents: AgentResponse[]; + isLoading: boolean; + onRequestDelete: (agent: AgentResponse) => void; + paginationProps: { + pageSize: number; + pageSizeOptions?: number[]; + currentItemsCount: number; + onPreviousPage: () => void; + onNextPage: () => void; + onPageSizeChange: (pageSize: number) => void; + hasPreviousPage: boolean; + hasNextPage: boolean; + totalCount?: number; + totalCountCapped?: boolean; + }; +}; + +const MAX_VISIBLE_INTEGRATION_ICONS = 3; + +function getProviderDisplayName(providerId: string): string { + return novuProviders.find((p) => p.id === providerId)?.displayName ?? providerId; +} + +function AgentIntegrationsCell({ agent }: { agent: AgentResponse }) { + const integrations = agent.integrations ?? []; + + if (integrations.length === 0) { + return ; + } + + const visible = integrations.slice(0, MAX_VISIBLE_INTEGRATION_ICONS); + const overflowCount = integrations.length - visible.length; + + return ( +
+
+ {visible.map((integration, index) => { + return ( + + + + + {integration.name} + + ); + })} + {overflowCount > 0 ? ( + +{overflowCount} + ) : null} +
+
+ ); +} + +function AgentsTableSkeletonRow() { + return ( + + +
+ +
+ + +
+
+
+ +
+
+ + + +
+
+
+ + + + + + +
+ ); +} + +export function AgentsTable({ agents, isLoading, onRequestDelete, paginationProps }: AgentsTableProps) { + const has = useHasPermission(); + const canWrite = has?.({ permission: PermissionsEnum.AGENT_WRITE }) ?? true; + + return ( + }> + + + Agent + Integrations + Last updated + + Actions + + + + {!isLoading && ( + + {agents.map((agent) => { + return ( + + +
+ + + +
+ + {agent.name} + + + {agent.identifier} + +
+
+
+ + + + + {formatDateSimple(agent.updatedAt)} + + + {canWrite ? ( + + + + Open menu + + + + onRequestDelete(agent)} + > + Delete + + + + ) : null} + +
+ ); + })} +
+ )} + {!isLoading && agents.length > 0 ? ( + + + + + + + + ) : null} +
+ ); +} diff --git a/apps/dashboard/src/components/agents/create-agent-dialog.tsx b/apps/dashboard/src/components/agents/create-agent-dialog.tsx new file mode 100644 index 00000000000..114a67a1db8 --- /dev/null +++ b/apps/dashboard/src/components/agents/create-agent-dialog.tsx @@ -0,0 +1,225 @@ +import { SLUG_IDENTIFIER_REGEX, slugIdentifierFormatMessage } from '@novu/shared'; +import type { FormEvent, ReactNode } from 'react'; +import { useId, useState } from 'react'; +import { RiArrowRightSLine, RiCloseLine, RiExternalLinkLine, RiInformationFill } from 'react-icons/ri'; +import type { CreateAgentBody } from '@/api/agents'; +import { Button } from '@/components/primitives/button'; +import { CompactButton } from '@/components/primitives/button-compact'; +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle } from '@/components/primitives/dialog'; +import { Hint, HintIcon } from '@/components/primitives/hint'; +import { Input } from '@/components/primitives/input'; +import { Textarea } from '@/components/primitives/textarea'; + +const DOCS_AGENTS_LEARN_MORE_HREF = 'https://docs.novu.co'; + +type CreateAgentDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (body: CreateAgentBody) => Promise; + isSubmitting: boolean; +}; + +type FormErrors = { + name?: string; + identifier?: string; +}; + +function RequiredFieldLabel({ htmlFor, children }: { htmlFor: string; children: ReactNode }) { + return ( + + ); +} + +export function CreateAgentDialog({ open, onOpenChange, onSubmit, isSubmitting }: CreateAgentDialogProps) { + const formId = useId(); + const nameId = `${formId}-name`; + const identifierId = `${formId}-identifier`; + const descriptionId = `${formId}-description`; + + const [name, setName] = useState(''); + const [identifier, setIdentifier] = useState(''); + const [description, setDescription] = useState(''); + const [errors, setErrors] = useState({}); + + const reset = () => { + setName(''); + setIdentifier(''); + setDescription(''); + setErrors({}); + }; + + const handleOpenChange = (next: boolean) => { + if (!next) { + reset(); + } + + onOpenChange(next); + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + const trimmedName = name.trim(); + const trimmedIdentifier = identifier.trim(); + const nextErrors: FormErrors = {}; + + if (!trimmedName) { + nextErrors.name = 'Name is required.'; + } + + if (!trimmedIdentifier) { + nextErrors.identifier = 'Identifier is required.'; + } else if (!SLUG_IDENTIFIER_REGEX.test(trimmedIdentifier)) { + nextErrors.identifier = slugIdentifierFormatMessage('identifier'); + } + + if (Object.keys(nextErrors).length > 0) { + setErrors(nextErrors); + + return; + } + + setErrors({}); + + const body: CreateAgentBody = { + name: trimmedName, + identifier: trimmedIdentifier, + }; + + const trimmedDescription = description.trim(); + + if (trimmedDescription) { + body.description = trimmedDescription; + } + + await onSubmit(body); + handleOpenChange(false); + }; + + return ( + + +
+
+
+ + Add agent + + + Give your agent a unified way to communicate with your users.{' '} + + Learn more + + + +
+ + + Close + + +
+
+ +
+
+
+
+ Agent name + { + setName(e.target.value); + setErrors((prev) => ({ ...prev, name: undefined })); + }} + placeholder="e.g. Wine Sommelier Agent" + hasError={Boolean(errors.name)} + aria-invalid={errors.name ? true : undefined} + aria-describedby={errors.name ? `${nameId}-error` : undefined} + /> + {errors.name ? ( + + ) : null} +
+ +
+ Identifier + { + setIdentifier(e.target.value); + setErrors((prev) => ({ ...prev, identifier: undefined })); + }} + placeholder="e.g. wine-sommelier-agent" + hasError={Boolean(errors.identifier)} + aria-invalid={errors.identifier ? true : undefined} + aria-describedby={ + errors.identifier ? `${identifierId}-hint ${identifierId}-error` : `${identifierId}-hint` + } + /> + + + Used in code and APIs. Must be unique. Letters, numbers, hyphens, underscores, and dots only (no + spaces). + + {errors.identifier ? ( + + ) : null} +
+ +
+ +