Add Activepieces integration for workflow automation

- Add Activepieces fork with SmoothSchedule custom piece
- Create integrations app with Activepieces service layer
- Add embed token endpoint for iframe integration
- Create Automations page with embedded workflow builder
- Add sidebar visibility fix for embed mode
- Add list inactive customers endpoint to Public API
- Include SmoothSchedule triggers: event created/updated/cancelled
- Include SmoothSchedule actions: create/update/cancel events, list resources/services/customers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-18 22:59:37 -05:00
parent 9848268d34
commit 3aa7199503
16292 changed files with 1284892 additions and 4708 deletions

View File

@@ -0,0 +1,70 @@
import { t } from 'i18next';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
type AgentResult,
AgentTaskStatus,
ContentBlockType,
isNil,
} from '@activepieces/shared';
import {
AgentToolBlock,
DoneBlock,
FailedBlock,
MarkdownBlock,
PromptBlock,
StructuredOutputBlock,
ThinkingBlock,
} from './timeline-blocks';
type AgentTimelineProps = {
className?: string;
agentResult?: AgentResult;
};
export const AgentTimeline = ({
agentResult,
className = '',
}: AgentTimelineProps) => {
if (isNil(agentResult)) {
return <p>{t('No agent output available')}</p>;
}
return (
<div className={`h-full flex w-full flex-col ${className}`}>
<ScrollArea className="flex-1 min-h-0 relative">
<div className="absolute left-2 top-4 bottom-8 w-px bg-border" />
<div className="space-y-7 pb-4">
{agentResult.prompt.length > 0 && (
<PromptBlock prompt={agentResult.prompt} />
)}
{agentResult.steps.map((step, index) => {
switch (step.type) {
case ContentBlockType.MARKDOWN:
return <MarkdownBlock key={index} step={step} index={index} />;
case ContentBlockType.TOOL_CALL:
return (
<AgentToolBlock key={index} block={step} index={index} />
);
default:
return null;
}
})}
{!isNil(agentResult.structuredOutput) && (
<StructuredOutputBlock output={agentResult.structuredOutput} />
)}
{agentResult.status === AgentTaskStatus.IN_PROGRESS && (
<ThinkingBlock />
)}
{agentResult.status === AgentTaskStatus.COMPLETED && <DoneBlock />}
{agentResult.status === AgentTaskStatus.FAILED && <FailedBlock />}
</div>
</ScrollArea>
</div>
);
};

View File

@@ -0,0 +1,278 @@
import { t } from 'i18next';
import {
CircleX,
Loader2,
Wrench,
MessageSquareText,
CircleCheckBig,
CheckCheck,
SquareTerminal,
Braces,
} from 'lucide-react';
import { ApMarkdown } from '@/components/custom/markdown';
import { DataList } from '@/components/data-list';
import { JsonViewer } from '@/components/json-viewer';
import { SimpleJsonViewer } from '@/components/simple-json-viewer';
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from '@/components/ui/accordion';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import {
ExecuteToolResponse,
isNil,
MarkdownContentBlock,
MarkdownVariant,
TASK_COMPLETION_TOOL_NAME,
ToolCallStatus,
ExecutionToolStatus,
type ToolCallContentBlock,
} from '@activepieces/shared';
import { agentToolHooks } from '../agent-tool-hooks';
interface AgentToolBlockProps {
block: ToolCallContentBlock;
index: number;
}
const parseJsonOrReturnOriginal = (json: unknown) => {
try {
return JSON.parse(json as string);
} catch {
return json;
}
};
const TimelineItem = ({
icon,
children,
iconLeft = 'left-0',
}: {
icon: React.ReactNode;
children: React.ReactNode;
iconLeft?: string;
}) => {
return (
<div className="relative pl-7 animate-fade">
<div
className={`absolute bg-background ${iconLeft} w-4 h-4 top-3.5 flex items-center justify-center`}
>
{icon}
</div>
{children}
</div>
);
};
export const AgentToolBlock = ({ block, index }: AgentToolBlockProps) => {
if ([TASK_COMPLETION_TOOL_NAME].includes(block.toolName ?? '')) return null;
const { data: metadata, isLoading } = agentToolHooks.useToolMetadata(block);
const output = block.output as ExecuteToolResponse | null;
const errorMessage = output?.errorMessage as string | null;
const isDone = block.status === ToolCallStatus.COMPLETED;
const isSuccess = output?.status ?? ExecutionToolStatus.FAILED;
const hasInstructions = !isNil(block.input?.instruction);
const resolvedFields = output?.resolvedInput ?? null;
const result = output?.output
? parseJsonOrReturnOriginal(output.output)
: null;
const defaultTab = resolvedFields ? 'resolvedFields' : 'result';
const renderStatusIcon = () => {
if (!isDone) return <Loader2 className="h-4 w-4 animate-spin shrink-0" />;
return isSuccess === ExecutionToolStatus.SUCCESS ? (
<CheckCheck className="h-4 w-4 text-success shrink-0" />
) : (
<CircleX className="h-4 w-4 text-destructive shrink-0" />
);
};
const renderToolIcon = () => {
if (isLoading) return <Loader2 className="h-4 w-4 animate-spin shrink-0" />;
if (metadata?.logoUrl)
return (
<img
src={metadata.logoUrl}
alt="Tool logo"
className="h-4 w-4 object-contain shrink-0"
/>
);
return <Wrench className="h-4 w-4 shrink-0" />;
};
const ToolHeader = (
<div className="flex items-center gap-2 w-full">
{renderToolIcon()}
<span
className={`flex gap-1 items-center ${
!isSuccess ? 'text-destructive' : ''
}`}
>
<span className="text-sm font-semibold">
{isLoading ? 'Loading...' : metadata?.displayName ?? 'Unknown Tool'}
{!isSuccess && t(' (Failed)')}
</span>
</span>
</div>
);
return (
<TimelineItem key={`step-${index}-${block.type}`} icon={renderStatusIcon()}>
<Accordion
type="single"
collapsible
className="border-0 w-full bg-accent/20 rounded-md text-foreground border border-border"
>
<AccordionItem value={`block-${index}`} className="border-0">
<AccordionTrigger className="p-3 text-sm">
{ToolHeader}
</AccordionTrigger>
<AccordionContent>
<div className="space-y-3 w-full my-2">
{hasInstructions && (
<ApMarkdown
variant={MarkdownVariant.BORDERLESS}
markdown={block.input?.instruction as string}
/>
)}
{!isLoading && (
<Tabs defaultValue={defaultTab} className="w-full">
<TabsList variant="outline" className="mb-0">
<TabsTrigger
value="resolvedFields"
variant="outline"
className="text-xs"
>
{t('Parameters')}
</TabsTrigger>
<TabsTrigger
value="result"
variant="outline"
className="text-xs"
>
{isNil(errorMessage) ? t('Output') : t('Error')}
</TabsTrigger>
</TabsList>
<TabsContent
value="resolvedFields"
className="overflow-hidden mt-3"
>
{resolvedFields ? (
<DataList data={resolvedFields} />
) : (
<div className="text-muted-foreground text-sm">
{t('No resolved fields')}
</div>
)}
</TabsContent>
<TabsContent value="result" className="overflow-hidden mt-3">
{result ? (
<SimpleJsonViewer
data={result}
hideCopyButton
maxHeight={300}
/>
) : !isNil(errorMessage) ? (
<ApMarkdown
variant={MarkdownVariant.BORDERLESS}
markdown={errorMessage}
/>
) : (
<div className="text-muted-foreground text-sm">
{t('No result')}
</div>
)}
</TabsContent>
</Tabs>
)}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</TimelineItem>
);
};
export const MarkdownBlock = ({
index,
step,
}: {
index: number;
step: MarkdownContentBlock;
}) => {
return (
<TimelineItem
key={`step-${index}-${step.type}`}
icon={<MessageSquareText className="h-4 w-4 text-muted-foreground" />}
>
<div className="bg-accent/20 rounded-md p-3 text-sm text-foreground border border-border">
<ApMarkdown
markdown={step.markdown}
variant={MarkdownVariant.BORDERLESS}
/>
</div>
</TimelineItem>
);
};
export const StructuredOutputBlock = ({ output }: { output: any }) => {
return (
<TimelineItem icon={<Braces className="h-4 w-4 text-muted-foreground" />}>
<JsonViewer json={output} title={t('output')} />
</TimelineItem>
);
};
export const ThinkingBlock = () => {
return (
<TimelineItem
icon={<Loader2 className="h-4 w-4 text-muted-foreground animate-spin" />}
>
<div className="bg-accent/20 rounded-md p-3 w-full text-sm text-foreground border border-border animate-pulse">
<span>{t('Agent is thinking...')}</span>
</div>
</TimelineItem>
);
};
export const PromptBlock = ({ prompt }: { prompt: string }) => {
return (
<TimelineItem icon={<SquareTerminal className="h-4 w-4 text-primary" />}>
<div className="bg-primary/5 rounded-md p-3 text-sm text-foreground border border-border">
<ApMarkdown markdown={prompt} variant={MarkdownVariant.BORDERLESS} />
</div>
</TimelineItem>
);
};
export const DoneBlock = () => {
return (
<TimelineItem icon={<CircleCheckBig className="h-4 w-4 text-green-600" />}>
<div className="border border-green-500/40 bg-green-50/60 rounded-md p-3 text-sm text-green-700 font-medium flex items-center gap-2">
<span>{t('Done!')}</span>
</div>
</TimelineItem>
);
};
export const FailedBlock = () => {
return (
<TimelineItem icon={<CircleX className="h-4 w-4 text-red-600" />}>
<div className="border border-red-500/40 bg-red-50/60 rounded-md p-3 text-sm text-red-700 font-medium flex items-center gap-2">
<span>{t('Failed')}</span>
</div>
</TimelineItem>
);
};

View File

@@ -0,0 +1,45 @@
import { useQuery } from '@tanstack/react-query';
import { ToolCallType, type ToolCallContentBlock } from '@activepieces/shared';
import { piecesApi } from '../pieces/lib/pieces-api';
type ToolMetadata = {
displayName?: string | null;
logoUrl?: string | null;
};
export const agentToolHooks = {
useToolMetadata(contentBlock: ToolCallContentBlock) {
return useQuery<ToolMetadata, Error>({
queryKey: [
'mcp-tool-metadata',
contentBlock.toolName,
contentBlock.toolCallType,
],
queryFn: async () => {
switch (contentBlock.toolCallType) {
case ToolCallType.PIECE: {
const piece = await piecesApi.get({
name: contentBlock.pieceName,
version: contentBlock.pieceVersion,
});
const actionMetadata = piece.actions[contentBlock.actionName];
return {
displayName:
actionMetadata?.displayName ?? contentBlock.actionName,
logoUrl: piece.logoUrl,
};
}
case ToolCallType.FLOW:
return {
displayName: contentBlock.displayName,
logoUrl: null,
};
default:
return { displayName: null, logoUrl: null };
}
},
});
},
};

View File

@@ -0,0 +1,98 @@
import { t } from 'i18next';
import { ChevronDown, Puzzle, Workflow } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { AgentTool, AgentToolType } from '@activepieces/shared';
import { AgentFlowToolDialog } from './flow-tool-dialog';
import { AgentPieceDialog } from './piece-tool-dialog';
type AddAgentToolDropdownProps = {
tools: AgentTool[];
disabled?: boolean;
onToolsUpdate: (tools: AgentTool[]) => void;
};
export const AddAgentToolDropdown = ({
tools,
disabled,
onToolsUpdate,
}: AddAgentToolDropdownProps) => {
const [openDropdown, setOpenDropdown] = useState(false);
const [showAddPieceDialog, setShowAddPieceDialog] = useState(false);
const [showAddFlowDialog, setShowAddFlowDialog] = useState(false);
return (
<DropdownMenu
modal={false}
open={openDropdown}
onOpenChange={setOpenDropdown}
>
<DropdownMenuTrigger disabled={disabled} asChild>
<Button variant="basic">
<span>{t('Add tool')}</span>
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<AgentPieceDialog
tools={tools}
open={showAddPieceDialog}
onToolsUpdate={(tools) => {
onToolsUpdate(tools);
setShowAddPieceDialog(false);
setOpenDropdown(false);
}}
onClose={() => {
setShowAddPieceDialog(false);
setOpenDropdown(false);
}}
>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
setShowAddPieceDialog(true);
}}
>
<Puzzle className="h-4 w-4 me-2" />
<span>{t('From piece')}</span>
</DropdownMenuItem>
</AgentPieceDialog>
<AgentFlowToolDialog
open={showAddFlowDialog}
selectedFlows={tools
.filter((tool) => tool.type === AgentToolType.FLOW)
.map((tool) => tool.flowId!)}
onToolsUpdate={(newTools) => {
onToolsUpdate(newTools);
setShowAddFlowDialog(false);
setOpenDropdown(false);
}}
onClose={() => {
setShowAddFlowDialog(false);
setOpenDropdown(false);
}}
tools={tools}
>
<DropdownMenuItem
onSelect={(e) => {
e.stopPropagation();
e.preventDefault();
setShowAddFlowDialog(true);
}}
>
<Workflow className="h-4 w-4 me-2" />
<span>{t('From flow')}</span>
</DropdownMenuItem>
</AgentFlowToolDialog>
</DropdownMenuContent>
</DropdownMenu>
);
};

View File

@@ -0,0 +1,20 @@
import { t } from 'i18next';
const formatNames = (names: string[]) => {
if (names.length === 1) {
return names[0];
}
const formattedNames = names.map((name, idx) => {
if (idx < names.length - 1) {
return `${name},`;
}
return `${t('and')} ${name}`;
});
return formattedNames.join(' ');
};
export const agentConfigUtils = {
formatNames,
};

View File

@@ -0,0 +1,20 @@
import { t } from 'i18next';
import { Plus } from 'lucide-react';
import { flowHooks } from '@/features/flows/lib/flow-hooks';
export const CreateMcpFlowButton = () => {
const { mutate: createMcpFlow, isPending } = flowHooks.useCreateMcpFlow();
return (
<div
onClick={() => createMcpFlow()}
className="border p-2 h-[150px] w-[150px] flex flex-col items-center justify-center hover:bg-accent hover:text-accent-foreground cursor-pointer rounded-lg border-dashed border-muted-foreground/50"
>
<Plus className="w-[40px] h-[40px] text-muted-foreground" />
<div className="mt-2 text-center text-md">
{isPending ? t('Creating...') : t('Create New Flow')}
</div>
</div>
);
};

View File

@@ -0,0 +1,102 @@
import { t } from 'i18next';
import { Workflow } from 'lucide-react';
import { useMemo } from 'react';
import { useDebounce } from 'use-debounce';
import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { PopulatedFlow } from '@activepieces/shared';
import { CreateMcpFlowButton } from './create-mcp-flow-button';
import { flowDialogUtils } from './flow-dialog-utils';
interface FlowDialogContentProps {
flows: PopulatedFlow[];
selectedFlows: string[];
setSelectedFlows: (value: string[] | ((prev: string[]) => string[])) => void;
searchQuery: string;
}
export const FlowDialogContent = ({
flows,
selectedFlows,
setSelectedFlows,
searchQuery,
}: FlowDialogContentProps) => {
const [debouncedQuery] = useDebounce(searchQuery, 300);
const filteredFlows = useMemo(() => {
if (!debouncedQuery) return flows;
const query = debouncedQuery.toLowerCase();
return flows.filter((flow) =>
flow.version.displayName.toLowerCase().includes(query),
);
}, [flows, debouncedQuery]);
const handleSelectFlow = (flowId: string) => {
setSelectedFlows((prev: string[]) => {
const newSelected = prev.includes(flowId)
? prev.filter((id: string) => id !== flowId)
: [...prev, flowId];
return newSelected;
});
};
return (
<ScrollArea className="grow overflow-y-auto rounded-md">
<div className="grid grid-cols-4 gap-4">
<CreateMcpFlowButton />
{filteredFlows.map((flow) => {
const tooltip = flowDialogUtils.getFlowTooltip(flow);
const isSelectable = flowDialogUtils.isFlowSelectable(flow);
return (
<Tooltip key={flow.id}>
<TooltipTrigger asChild>
<div
className={`border p-2 h-[150px] w-[150px] flex flex-col items-center justify-center hover:bg-accent hover:text-accent-foreground cursor-pointer rounded-lg relative ${
!isSelectable ? 'opacity-50' : ''
} ${selectedFlows.includes(flow.id) ? 'bg-accent' : ''}`}
onClick={() => isSelectable && handleSelectFlow(flow.id)}
>
<Checkbox
checked={selectedFlows.includes(flow.id)}
onCheckedChange={() =>
isSelectable && handleSelectFlow(flow.id)
}
className="absolute top-2 left-2"
onClick={(e) => e.stopPropagation()}
disabled={!isSelectable}
/>
<Workflow className="w-[40px] h-[40px] text-muted-foreground" />
<div className="w-full mt-2 text-center text-md px-2 text-ellipsis overflow-hidden">
{flow.version.displayName}
</div>
</div>
</TooltipTrigger>
{tooltip && (
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
)}
</Tooltip>
);
})}
</div>
{filteredFlows.length === 0 && (
<div className="text-center text-muted-foreground py-8">
{searchQuery ? t('No flows found') : t('No flows available')}
</div>
)}
</ScrollArea>
);
};

View File

@@ -0,0 +1,24 @@
import { t } from 'i18next';
import { PopulatedFlow, FlowVersionState } from '@activepieces/shared';
const isFlowSelectable = (flow: PopulatedFlow) => {
return (
flow.version.state === FlowVersionState.LOCKED && flow.status === 'ENABLED'
);
};
const getFlowTooltip = (flow: PopulatedFlow) => {
if (flow.version.state !== FlowVersionState.LOCKED) {
return t('Flow must be published to be selected');
}
if (flow.status !== 'ENABLED') {
return t('Flow must be enabled to be selected');
}
return '';
};
export const flowDialogUtils = {
isFlowSelectable,
getFlowTooltip,
};

View File

@@ -0,0 +1,147 @@
import { DialogTrigger } from '@radix-ui/react-dialog';
import { useQuery } from '@tanstack/react-query';
import { t } from 'i18next';
import { Search } from 'lucide-react';
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { flowsApi } from '@/features/flows/lib/flows-api';
import { authenticationSession } from '@/lib/authentication-session';
import {
PopulatedFlow,
AgentTool,
FlowTriggerType,
AgentToolType,
} from '@activepieces/shared';
import { FlowDialogContent } from './flow-dialog-content';
type AgentFlowToolDialogProps = {
children: React.ReactNode;
selectedFlows: string[];
open: boolean;
onToolsUpdate: (tools: AgentTool[]) => void;
onClose: () => void;
tools: AgentTool[];
};
export function AgentFlowToolDialog({
open,
selectedFlows: initialSelectedFlows,
onToolsUpdate,
children,
onClose,
tools,
}: AgentFlowToolDialogProps) {
const [searchQuery, setSearchQuery] = useState('');
const [selectedFlows, setSelectedFlows] =
useState<string[]>(initialSelectedFlows);
const projectId = authenticationSession.getProjectId();
const { data: flows } = useQuery({
queryKey: ['flows', projectId],
queryFn: async () => {
const flows = await flowsApi
.list({
cursor: undefined,
limit: 1000,
projectId: projectId!,
})
.then((response) => {
return response.data.filter(
(flow: PopulatedFlow) =>
flow.version.trigger.type === FlowTriggerType.PIECE &&
flow.version.trigger.settings.pieceName ===
'@activepieces/piece-mcp',
);
});
return flows;
},
});
const handleSave = () => {
const newTools = selectedFlows.map((flowId) => ({
type: AgentToolType.FLOW,
flowId: flowId,
toolName: flowId,
})) as AgentTool[];
const nonFlowTools: AgentTool[] = tools.filter(
(tool) => tool.type !== AgentToolType.FLOW,
);
const updatedTools = [...nonFlowTools, ...newTools];
onToolsUpdate(updatedTools);
handleClose();
};
const handleClose = () => {
setSearchQuery('');
onClose();
};
return (
<Dialog
open={open}
onOpenChange={(open) => {
if (!open) {
handleClose();
}
}}
>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="w-[90vw] max-w-[750px] h-[80vh] max-h-[800px] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{t('Add Flow Tools')}</DialogTitle>
<DialogDescription>
{t('Select flows to add as agent tools')}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 px-1">
<div className="relative mt-1">
<Search className="absolute left-2 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('Search flows')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
</div>
<ScrollArea className="grow overflow-y-auto px-1 pt-4">
<FlowDialogContent
flows={flows || []}
searchQuery={searchQuery}
selectedFlows={selectedFlows}
setSelectedFlows={setSelectedFlows}
/>
</ScrollArea>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="ghost">
{t('Close')}
</Button>
</DialogClose>
<Button type="button" onClick={handleSave}>
{t('Save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,83 @@
import { t } from 'i18next';
import { Workflow, Trash2, EllipsisVertical } from 'lucide-react';
import { useState } from 'react';
import { ConfirmationDeleteDialog } from '@/components/delete-dialog';
import { Card, CardContent } from '@/components/ui/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { AgentFlowTool as AgentFlowToolType } from '@activepieces/shared';
type AgentFlowToolProps = {
disabled?: boolean;
tool: AgentFlowToolType;
removeTool: (toolIds: string[]) => Promise<void>;
};
export const AgentFlowTool = ({
disabled,
tool,
removeTool,
}: AgentFlowToolProps) => {
const [open, setOpen] = useState(false);
const openFlow = () => {
window.open(`/flows/${tool.flowId}`, '_blank');
};
return (
<Card key={`flow-${tool.toolName}`}>
<CardContent className="flex items-center justify-between p-3 min-h-[48px]">
<div
className="flex items-center gap-3 min-w-0 group cursor-pointer"
onClick={openFlow}
>
<div className="h-8 w-8 rounded-md bg-muted flex items-center justify-center shrink-0">
<Workflow className="h-5 w-5 text-muted-foreground" />
</div>
<div className="min-w-0">
<h3 className="text-sm font-medium truncate">
<span className="group-hover:underline">
{tool.flowId || t('Flow')}
</span>
</h3>
</div>
</div>
<div className="flex items-center gap-2">
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
disabled={disabled}
className="rounded-full p-2 hover:bg-muted cursor-pointer"
asChild
>
<EllipsisVertical className="h-8 w-8" />
</DropdownMenuTrigger>
<DropdownMenuContent
noAnimationOnOut={true}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<ConfirmationDeleteDialog
title={`${t('Delete')} ${tool.flowId}`}
message={t('Are you sure you want to delete this tool?')}
mutationFn={async () => await removeTool([tool.toolName])}
entityName={t('Tool')}
>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<div className="flex cursor-pointer flex-row gap-2 items-center">
<Trash2 className="h-4 w-4 text-destructive" />
<span className="text-destructive">{t('Delete')}</span>
</div>
</DropdownMenuItem>
</ConfirmationDeleteDialog>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,108 @@
import { t } from 'i18next';
import { ControllerRenderProps } from 'react-hook-form';
import { ScrollArea } from '@/components/ui/scroll-area';
import { piecesHooks } from '@/features/pieces/lib/pieces-hooks';
import type { AgentTool } from '@activepieces/shared';
import { AgentToolType } from '@activepieces/shared';
import { AddAgentToolDropdown } from './add-agent-tool-dropwdown';
import { AgentFlowTool } from './flow-tool';
import { AgentPieceTool } from './piece-tool';
interface AgentToolsProps {
agentToolsField: ControllerRenderProps;
disabled?: boolean;
}
export const AgentTools = ({ disabled, agentToolsField }: AgentToolsProps) => {
const tools = Array.isArray(agentToolsField.value)
? (agentToolsField.value as AgentTool[])
: [];
const onToolsUpdate = (tools: AgentTool[]) => agentToolsField.onChange(tools);
const { pieces } = piecesHooks.usePieces({});
const removeTool = async (toolIds: string[]): Promise<void> => {
const newTools = tools.filter((tool) => !toolIds.includes(tool.toolName));
onToolsUpdate(newTools);
};
const piecesCount =
tools.filter((tool) => tool.type === AgentToolType.PIECE).length || 0;
const flowsCount =
tools.filter((tool) => tool.type === AgentToolType.FLOW).length || 0;
const totalToolsCount = piecesCount + flowsCount;
const hasTools = totalToolsCount > 0;
const pieceToToolMap = tools.reduce((acc, tool) => {
const key =
tool.type === AgentToolType.PIECE
? tool.pieceMetadata?.pieceName
: tool.flowId;
if (key) {
acc[key] = acc[key] || [];
acc[key].push(tool);
}
return acc;
}, {} as Record<string, AgentTool[]>);
return (
<div>
<div className="flex items-center justify-between">
<div className="space-y-0">
<h2 className="text-sm flex font-medium items-center gap-2">
{t('Tools')}
</h2>
</div>
<div className="flex gap-2">
<AddAgentToolDropdown
disabled={disabled}
onToolsUpdate={(tools) => {
onToolsUpdate?.(tools);
}}
tools={tools}
/>
</div>
</div>
<div className="mt-2">
{hasTools && (
<ScrollArea>
<div className="space-y-2">
{pieceToToolMap &&
Object.entries(pieceToToolMap).map(([toolKey, tools]) => {
if (tools[0].type === AgentToolType.PIECE) {
return (
<AgentPieceTool
disabled={disabled}
key={toolKey}
tools={tools}
pieces={pieces || []}
removeTool={removeTool}
/>
);
} else if (tools[0].type === AgentToolType.FLOW) {
return (
<AgentFlowTool
disabled={disabled}
key={toolKey}
tool={tools[0]}
removeTool={removeTool}
/>
);
}
return null;
})}
</div>
</ScrollArea>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,123 @@
import { t } from 'i18next';
import React, { useState } from 'react';
// eslint-disable-next-line import/no-restricted-paths
import { CreateOrEditConnectionDialog } from '@/app/connections/create-edit-connection-dialog';
import { SearchableSelect } from '@/components/custom/searchable-select';
import { Label } from '@/components/ui/label';
import { appConnectionsQueries } from '@/features/connections/lib/app-connections-hooks';
import { authenticationSession } from '@/lib/authentication-session';
import { PieceMetadataModelSummary } from '@activepieces/pieces-framework';
import { isNil } from '@activepieces/shared';
type ConnectionDropdownProps = {
piece: PieceMetadataModelSummary;
value: string | null;
onChange: (connectionExternalId: string | null) => void;
disabled?: boolean;
label?: string;
placeholder?: string;
showLabel?: boolean;
required?: boolean;
showError?: boolean;
};
export const ConnectionDropdown = React.memo(
({
piece,
value,
onChange,
disabled = false,
label = t('Connection'),
placeholder = t('Select a connection'),
showLabel = true,
required = false,
showError = false,
}: ConnectionDropdownProps) => {
const [connectionDialogOpen, setConnectionDialogOpen] = useState(false);
const {
data: connections,
isLoading: connectionsLoading,
refetch: refetchConnections,
isRefetching: isRefetchingConnections,
} = appConnectionsQueries.useAppConnections({
request: {
pieceName: piece?.name || '',
projectId: authenticationSession.getProjectId()!,
limit: 1000,
},
extraKeys: [piece?.name, authenticationSession.getProjectId()!],
staleTime: 0,
});
const pieceHasAuth = !isNil(piece?.auth);
const shouldShowError =
showError && required && pieceHasAuth && value === null;
if (!piece || !pieceHasAuth) {
return null;
}
const connectionOptions =
connections?.data?.map((connection) => ({
label: connection.displayName,
value: connection.externalId,
})) ?? [];
const connectionOptionsWithNewConnectionOption = [
{ label: t('+ New Connection'), value: '' },
...connectionOptions,
];
const handleChange = (selectedValue: string | null) => {
if (selectedValue) {
onChange(selectedValue as string);
} else {
setConnectionDialogOpen(true);
}
};
return (
<>
<CreateOrEditConnectionDialog
piece={piece}
open={connectionDialogOpen}
setOpen={(open, connection) => {
setConnectionDialogOpen(open);
if (connection) {
onChange(connection.externalId);
refetchConnections();
}
}}
reconnectConnection={null}
isGlobalConnection={false}
/>
<div className="space-y-2">
{showLabel && <Label>{label}</Label>}
<SearchableSelect
value={value ?? undefined}
onChange={handleChange}
options={connectionOptionsWithNewConnectionOption}
placeholder={placeholder}
loading={connectionsLoading || isRefetchingConnections}
disabled={disabled}
showDeselect={!required && !disabled && value !== null}
triggerClassName={
shouldShowError ? 'border-destructive' : undefined
}
/>
{shouldShowError && (
<p className="text-sm font-medium text-destructive wrap-break-word">
{t('Connection is required')}
</p>
)}
</div>
</>
);
},
);
ConnectionDropdown.displayName = 'ConnectionDropdown';

View File

@@ -0,0 +1,251 @@
import { DialogTrigger } from '@radix-ui/react-dialog';
import { t } from 'i18next';
import { ChevronLeft, Search } from 'lucide-react';
import React, { useState, useMemo } from 'react';
import { useDebounce } from 'use-debounce';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { stepsHooks } from '@/features/pieces/lib/steps-hooks';
import { PieceStepMetadataWithSuggestions } from '@/lib/types';
import {
isNil,
AgentTool,
AgentPieceTool,
AgentToolType,
} from '@activepieces/shared';
import { PieceActionsDialog } from './piece-actions';
import { PiecesContent } from './pieces-content';
type AgentPieceDialogProps = {
children: React.ReactNode;
tools: AgentTool[];
open: boolean;
onToolsUpdate: (tools: AgentTool[]) => void;
onClose: () => void;
};
export type ActionInfo = {
actionName: string;
actionDisplayName: string;
};
export function AgentPieceDialog({
tools,
open,
onToolsUpdate,
children,
onClose,
}: AgentPieceDialogProps) {
const [searchQuery, setSearchQuery] = useState('');
const [selectedConnectionExternalId, setSelectedConnectionExternalId] =
useState<string | null>(null);
console.log('@@@@@@@@@@@@@@@@@@@@@222222222');
console.log(selectedConnectionExternalId);
console.log('@@@@@@@@@@@@@@@@@@@@@222222222');
const [debouncedQuery] = useDebounce(searchQuery, 300);
const [showValidationErrors, setShowValidationErrors] = useState(false);
const { metadata, isLoading: isPiecesLoading } =
stepsHooks.useAllStepsMetadata({
searchQuery: debouncedQuery,
type: 'action',
});
const [selectedPiece, setSelectedPiece] =
useState<PieceStepMetadataWithSuggestions | null>(null);
const [selectedActions, setSelectedActions] = useState<ActionInfo[]>([]);
const pieceMetadata = useMemo(() => {
return (
metadata?.filter(
(m): m is PieceStepMetadataWithSuggestions =>
'suggestedActions' in m && 'suggestedTriggers' in m,
) ?? []
);
}, [metadata]);
const handlePieceSelect = (piece: PieceStepMetadataWithSuggestions) => {
const existingTools = tools?.filter(
(tool): tool is AgentPieceTool =>
tool.type === AgentToolType.PIECE &&
tool.pieceMetadata?.pieceName === piece.pieceName,
);
if (existingTools && existingTools.length > 0) {
setSelectedActions(
existingTools.map((tool) => ({
actionName: tool.pieceMetadata?.actionName,
actionDisplayName: tool.pieceMetadata?.actionName,
})),
);
setSelectedConnectionExternalId(
(existingTools[0].pieceMetadata?.predefinedInput?.auth as string) ||
null,
);
}
setSelectedPiece(piece);
};
const handleActionSelect = (action: ActionInfo) => {
setSelectedActions((prev) => {
const isAlreadySelected = prev.some(
(a) => a.actionName === action.actionName,
);
const newSelected = isAlreadySelected
? prev.filter((a) => a.actionName !== action.actionName)
: [...prev, action];
return newSelected;
});
};
const handleSelectAll = (checked: boolean) => {
if (checked && selectedPiece) {
setSelectedActions(
selectedPiece.suggestedActions?.map((a) => ({
actionName: a.name,
actionDisplayName: a.displayName,
})) ?? [],
);
} else {
setSelectedActions([]);
}
};
const handleSave = () => {
if (!isNil(selectedPiece?.auth) && isNil(selectedConnectionExternalId)) {
setShowValidationErrors(true);
return;
}
setShowValidationErrors(false);
if (!selectedPiece) return;
const newTools: AgentTool[] = selectedActions.map((action) => ({
type: AgentToolType.PIECE,
toolName: action.actionName,
pieceMetadata: {
pieceVersion: selectedPiece.pieceVersion,
pieceName: selectedPiece.pieceName,
actionName: action.actionName,
predefinedInput: {
auth: !isNil(selectedConnectionExternalId)
? `{{connections['${selectedConnectionExternalId}']}}`
: undefined,
},
},
}));
const oldTools = tools;
onToolsUpdate([...oldTools, ...newTools]);
handleClose();
};
const handleClose = () => {
setSelectedPiece(null);
setSearchQuery('');
setSelectedActions([]);
setShowValidationErrors(false);
onClose();
};
return (
<Dialog
open={open}
onOpenChange={(open) => {
if (!open) {
handleClose();
}
}}
>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="w-[90vw] max-w-[750px] h-[80vh] max-h-[800px] flex flex-col overflow-hidden">
<DialogHeader className={`${selectedPiece ? 'gap-2' : 'gap-0'}`}>
<DialogTitle>
{selectedPiece ? (
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => {
setSelectedPiece(null);
setSearchQuery('');
}}
>
<ChevronLeft className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('Back')}</TooltipContent>
</Tooltip>
{selectedPiece.displayName}
</div>
) : (
t('Add Tool')
)}
</DialogTitle>
</DialogHeader>
{selectedPiece ? (
<PieceActionsDialog
piece={selectedPiece}
selectedActions={selectedActions}
onSelectAction={handleActionSelect}
onSelectAll={handleSelectAll}
selectedConnectionExternalId={selectedConnectionExternalId}
setSelectedConnectionExternalId={setSelectedConnectionExternalId}
showValidationErrors={showValidationErrors}
/>
) : (
<>
<div className="flex flex-col gap-4 px-1">
<div className="relative mt-1">
<Search className="absolute left-2 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('Search')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
</div>
<ScrollArea className="grow overflow-y-auto px-1 pt-4">
<PiecesContent
isPiecesLoading={isPiecesLoading}
pieceMetadata={pieceMetadata}
onPieceSelect={handlePieceSelect}
/>
</ScrollArea>
</>
)}
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="ghost">
{t('Close')}
</Button>
</DialogClose>
<Button loading={false} type="button" onClick={handleSave}>
{t('Save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,129 @@
import { t } from 'i18next';
import React from 'react';
import { Checkbox } from '@/components/ui/checkbox';
import { ScrollArea } from '@/components/ui/scroll-area';
import { piecesHooks } from '@/features/pieces/lib/pieces-hooks';
import { PieceStepMetadataWithSuggestions } from '@/lib/types';
import { isNil } from '@activepieces/shared';
import { ConnectionDropdown } from './connection-dropdown';
import { ActionInfo } from '.';
interface PieceActionsDialogProps {
piece: PieceStepMetadataWithSuggestions;
selectedActions: ActionInfo[];
onSelectAction: (action: ActionInfo) => void;
onSelectAll: (checked: boolean) => void;
selectedConnectionExternalId: string | null;
setSelectedConnectionExternalId: (
connectionExternalId: string | null,
) => void;
showValidationErrors?: boolean;
}
export const PieceActionsDialog: React.FC<PieceActionsDialogProps> = ({
piece,
selectedActions,
onSelectAction,
onSelectAll,
selectedConnectionExternalId,
setSelectedConnectionExternalId,
showValidationErrors = false,
}) => {
const { pieces } = piecesHooks.usePieces({});
const selectedPiece = pieces?.find((p) => p.name === piece.pieceName);
const allSelected =
piece.suggestedActions &&
piece.suggestedActions.length > 0 &&
piece.suggestedActions.every((a) =>
selectedActions.some((action) => action.actionName === a.name),
);
const someSelected = selectedActions.length > 0 && !allSelected;
const pieceHasAuth = selectedPiece && !isNil(selectedPiece.auth);
return (
<>
{pieceHasAuth && (
<div className="px-3 mb-4">
<div className="space-y-3">
<h4 className="text-sm font-semibold">{t('Connection')}</h4>
<ConnectionDropdown
piece={selectedPiece}
value={selectedConnectionExternalId}
onChange={setSelectedConnectionExternalId}
placeholder={t('Select a connection')}
showLabel={false}
required={true}
showError={showValidationErrors}
/>
</div>
</div>
)}
<div className="flex items-center mb-2 gap-4 px-3">
<Checkbox
checked={allSelected ? true : someSelected ? 'indeterminate' : false}
onCheckedChange={(checked) => onSelectAll(!!checked)}
/>
<span className="text-sm font-bold select-none">{t('Select all')}</span>
</div>
<ScrollArea className="grow overflow-y-auto rounded-md">
<div className="flex flex-col gap-2">
{piece.suggestedActions &&
piece.suggestedActions.map((action) => (
<div
key={action.name}
className="flex items-start gap-4 rounded-md px-3 py-2 hover:bg-accent cursor-pointer"
onClick={() =>
onSelectAction({
actionName: action.name,
actionDisplayName: action.displayName,
})
}
>
<Checkbox
checked={selectedActions.some(
(selectedAction) =>
selectedAction.actionName === action.name,
)}
onCheckedChange={() =>
onSelectAction({
actionName: action.name,
actionDisplayName: action.displayName,
})
}
className="mt-1"
onClick={(e) => e.stopPropagation()}
/>
<div className="flex gap-2">
<img src={piece.logoUrl} alt="" className="w-5 h-5 mt-1" />
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">
{action.displayName}
</span>
</div>
{action.description && (
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{action.description}
</div>
)}
</div>
</div>
</div>
))}
{piece.suggestedActions && piece.suggestedActions.length === 0 && (
<div className="text-center text-muted-foreground py-8">
{t('No actions available')}
</div>
)}
</div>
</ScrollArea>
</>
);
};

View File

@@ -0,0 +1,52 @@
import { t } from 'i18next';
import React from 'react';
import { LoadingSpinner } from '@/components/ui/spinner';
import { PieceStepMetadataWithSuggestions } from '@/lib/types';
interface PiecesContentProps {
isPiecesLoading: boolean;
pieceMetadata: PieceStepMetadataWithSuggestions[];
onPieceSelect: (piece: PieceStepMetadataWithSuggestions) => void;
}
export const PiecesContent: React.FC<PiecesContentProps> = ({
isPiecesLoading,
pieceMetadata,
onPieceSelect,
}) => {
if (isPiecesLoading) {
return (
<div className="flex items-center justify-center w-full h-full">
<LoadingSpinner />
</div>
);
}
if (!isPiecesLoading && pieceMetadata && pieceMetadata.length === 0) {
return (
<div className="text-center h-full flex items-center justify-center">
{t('No pieces found')}
</div>
);
}
return (
<div className="grid grid-cols-4 gap-4">
{pieceMetadata.map((piece, index) => (
<div
key={index}
onClick={() => onPieceSelect(piece)}
className="border p-2 h-[150px] w-[150px] flex flex-col items-center justify-center hover:bg-accent hover:text-accent-foreground cursor-pointer rounded-lg"
>
<img
className="w-[40px] h-[40px]"
src={piece.logoUrl}
alt={piece.displayName}
/>
<div className="mt-2 text-center text-md">{piece.displayName}</div>
</div>
))}
</div>
);
};

View File

@@ -0,0 +1,130 @@
import { t } from 'i18next';
import { EllipsisVertical, Puzzle, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { ConfirmationDeleteDialog } from '@/components/delete-dialog';
import { Card, CardContent } from '@/components/ui/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { PieceMetadataModelSummary } from '@activepieces/pieces-framework';
import { AgentTool, AgentToolType } from '@activepieces/shared';
import { agentConfigUtils } from './config-utils';
type AgentPieceToolProps = {
disabled?: boolean;
tools: AgentTool[];
pieces: PieceMetadataModelSummary[];
removeTool: (toolIds: string[]) => Promise<void>;
};
type PieceInfo = {
displayName: string;
logoUrl?: string;
};
export const AgentPieceTool = ({
disabled,
tools,
pieces,
removeTool,
}: AgentPieceToolProps) => {
const [open, setOpen] = useState(false);
const getPieceInfo = (tool: AgentTool) => {
if (tool.type !== AgentToolType.PIECE || !tool.pieceMetadata) {
return { displayName: 'Unknown', logoUrl: undefined };
}
const pieceMetadata = pieces?.find(
(p) => p.name === tool.pieceMetadata?.pieceName,
);
return {
displayName: pieceMetadata?.displayName || tool.pieceMetadata.pieceName,
logoUrl: pieceMetadata?.logoUrl,
};
};
const pieceInfoMap: Record<string, PieceInfo> = {};
tools.forEach((tool: AgentTool) => {
pieceInfoMap[tool.toolName] = getPieceInfo(tool);
});
const actionDisplayNames = tools
.map((tool) => {
if (tool.type === AgentToolType.PIECE) {
return tool.pieceMetadata?.actionName;
}
return undefined;
})
.filter((name) => name !== undefined);
const toolName =
tools[0].type === AgentToolType.PIECE
? pieceInfoMap[tools[0].toolName]?.displayName
: undefined;
return (
<Card key={`piece-${toolName}`}>
<CardContent className="flex items-center justify-between p-3 min-h-[48px]">
<div className="flex items-center gap-3 min-w-0">
<div className="h-8 w-8 rounded-md bg-muted flex items-center justify-center shrink-0">
{pieceInfoMap[tools[0].toolName]?.logoUrl ? (
<img
src={pieceInfoMap[tools[0].toolName].logoUrl}
alt={pieceInfoMap[tools[0].toolName].displayName}
className="h-5 w-5 object-contain"
/>
) : (
<Puzzle className="h-5 w-5 text-muted-foreground" />
)}
</div>
<div className="min-w-0">
<h3 className="text-sm font-medium truncate">
{`${pieceInfoMap[tools[0].toolName]?.displayName}`}
</h3>
<span className="text-xs text-muted-foreground">
{agentConfigUtils.formatNames(actionDisplayNames)}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
disabled={disabled}
className="rounded-full p-2 hover:bg-muted cursor-pointer"
asChild
>
<EllipsisVertical className="h-8 w-8" />
</DropdownMenuTrigger>
<DropdownMenuContent
noAnimationOnOut={true}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<ConfirmationDeleteDialog
title={`${t('Delete')} ${toolName}`}
message={t('Are you sure you want to delete this tool?')}
mutationFn={async () =>
await removeTool(tools.map((tool) => tool.toolName))
}
entityName={t('Tool')}
>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<div className="flex cursor-pointer flex-row gap-2 items-center">
<Trash2 className="h-4 w-4 text-destructive" />
<span className="text-destructive">{t('Delete')}</span>
</div>
</DropdownMenuItem>
</ConfirmationDeleteDialog>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,142 @@
import { t } from 'i18next';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { AgentOutputFieldType } from '@activepieces/shared';
import { FieldTypeIcon } from './field-type-icon';
interface AddFieldPopoverProps {
onAddField: (
type: AgentOutputFieldType,
name: string,
description: string,
) => void;
disabled: boolean;
}
export const AddFieldPopover = ({
onAddField,
disabled,
}: AddFieldPopoverProps) => {
const [fieldType, setFieldType] = useState<AgentOutputFieldType | undefined>(
undefined,
);
const [fieldName, setFieldName] = useState('');
const [fieldDescription, setFieldDescription] = useState('');
const [open, setOpen] = useState(false);
const handleAdd = () => {
if (fieldType && fieldName.trim() && fieldDescription.trim()) {
onAddField(fieldType, fieldName.trim(), fieldDescription.trim());
setFieldType(undefined);
setFieldName('');
setFieldDescription('');
setOpen(false);
}
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full" disabled={disabled}>
<Plus className="h-4 w-4 mr-2" />
{t('Add Field')}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-80"
side="bottom"
align="center"
sideOffset={10}
>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Field Type</label>
<Select
value={fieldType}
onValueChange={(value) =>
setFieldType(value as AgentOutputFieldType)
}
>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value={AgentOutputFieldType.TEXT}>
<div className="flex items-center">
<FieldTypeIcon
type={AgentOutputFieldType.TEXT}
className="h-4 w-4 mr-2 text-muted-foreground"
/>
<span>Text</span>
</div>
</SelectItem>
<SelectItem value={AgentOutputFieldType.NUMBER}>
<div className="flex items-center">
<FieldTypeIcon
type={AgentOutputFieldType.NUMBER}
className="h-4 w-4 mr-2 text-muted-foreground"
/>
<span>Number</span>
</div>
</SelectItem>
<SelectItem value={AgentOutputFieldType.BOOLEAN}>
<div className="flex items-center">
<FieldTypeIcon
type={AgentOutputFieldType.BOOLEAN}
className="h-4 w-4 mr-2 text-muted-foreground"
/>
<span>Yes/No</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Field Name</label>
<Input
id="field-name"
placeholder="Enter field name"
value={fieldName}
onChange={(e) => setFieldName(e.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Field Description</label>
<Input
id="field-description"
placeholder="Enter field description"
value={fieldDescription}
onChange={(e) => setFieldDescription(e.target.value)}
/>
</div>
<Button
className="w-full"
onClick={handleAdd}
variant={'default'}
disabled={
!fieldType || !fieldName.trim() || !fieldDescription.trim()
}
>
Add
</Button>
</div>
</PopoverContent>
</Popover>
);
};

View File

@@ -0,0 +1,28 @@
import {
Type as TextIcon,
Hash as NumberIcon,
CheckSquare as BooleanIcon,
} from 'lucide-react';
import { AgentOutputFieldType } from '@activepieces/shared';
interface FieldTypeIconProps {
type: AgentOutputFieldType;
className?: string;
}
export const FieldTypeIcon = ({
type,
className = 'h-4 w-4',
}: FieldTypeIconProps) => {
switch (type) {
case AgentOutputFieldType.TEXT:
return <TextIcon className={className} />;
case AgentOutputFieldType.NUMBER:
return <NumberIcon className={className} />;
case AgentOutputFieldType.BOOLEAN:
return <BooleanIcon className={className} />;
default:
return null;
}
};

View File

@@ -0,0 +1,93 @@
import { t } from 'i18next';
import { X } from 'lucide-react';
import { ControllerRenderProps } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { AgentOutputFieldType, AgentOutputField } from '@activepieces/shared';
import { AddFieldPopover } from './add-field-popover';
import { FieldTypeIcon } from './field-type-icon';
export const AgentStructuredOutput = ({
structuredOutputField,
disabled,
}: {
structuredOutputField: ControllerRenderProps;
disabled: boolean;
}) => {
const value = structuredOutputField.value;
const outputFields = Array.isArray(value)
? (value as AgentOutputField[])
: [];
const handleAddField = (
type: AgentOutputFieldType,
name: string,
description: string,
) => {
const newField = { displayName: name, description, type };
structuredOutputField.onChange([...(outputFields ?? []), newField]);
};
const handleRemoveField = (displayName: string) => {
const newFields = outputFields.filter((f) => f.displayName !== displayName);
structuredOutputField.onChange(newFields);
};
return (
<div>
<div className="flex items-center justify-between mb-2">
<h2 className="text-sm font-medium">{t('Structured Output')}</h2>
</div>
<div className="flex flex-col gap-2 mt-4">
{outputFields.length > 0 ? (
<Card>
<CardContent className="px-2 py-2">
<div className="flex flex-col gap-3">
{outputFields.map((field, idx) => (
<div
key={field.displayName + field.type + idx}
className="flex items-center justify-between"
>
<div className="grid grid-cols-12 gap-2 w-full items-center">
<div className="col-span-1 flex items-center justify-center h-full">
<FieldTypeIcon type={field.type} className="h-4 w-4" />
</div>
<div className="col-span-10 flex flex-col justify-center">
<span className="font-medium text-sm">
{field.displayName}
</span>
{field.description && (
<span className="text-xs text-muted-foreground">
{field.description}
</span>
)}
</div>
<div className="col-span-1 flex items-center justify-end">
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveField(field.displayName)}
disabled={disabled}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
) : (
<div className="text-muted-foreground text-sm">
{t('No structured output fields yet.')}
</div>
)}
<AddFieldPopover disabled={disabled} onAddField={handleAddField} />
</div>
</div>
);
};

View File

@@ -0,0 +1,88 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { HttpStatusCode } from 'axios';
import { t } from 'i18next';
import { UseFormReturn } from 'react-hook-form';
import { toast } from 'sonner';
import { internalErrorToast } from '@/components/ui/sonner';
import { api } from '@/lib/api';
import { authenticationSession } from '@/lib/authentication-session';
import { Alert, AlertChannel } from '@activepieces/ee-shared';
import { alertsApi } from './api';
type Params = {
email: string;
};
export const alertsKeys = {
all: ['alerts-email-list'] as const,
};
type Options = {
setOpen: (open: boolean) => void;
form: UseFormReturn<any>;
};
export const alertMutations = {
useCreateAlert: ({ setOpen, form }: Options) => {
const queryClient = useQueryClient();
return useMutation<Alert, Error, Params>({
mutationFn: async (params) =>
alertsApi.create({
receiver: params.email,
projectId: authenticationSession.getProjectId()!,
channel: AlertChannel.EMAIL,
}),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: alertsKeys.all });
toast.success(t('Your changes have been saved.'), {
duration: 3000,
});
setOpen(false);
},
onError: (error) => {
if (api.isError(error)) {
switch (error.response?.status) {
case HttpStatusCode.Conflict:
form.setError('root.serverError', {
message: t('The email is already added.'),
});
break;
default: {
internalErrorToast();
break;
}
}
}
},
});
},
useDeleteAlert: () => {
const queryClient = useQueryClient();
return useMutation<void, Error, Alert>({
mutationFn: (alert) => alertsApi.delete(alert.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: alertsKeys.all });
toast.success(t('Your changes have been saved.'), {
duration: 3000,
});
},
});
},
};
export const alertQueries = {
useAlertsEmailList: () =>
useQuery<Alert[], Error, Alert[]>({
queryKey: alertsKeys.all,
queryFn: async () => {
const page = await alertsApi.list({
projectId: authenticationSession.getProjectId()!,
limit: 100,
});
return page.data;
},
}),
};

View File

@@ -0,0 +1,19 @@
import { api } from '@/lib/api';
import {
Alert,
CreateAlertParams,
ListAlertsParams,
} from '@activepieces/ee-shared';
import { SeekPage } from '@activepieces/shared';
export const alertsApi = {
create(request: CreateAlertParams): Promise<Alert> {
return api.post<Alert>('/v1/alerts', request);
},
list(request: ListAlertsParams): Promise<SeekPage<Alert>> {
return api.get<SeekPage<Alert>>('/v1/alerts', request);
},
delete(alertId: string): Promise<void> {
return api.delete<void>(`/v1/alerts/${alertId}`);
},
};

View File

@@ -0,0 +1,133 @@
import { t } from 'i18next';
import React, { useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { authenticationSession } from '@/lib/authentication-session';
import { useRedirectAfterLogin } from '@/lib/navigation-utils';
import {
ApFlagId,
ThirdPartyAuthnProvidersToShowMap,
} from '@activepieces/shared';
import { HorizontalSeparatorWithText } from '../../../components/ui/separator';
import { flagsHooks } from '../../../hooks/flags-hooks';
import { SignInForm } from './sign-in-form';
import { SignUpForm } from './sign-up-form';
import { ThirdPartyLogin } from './third-party-logins';
const BottomNote = ({ isSignup }: { isSignup: boolean }) => {
const [searchParams] = useSearchParams();
const searchQuery = searchParams.toString();
return isSignup ? (
<div className="mb-4 text-center text-sm">
{t('Already have an account?')}
<Link
to={`/sign-in?${searchQuery}`}
className="pl-1 text-muted-foreground hover:text-primary text-sm transition-all duration-200"
>
{t('Sign in')}
</Link>
</div>
) : (
<div className="mb-4 text-center text-sm">
{t("Don't have an account?")}
<Link
to={`/sign-up?${searchQuery}`}
className="pl-1 text-muted-foreground hover:text-primary text-sm transition-all duration-200"
>
{t('Sign up')}
</Link>
</div>
);
};
const AuthSeparator = ({
isEmailAuthEnabled,
}: {
isEmailAuthEnabled: boolean;
}) => {
const { data: thirdPartyAuthProviders } =
flagsHooks.useFlag<ThirdPartyAuthnProvidersToShowMap>(
ApFlagId.THIRD_PARTY_AUTH_PROVIDERS_TO_SHOW_MAP,
);
return (thirdPartyAuthProviders?.google || thirdPartyAuthProviders?.saml) &&
isEmailAuthEnabled ? (
<HorizontalSeparatorWithText className="my-4">
{t('OR')}
</HorizontalSeparatorWithText>
) : null;
};
const AuthFormTemplate = React.memo(
({ form }: { form: 'signin' | 'signup' }) => {
const isSignUp = form === 'signup';
const token = authenticationSession.getToken();
const redirectAfterLogin = useRedirectAfterLogin();
const [showCheckYourEmailNote, setShowCheckYourEmailNote] = useState(false);
const { data: isEmailAuthEnabled } = flagsHooks.useFlag<boolean>(
ApFlagId.EMAIL_AUTH_ENABLED,
);
const data = {
signin: {
title: t('Welcome Back!'),
description: t('Enter your email below to sign in to your account'),
showNameFields: false,
},
signup: {
title: t("Let's Get Started!"),
description: t('Create your account and start flowing!'),
showNameFields: true,
},
}[form];
if (token) {
redirectAfterLogin();
}
return (
<Card className="w-md rounded-sm drop-shadow-xl">
{!showCheckYourEmailNote && (
<CardHeader className="text-center">
<CardTitle className="text-2xl">{data.title}</CardTitle>
<CardDescription>{data.description}</CardDescription>
</CardHeader>
)}
<CardContent>
{!showCheckYourEmailNote && <ThirdPartyLogin isSignUp={isSignUp} />}
<AuthSeparator
isEmailAuthEnabled={
(isEmailAuthEnabled ?? true) && !showCheckYourEmailNote
}
></AuthSeparator>
{isEmailAuthEnabled ? (
isSignUp ? (
<SignUpForm
setShowCheckYourEmailNote={setShowCheckYourEmailNote}
showCheckYourEmailNote={showCheckYourEmailNote}
/>
) : (
<SignInForm />
)
) : null}
</CardContent>
<BottomNote isSignup={isSignUp}></BottomNote>
</Card>
);
},
);
AuthFormTemplate.displayName = 'AuthFormTemplate';
export { AuthFormTemplate };

View File

@@ -0,0 +1,131 @@
import { Popover } from '@radix-ui/react-popover';
import { useMutation } from '@tanstack/react-query';
import { t } from 'i18next';
import { useRef, useState } from 'react';
import { SubmitHandler, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { PasswordValidator } from '@/features/authentication/components/password-validator';
import { passwordValidation } from '@/features/authentication/lib/password-validation-utils';
import { HttpError } from '@/lib/api';
import { authenticationApi } from '@/lib/authentication-api';
import { ResetPasswordRequestBody } from '@activepieces/ee-shared';
const ChangePasswordForm = () => {
const navigate = useNavigate();
const queryParams = new URLSearchParams(window.location.search);
const [serverError, setServerError] = useState('');
const [isPasswordFocused, setPasswordFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const form = useForm<{
otp: string;
identityId: string;
newPassword: string;
}>({
defaultValues: {
otp: queryParams.get('otpcode') || '',
identityId: queryParams.get('identityId') || '',
newPassword: '',
},
});
const { mutate, isPending } = useMutation<
void,
HttpError,
ResetPasswordRequestBody
>({
mutationFn: authenticationApi.resetPassword,
onSuccess: () => {
toast.success(t('Your password was changed successfully'), {
duration: 3000,
});
navigate('/sign-in');
},
onError: (error) => {
setServerError(
t('Your password reset request has expired, please request a new one'),
);
console.error(error);
},
});
const onSubmit: SubmitHandler<ResetPasswordRequestBody> = (data) => {
mutate(data);
};
return (
<Card className="w-md rounded-sm drop-shadow-xl">
<CardHeader>
<CardTitle className="text-2xl">{t('Reset Password')}</CardTitle>
<CardDescription>{t('Enter your new password')}</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form className="grid gap-2">
<FormField
control={form.control}
name="newPassword"
rules={{
required: t('Password is required'),
validate: passwordValidation,
}}
render={({ field }) => (
<FormItem
className="grid space-y-2"
onClick={() => inputRef?.current?.focus()}
onFocus={() => setPasswordFocused(true)}
>
<Label htmlFor="newPassword">{t('Password')}</Label>
<Popover open={isPasswordFocused}>
<PopoverTrigger asChild>
<Input
{...field}
required
id="newPassword"
type="password"
placeholder={'********'}
className="rounded-sm"
ref={inputRef}
onBlur={() => setPasswordFocused(false)}
onChange={(e) => field.onChange(e)}
/>
</PopoverTrigger>
<PopoverContent className="absolute border-2 bg-background p-2 rounded-md right-60 -bottom-16 flex flex-col">
<PasswordValidator
password={form.getValues().newPassword}
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
{serverError && <FormMessage>{serverError}</FormMessage>}
<Button
className="w-full mt-2"
loading={isPending}
onClick={(e) => form.handleSubmit(onSubmit)(e)}
>
{t('Confirm')}
</Button>
</form>
</Form>
</CardContent>
</Card>
);
};
export { ChangePasswordForm };

View File

@@ -0,0 +1,53 @@
import { useMutation } from '@tanstack/react-query';
import { t } from 'i18next';
import { MailCheck } from 'lucide-react';
import { toast } from 'sonner';
import { authenticationApi } from '@/lib/authentication-api';
import { CreateOtpRequestBody, OtpType } from '@activepieces/ee-shared';
const CheckEmailNote = ({ email, type }: CreateOtpRequestBody) => {
const { mutate: resendVerification } = useMutation({
mutationFn: authenticationApi.sendOtpEmail,
onSuccess: () => {
toast.success(
type === OtpType.EMAIL_VERIFICATION
? t('Verification email resent, if previous one expired.')
: t('Password reset link resent, if previous one expired.'),
{
duration: 3000,
},
);
},
});
return (
<div className="gap-2 w-full flex flex-col">
<div className="gap-4 w-full flex flex-row items-center justify-center">
<MailCheck className="w-16 h-16" />
<span className="text-left w-fit">
{type === OtpType.EMAIL_VERIFICATION
? t('We sent you a link to complete your registration to')
: t('We sent you a link to reset your password to')}
<strong>&nbsp;{email}</strong>.
</span>
</div>
<div className="flex flex-row gap-1">
{t("Didn't receive an email or it expired?")}
<button
className="cursor-pointer text-primary underline"
onClick={() =>
resendVerification({
email,
type,
})
}
>
{t('Resend')}
</button>
</div>
</div>
);
};
CheckEmailNote.displayName = 'CheckEmailNote';
export { CheckEmailNote };

View File

@@ -0,0 +1,24 @@
import { Check, X } from 'lucide-react';
import { passwordRules } from '@/features/authentication/lib/password-validation-utils';
const PasswordValidator = ({ password }: { password: string }) => {
return (
<>
{passwordRules.map((rule, index) => {
return (
<div key={index} className="flex flex-row gap-2">
{rule.condition(password) ? (
<Check className="text-success" />
) : (
<X className="text-destructive" />
)}
<span>{rule.label}</span>
</div>
);
})}
</>
);
};
PasswordValidator.displayName = 'PasswordValidator';
export { PasswordValidator };

View File

@@ -0,0 +1,118 @@
import { typeboxResolver } from '@hookform/resolvers/typebox';
import { Type, Static } from '@sinclair/typebox';
import { useMutation } from '@tanstack/react-query';
import { t } from 'i18next';
import { useState } from 'react';
import { SubmitHandler, useForm } from 'react-hook-form';
import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { CheckEmailNote } from '@/features/authentication/components/check-email-note';
import { HttpError } from '@/lib/api';
import { authenticationApi } from '@/lib/authentication-api';
import { CreateOtpRequestBody, OtpType } from '@activepieces/ee-shared';
const FormSchema = Type.Object({
email: Type.String({
errorMessage: t('Please enter your email'),
}),
type: Type.Enum(OtpType),
});
type FormSchema = Static<typeof FormSchema>;
const ResetPasswordForm = () => {
const [isSent, setIsSent] = useState<boolean>(false);
const form = useForm<FormSchema>({
resolver: typeboxResolver(FormSchema),
defaultValues: {
type: OtpType.PASSWORD_RESET,
},
});
const { mutate, isPending } = useMutation<
void,
HttpError,
CreateOtpRequestBody
>({
mutationFn: authenticationApi.sendOtpEmail,
onSuccess: () => setIsSent(true),
});
const onSubmit: SubmitHandler<CreateOtpRequestBody> = (data) => {
mutate(data);
};
return (
<Card className="w-md rounded-sm drop-shadow-xl">
<CardHeader>
<CardTitle className="text-2xl">
{isSent ? t('Check Your Inbox') : t('Reset Password')}
</CardTitle>
<CardDescription>
{isSent ? (
<CheckEmailNote
email={form.getValues().email.trim().toLocaleLowerCase()}
type={OtpType.PASSWORD_RESET}
/>
) : (
<span>
{t(
`If the user exists we'll send you an email with a link to reset your password.`,
)}
</span>
)}
</CardDescription>
</CardHeader>
<CardContent>
{!isSent && (
<Form {...form}>
<form className="grid ">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="w-full grid space-y-2">
<Label htmlFor="email">{t('Email')}</Label>
<Input
{...field}
type="text"
placeholder={'email@example.com'}
/>
<FormMessage />
</FormItem>
)}
/>
<Button
className="w-full mt-4"
loading={isPending}
onClick={(e) => form.handleSubmit(onSubmit)(e)}
>
{t('Send Password Reset Link')}
</Button>
</form>
</Form>
)}
<div className="mt-4 text-center text-sm">
<Link to="/sign-in" className="text-muted-foreground">
{t('Back to sign in')}
</Link>
</div>
</CardContent>
</Card>
);
};
ResetPasswordForm.displayName = 'ResetPassword';
export { ResetPasswordForm };

View File

@@ -0,0 +1,219 @@
import { typeboxResolver } from '@hookform/resolvers/typebox';
import { Static, Type } from '@sinclair/typebox';
import { useMutation } from '@tanstack/react-query';
import { t } from 'i18next';
import { useState } from 'react';
import { SubmitHandler, useForm } from 'react-hook-form';
import { Link, Navigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { flagsHooks } from '@/hooks/flags-hooks';
import { HttpError, api } from '@/lib/api';
import { authenticationApi } from '@/lib/authentication-api';
import { authenticationSession } from '@/lib/authentication-session';
import { useRedirectAfterLogin } from '@/lib/navigation-utils';
import { formatUtils } from '@/lib/utils';
import { OtpType } from '@activepieces/ee-shared';
import {
ApEdition,
ApFlagId,
AuthenticationResponse,
ErrorCode,
isNil,
SignInRequest,
} from '@activepieces/shared';
import { CheckEmailNote } from './check-email-note';
const SignInSchema = Type.Object({
email: Type.String({
pattern: formatUtils.emailRegex.source,
errorMessage: t('Email is invalid'),
}),
password: Type.String({
minLength: 1,
errorMessage: t('Password is required'),
}),
});
type SignInSchema = Static<typeof SignInSchema>;
const SignInForm: React.FC = () => {
const [showCheckYourEmailNote, setShowCheckYourEmailNote] = useState(false);
const form = useForm<SignInSchema>({
resolver: typeboxResolver(SignInSchema),
defaultValues: {
email: '',
password: '',
},
mode: 'onChange',
});
const { data: edition } = flagsHooks.useFlag(ApFlagId.EDITION);
const { data: userCreated } = flagsHooks.useFlag(ApFlagId.USER_CREATED);
const redirectAfterLogin = useRedirectAfterLogin();
const { mutate, isPending } = useMutation<
AuthenticationResponse,
HttpError,
SignInRequest
>({
mutationFn: authenticationApi.signIn,
onSuccess: (data) => {
authenticationSession.saveResponse(data, false);
redirectAfterLogin();
},
onError: (error) => {
if (api.isError(error)) {
const errorCode: ErrorCode | undefined = (
error.response?.data as { code: ErrorCode }
)?.code;
if (isNil(errorCode)) {
form.setError('root.serverError', {
message: t('Something went wrong, please try again later'),
});
return;
}
switch (errorCode) {
case ErrorCode.INVALID_CREDENTIALS: {
form.setError('root.serverError', {
message: t('Invalid email or password'),
});
break;
}
case ErrorCode.USER_IS_INACTIVE: {
form.setError('root.serverError', {
message: t('User has been deactivated'),
});
break;
}
case ErrorCode.EMAIL_IS_NOT_VERIFIED: {
setShowCheckYourEmailNote(true);
break;
}
case ErrorCode.DOMAIN_NOT_ALLOWED: {
form.setError('root.serverError', {
message: t(`Email domain is disallowed`),
});
break;
}
case ErrorCode.EMAIL_AUTH_DISABLED: {
form.setError('root.serverError', {
message: t(`Email authentication has been disabled`),
});
break;
}
default: {
form.setError('root.serverError', {
message: t('Something went wrong, please try again later'),
});
}
}
}
},
});
const onSubmit: SubmitHandler<SignInRequest> = (data) => {
form.setError('root.serverError', {
message: undefined,
});
mutate(data);
};
if (!userCreated) {
return <Navigate to="/sign-up" />;
}
return (
<>
<Form {...form}>
<form className="grid space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="grid space-y-2">
<Label htmlFor="email">{t('Email')}</Label>
<Input
{...field}
required
id="email"
type="text"
placeholder={'email@example.com'}
className="rounded-sm"
tabIndex={1}
data-testid="sign-in-email"
onChange={(e) => {
field.onChange(e);
setShowCheckYourEmailNote(false);
}}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem className="grid space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">{t('Password')}</Label>
{edition !== ApEdition.COMMUNITY && (
<Link
to="/forget-password"
className="text-muted-foreground text-sm hover:text-primary transition-all duration-200"
>
{t('Forgot your password?')}
</Link>
)}
</div>
<Input
{...field}
required
id="password"
type="password"
placeholder={'********'}
className="rounded-sm"
tabIndex={2}
data-testid="sign-in-password"
/>
<FormMessage />
</FormItem>
)}
/>
{form?.formState?.errors?.root?.serverError && (
<FormMessage>
{form.formState.errors.root.serverError.message}
</FormMessage>
)}
<Button
loading={isPending}
onClick={(e) => form.handleSubmit(onSubmit)(e)}
tabIndex={3}
data-testid="sign-in-button"
>
{t('Sign in')}
</Button>
</form>
</Form>
{showCheckYourEmailNote && (
<div className="mt-4">
<CheckEmailNote
email={form.getValues().email}
type={OtpType.EMAIL_VERIFICATION}
/>
</div>
)}
</>
);
};
SignInForm.displayName = 'SignIn';
export { SignInForm };

View File

@@ -0,0 +1,377 @@
import { useMutation } from '@tanstack/react-query';
import { t } from 'i18next';
import { useMemo, useRef, useState } from 'react';
import { SubmitHandler, useForm } from 'react-hook-form';
import { Link, useSearchParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { CheckEmailNote } from '@/features/authentication/components/check-email-note';
import { PasswordValidator } from '@/features/authentication/components/password-validator';
import { flagsHooks } from '@/hooks/flags-hooks';
import { HttpError, api } from '@/lib/api';
import { authenticationApi } from '@/lib/authentication-api';
import { authenticationSession } from '@/lib/authentication-session';
import { useRedirectAfterLogin } from '@/lib/navigation-utils';
import { cn, formatUtils } from '@/lib/utils';
import { OtpType } from '@activepieces/ee-shared';
import {
ApEdition,
ApFlagId,
AuthenticationResponse,
ErrorCode,
isNil,
SignUpRequest,
} from '@activepieces/shared';
import { passwordValidation } from '../lib/password-validation-utils';
type SignUpSchema = {
email: string;
firstName: string;
lastName: string;
password: string;
newsLetter: boolean;
};
const SignUpForm = ({
showCheckYourEmailNote,
setShowCheckYourEmailNote,
}: {
showCheckYourEmailNote: boolean;
setShowCheckYourEmailNote: (value: boolean) => void;
}) => {
const [searchParams] = useSearchParams();
const { data: termsOfServiceUrl } = flagsHooks.useFlag<string>(
ApFlagId.TERMS_OF_SERVICE_URL,
);
const { data: privacyPolicyUrl } = flagsHooks.useFlag<string>(
ApFlagId.PRIVACY_POLICY_URL,
);
const form = useForm<SignUpSchema>({
defaultValues: {
newsLetter: false,
password: '',
email: searchParams.get('email') || '',
},
});
const websiteName = flagsHooks.useWebsiteBranding()?.websiteName;
const { data: edition } = flagsHooks.useFlag<ApEdition>(ApFlagId.EDITION);
const showNewsLetterCheckbox = useMemo(() => {
if (!edition || !websiteName) {
return false;
}
switch (edition) {
case ApEdition.CLOUD: {
if (
typeof websiteName === 'string' &&
websiteName.toLowerCase() === 'activepieces'
) {
form.setValue('newsLetter', true);
return true;
}
return false;
}
case ApEdition.ENTERPRISE:
return false;
case ApEdition.COMMUNITY: {
form.setValue('newsLetter', true);
return true;
}
}
}, [edition, websiteName]);
const redirectAfterLogin = useRedirectAfterLogin();
const { mutate, isPending } = useMutation<
AuthenticationResponse,
HttpError,
SignUpRequest
>({
mutationFn: authenticationApi.signUp,
onSuccess: (data) => {
if (data.verified) {
authenticationSession.saveResponse(data, false);
redirectAfterLogin();
} else {
setShowCheckYourEmailNote(true);
}
},
onError: (error) => {
if (api.isError(error)) {
const errorCode: ErrorCode | undefined = (
error.response?.data as { code: ErrorCode }
)?.code;
if (isNil(errorCode)) {
form.setError('root.serverError', {
message: t('Something went wrong, please try again later'),
});
return;
}
switch (errorCode) {
case ErrorCode.EMAIL_IS_NOT_VERIFIED: {
setShowCheckYourEmailNote(true);
break;
}
case ErrorCode.INVITATION_ONLY_SIGN_UP: {
form.setError('root.serverError', {
message: t(
'Sign up is restricted. You need an invitation to join. Please contact the administrator.',
),
});
break;
}
case ErrorCode.EXISTING_USER: {
form.setError('root.serverError', {
message: t('Email is already used'),
});
break;
}
case ErrorCode.EMAIL_AUTH_DISABLED: {
form.setError('root.serverError', {
message: t('Email authentication is disabled'),
});
break;
}
case ErrorCode.DOMAIN_NOT_ALLOWED: {
form.setError('root.serverError', {
message: t('Email domain is disallowed'),
});
break;
}
default: {
form.setError('root.serverError', {
message: t('Something went wrong, please try again later'),
});
break;
}
}
}
},
});
const onSubmit: SubmitHandler<SignUpSchema> = (data) => {
form.setError('root.serverError', {
message: undefined,
});
mutate({
...data,
email: data.email.trim().toLowerCase(),
trackEvents: true,
});
};
const [isPasswordFocused, setPasswordFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
return showCheckYourEmailNote ? (
<div className="pt-6">
<CheckEmailNote
email={form.getValues().email.trim().toLowerCase()}
type={OtpType.EMAIL_VERIFICATION}
/>
</div>
) : (
<>
<Form {...form}>
<form className="grid space-y-4">
<div className={'flex flex-row gap-2'}>
<FormField
control={form.control}
name="firstName"
rules={{
required: t('First name is required'),
}}
render={({ field }) => (
<FormItem className="w-full grid space-y-2">
<Label htmlFor="firstName">{t('First Name')}</Label>
<Input
{...field}
required
id="firstName"
type="text"
placeholder={'John'}
className="rounded-sm"
data-testid="sign-up-first-name"
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
rules={{
required: t('Last name is required'),
}}
render={({ field }) => (
<FormItem className="w-full grid space-y-2">
<Label htmlFor="lastName">{t('Last Name')}</Label>
<Input
{...field}
required
id="lastName"
type="text"
placeholder={'Doe'}
className="rounded-sm"
data-testid="sign-up-last-name"
/>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="email"
rules={{
required: t('Email is required'),
validate: (email: string) =>
formatUtils.emailRegex.test(email) || t('Email is invalid'),
}}
render={({ field }) => (
<FormItem className="grid space-y-2">
<Label htmlFor="email">{t('Email')}</Label>
<Input
{...field}
required
id="email"
type="email"
placeholder={'email@example.com'}
className="rounded-sm"
data-testid="sign-up-email"
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
rules={{
required: t('Password is required'),
validate: passwordValidation,
}}
render={({ field }) => (
<FormItem
className="grid space-y-2"
onClick={() => inputRef?.current?.focus()}
onFocus={() => {
setPasswordFocused(true);
setTimeout(() => inputRef?.current?.focus());
}}
onBlur={() => setPasswordFocused(false)}
>
<Label htmlFor="password">{t('Password')}</Label>
<Popover open={isPasswordFocused}>
<PopoverTrigger asChild>
<Input
{...field}
required
id="password"
type="password"
placeholder={'********'}
className="rounded-sm"
ref={inputRef}
data-testid="sign-up-password"
onChange={(e) => field.onChange(e)}
/>
</PopoverTrigger>
<PopoverContent className="absolute border-2 bg-background p-2 pointer-events-none! rounded-md right-60 -bottom-16 flex flex-col">
<PasswordValidator password={form.getValues().password} />
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
{showNewsLetterCheckbox && (
<FormField
control={form.control}
name="newsLetter"
render={({ field }) => (
<FormItem className="flex items-center gap-2 ">
<FormControl>
<Checkbox
id="newsLetter"
className="m-0!"
checked={field.value}
onCheckedChange={field.onChange}
></Checkbox>
</FormControl>
<Label htmlFor="newsLetter">
{t(`Receive updates and newsletters from activepieces`)}
</Label>
<FormMessage />
</FormItem>
)}
/>
)}
{form?.formState?.errors?.root?.serverError && (
<FormMessage>
{form.formState.errors.root.serverError.message}
</FormMessage>
)}
<Button
loading={isPending}
onClick={(e) => form.handleSubmit(onSubmit)(e)}
data-testid="sign-up-button"
>
{t('Sign up')}
</Button>
</form>
</Form>
{edition === ApEdition.CLOUD && (
<div
className={cn('text-center text-sm', {
'mt-4': termsOfServiceUrl || privacyPolicyUrl,
})}
>
{(termsOfServiceUrl || privacyPolicyUrl) &&
t('By creating an account, you agree to our')}
{termsOfServiceUrl && (
<Link
to={termsOfServiceUrl || ''}
target="_blank"
className="px-1 text-muted-foreground hover:text-primary text-sm transition-all duration-200"
>
{t('terms of service')}
</Link>
)}
{termsOfServiceUrl && privacyPolicyUrl && t('and')}
{privacyPolicyUrl && (
<Link
to={privacyPolicyUrl || ''}
target="_blank"
className="pl-1 text-muted-foreground hover:text-primary text-sm transition-all duration-200"
>
{t('privacy policy')}
</Link>
)}
.
</div>
)}
</>
);
};
SignUpForm.displayName = 'SignUp';
export { SignUpForm };

View File

@@ -0,0 +1,85 @@
import { t } from 'i18next';
import React from 'react';
import { Button } from '@/components/ui/button';
import { internalErrorToast } from '@/components/ui/sonner';
import {
ApFlagId,
ThirdPartyAuthnProviderEnum,
ThirdPartyAuthnProvidersToShowMap,
} from '@activepieces/shared';
import GoogleIcon from '../../../assets/img/custom/auth/google-icon.svg';
import SamlIcon from '../../../assets/img/custom/auth/saml.svg';
import { flagsHooks } from '../../../hooks/flags-hooks';
import { authenticationApi } from '../../../lib/authentication-api';
import { oauth2Utils } from '../../../lib/oauth2-utils';
const ThirdPartyIcon = ({ icon }: { icon: string }) => {
return <img src={icon} alt="icon" width={24} height={24} className="mr-2" />;
};
const ThirdPartyLogin = React.memo(({ isSignUp }: { isSignUp: boolean }) => {
const { data: thirdPartyAuthProviders } =
flagsHooks.useFlag<ThirdPartyAuthnProvidersToShowMap>(
ApFlagId.THIRD_PARTY_AUTH_PROVIDERS_TO_SHOW_MAP,
);
const { data: thirdPartyRedirectUrl } = flagsHooks.useFlag<string>(
ApFlagId.THIRD_PARTY_AUTH_PROVIDER_REDIRECT_URL,
);
const thirdPartyLogin = oauth2Utils.useThirdPartyLogin();
const handleProviderClick = async (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>,
providerName: ThirdPartyAuthnProviderEnum,
) => {
event.preventDefault();
event.stopPropagation();
const { loginUrl } = await authenticationApi.getFederatedAuthLoginUrl(
providerName,
);
if (!loginUrl || !thirdPartyRedirectUrl) {
internalErrorToast();
return;
}
thirdPartyLogin(loginUrl, providerName);
};
const signInWithSaml = () =>
(window.location.href = '/api/v1/authn/saml/login');
return (
<div className="flex flex-col gap-4">
{thirdPartyAuthProviders?.google && (
<Button
variant="outline"
className="w-full rounded-sm"
onClick={(e) =>
handleProviderClick(e, ThirdPartyAuthnProviderEnum.GOOGLE)
}
>
<ThirdPartyIcon icon={GoogleIcon} />
{isSignUp
? `${t(`Sign up With`)} ${t('Google')}`
: `${t(`Sign in With`)} ${t('Google')}`}
</Button>
)}
{thirdPartyAuthProviders?.saml && (
<Button
variant="outline"
className="w-full rounded-sm"
onClick={signInWithSaml}
>
<ThirdPartyIcon icon={SamlIcon} />
{isSignUp
? `${t(`Sign up With`)} ${t('SAML')}`
: `${t(`Sign in With`)} ${t('SAML')}`}
</Button>
)}
</div>
);
});
ThirdPartyLogin.displayName = 'ThirdPartyLogin';
export { ThirdPartyLogin };

View File

@@ -0,0 +1,108 @@
import { useMutation } from '@tanstack/react-query';
import { HttpStatusCode } from 'axios';
import { t } from 'i18next';
import { MailCheck, MailX } from 'lucide-react';
import { useEffect, useState, useRef } from 'react';
import { Navigate, useNavigate, useSearchParams } from 'react-router-dom';
import { Card } from '@/components/ui/card';
import { FullLogo } from '@/components/ui/full-logo';
import { internalErrorToast } from '@/components/ui/sonner';
import { LoadingSpinner } from '@/components/ui/spinner';
import { usePartnerStack } from '@/hooks/use-partner-stack';
import { api } from '@/lib/api';
import { authenticationApi } from '@/lib/authentication-api';
const VerifyEmail = () => {
const [isExpired, setIsExpired] = useState(false);
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const otp = searchParams.get('otpcode');
const identityId = searchParams.get('identityId');
const hasMutated = useRef(false);
const { reportSignup } = usePartnerStack();
const { mutate, isPending } = useMutation({
mutationFn: async () => {
return await authenticationApi.verifyEmail({
otp: otp!,
identityId: identityId!,
});
},
onSuccess: ({ email, firstName }) => {
reportSignup(email, firstName);
setTimeout(() => navigate('/sign-in'), 5000);
},
onError: (error) => {
if (
api.isError(error) &&
error.response?.status === HttpStatusCode.Gone
) {
setIsExpired(true);
setTimeout(() => navigate('/sign-in'), 5000);
} else {
console.error(error);
internalErrorToast();
setTimeout(() => navigate('/sign-in'), 5000);
}
},
});
useEffect(() => {
if (otp && identityId && !hasMutated.current) {
mutate();
hasMutated.current = true;
}
}, [otp, identityId, mutate]);
if (!otp || !identityId) {
return <Navigate to="/sign-in" replace />;
}
return (
<div className="mx-auto h-screen w-screen flex flex-col items-center justify-center gap-2">
<FullLogo />
<Card className="w-md rounded-sm drop-shadow-xl p-4">
<div className="gap-2 w-full flex flex-col">
<div className="gap-4 w-full flex flex-row items-center justify-center">
{!isPending && !isExpired && (
<>
<MailCheck className="w-16 h-16" />
<span className="text-left w-fit">
{t(
'Email has been verified. You will be redirected to sign in...',
)}
</span>
</>
)}
{isPending && !isExpired && (
<>
<LoadingSpinner className="size-6" />
<span className="text-left w-fit">
{t('Verifying email...')}
</span>
</>
)}
{isExpired && (
<>
<MailX className="w-16 h-16" />
<div className="text-left w-fit">
<div>
{t(
'invitation has expired, once you sign in again you will be able to resend the verification email.',
)}
</div>
<div>{t('Redirecting to sign in...')}</div>
</div>
</>
)}
</div>
</div>
</Card>
</div>
);
};
VerifyEmail.displayName = 'VerifyEmail';
export { VerifyEmail };

View File

@@ -0,0 +1,63 @@
import { t } from 'i18next';
const MIN_LENGTH = 8;
const MAX_LENGTH = 64;
const SPECIAL_CHARACTER_REGEX = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/;
const LOWERCASE_REGEX = /[a-z]/;
const UPPERCASE_REGEX = /[A-Z]/;
const NUMBER_REGEX = /[0-9]/;
type ValidationRule = {
label: string;
condition: (password: string) => boolean;
};
const validationMessages = {
minLength: t(`Password must be at least ${MIN_LENGTH} characters long`),
maxLength: t(`Password can't be more than ${MAX_LENGTH} characters long`),
specialCharacter: t('Password must contain at least one special character'),
lowercase: t('Password must contain at least one lowercase letter'),
uppercase: t('Password must contain at least one uppercase letter'),
number: t('Password must contain at least one number'),
};
const passwordRules: ValidationRule[] = [
{
label: t('8-64 Characters'),
condition: (password: string) =>
password.length >= MIN_LENGTH && password.length <= MAX_LENGTH,
},
{
label: t('Special Character'),
condition: (password: string) => SPECIAL_CHARACTER_REGEX.test(password),
},
{
label: t('Lowercase'),
condition: (password: string) => LOWERCASE_REGEX.test(password),
},
{
label: t('Uppercase'),
condition: (password: string) => UPPERCASE_REGEX.test(password),
},
{
label: t('Number'),
condition: (password: string) => NUMBER_REGEX.test(password),
},
];
const passwordValidation = {
hasSpecialCharacter: (value: string) =>
SPECIAL_CHARACTER_REGEX.test(value) || validationMessages.specialCharacter,
minLength: (value: string) =>
value.length >= MIN_LENGTH || validationMessages.minLength,
maxLength: (value: string) =>
value.length <= MAX_LENGTH || validationMessages.maxLength,
hasLowercaseCharacter: (value: string) =>
LOWERCASE_REGEX.test(value) || validationMessages.lowercase,
hasUppercaseCharacter: (value: string) =>
UPPERCASE_REGEX.test(value) || validationMessages.uppercase,
hasNumber: (value: string) =>
NUMBER_REGEX.test(value) || validationMessages.number,
};
export { passwordValidation, passwordRules };

View File

@@ -0,0 +1,118 @@
import { typeboxResolver } from '@hookform/resolvers/typebox';
import { Static, Type } from '@sinclair/typebox';
import { useQueryClient } from '@tanstack/react-query';
import { t } from 'i18next';
import { useForm } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { LoadingSpinner } from '@/components/ui/spinner';
import { platformHooks } from '@/hooks/platform-hooks';
const LicenseKeySchema = Type.Object({
tempLicenseKey: Type.String({
errorMessage: t('License key is invalid'),
}),
});
type LicenseKeySchema = Static<typeof LicenseKeySchema>;
interface ActivateLicenseDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
}
export const ActivateLicenseDialog = ({
isOpen,
onOpenChange,
}: ActivateLicenseDialogProps) => {
const queryClinet = useQueryClient();
const form = useForm<LicenseKeySchema>({
resolver: typeboxResolver(LicenseKeySchema),
defaultValues: {
tempLicenseKey: '',
},
mode: 'onChange',
});
const { mutate: activateLicenseKey, isPending } =
platformHooks.useUpdateLisenceKey(queryClinet);
const handleSubmit = (data: LicenseKeySchema) => {
form.clearErrors();
activateLicenseKey(data.tempLicenseKey, {
onSuccess: () => handleClose(),
});
};
const handleClose = () => {
form.reset();
form.clearErrors();
onOpenChange(false);
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Activate License Key')}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form className="space-y-4">
<FormField
control={form.control}
name="tempLicenseKey"
render={({ field }) => (
<FormItem>
<Input
{...field}
required
type="text"
placeholder={t('Enter your license key')}
disabled={isPending}
/>
<FormMessage />
</FormItem>
)}
/>
{form?.formState?.errors?.root?.serverError && (
<FormMessage>
{form.formState.errors.root.serverError.message}
</FormMessage>
)}
</form>
</Form>
<DialogFooter className="gap-2">
<DialogClose asChild>
<Button
variant="outline"
onClick={handleClose}
disabled={isPending}
>
{t('Cancel')}
</Button>
</DialogClose>
<Button
onClick={form.handleSubmit(handleSubmit)}
disabled={isPending || !form.watch('tempLicenseKey')?.trim()}
className="min-w-20"
>
{isPending ? <LoadingSpinner className="size-4" /> : t('Activate')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,121 @@
import { t } from 'i18next';
import { CircleHelp, Plus, Zap } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import {
TooltipContent,
TooltipProvider,
Tooltip,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { flagsHooks } from '@/hooks/flags-hooks';
import { PRICE_PER_EXTRA_ACTIVE_FLOWS } from '@activepieces/ee-shared';
import {
ApEdition,
ApFlagId,
isNil,
PlanName,
PlatformBillingInformation,
} from '@activepieces/shared';
import { useManagePlanDialogStore } from '../../lib/active-flows-addon-dialog-state';
type BusinessActiveFlowsProps = {
platformSubscription: PlatformBillingInformation;
};
export function ActiveFlowAddon({
platformSubscription,
}: BusinessActiveFlowsProps) {
const { openDialog } = useManagePlanDialogStore();
const { plan, usage } = platformSubscription;
const currentActiveFlows = usage.activeFlows || 0;
const { data: edition } = flagsHooks.useFlag<ApEdition>(ApFlagId.EDITION);
const canManageActiveFlowsLimit =
edition !== ApEdition.COMMUNITY && plan.plan === PlanName.STANDARD;
const activeFlowsLimit = plan.activeFlowsLimit;
const usagePercentage =
!isNil(activeFlowsLimit) && activeFlowsLimit > 0
? Math.round((currentActiveFlows / activeFlowsLimit) * 100)
: 0;
return (
<Card className="w-full">
<CardHeader className="border-b">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-lg border">
<Zap className="w-5 h-5" />
</div>
<div>
<h3 className="text-lg font-semibold">{t('Active Flows')}</h3>
<p className="text-sm text-muted-foreground">
{t('Monitor your active flows usage')}
</p>
</div>
</div>
{canManageActiveFlowsLimit && (
<Button
variant="default"
className="gap-2"
onClick={() => {
openDialog();
}}
>
<Plus className="w-4 h-4" />
{t('Manage Active Flows')}
</Button>
)}
</div>
</CardHeader>
<CardContent className="p-6">
<div className="space-y-4">
<div className="flex items-center gap-2">
<h4 className="text-base font-medium">{t('Active Flows Usage')}</h4>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<CircleHelp className="w-4 h-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent side="bottom">
{t(
`Count of active flows, $${PRICE_PER_EXTRA_ACTIVE_FLOWS} for extra 5 active flows`,
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="rounded-lg space-y-3">
<div className="flex justify-between items-center text-sm">
<span className="text-muted-foreground">
{currentActiveFlows.toLocaleString()} /{' '}
{isNil(activeFlowsLimit)
? 'Unlimited'
: activeFlowsLimit.toLocaleString()}
</span>
<span className="text-xs font-medium text-muted-foreground">
{t('Plan Limit')}
</span>
</div>
<Progress value={usagePercentage} className="w-full" />
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{usagePercentage}% of plan allocation used
</span>
{usagePercentage > 80 && (
<span className="text-destructive font-medium">
Approaching limit
</span>
)}
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,294 @@
import dayjs from 'dayjs';
import { t } from 'i18next';
import { Zap, Info, Loader2 } from 'lucide-react';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Slider } from '@/components/ui/slider';
import { platformHooks } from '@/hooks/platform-hooks';
import { cn } from '@/lib/utils';
import {
ApSubscriptionStatus,
PRICE_PER_EXTRA_ACTIVE_FLOWS,
} from '@activepieces/ee-shared';
import { PlatformPlan } from '@activepieces/shared';
import { useManagePlanDialogStore } from '../../lib/active-flows-addon-dialog-state';
import { billingMutations, billingQueries } from '../../lib/billing-hooks';
export function PurchaseExtraFlowsDialog() {
const { closeDialog, isOpen } = useManagePlanDialogStore();
const { platform } = platformHooks.useCurrentPlatform();
const { data: platformPlanInfo, isLoading: isPlatformSubscriptionLoading } =
billingQueries.usePlatformSubscription(platform.id);
const activeFlowsUsage = platformPlanInfo?.usage?.activeFlows ?? 0;
const activeFlowsLimit = platformPlanInfo?.plan.activeFlowsLimit ?? 0;
const platformPlan = platformPlanInfo?.plan as PlatformPlan;
const [selectedLimit, setSelectedLimit] = useState(activeFlowsLimit);
const flowPrice = PRICE_PER_EXTRA_ACTIVE_FLOWS;
const maxFlows = 100;
const baseActiveFlows = 10;
const isUpgrade = selectedLimit > activeFlowsLimit;
const isSame = selectedLimit === activeFlowsLimit;
const isDowngrade = selectedLimit < activeFlowsLimit;
const difference = Math.abs(selectedLimit - activeFlowsLimit);
const calculatePaidFlows = (limit: number) =>
Math.max(0, limit - baseActiveFlows);
const currentPaidFlows = calculatePaidFlows(activeFlowsLimit);
const newPaidFlows = calculatePaidFlows(selectedLimit);
const currentCost = currentPaidFlows * flowPrice;
const additionalCost = isUpgrade
? (newPaidFlows - currentPaidFlows) * flowPrice
: 0;
const newTotalCost = newPaidFlows * flowPrice;
const {
mutate: updateActiveFlowsLimit,
isPending: isUpdateActiveFlowsLimitPending,
} = billingMutations.useUpdateActiveFlowsLimit(() => closeDialog());
const {
mutate: createSubscription,
isPending: isCreatingSubscriptionPending,
} = billingMutations.useCreateSubscription(() => closeDialog());
useEffect(() => {
setSelectedLimit(activeFlowsLimit);
}, [isOpen]);
const isLoading =
isUpdateActiveFlowsLimitPending || isCreatingSubscriptionPending;
const handlePurchase = () => {
if (!isSame) {
if (
platformPlan.stripeSubscriptionStatus !== ApSubscriptionStatus.ACTIVE
) {
createSubscription({ newActiveFlowsLimit: selectedLimit });
} else {
updateActiveFlowsLimit({ newActiveFlowsLimit: selectedLimit });
}
}
};
const formatDate = () =>
dayjs(
dayjs.unix(platformPlan.stripeSubscriptionEndDate!).toISOString(),
).format('MMM D, YYYY');
if (isPlatformSubscriptionLoading) return null;
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && closeDialog()}>
<DialogContent
className={cn(
'max-w-[480px] transition-all border duration-300 ease-in-out',
)}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-lg">
{t('Purchase Extra Active Flows')}
</DialogTitle>
<DialogDescription>
{t(
'Currently using {activeFlowsUsage} of {activeFlowsLimit} flows',
{ activeFlowsUsage, activeFlowsLimit },
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
<div className="space-y-3">
<div className="flex justify-between text-sm font-medium">
<span>{t('Select your new limit')}</span>
<span className="text-primary font-semibold">
{t('{selectedLimit} flows', { selectedLimit })}
</span>
</div>
<Slider
value={[selectedLimit]}
onValueChange={(v) => setSelectedLimit(v[0])}
min={baseActiveFlows}
max={maxFlows}
step={1}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{baseActiveFlows}</span>
<span>{maxFlows}</span>
</div>
</div>
<div
className={cn(
'rounded-lg border p-4 transition-all duration-300 ease-in-out',
isUpgrade
? 'bg-primary/5 border-primary/30'
: isDowngrade
? 'bg-amber-50 border-amber-200'
: 'bg-muted/40 border-border',
)}
>
{isUpgrade && (
<div className="space-y-3 animate-in fade-in duration-300">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{t('Current limit')}
</span>
<span>
{t('{activeFlowsLimit} flows', { activeFlowsLimit })}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{t('Current cost')}
</span>
<span>
{t('${currentCost}/mo', {
currentCost: currentCost.toFixed(2),
})}
</span>
</div>
<div className="h-px bg-border" />
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{t('Additional flows')}
</span>
<span className="text-primary font-medium">
{t('+{difference}', { difference })}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{t('Additional cost')}
</span>
<span className="text-primary font-medium">
{t('+${additionalCost}/mo', {
additionalCost: additionalCost.toFixed(2),
})}
</span>
</div>
<div className="h-px bg-border" />
<div className="flex justify-between text-sm font-medium">
<span>{t('New total')}</span>
<span>{t('{selectedLimit} flows', { selectedLimit })}</span>
</div>
<div className="flex justify-between items-baseline">
<span className="text-sm font-medium">
{t('New monthly cost')}
</span>
<span className="text-xl font-bold text-primary">
{t('${newTotalCost}/mo', {
newTotalCost: newTotalCost.toFixed(2),
})}
</span>
</div>
<div className="h-px bg-border" />
<div className="flex justify-between items-baseline">
<span className="text-sm font-semibold">
{t('Due today')}
</span>
<span className="text-2xl font-bold text-primary">
{t('${additionalCost}', {
additionalCost: additionalCost.toFixed(2),
})}
</span>
</div>
</div>
)}
{isDowngrade && (
<div className="space-y-3 animate-in fade-in duration-300">
<div className="flex items-start text-sm gap-2">
<Info className="w-4 h-4 mt-0.5 text-amber-500 shrink-0" />
<div className="space-y-2">
<p className="font-medium">
{t(
'New limit: {selectedLimit} flows ({difference} flows)',
{ selectedLimit, difference },
)}
</p>
<p className="text-muted-foreground">
{t('Change takes effect on {date}.', {
date: formatDate(),
})}
</p>
</div>
</div>
</div>
)}
{isSame && (
<div className="space-y-3 animate-in fade-in duration-300">
<div className="flex items-start gap-2 text-sm text-muted-foreground">
<Info className="w-4 h-4 mt-0.5 shrink-0" />
<div>
<p className="font-medium text-foreground mb-1">
{t('No changes')}
</p>
<p>
{t(
'Your flow limit remains at {activeFlowsLimit} flows (${currentCost}/mo)',
{
activeFlowsLimit,
currentCost: currentCost.toFixed(2),
},
)}
</p>
</div>
</div>
</div>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => closeDialog()}
disabled={isLoading}
>
{t('Cancel')}
</Button>
<Button
onClick={handlePurchase}
className="gap-2"
disabled={isSame || isLoading}
>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Zap className="w-4 h-4" />
)}
{isLoading
? t('Processing...')
: isUpgrade
? t('Purchase +{difference} flows', { difference })
: isDowngrade
? t('Confirm Downgrade')
: t('No Changes')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,309 @@
import { useQueryClient } from '@tanstack/react-query';
import { t } from 'i18next';
import { Sparkles, Info, Loader2 } from 'lucide-react';
import { useEffect, useState, useMemo, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Progress } from '@/components/ui/progress';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from '@/components/ui/tooltip';
import { ApSubscriptionStatus } from '@activepieces/ee-shared';
import {
AiOverageState,
PlatformBillingInformation,
} from '@activepieces/shared';
import { billingMutations } from '../lib/billing-hooks';
import { EnableAIOverageDialog } from './enable-ai-credits-overage';
interface AiCreditUsageProps {
platformSubscription: PlatformBillingInformation;
}
export function AICreditUsage({ platformSubscription }: AiCreditUsageProps) {
const queryClient = useQueryClient();
const { plan, usage } = platformSubscription;
const [isOpen, setIsOpen] = useState(false);
const planIncludedCredits = plan.includedAiCredits;
const overageLimit = plan.aiCreditsOverageLimit;
const totalCreditsUsed = usage.aiCredits;
const hasActiveSubscription =
plan.stripeSubscriptionStatus === ApSubscriptionStatus.ACTIVE;
const aiOverrageState =
plan.aiCreditsOverageState ?? AiOverageState.NOT_ALLOWED;
const overageConfig = useMemo(() => {
const isAllowed = aiOverrageState !== AiOverageState.NOT_ALLOWED;
const isEnabled = aiOverrageState === AiOverageState.ALLOWED_AND_ON;
return {
allowed: isAllowed,
enabled: isEnabled,
canToggle: isAllowed,
};
}, [aiOverrageState]);
const [usageBasedEnabled, setUsageBasedEnabled] = useState(
overageConfig.enabled,
);
const [usageLimit, setUsageLimit] = useState<number>(overageLimit ?? 500);
const {
mutate: setAiCreditOverageLimit,
isPending: settingAiCreditsOverageLimit,
} = billingMutations.useSetAiCreditOverageLimit(queryClient);
const {
mutate: toggleAiCreditsOverageEnabled,
isPending: togglingAiCreditsOverageEnabled,
} = billingMutations.useToggleAiCreditOverageEnabled(queryClient);
const creditMetrics = useMemo(() => {
const creditsUsedFromPlan = Math.min(totalCreditsUsed, planIncludedCredits);
const overageCreditsUsed = Math.max(
0,
totalCreditsUsed - planIncludedCredits,
);
const planUsagePercentage = Math.min(
100,
Math.round((creditsUsedFromPlan / planIncludedCredits) * 100),
);
const overageUsagePercentage =
usageBasedEnabled && overageLimit
? Math.min(100, Math.round((overageCreditsUsed / overageLimit) * 100))
: 0;
return {
creditsUsedFromPlan,
overageCreditsUsed,
planUsagePercentage,
overageUsagePercentage,
isPlanLimitApproaching: planUsagePercentage > 80,
isPlanLimitExceeded: totalCreditsUsed > planIncludedCredits,
isOverageLimitApproaching: overageUsagePercentage > 80,
};
}, [totalCreditsUsed, planIncludedCredits, usageBasedEnabled, overageLimit]);
const handleSaveAiCreditUsageLimit = useCallback(() => {
setAiCreditOverageLimit({ limit: usageLimit });
}, [setAiCreditOverageLimit, usageLimit]);
const handleToggleAiCreditUsage = useCallback(() => {
const newState = usageBasedEnabled
? AiOverageState.ALLOWED_BUT_OFF
: AiOverageState.ALLOWED_AND_ON;
if (!hasActiveSubscription) {
setIsOpen(true);
} else {
toggleAiCreditsOverageEnabled(
{ state: newState },
{
onSuccess: () => {
setUsageBasedEnabled(!usageBasedEnabled);
},
},
);
}
}, [usageBasedEnabled, toggleAiCreditsOverageEnabled]);
useEffect(() => {
setUsageBasedEnabled(overageConfig.enabled);
}, [overageConfig.enabled]);
useEffect(() => {
setUsageLimit(overageLimit ?? 500);
}, [overageLimit]);
return (
<Card className="w-full">
<CardHeader className="border-b">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-lg border">
<Sparkles className="w-5 h-5" />
</div>
<div>
<h3 className="text-lg font-semibold">{t('AI Credits')}</h3>
<p className="text-sm text-muted-foreground">
Manage your AI usage and limits
</p>
</div>
</div>
{overageConfig.canToggle && (
<div className="flex items-center gap-3 py-2">
<span className="text-sm font-medium">
{t('Usage Based Billing')}
</span>
<Switch
checked={usageBasedEnabled}
disabled={togglingAiCreditsOverageEnabled}
onCheckedChange={handleToggleAiCreditUsage}
/>
</div>
)}
</div>
</CardHeader>
<CardContent className="p-6 space-y-10">
<div className="space-y-4">
<div className="flex items-center gap-2">
<h4 className="text-base font-medium">{t('Plan Credits Usage')}</h4>
<Tooltip>
<TooltipTrigger>
<Info className="w-4 h-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
Credits reset monthly with your billing cycle
</TooltipContent>
</Tooltip>
</div>
<div className="rounded-lg space-y-3">
<div className="flex justify-between items-center text-sm">
<span className="text-muted-foreground">
{Math.round(creditMetrics.creditsUsedFromPlan)} /{' '}
{planIncludedCredits}
</span>
<span className="text-xs font-medium text-muted-foreground">
{t('Plan Included')}
</span>
</div>
<Progress
value={creditMetrics.planUsagePercentage}
className="w-full"
/>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{creditMetrics.planUsagePercentage}% of plan credits used
</span>
{creditMetrics.isPlanLimitApproaching &&
!creditMetrics.isPlanLimitExceeded && (
<span className="text-orange-600 font-medium">
Approaching limit
</span>
)}
{creditMetrics.isPlanLimitExceeded && (
<span className="text-destructive font-medium">
Plan limit exceeded
</span>
)}
</div>
</div>
</div>
{usageBasedEnabled && overageConfig.canToggle && (
<>
<Separator />
<div className="space-y-4">
<div className="flex items-center gap-2">
<h4 className="text-base font-medium">
{t('Additional Credits Usage')}
</h4>
<Tooltip>
<TooltipTrigger>
<Info className="w-4 h-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
Credits used beyond your plan limit ($0.01 each)
</TooltipContent>
</Tooltip>
</div>
<div className="rounded-lg space-y-3">
<div className="flex justify-between items-center text-sm">
<span className="text-muted-foreground">
{creditMetrics.overageCreditsUsed} /{' '}
{overageLimit ?? 'unknown'}
</span>
<span className="text-xs font-medium text-muted-foreground">
{t('Usage Limit')}
</span>
</div>
<Progress
value={creditMetrics.overageUsagePercentage}
className="w-full"
/>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{creditMetrics.overageUsagePercentage}% of usage limit used
</span>
{creditMetrics.isOverageLimitApproaching && (
<span className="text-destructive font-medium">
Approaching usage limit
</span>
)}
</div>
</div>
</div>
<Separator />
<div className="space-y-4">
<div>
<h5 className="text-base font-medium mb-1">
{t('Set Usage Limit')}
</h5>
<p className="text-sm text-muted-foreground">
Set a maximum number of additional AI credits to prevent
unexpected charges
</p>
</div>
<div className="rounded-lg space-y-4">
<div className="flex items-end gap-3">
<div className="flex-1 max-w-xs space-y-2">
<Input
type="number"
placeholder="Enter limit"
value={usageLimit}
onChange={(e) => setUsageLimit(Number(e.target.value))}
className="w-full"
min="0"
/>
</div>
<Button
onClick={handleSaveAiCreditUsageLimit}
disabled={settingAiCreditsOverageLimit}
className="whitespace-nowrap"
>
{settingAiCreditsOverageLimit && (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
)}
{t('Save Limit')}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Recommended: Set 20-50% above your expected monthly overage
usage
</p>
</div>
</div>
<div className="text-sm text-muted-foreground bg-muted/30 rounded-lg p-3">
{t('$1 per 1000 additional credits beyond plan limit')}
</div>
</>
)}
<Separator />
<EnableAIOverageDialog isOpen={isOpen} onOpenChange={setIsOpen} />
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,54 @@
import { t } from 'i18next';
import { Info } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { billingMutations } from '../lib/billing-hooks';
interface EnableAIOverageDialogProps {
isOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function EnableAIOverageDialog({
isOpen,
onOpenChange,
}: EnableAIOverageDialogProps) {
const {
mutate: createSubscription,
isPending: isCreatingSubscriptionPending,
} = billingMutations.useCreateSubscription(onOpenChange);
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[420px] p-8 text-center">
<div className="flex flex-col items-center">
<div className="rounded-full bg-purple-50 p-4 mb-6">
<Info className="w-10 h-10 text-primary" />
</div>
<h2 className="text-2xl font-semibold">
{t('Start a Subscription')}
</h2>
<p className="mt-2 text-sm max-w-sm">
{t(
'To enable AI credit overage and unlock advanced features, please start your subscription first.',
)}
</p>
<div className="mt-8 flex flex-col w-full gap-3">
<Button
onClick={() => createSubscription({ newActiveFlowsLimit: 0 })}
disabled={isCreatingSubscriptionPending}
loading={isCreatingSubscriptionPending}
className="w-full"
>
{t('Start Subscription (Free)')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,84 @@
import { t } from 'i18next';
import { AlertCircle, RefreshCw, Home } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { CardContent } from '@/components/ui/card';
export const Error = () => {
const navigate = useNavigate();
const [countdown, setCountdown] = useState(5);
useEffect(() => {
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
navigate('/platform/setup/billing');
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [navigate]);
return (
<div className="h-full bg-background flex items-center justify-center p-4">
<div className="w-full max-w-md border-destructive/20">
<CardContent className="pt-8 pb-6 px-6">
<div className="text-center space-y-6">
<div className="mx-auto w-20 h-20 bg-destructive/10 rounded-full flex items-center justify-center">
<AlertCircle className="w-10 h-10 text-destructive" />
</div>
<div className="space-y-3">
<h1 className="text-2xl font-semibold text-foreground">
{t('Something went wrong')}
</h1>
<p className="text-lg text-muted-foreground">
{t('Subscription update failed')}
</p>
</div>
<div className="bg-muted/30 rounded-lg p-4 text-left">
<h3 className="text-sm font-medium text-foreground mb-2">
{t('What you can do:')}
</h3>
<ul className="text-sm text-muted-foreground space-y-1">
<li>{t('Verify your payment method')}</li>
<li>{t('Try again in a few moments')}</li>
<li>{t('Contact support if issues persist')}</li>
</ul>
</div>
<div className="flex flex-col gap-3 pt-2">
<Button
onClick={() => navigate('/platform/setup/billing')}
className="w-full"
>
<RefreshCw className="w-4 h-4 mr-2" />
{t('Try Again')}
</Button>
<Button
onClick={() => navigate('/dashboard')}
variant="outline"
className="w-full"
>
<Home className="w-4 h-4 mr-2" />
{t('Go to Dashboard')}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t('Redirecting to billing in {countdown} seconds...', {
countdown,
})}
</p>
</div>
</CardContent>
</div>
</div>
);
};

View File

@@ -0,0 +1,115 @@
import { t } from 'i18next';
import { Check, Lock } from 'lucide-react';
import { StatusIconWithText } from '@/components/ui/status-icon-with-text';
import {
PlatformPlanLimits,
PlatformWithoutSensitiveData,
} from '@activepieces/shared';
const LICENSE_PROPS_MAP = {
environmentsEnabled: {
label: 'Team Collaboration via Git',
description:
'Work together on projects with version control and team features',
},
analyticsEnabled: {
label: 'Analytics',
description: 'View reports and insights about your workflow performance',
},
auditLogEnabled: {
label: 'Audit Log',
description: 'Track all changes and activities in your workspace',
},
embeddingEnabled: {
label: 'Embedding',
description: 'Add workflows directly into your website or application',
},
globalConnectionsEnabled: {
label: 'Global Connections',
description: 'Create centralized connections for your projects',
},
managePiecesEnabled: {
label: 'Manage Pieces',
description: 'Create and organize custom building blocks for workflows',
},
manageTemplatesEnabled: {
label: 'Manage Templates',
description: 'Save and share workflow templates across your team',
},
customAppearanceEnabled: {
label: 'Brand Activepieces',
description: 'Customize the look and feel with your company branding',
},
teamProjectsLimit: {
label: 'Team Projects Limit',
description: 'Control the number of projects your team can create',
},
projectRolesEnabled: {
label: 'Project Roles',
description: 'Control who can view, edit, or manage different projects',
},
customDomainsEnabled: {
label: 'Custom Domains',
description: 'Use your own web address instead of the default domain',
},
apiKeysEnabled: {
label: 'API Keys',
description: 'Connect external services and applications to your workflows',
},
ssoEnabled: {
label: 'Single Sign On',
description: 'Log in using your company account without separate passwords',
},
customRolesEnabled: {
label: 'Custom Roles',
description: 'Create and manage custom roles for your team',
},
};
export const FeatureStatus = ({
platform,
}: {
platform: PlatformWithoutSensitiveData;
}) => {
return (
<div className="grid grid-cols-3 gap-3">
{Object.entries(LICENSE_PROPS_MAP)
.sort(([aKey], [bKey]) => {
const aEnabled = platform?.plan?.[aKey as keyof PlatformPlanLimits];
const bEnabled = platform?.plan?.[bKey as keyof PlatformPlanLimits];
return (aEnabled ? 0 : 1) - (bEnabled ? 0 : 1);
})
.map(([key, value]) => {
const featureEnabled =
platform?.plan?.[key as keyof PlatformPlanLimits];
return (
<div
key={key}
className="flex items-center justify-between p-3 rounded-lg bg-accent/50"
>
<div className="flex flex-col">
<span className="text-sm font-medium">{t(value.label)}</span>
<span className="text-xs text-muted-foreground">
{t(value.description)}
</span>
</div>
{featureEnabled ? (
<StatusIconWithText
icon={Check}
text="Enabled"
variant="success"
/>
) : (
<StatusIconWithText
icon={Lock}
text="Upgrade"
variant="default"
/>
)}
</div>
);
})}
</div>
);
};

View File

@@ -0,0 +1,117 @@
import dayjs from 'dayjs';
import { t } from 'i18next';
import { Shield, AlertTriangle, Check, Zap } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { StatusIconWithText } from '@/components/ui/status-icon-with-text';
import { formatUtils } from '@/lib/utils';
import { isNil, PlatformWithoutSensitiveData } from '@activepieces/shared';
import { ActivateLicenseDialog } from './activate-license-dialog';
import { FeatureStatus } from './features-status';
export const LicenseKey = ({
platform,
}: {
platform: PlatformWithoutSensitiveData;
}) => {
const [isActivateLicenseKeyDialogOpen, setIsActivateLicenseKeyDialogOpen] =
useState(false);
const expired =
!isNil(platform?.plan?.licenseExpiresAt) &&
dayjs(platform.plan.licenseExpiresAt).isBefore(dayjs());
const expiresSoon =
!expired &&
!isNil(platform?.plan?.licenseExpiresAt) &&
dayjs(platform.plan.licenseExpiresAt).isBefore(dayjs().add(7, 'day'));
const getStatusBadge = () => {
if (expired) {
return (
<StatusIconWithText
text={t('Expired')}
icon={AlertTriangle}
variant="error"
/>
);
}
if (expiresSoon) {
return (
<StatusIconWithText
text={t('Expires soon')}
icon={AlertTriangle}
variant="default"
/>
);
}
return (
<StatusIconWithText text={t('Active')} icon={Check} variant="success" />
);
};
return (
<Card>
<CardHeader className="border-b">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-lg border">
<Shield className="w-5 h-5" />
</div>
<div>
<h3 className="text-lg font-semibold">{t('License Key')}</h3>
<p className="text-sm text-muted-foreground">
{t('Activate your platform and unlock enterprise features')}
</p>
</div>
</div>
<Button
variant="default"
onClick={() => setIsActivateLicenseKeyDialogOpen(true)}
>
<Zap className="w-4 h-4" />
{platform.plan.licenseKey
? t('Update License')
: t('Activate License')}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6 p-6">
{platform.plan.licenseKey && (
<div className="flex items-center justify-between p-4 bg-accent/50 rounded-lg">
<div className="flex items-center gap-3">
<div className="w-2 h-2 bg-green-500 rounded-full" />
<div>
<p className="text-sm font-medium">{t('License Active')}</p>
{!isNil(platform.plan.licenseExpiresAt) && (
<p className="text-xs text-muted-foreground">
{t('Valid until')}{' '}
{formatUtils.formatDateOnly(
dayjs(platform.plan.licenseExpiresAt).toDate(),
)}
</p>
)}
</div>
</div>
{getStatusBadge()}
</div>
)}
<div>
<h3 className="text-base font-semibold mb-4">
{t('Enabled Features')}
</h3>
<FeatureStatus platform={platform} />
</div>
</CardContent>
<ActivateLicenseDialog
isOpen={isActivateLicenseKeyDialogOpen}
onOpenChange={setIsActivateLicenseKeyDialogOpen}
/>
</Card>
);
};
LicenseKey.displayName = 'LicenseKeys';

View File

@@ -0,0 +1,56 @@
import dayjs from 'dayjs';
import { t } from 'i18next';
import { CalendarDays } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { isNil, PlatformBillingInformation } from '@activepieces/shared';
type SubscriptionInfoProps = {
info: PlatformBillingInformation;
};
export const SubscriptionInfo = ({ info }: SubscriptionInfoProps) => {
return (
<div className="space-y-4">
<Badge variant="accent" className="rounded-sm text-sm">
{isNil(info.plan.plan)
? t('Free')
: info?.plan.plan.charAt(0).toUpperCase() + info?.plan.plan.slice(1)}
</Badge>
<div className="flex items-baseline gap-2">
<div className="text-5xl font-semibold">
${info.nextBillingAmount || Number(0).toFixed(2)}
</div>
<div className="text-xl text-muted-foreground">{t('/month')}</div>
</div>
{info?.nextBillingDate && isNil(info.cancelAt) && (
<div className="text-sm text-muted-foreground flex items-center gap-2">
<CalendarDays className="w-4 h-4" />
<span>
{t('Next billing date ')}
<span className="font-semibold">
{dayjs(dayjs.unix(info.nextBillingDate).toISOString()).format(
'MMM D, YYYY',
)}
</span>
</span>
</div>
)}
{info?.cancelAt && (
<div className="text-sm text-muted-foreground flex items-center gap-2">
<CalendarDays className="w-4 h-4" />
<span>
{t('Subscription will end')}{' '}
<span className="font-semibold">
{dayjs(dayjs.unix(info.cancelAt).toISOString()).format(
'MMM D, YYYY',
)}
</span>
</span>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,113 @@
import { t } from 'i18next';
import { Check, TrendingUp, TrendingDown } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { CardContent } from '@/components/ui/card';
export const Success = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [countdown, setCountdown] = useState(5);
const action = searchParams.get('action') || '';
useEffect(() => {
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
navigate('/platform/setup/billing');
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [navigate]);
const getActionConfig = () => {
switch (action) {
case 'upgrade':
return {
icon: TrendingUp,
iconBg: 'bg-emerald-50 dark:bg-emerald-950',
iconColor: 'text-emerald-600 dark:text-emerald-400',
title: t('Successfully Upgraded!'),
description: t('Subscription updated successfully'),
};
case 'downgrade':
return {
icon: TrendingDown,
iconBg: 'bg-orange-50 dark:bg-orange-950',
iconColor: 'text-orange-600 dark:text-orange-400',
title: t('Plan Downgraded'),
description: t('Subscription updated successfully'),
};
case 'create':
return {
icon: Check,
iconBg: 'bg-primary/10',
iconColor: 'text-primary',
title: t('Success!'),
description: t('Subscription created successfully'),
};
default:
return {
icon: Check,
iconBg: 'bg-primary/10',
iconColor: 'text-primary',
title: t('Success!'),
description: t('Subscription updated successfully'),
};
}
};
const config = getActionConfig();
const IconComponent = config.icon;
return (
<div className="h-full bg-background flex items-center justify-center p-4">
<div className="w-full max-w-md">
<CardContent className="pt-8 pb-6 px-6">
<div className="text-center space-y-6">
<div
className={`mx-auto w-20 h-20 ${config.iconBg} rounded-full flex items-center justify-center`}
>
<IconComponent className={`w-10 h-10 ${config.iconColor}`} />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-semibold text-foreground">
{config.title}
</h1>
<p className="text-lg text-muted-foreground">
{config.description}
</p>
</div>
<div className="flex flex-col gap-3 pt-2">
<Button onClick={() => navigate('/')} className="w-full">
{t('Go to Dashboard')}
</Button>
<Button
onClick={() => navigate('/platform/setup/billing')}
variant="outline"
className="w-full"
>
{t('View Billing Details')}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t('Redirecting to billing in {countdown} seconds...', {
countdown,
})}
</p>
</div>
</CardContent>
</div>
</div>
);
};

View File

@@ -0,0 +1,15 @@
import { create } from 'zustand';
interface ActiveFlowsAddonDialogStore {
isOpen: boolean;
openDialog: () => void;
closeDialog: () => void;
}
export const useManagePlanDialogStore = create<ActiveFlowsAddonDialogStore>(
(set) => ({
isOpen: false,
openDialog: () => set({ isOpen: true }),
closeDialog: () => set({ isOpen: false }),
}),
);

View File

@@ -0,0 +1,41 @@
import { api } from '@/lib/api';
import {
ToggleAiCreditsOverageEnabledParams,
SetAiCreditsOverageLimitParams,
UpdateActiveFlowsAddonParams,
CreateSubscriptionParams,
} from '@activepieces/ee-shared';
import { PlatformPlan, PlatformBillingInformation } from '@activepieces/shared';
export const platformBillingApi = {
getSubscriptionInfo() {
return api.get<PlatformBillingInformation>('/v1/platform-billing/info');
},
getPortalLink() {
return api.post<string>('/v1/platform-billing/portal');
},
updateActiveFlowsLimits(params: UpdateActiveFlowsAddonParams) {
return api.post<string>(
'/v1/platform-billing/update-active-flows-addon',
params,
);
},
createSubscription(params: CreateSubscriptionParams) {
return api.post<string>(
'/v1/platform-billing/create-checkout-session',
params,
);
},
setAiCreditsOverageLimit(params: SetAiCreditsOverageLimitParams) {
return api.post<PlatformPlan>(
'/v1/platform-billing/set-ai-credits-overage-limit',
params,
);
},
toggleAiCreditsOverageEnabled(params: ToggleAiCreditsOverageEnabledParams) {
return api.post<PlatformPlan>(
'/v1/platform-billing/update-ai-overage-state',
params,
);
},
};

View File

@@ -0,0 +1,129 @@
import { QueryClient, useMutation, useQuery } from '@tanstack/react-query';
import { t } from 'i18next';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { internalErrorToast } from '@/components/ui/sonner';
import { api } from '@/lib/api';
import {
ToggleAiCreditsOverageEnabledParams,
SetAiCreditsOverageLimitParams,
UpdateActiveFlowsAddonParams,
CreateSubscriptionParams,
} from '@activepieces/ee-shared';
import { ApErrorParams, ErrorCode } from '@activepieces/shared';
import { platformBillingApi } from './api';
export const billingKeys = {
platformSubscription: (platformId: string) =>
['platform-billing-subscription', platformId] as const,
};
export const billingMutations = {
usePortalLink: () => {
return useMutation({
mutationFn: async () => {
const portalLink = await platformBillingApi.getPortalLink();
window.open(portalLink, '_blank');
},
});
},
useUpdateActiveFlowsLimit: (setIsOpen?: (isOpen: boolean) => void) => {
const navigate = useNavigate();
return useMutation({
mutationFn: (params: UpdateActiveFlowsAddonParams) =>
platformBillingApi.updateActiveFlowsLimits(params),
onSuccess: (url) => {
setIsOpen?.(false);
navigate(url);
toast.success(t('Plan updated successfully'), {
duration: 3000,
});
},
onError: () => {
navigate(`/platform/setup/billing/error`);
},
});
},
useCreateSubscription: (setIsOpen?: (isOpen: boolean) => void) => {
return useMutation({
mutationFn: async (params: CreateSubscriptionParams) => {
const checkoutSessionURl = await platformBillingApi.createSubscription(
params,
);
window.open(checkoutSessionURl, '_blank');
},
onSuccess: () => {
setIsOpen?.(false);
},
onError: (error) => {
toast.error(t('Starting Subscription failed'), {
description: t(error.message),
duration: 3000,
});
},
});
},
useSetAiCreditOverageLimit: (queryClient: QueryClient) => {
return useMutation({
mutationFn: (params: SetAiCreditsOverageLimitParams) =>
platformBillingApi.setAiCreditsOverageLimit(params),
onSuccess: (data) => {
queryClient.invalidateQueries({
queryKey: billingKeys.platformSubscription(data.platformId),
});
toast.success(t('AI credit usage limit updated successfully'), {
duration: 3000,
});
},
onError: (error) => {
if (api.isError(error)) {
const apError = error.response?.data as ApErrorParams;
if (apError.code === ErrorCode.VALIDATION) {
toast.error(t('Setting AI credit usage limit failed'), {
description: t(apError.params.message),
duration: 3000,
});
return;
}
}
internalErrorToast();
},
});
},
useToggleAiCreditOverageEnabled: (queryClient: QueryClient) => {
return useMutation({
mutationFn: (params: ToggleAiCreditsOverageEnabledParams) =>
platformBillingApi.toggleAiCreditsOverageEnabled(params),
onSuccess: (data) => {
queryClient.invalidateQueries({
queryKey: billingKeys.platformSubscription(data.platformId),
});
toast.success(t('AI credits overage updated successfully'), {});
},
onError: (error) => {
if (api.isError(error)) {
const apError = error.response?.data as ApErrorParams;
if (apError.code === ErrorCode.VALIDATION) {
toast.error(t('Setting AI credit usage limit failed'), {
description: t(apError.params.message),
duration: 3000,
});
return;
}
}
internalErrorToast();
},
});
},
};
export const billingQueries = {
usePlatformSubscription: (platformId: string) => {
return useQuery({
queryKey: billingKeys.platformSubscription(platformId),
queryFn: platformBillingApi.getSubscriptionInfo,
});
},
};

View File

@@ -0,0 +1,160 @@
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Button, ButtonProps } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import MessageLoading from './message-loading';
// ChatBubble
const chatBubbleVariant = cva('flex gap-2 w-full items-start relative group', {
variants: {
variant: {
received: 'self-start',
sent: 'self-end flex-row-reverse',
},
layout: {
default: '',
ai: 'max-w-full w-full items-center',
},
},
defaultVariants: {
variant: 'received',
layout: 'default',
},
});
interface ChatBubbleProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof chatBubbleVariant> {}
const ChatBubble = React.forwardRef<HTMLDivElement, ChatBubbleProps>(
({ className, variant, layout, children, ...props }, ref) => (
<div
className={cn(
chatBubbleVariant({ variant, layout, className }),
'relative group',
)}
ref={ref}
{...props}
>
{React.Children.map(children, (child) =>
React.isValidElement(child) && typeof child.type !== 'string'
? React.cloneElement(child, {
variant,
layout,
} as React.ComponentProps<typeof child.type>)
: child,
)}
</div>
),
);
ChatBubble.displayName = 'ChatBubble';
// ChatBubbleAvatar
interface ChatBubbleAvatarProps {
src?: string;
fallback?: React.ReactNode;
className?: string;
}
const ChatBubbleAvatar: React.FC<ChatBubbleAvatarProps> = ({
src,
fallback,
className,
}) => (
<Avatar>
<AvatarImage
src={src}
alt="Avatar"
className={cn('aspect-square p-2', className)}
/>
<AvatarFallback className="bg-background border">{fallback}</AvatarFallback>
</Avatar>
);
// ChatBubbleMessage
const chatBubbleMessageVariants = cva('px-1', {
variants: {
variant: {
received: 'bg-background text-foreground rounded-3xl py-2',
sent: 'bg-accent text-accent-foreground rounded-3xl py-3 px-5',
},
layout: {
default: '',
ai: 'border-t w-full rounded-none bg-transparent',
},
},
defaultVariants: {
variant: 'received',
layout: 'default',
},
});
interface ChatBubbleMessageProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof chatBubbleMessageVariants> {
isLoading?: boolean;
}
const ChatBubbleMessage = React.forwardRef<
HTMLDivElement,
ChatBubbleMessageProps
>(
(
{ className, variant, layout, isLoading = false, children, ...props },
ref,
) => (
<div
className={cn(
chatBubbleMessageVariants({ variant, layout, className }),
'wrap-break-word max-w-full whitespace-pre-wrap overflow-x-auto',
)}
ref={ref}
{...props}
>
{isLoading ? (
<div className="flex items-center space-x-2">
<MessageLoading />
</div>
) : (
children
)}
</div>
),
);
ChatBubbleMessage.displayName = 'ChatBubbleMessage';
// ChatBubbleAction
type ChatBubbleActionProps = ButtonProps & {
icon: React.ReactNode;
};
const ChatBubbleAction: React.FC<ChatBubbleActionProps> = ({
icon,
onClick,
className,
variant = 'ghost',
size = 'icon',
...props
}) => (
<Button
variant={variant}
size={size}
className={className}
onClick={onClick}
{...props}
>
{icon}
</Button>
);
export {
ChatBubble,
ChatBubbleAvatar,
ChatBubbleMessage,
chatBubbleVariant,
chatBubbleMessageVariants,
ChatBubbleAction,
};

View File

@@ -0,0 +1,45 @@
// @hidden
export default function MessageLoading() {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className="text-foreground"
>
<circle cx="4" cy="12" r="2" fill="currentColor">
<animate
id="spinner_qFRN"
begin="0;spinner_OcgL.end+0.25s"
attributeName="cy"
calcMode="spline"
dur="0.6s"
values="12;6;12"
keySplines=".33,.66,.66,1;.33,0,.66,.33"
/>
</circle>
<circle cx="12" cy="12" r="2" fill="currentColor">
<animate
begin="spinner_qFRN.begin+0.1s"
attributeName="cy"
calcMode="spline"
dur="0.6s"
values="12;6;12"
keySplines=".33,.66,.66,1;.33,0,.66,.33"
/>
</circle>
<circle cx="20" cy="12" r="2" fill="currentColor">
<animate
id="spinner_OcgL"
begin="spinner_qFRN.begin+0.2s"
attributeName="cy"
calcMode="spline"
dur="0.6s"
values="12;6;12"
keySplines=".33,.66,.66,1;.33,0,.66,.33"
/>
</circle>
</svg>
);
}

View File

@@ -0,0 +1,54 @@
import { FileIcon, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
type FileInputPreviewProps = {
file: File;
index: number;
onRemove: (index: number) => void;
};
export const FileInputPreview = ({
file,
index,
onRemove,
}: FileInputPreviewProps) => {
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
return (
<div key={index} className="relative inline-block mr-2 mt-2 mb-3">
{isImage && (
<img
src={URL.createObjectURL(file)}
alt={file.name}
className="w-20 h-20 object-cover rounded-lg"
/>
)}
{isVideo && (
<video
src={URL.createObjectURL(file)}
className="w-20 h-20 object-cover rounded-lg"
/>
)}
{!isImage && !isVideo && (
<div className="w-20 h-20 bg-foreground text-background rounded-lg flex items-center justify-center">
<FileIcon className="w-8 h-8" />
</div>
)}
<Button
variant="destructive"
size="icon"
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove(index);
}}
className="absolute -top-2 -right-2 rounded-full p-1 size-6"
>
<X className="w-3 h-3" />
</Button>
<p className="text-xs mt-1 truncate w-20">{file.name}</p>
</div>
);
};

View File

@@ -0,0 +1,172 @@
import { ArrowUpIcon, Paperclip } from 'lucide-react';
import * as React from 'react';
import { useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { ResizableTextareaProps, Textarea } from '@/components/ui/textarea';
import { cn, useElementSize } from '@/lib/utils';
import { isNil } from '@activepieces/shared';
import { FileInputPreview } from './file-input-preview';
export interface ChatMessage {
textContent: string;
files: File[];
}
interface ChatInputProps extends Omit<ResizableTextareaProps, 'onSubmit'> {
onSendMessage: (message: ChatMessage) => void;
disabled?: boolean;
placeholder?: string;
}
const ChatInput = React.forwardRef<HTMLTextAreaElement, ChatInputProps>(
(
{
className,
onSendMessage,
disabled = false,
placeholder = 'Type your message here...',
...props
},
ref,
) => {
const [input, setInput] = useState('');
const [files, setFiles] = useState<File[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const filesPreviewContainerRef = useRef<HTMLDivElement | null>(null);
const filesPreviewContainerSize = useElementSize(filesPreviewContainerRef);
const handleFileChange = (selectedFiles: File[]) => {
if (selectedFiles) {
setFiles((prevFiles) => {
const newFiles = [...prevFiles, ...selectedFiles];
return newFiles;
});
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const removeFile = (index: number) => {
setFiles((prevFiles) => prevFiles.filter((_, i) => i !== index));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if ((!input && files.length === 0) || disabled) return;
onSendMessage({
textContent: input,
files: files,
});
// Clear input fields
setInput('');
setFiles([]);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!disabled && (input || files.length > 0)) {
handleSubmit(e as unknown as React.FormEvent);
}
}
};
return (
<div
className="w-full"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const selectedFiles = Array.from(e.dataTransfer.files);
handleFileChange(selectedFiles);
}}
>
<form onSubmit={handleSubmit} className="flex flex-col">
<div className="rounded-lg border shadow-xs">
{files.length > 0 && (
<div
className="px-4 py-3 w-full transition-all overflow-hidden"
style={{
height: `${filesPreviewContainerSize.height}px`,
}}
>
<div
ref={filesPreviewContainerRef}
className="flex items-start gap-3 flex-wrap"
>
{files.map((file, index) => (
<FileInputPreview
key={`${file.name}-${index}`}
file={file}
index={index}
onRemove={removeFile}
/>
))}
</div>
</div>
)}
<Textarea
autoComplete="off"
ref={ref}
autoFocus
minRows={1}
maxRows={6}
name="message"
className={cn(
'px-4 py-3 text-sm placeholder:text-muted-foreground focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 w-full resize-none border-0 shadow-none focus-visible:ring-0',
className,
)}
value={input}
onKeyDown={handleKeyDown}
onChange={(e) => setInput(e.target.value)}
onPaste={(e) => {
const selectedFiles = Array.from(e.clipboardData.items)
.filter((item) => item.kind === 'file')
.map((item) => item.getAsFile())
.filter((item) => !isNil(item));
handleFileChange(selectedFiles);
}}
placeholder={placeholder}
disabled={disabled}
{...props}
/>
<div className="flex justify-end items-center gap-4 px-4 py-2">
<label htmlFor="file-upload" className="cursor-pointer">
<Paperclip className="w-4 h-4 text-muted-foreground hover:text-foreground" />
</label>
<input
ref={fileInputRef}
id="file-upload"
type="file"
multiple
onChange={(e) => {
handleFileChange(
(e.target.files && Array.from(e.target.files)) || [],
);
}}
className="hidden"
/>
<Button
disabled={(!input && files.length === 0) || disabled}
type="submit"
size="icon"
variant="default"
>
<ArrowUpIcon className="w-4 h-4" />
</Button>
</div>
</div>
</form>
</div>
);
},
);
ChatInput.displayName = 'ChatInput';
export { ChatInput };

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { ChatUIResponse } from '@activepieces/shared';
interface ChatIntroProps {
chatUI: ChatUIResponse | null | undefined;
botName: string;
}
export function ChatIntro({ chatUI, botName }: ChatIntroProps) {
return (
<div className="flex items-center justify-center py-8 px-4 font-bold">
<div className="flex flex-col items-center gap-1">
<div className="flex items-center justify-center p-3 rounded-full">
<img
src={chatUI?.platformLogoUrl}
alt="Bot Avatar"
className="w-10 h-10"
/>
</div>
<div className="flex items-center gap-1 justify-center">
<p className="animate-typing overflow-hidden whitespace-nowrap pr-1 hidden lg:block lg:text-xl text-foreground leading-8">
Hi! I&apos;m {botName} 👋 How can I help you today?
</p>
<p className="animate-typing-sm overflow-hidden whitespace-nowrap pr-1 lg:hidden text-xl text-foreground leading-8">
Hi! I&apos;m {botName} 👋
</p>
<span className="w-4 h-4 rounded-full bg-foreground animate-[fade_0.15s_ease-out_forwards_0.7s_reverse]" />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,91 @@
import { BotIcon, CircleX, RotateCcw } from 'lucide-react';
import React from 'react';
import { ApErrorParams, ChatUIResponse, ErrorCode } from '@activepieces/shared';
import {
ChatBubble,
ChatBubbleAction,
ChatBubbleAvatar,
ChatBubbleMessage,
} from '../chat-bubble';
const formatError = (
projectId: string | undefined | null,
flowId: string,
error: ApErrorParams,
) => {
switch (error.code) {
case ErrorCode.NO_CHAT_RESPONSE:
return projectId ? (
<span>
No response from the chatbot. Ensure that{' '}
<strong>Respond on UI</strong> is in{' '}
<a
href={`/projects/${projectId}/flows/${flowId}`}
className="text-primary underline"
target="_blank"
rel="noreferrer"
>
your flow
</a>
.
</span>
) : (
<span>
The chatbot is not responding. It seems there might be an issue with
how this chat was set up. Please contact the person who shared this
chat link with you for assistance.
</span>
);
case ErrorCode.FLOW_NOT_FOUND:
return (
<span>The chat flow you are trying to access no longer exists.</span>
);
case ErrorCode.VALIDATION:
return <span>{`Validation error: ${error.params.message}`}</span>;
default:
return <span>Something went wrong. Please try again.</span>;
}
};
interface ErrorBubbleProps {
chatUI: ChatUIResponse | null | undefined;
flowId: string;
sendingError: ApErrorParams;
sendMessage: (arg0: { isRetrying: boolean; message?: any }) => void;
}
export const ErrorBubble = ({
chatUI,
flowId,
sendingError,
sendMessage,
}: ErrorBubbleProps) => (
<ChatBubble variant="received" className="pb-8">
<div className="relative">
<ChatBubbleAvatar
src={chatUI?.platformLogoUrl}
fallback={<BotIcon className="size-5" />}
/>
<div className="absolute -bottom-[2px] -right-[2px]">
<CircleX className="size-4 text-destructive" strokeWidth={3} />
</div>
</div>
<ChatBubbleMessage className="text-destructive">
{formatError(chatUI?.projectId, flowId, sendingError)}
</ChatBubbleMessage>
<div className="flex gap-1">
<ChatBubbleAction
variant="outline"
className="size-5 mt-2"
icon={<RotateCcw className="size-3" />}
onClick={() => {
sendMessage({ isRetrying: true });
}}
/>
</div>
</ChatBubble>
);
ErrorBubble.displayName = 'ErrorBubble';

View File

@@ -0,0 +1,147 @@
import { Static, Type } from '@sinclair/typebox';
import { BotIcon } from 'lucide-react';
import React from 'react';
import { cn } from '@/lib/utils';
import {
ApErrorParams,
ChatUIResponse,
FileResponseInterface,
isNil,
} from '@activepieces/shared';
import {
ChatBubble,
ChatBubbleAvatar,
ChatBubbleMessage,
} from '../chat-bubble';
import { ChatMessage } from '../chat-input';
import { MultiMediaMessage } from '../chat-message';
import { ErrorBubble } from './error-bubble';
export const Messages = Type.Array(
Type.Object({
role: Type.Union([Type.Literal('user'), Type.Literal('bot')]),
textContent: Type.Optional(Type.String()),
files: Type.Optional(Type.Array(FileResponseInterface)),
}),
);
export type Messages = Static<typeof Messages>;
interface ChatMessageListProps extends React.HTMLAttributes<HTMLDivElement> {
messagesRef?: React.RefObject<HTMLDivElement>;
messages?: Messages;
chatUI?: ChatUIResponse | null | undefined;
sendingError?: ApErrorParams | null;
isSending?: boolean;
flowId?: string;
sendMessage?: (arg0: { isRetrying: boolean; message: ChatMessage }) => void;
setSelectedImage?: (image: string | null) => void;
}
const ChatMessageList = React.forwardRef<HTMLDivElement, ChatMessageListProps>(
(
{
className,
children,
messagesRef,
messages,
chatUI,
sendingError,
isSending,
flowId,
sendMessage,
setSelectedImage,
...props
},
ref,
) => {
if (messages && messages.length > 0) {
return (
<div className="h-full w-full max-w-3xl flex items-center justify-center overflow-y-auto">
<div
className={cn('flex flex-col w-full h-full p-4 gap-2', className)}
ref={messagesRef || ref}
{...props}
>
{messages.map((message, index) => {
const isLastMessage = index === messages.length - 1;
return (
<ChatBubble
id={isLastMessage ? 'last-message' : undefined}
key={index}
variant={message.role === 'user' ? 'sent' : 'received'}
className={cn(
'flex items-start',
isLastMessage ? 'pb-8' : '',
)}
>
{message.role === 'bot' && (
<ChatBubbleAvatar
src={chatUI?.platformLogoUrl}
fallback={<BotIcon className="size-5" />}
/>
)}
<ChatBubbleMessage
className={cn(
'flex flex-col gap-2',
message.role === 'bot' ? 'w-full' : '',
)}
>
<MultiMediaMessage
textContent={message.textContent}
attachments={message.files}
role={message.role}
setSelectedImage={setSelectedImage || (() => {})}
/>
</ChatBubbleMessage>
</ChatBubble>
);
})}
{sendingError && !isSending && flowId && sendMessage && (
<ErrorBubble
chatUI={chatUI}
flowId={flowId}
sendingError={sendingError}
sendMessage={(arg0) => {
if (!isNil(arg0.message)) {
sendMessage({
isRetrying: false,
message: arg0.message!,
});
}
}}
/>
)}
{isSending && (
<ChatBubble variant="received" className="pb-8">
<ChatBubbleAvatar
src={chatUI?.platformLogoUrl}
fallback={<BotIcon className="size-5" />}
/>
<ChatBubbleMessage isLoading />
</ChatBubble>
)}
</div>
</div>
);
}
return (
<div className="h-full w-full flex items-center justify-center overflow-y-auto">
<div
className={cn('flex flex-col w-full h-full p-4 gap-2', className)}
ref={ref}
{...props}
>
{children}
</div>
</div>
);
},
);
ChatMessageList.displayName = 'ChatMessageList';
export { ChatMessageList };

View File

@@ -0,0 +1,47 @@
import { FileIcon, VideoIcon } from 'lucide-react';
import React from 'react';
interface FileMessageProps {
content: string;
mimeType?: string;
fileName?: string;
role?: 'user' | 'bot';
}
export const FileMessage: React.FC<FileMessageProps> = ({
content,
mimeType,
fileName,
role,
}) => {
const isVideo = mimeType?.startsWith('video/');
return (
<a
className="p-2 w-80 rounded-lg border px-2 max-w-full hover:bg-muted transition-colors cursor-pointer"
href={content}
download={fileName ?? 'file'}
>
<div className="flex flex-row items-center gap-2">
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-md">
<div className="h-full w-full flex items-center justify-center bg-foreground text-background">
{isVideo ? (
<VideoIcon className="h-5 w-5" />
) : (
<FileIcon className="h-5 w-5" />
)}
</div>
</div>
<div className="overflow-hidden flex flex-col gap-1">
<div className="truncate font-semibold text-sm leading-none">
{fileName ?? (role === 'user' ? 'Untitled File' : 'Download File')}
</div>
{fileName && (
<div className="truncate text-sm text-token-text-tertiary leading-none">
{role === 'user' ? 'View File' : 'Download File'}
</div>
)}
</div>
</div>
</a>
);
};

View File

@@ -0,0 +1,68 @@
import { Download, X } from 'lucide-react';
import React, { useEffect } from 'react';
import { Button } from '@/components/ui/button';
interface ImageDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
imageUrl: string | null;
}
export const ImageDialog: React.FC<ImageDialogProps> = ({
open,
onOpenChange,
imageUrl,
}) => {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onOpenChange(false);
};
document.addEventListener('keydown', handler);
return () => {
document.removeEventListener('keydown', handler);
};
}, []);
return open ? (
<div
className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center transition-colors duration-300"
onKeyDown={(e) => {
if (e.key === 'Escape') onOpenChange(false);
}}
>
<div className="bg-transparent border-none shadow-none flex items-center justify-center px-4">
<div className="relative">
<img
src={imageUrl || ''}
alt="Full size image"
className="h-auto object-contain max-h-[90vh] sm:max-w-[90vw] shadow-xs rounded-md"
/>
</div>
<div className="flex gap-2 absolute top-2 right-2">
<Button
size="icon"
variant="accent"
onClick={() => {
const link = document.createElement('a');
link.href = imageUrl || '';
link.download = 'image';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}}
>
<Download className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="accent"
onClick={() => onOpenChange(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</div>
) : null;
};

View File

@@ -0,0 +1,41 @@
import { Download } from 'lucide-react';
import React from 'react';
import ImageWithFallback from '@/components/ui/image-with-fallback';
interface ImageMessageProps {
content: string;
setSelectedImage: (image: string | null) => void;
}
export const ImageMessage: React.FC<ImageMessageProps> = ({
content,
setSelectedImage,
}) => {
return (
<div className="w-fit">
<div className="relative group">
<ImageWithFallback
src={content}
alt="Received image"
className="w-80 h-auto rounded-md cursor-pointer"
onClick={() => setSelectedImage(content)}
/>
<button
onClick={(e) => {
e.stopPropagation();
const link = document.createElement('a');
link.href = content;
link.download = 'image';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}}
className="absolute top-2 right-2 bg-black bg-opacity-50 rounded-full p-1 hover:bg-opacity-75 transition-opacity opacity-0 group-hover:opacity-100"
>
<Download className="h-4 w-4 text-white" />
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { FileResponseInterface } from '@activepieces/shared';
import { FileMessage } from './file-message';
import { ImageMessage } from './image-message';
import { TextMessage } from './text-message';
interface MultiMediaMessageProps {
textContent?: string;
role: 'user' | 'bot';
attachments?: FileResponseInterface[];
setSelectedImage: (image: string | null) => void;
}
export const MultiMediaMessage: React.FC<MultiMediaMessageProps> = ({
textContent,
role,
attachments,
setSelectedImage,
}) => {
return (
<div className="flex flex-col gap-2">
{/* Text content */}
{textContent && <TextMessage content={textContent} role={role} />}
{/* Attachments */}
{attachments && attachments.length > 0 && (
<div className="flex flex-col gap-2 mt-2">
{attachments.map((attachment, index) => {
if ('url' in attachment && 'mimeType' in attachment) {
const isImage = attachment.mimeType?.startsWith('image/');
return isImage ? (
<ImageMessage
key={index}
content={attachment.url}
setSelectedImage={setSelectedImage}
/>
) : (
<FileMessage
key={index}
content={attachment.url}
mimeType={attachment.mimeType}
fileName={attachment.fileName}
role={role}
/>
);
}
})}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,150 @@
import { javascript } from '@codemirror/lang-javascript';
import { githubDark, githubLight } from '@uiw/codemirror-theme-github';
import ReactCodeMirror, {
EditorState,
EditorView,
} from '@uiw/react-codemirror';
import { CodeIcon, Copy } from 'lucide-react';
import React from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { CopyButton } from '@/components/custom/clipboard/copy-button';
import { useTheme } from '@/components/theme-provider';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { FileResponseInterface } from '@activepieces/shared';
interface TextMessageProps {
content: string;
role: 'user' | 'bot';
attachments?: FileResponseInterface[];
}
export const TextMessage: React.FC<TextMessageProps> = React.memo(
({ content, role }) => {
const { theme } = useTheme();
const extensions = [
theme === 'dark' ? githubDark : githubLight,
EditorState.readOnly.of(true),
EditorView.editable.of(false),
javascript({ jsx: false, typescript: true }),
];
return (
<>
<Markdown
remarkPlugins={[remarkGfm]}
className="bg-inherit"
components={{
code({ node, inline, className, children, ...props }: any) {
if (role === 'user') {
return <div className="font-mono text-sm">{children}</div>;
}
const match = /language-(\w+)/.exec(className || '');
return !inline && match && match[1] ? (
<div
className={cn(
'relative border rounded-md p-4 pt-12',
theme === 'dark' ? 'bg-[#0E1117]' : 'bg-background',
)}
>
<ReactCodeMirror
value={String(children).trim()}
className="border-none"
width="100%"
minWidth="100%"
maxWidth="100%"
minHeight="50px"
basicSetup={{
syntaxHighlighting: true,
foldGutter: false,
lineNumbers: false,
searchKeymap: true,
lintKeymap: true,
autocompletion: false,
highlightActiveLine: false,
highlightActiveLineGutter: false,
highlightSpecialChars: false,
indentOnInput: false,
bracketMatching: false,
closeBrackets: false,
}}
lang={match[1]}
theme={theme === 'dark' ? githubDark : githubLight}
readOnly={true}
extensions={extensions}
/>
<div className="absolute top-4 left-5 text-xs text-gray-500">
<div className="flex items-center gap-1">
<CodeIcon className="size-3" />
<span>{match[1]}</span>
</div>
</div>
<CopyCode
textToCopy={String(children).trim()}
className="absolute top-2 right-2 text-xs text-gray-500"
/>
</div>
) : (
<code
className={cn(
className,
'bg-gray-200 px-[6px] py-[2px] rounded-xs font-mono text-sm',
)}
{...props}
>
{String(children).trim()}
</code>
);
},
}}
>
{content}
</Markdown>
{role === 'bot' && (
<CopyButton
textToCopy={content}
tooltipSide="bottom"
className="size-6 p-1 mt-2"
/>
)}
</>
);
},
(prevProps, nextProps) => {
return (
prevProps.content === nextProps.content &&
prevProps.role === nextProps.role
);
},
);
TextMessage.displayName = 'TextMessage';
const CopyCode = ({
textToCopy,
className,
}: {
textToCopy: string;
className?: string;
}) => {
const [isCopied, setIsCopied] = React.useState(false);
return (
<div className={className}>
<Button
variant="ghost"
className="gap-2"
size="xs"
onClick={() => {
setIsCopied(true);
navigator.clipboard.writeText(textToCopy);
setTimeout(() => setIsCopied(false), 1500);
}}
>
<Copy className="size-4" />
<span className="text-xs">{isCopied ? 'Copied!' : 'Copy Code'}</span>
</Button>
</div>
);
};

View File

@@ -0,0 +1,47 @@
import { t } from 'i18next';
import { Control } from 'react-hook-form';
import { projectHooks } from '@/hooks/project-hooks';
import { isNil } from '@activepieces/shared';
import { MultiSelectPieceProperty } from '../../../components/custom/multi-select-piece-property';
import { FormField, FormItem, FormMessage } from '../../../components/ui/form';
import { Label } from '../../../components/ui/label';
export const AssignConnectionToProjectsControl = ({
control,
name,
}: {
control: Control<any>;
name: string;
}) => {
const { data: projects } = projectHooks.useProjects();
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem className="flex flex-col gap-2">
<Label>{t('Available for Projects')}</Label>
<MultiSelectPieceProperty
placeholder={t('Select projects')}
options={
projects?.map((project) => ({
value: project.id,
label: project.displayName,
})) ?? []
}
loading={!projects}
onChange={(value) => {
field.onChange(isNil(value) ? [] : value);
}}
initialValues={field.value}
showDeselect={field.value.length > 0}
/>
<FormMessage className="mt-4!" />
</FormItem>
)}
/>
);
};

View File

@@ -0,0 +1,165 @@
import { typeboxResolver } from '@hookform/resolvers/typebox';
import { DialogTrigger } from '@radix-ui/react-dialog';
import { Static, Type } from '@sinclair/typebox';
import { t } from 'i18next';
import { Pencil } from 'lucide-react';
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { globalConnectionsMutations } from '../lib/global-connections-hooks';
import { AssignConnectionToProjectsControl } from './assign-global-connection-to-projects';
const EditGlobalConnectionSchema = Type.Object({
displayName: Type.String(),
projectIds: Type.Array(Type.String()),
});
type EditGlobalConnectionSchema = Static<typeof EditGlobalConnectionSchema>;
type EditGlobalConnectionDialogProps = {
connectionId: string;
currentName: string;
projectIds: string[];
onEdit: () => void;
userHasPermissionToEdit: boolean;
};
const EditGlobalConnectionDialog: React.FC<EditGlobalConnectionDialogProps> = ({
connectionId,
currentName,
projectIds,
onEdit,
userHasPermissionToEdit,
}) => {
const [isOpen, setIsOpen] = useState(false);
const editConnectionForm = useForm<EditGlobalConnectionSchema>({
resolver: typeboxResolver(EditGlobalConnectionSchema),
defaultValues: {
displayName: currentName,
projectIds: projectIds,
},
});
const {
mutate: updateGlobalConnection,
isPending: isUpdatingGlobalConnection,
} = globalConnectionsMutations.useUpdateGlobalConnection(
onEdit,
setIsOpen,
editConnectionForm,
);
return (
<Tooltip>
<Dialog open={isOpen} onOpenChange={(open) => setIsOpen(open)}>
<DialogTrigger asChild>
<>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={!userHasPermissionToEdit}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setIsOpen(true);
}}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{!userHasPermissionToEdit ? t('Permission needed') : t('Edit')}
</TooltipContent>
</>
</DialogTrigger>
<DialogContent onInteractOutside={(event) => event.preventDefault()}>
<DialogHeader>
<DialogTitle>{t('Edit Global Connection')}</DialogTitle>
</DialogHeader>
<Form {...editConnectionForm}>
<form
onSubmit={editConnectionForm.handleSubmit((data) =>
updateGlobalConnection({
connectionId,
displayName: data.displayName,
projectIds: data.projectIds,
currentName: currentName,
}),
)}
>
<div className="grid space-y-4">
<FormField
control={editConnectionForm.control}
name="displayName"
render={({ field }) => (
<FormItem className="grid space-y-2">
<Label htmlFor="displayName">{t('Name')}</Label>
<Input
{...field}
id="displayName"
placeholder={t('Connection Name')}
className="rounded-sm"
/>
<FormMessage />
</FormItem>
)}
/>
<AssignConnectionToProjectsControl
control={editConnectionForm.control}
name="projectIds"
/>
{editConnectionForm?.formState?.errors?.root?.serverError && (
<FormMessage>
{
editConnectionForm.formState.errors.root.serverError
.message
}
</FormMessage>
)}
</div>
<DialogFooter className="mt-8">
<Button
type="button"
variant="outline"
disabled={isUpdatingGlobalConnection}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setIsOpen(false);
}}
>
{t('Cancel')}
</Button>
<Button loading={isUpdatingGlobalConnection}>
{t('Save')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</Tooltip>
);
};
export { EditGlobalConnectionDialog };

View File

@@ -0,0 +1,145 @@
import { typeboxResolver } from '@hookform/resolvers/typebox';
import { DialogClose, DialogTrigger } from '@radix-ui/react-dialog';
import { Static, Type } from '@sinclair/typebox';
import { t } from 'i18next';
import { Pencil } from 'lucide-react';
import { useState, forwardRef } from 'react';
import { useForm } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { appConnectionsMutations } from '../lib/app-connections-hooks';
const RenameConnectionSchema = Type.Object({
displayName: Type.String(),
});
type RenameConnectionSchema = Static<typeof RenameConnectionSchema>;
type RenameConnectionDialogProps = {
connectionId: string;
currentName: string;
userHasPermissionToRename: boolean;
onRename: () => void;
};
const RenameConnectionDialog = forwardRef<
HTMLDivElement,
RenameConnectionDialogProps
>(({ connectionId, currentName, userHasPermissionToRename, onRename }, _) => {
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const renameConnectionForm = useForm<RenameConnectionSchema>({
resolver: typeboxResolver(RenameConnectionSchema),
defaultValues: {
displayName: currentName,
},
});
const { mutate: renameConnection, isPending } =
appConnectionsMutations.useRenameAppConnection({
currentName,
setIsRenameDialogOpen,
renameConnectionForm,
refetch: onRename,
});
return (
<Tooltip>
<Dialog
open={isRenameDialogOpen}
onOpenChange={(open) => setIsRenameDialogOpen(open)}
>
<DialogTrigger asChild>
<>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={!userHasPermissionToRename}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setIsRenameDialogOpen(true);
}}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{!userHasPermissionToRename ? t('Permission needed') : t('Edit')}
</TooltipContent>
</>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t('Rename')} {currentName}
</DialogTitle>
</DialogHeader>
<Form {...renameConnectionForm}>
<form
className="grid space-y-4"
onSubmit={renameConnectionForm.handleSubmit((data) =>
renameConnection({
connectionId,
displayName: data.displayName,
}),
)}
>
<FormField
control={renameConnectionForm.control}
name="displayName"
render={({ field }) => (
<FormItem className="grid space-y-2">
<Label htmlFor="displayName">{t('Name')}</Label>
<Input
{...field}
id="displayName"
placeholder={t('New Connection Name')}
className="rounded-sm"
/>
<FormMessage />
</FormItem>
)}
/>
{renameConnectionForm?.formState?.errors?.root?.serverError && (
<FormMessage>
{
renameConnectionForm.formState.errors.root.serverError
.message
}
</FormMessage>
)}
<DialogFooter className="justify-end">
<DialogClose asChild>
<Button variant={'outline'}>{t('Cancel')}</Button>
</DialogClose>
<Button loading={isPending}>{t('Confirm')}</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</Tooltip>
);
});
RenameConnectionDialog.displayName = 'RenameConnectionDialog';
export { RenameConnectionDialog };

View File

@@ -0,0 +1,53 @@
import { api } from '@/lib/api';
import {
AppConnectionOwners,
AppConnectionWithoutSensitiveData,
ListAppConnectionOwnersRequestQuery,
ListAppConnectionsRequestQuery,
ReplaceAppConnectionsRequestBody,
SeekPage,
UpdateConnectionValueRequestBody,
UpsertAppConnectionRequestBody,
} from '@activepieces/shared';
export const appConnectionsApi = {
list(
request: ListAppConnectionsRequestQuery,
): Promise<SeekPage<AppConnectionWithoutSensitiveData>> {
return api.get<SeekPage<AppConnectionWithoutSensitiveData>>(
'/v1/app-connections',
request,
);
},
upsert(
request: UpsertAppConnectionRequestBody,
): Promise<AppConnectionWithoutSensitiveData> {
return api.post<AppConnectionWithoutSensitiveData>(
'/v1/app-connections',
request,
);
},
delete(id: string): Promise<void> {
return api.delete<void>(`/v1/app-connections/${id}`);
},
update(
id: string,
request: UpdateConnectionValueRequestBody,
): Promise<AppConnectionWithoutSensitiveData> {
return api.post<AppConnectionWithoutSensitiveData>(
`/v1/app-connections/${id}`,
request,
);
},
replace(request: ReplaceAppConnectionsRequestBody): Promise<void> {
return api.post<void>(`/v1/app-connections/replace`, request);
},
getOwners(
request: ListAppConnectionOwnersRequestQuery,
): Promise<SeekPage<AppConnectionOwners>> {
return api.get<SeekPage<AppConnectionOwners>>(
'/v1/app-connections/owners',
request,
);
},
};

View File

@@ -0,0 +1,39 @@
import { api } from '@/lib/api';
import {
AppConnectionWithoutSensitiveData,
ListGlobalConnectionsRequestQuery,
SeekPage,
UpdateGlobalConnectionValueRequestBody,
UpsertGlobalConnectionRequestBody,
} from '@activepieces/shared';
export const globalConnectionsApi = {
list(
request: ListGlobalConnectionsRequestQuery,
): Promise<SeekPage<AppConnectionWithoutSensitiveData>> {
return api.get<SeekPage<AppConnectionWithoutSensitiveData>>(
'/v1/global-connections',
request,
);
},
upsert(
request: UpsertGlobalConnectionRequestBody,
): Promise<AppConnectionWithoutSensitiveData> {
return api.post<AppConnectionWithoutSensitiveData>(
'/v1/global-connections',
request,
);
},
delete(id: string): Promise<void> {
return api.delete<void>(`/v1/global-connections/${id}`);
},
update(
id: string,
request: UpdateGlobalConnectionValueRequestBody,
): Promise<AppConnectionWithoutSensitiveData> {
return api.post<AppConnectionWithoutSensitiveData>(
`/v1/global-connections/${id}`,
request,
);
},
};

View File

@@ -0,0 +1,29 @@
import { api } from '@/lib/api';
import {
ListOAuth2AppRequest,
OAuthApp,
UpsertOAuth2AppRequest,
} from '@activepieces/ee-shared';
import { ApEdition, SeekPage } from '@activepieces/shared';
export const oauthAppsApi = {
listCloudOAuth2Apps(
edition: ApEdition,
): Promise<Record<string, { clientId: string }>> {
return api.get<Record<string, { clientId: string }>>(
'https://secrets.activepieces.com/apps',
{
edition,
},
);
},
listPlatformOAuth2Apps(request: ListOAuth2AppRequest) {
return api.get<SeekPage<OAuthApp>>('/v1/oauth-apps', request);
},
delete(credentialId: string) {
return api.delete<void>(`/v1/oauth-apps/${credentialId}`);
},
upsert(request: UpsertOAuth2AppRequest) {
return api.post<OAuthApp>('/v1/oauth-apps', request);
},
};

View File

@@ -0,0 +1,309 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { t } from 'i18next';
import { UseFormReturn } from 'react-hook-form';
import { toast } from 'sonner';
import { useEmbedding } from '@/components/embed-provider';
import { internalErrorToast } from '@/components/ui/sonner';
import { projectMembersApi } from '@/features/members/lib/project-members-api';
import { api } from '@/lib/api';
import { authenticationSession } from '@/lib/authentication-session';
import {
getAuthPropertyForValue,
PieceAuthProperty,
} from '@activepieces/pieces-framework';
import {
ApErrorParams,
AppConnectionScope,
AppConnectionWithoutSensitiveData,
ErrorCode,
isNil,
ListAppConnectionsRequestQuery,
ReplaceAppConnectionsRequestBody,
UpsertAppConnectionRequestBody,
} from '@activepieces/shared';
import { appConnectionsApi } from './api/app-connections';
import { globalConnectionsApi } from './api/global-connections';
import {
ConnectionNameAlreadyExists,
NoProjectSelected,
isConnectionNameUnique,
} from './utils';
type UseReplaceConnectionsProps = {
setDialogOpen: (isOpen: boolean) => void;
refetch: () => void;
};
type UseRenameAppConnectionProps = {
currentName: string;
setIsRenameDialogOpen: (isOpen: boolean) => void;
renameConnectionForm: UseFormReturn<{
displayName: string;
}>;
refetch: () => void;
};
type UseUpsertAppConnectionProps = {
isGlobalConnection: boolean;
reconnectConnection: AppConnectionWithoutSensitiveData | null;
externalIdComingFromSdk?: string | null;
setErrorMessage: (message: string) => void;
form: UseFormReturn<{
request: UpsertAppConnectionRequestBody & {
projectIds: string[];
};
}>;
setOpen: (
open: boolean,
connection?: AppConnectionWithoutSensitiveData,
) => void;
};
export const appConnectionsMutations = {
useUpsertAppConnection: ({
isGlobalConnection,
reconnectConnection,
externalIdComingFromSdk,
setErrorMessage,
form,
setOpen,
}: UseUpsertAppConnectionProps) => {
return useMutation({
mutationFn: async () => {
setErrorMessage('');
const formValues = form.getValues().request;
const isNameUnique = await isConnectionNameUnique(
isGlobalConnection,
formValues.displayName,
);
if (
!isNameUnique &&
reconnectConnection?.displayName !== formValues.displayName &&
(isNil(externalIdComingFromSdk) || externalIdComingFromSdk === '')
) {
throw new ConnectionNameAlreadyExists();
}
if (isGlobalConnection) {
if (formValues.projectIds.length === 0) {
throw new NoProjectSelected();
}
return globalConnectionsApi.upsert({
...formValues,
projectIds: formValues.projectIds,
scope: AppConnectionScope.PLATFORM,
});
}
return appConnectionsApi.upsert(formValues);
},
onSuccess: (connection) => {
setOpen(false, connection);
setErrorMessage('');
},
onError: (err) => {
if (err instanceof ConnectionNameAlreadyExists) {
form.setError('request.displayName', {
message: err.message,
});
} else if (err instanceof NoProjectSelected) {
form.setError('request.projectIds', {
message: err.message,
});
} else if (api.isError(err)) {
const apError = err.response?.data as ApErrorParams;
switch (apError.code) {
case ErrorCode.INVALID_CLOUD_CLAIM: {
setErrorMessage(
t(
'Could not claim the authorization code, make sure you have correct settings and try again.',
),
);
break;
}
case ErrorCode.INVALID_CLAIM: {
setErrorMessage(
t('Connection failed with error {msg}', {
msg: apError.params.message,
}),
);
break;
}
case ErrorCode.INVALID_APP_CONNECTION: {
setErrorMessage(
t('Connection failed with error {msg}', {
msg: apError.params.error,
}),
);
break;
}
// can happen in embedding sdk connect method
case ErrorCode.PERMISSION_DENIED: {
setErrorMessage(
t(`You don't have the permission to create a connection.`),
);
break;
}
default: {
setErrorMessage('Unexpected error, please contact support');
internalErrorToast();
console.error(err);
}
}
}
},
});
},
useBulkDeleteAppConnections: (refetch: () => void) => {
return useMutation({
mutationFn: async (ids: string[]) => {
await Promise.all(ids.map((id) => appConnectionsApi.delete(id)));
},
onSuccess: () => {
refetch();
},
onError: () => {
internalErrorToast();
},
});
},
useRenameAppConnection: ({
currentName,
setIsRenameDialogOpen,
renameConnectionForm,
refetch,
}: UseRenameAppConnectionProps) => {
return useMutation({
mutationFn: async ({
connectionId,
displayName,
}: {
connectionId: string;
displayName: string;
}) => {
const existingConnection = await isConnectionNameUnique(
false,
displayName,
);
if (!existingConnection && displayName !== currentName) {
throw new ConnectionNameAlreadyExists();
}
return appConnectionsApi.update(connectionId, { displayName });
},
onSuccess: () => {
refetch();
toast.success(t('Success'), {
description: t('Connection has been renamed.'),
duration: 3000,
});
setIsRenameDialogOpen(false);
},
onError: (error) => {
if (error instanceof ConnectionNameAlreadyExists) {
renameConnectionForm.setError('displayName', {
message: error.message,
});
} else {
internalErrorToast();
}
},
});
},
useReplaceConnections: ({
setDialogOpen,
refetch,
}: UseReplaceConnectionsProps) => {
return useMutation({
mutationFn: async (request: ReplaceAppConnectionsRequestBody) => {
await appConnectionsApi.replace(request);
},
onSuccess: () => {
toast.success(t('Success'), {
description: t('Connections replaced successfully'),
});
setDialogOpen(false);
refetch();
},
onError: () => {
toast.error(t('Error'), {
description: t('Failed to replace connections'),
});
},
});
},
};
type UseConnectionsProps = {
request: ListAppConnectionsRequestQuery;
extraKeys: any[];
enabled?: boolean;
staleTime?: number;
pieceAuth?: PieceAuthProperty | PieceAuthProperty[] | undefined;
};
export const appConnectionsQueries = {
useAppConnections: ({
request,
extraKeys,
enabled,
staleTime,
pieceAuth,
}: UseConnectionsProps) => {
return useQuery({
queryKey: ['app-connections', ...extraKeys],
queryFn: async () => {
const connections = await appConnectionsApi.list(request);
if (pieceAuth) {
return {
...connections,
data: connections.data.filter(
(connection) =>
!isNil(
getAuthPropertyForValue({
authValueType: connection.type,
pieceAuth,
}),
),
),
};
}
return connections;
},
enabled,
staleTime,
});
},
useConnectionsOwners: () => {
const projectId = authenticationSession.getProjectId() ?? '';
const isEmbedding = useEmbedding().embedState.isEmbedded;
return useQuery({
queryKey: ['app-connections-owners', projectId],
queryFn: async () => {
const { data: owners } = await appConnectionsApi.getOwners({
projectId,
});
const { data: projectMembers } = await projectMembersApi.list({
projectId,
});
if (isEmbedding) {
return owners.filter(
(owner) =>
!isNil(
projectMembers.find(
(member) => member.user.email === owner.email,
),
),
);
}
return owners;
},
});
},
};

View File

@@ -0,0 +1,115 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { t } from 'i18next';
import { UseFormReturn } from 'react-hook-form';
import { toast } from 'sonner';
import { internalErrorToast } from '@/components/ui/sonner';
import {
AppConnectionWithoutSensitiveData,
ListGlobalConnectionsRequestQuery,
} from '@activepieces/shared';
import { globalConnectionsApi } from './api/global-connections';
import {
NoProjectSelected,
ConnectionNameAlreadyExists,
isConnectionNameUnique,
} from './utils';
type UseGlobalConnectionsProps = {
request: ListGlobalConnectionsRequestQuery;
extraKeys: any[];
staleTime?: number;
gcTime?: number;
};
export const globalConnectionsQueries = {
useGlobalConnections: ({
request,
extraKeys,
staleTime,
gcTime,
}: UseGlobalConnectionsProps) =>
useQuery({
queryKey: ['globalConnections', ...extraKeys],
staleTime,
gcTime,
queryFn: () => {
return globalConnectionsApi.list(request);
},
}),
};
export const globalConnectionsMutations = {
useBulkDeleteGlobalConnections: (refetch: () => void) =>
useMutation({
mutationFn: async (ids: string[]) => {
await Promise.all(ids.map((id) => globalConnectionsApi.delete(id)));
},
onSuccess: () => {
refetch();
},
onError: () => {
internalErrorToast();
},
}),
useUpdateGlobalConnection: (
refetch: () => void,
setIsOpen: (isOpen: boolean) => void,
editConnectionForm: UseFormReturn<{
displayName: string;
projectIds: string[];
}>,
) =>
useMutation<
AppConnectionWithoutSensitiveData,
Error,
{
connectionId: string;
displayName: string;
projectIds: string[];
currentName: string;
}
>({
mutationFn: async ({
connectionId,
displayName,
projectIds,
currentName,
}) => {
if (
!(await isConnectionNameUnique(true, displayName)) &&
displayName !== currentName
) {
throw new ConnectionNameAlreadyExists();
}
if (projectIds.length === 0) {
throw new NoProjectSelected();
}
return globalConnectionsApi.update(connectionId, {
displayName,
projectIds,
});
},
onSuccess: () => {
refetch();
toast.success(t('Connection has been updated.'), {
duration: 3000,
});
setIsOpen(false);
},
onError: (error) => {
if (error instanceof ConnectionNameAlreadyExists) {
editConnectionForm.setError('displayName', {
message: error.message,
});
} else if (error instanceof NoProjectSelected) {
editConnectionForm.setError('projectIds', {
message: error.message,
});
} else {
internalErrorToast();
}
},
}),
};

View File

@@ -0,0 +1,125 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { t } from 'i18next';
import { toast } from 'sonner';
import { flagsHooks } from '@/hooks/flags-hooks';
import { platformHooks } from '@/hooks/platform-hooks';
import { PiecesOAuth2AppsMap } from '@/lib/oauth2-utils';
import { UpsertOAuth2AppRequest } from '@activepieces/ee-shared';
import { ApEdition, ApFlagId, AppConnectionType } from '@activepieces/shared';
import { oauthAppsApi } from './api/oauth-apps';
export const oauthAppsMutations = {
useDeleteOAuthApp: (refetch: () => void, setOpen: (open: boolean) => void) =>
useMutation({
mutationFn: async (credentialId: string) => {
await oauthAppsApi.delete(credentialId);
refetch();
},
onSuccess: () => {
toast.success(t('OAuth2 Credentials Deleted'), {
duration: 3000,
});
setOpen(false);
},
}),
useUpsertOAuthApp: (
refetch: () => void,
setOpen: (open: boolean) => void,
onConfigurationDone: () => void,
) =>
useMutation({
mutationFn: async (request: UpsertOAuth2AppRequest) => {
await oauthAppsApi.upsert(request);
refetch();
},
onSuccess: () => {
toast.success(t('OAuth2 Credentials Updated'), {
duration: 3000,
});
onConfigurationDone();
setOpen(false);
},
}),
};
export const oauthAppsQueries = {
useOAuthAppConfigured(pieceId: string) {
const query = useQuery({
queryKey: ['oauth2-apps-configured'],
queryFn: async () => {
const response = await oauthAppsApi.listPlatformOAuth2Apps({
limit: 1000000,
});
return response.data;
},
select: (data) => {
return data.find((app) => app.pieceName === pieceId);
},
staleTime: Infinity,
});
return {
refetch: query.refetch,
oauth2App: query.data,
};
},
usePiecesOAuth2AppsMap() {
const { platform } = platformHooks.useCurrentPlatform();
const { data: edition } = flagsHooks.useFlag<ApEdition>(ApFlagId.EDITION);
return useQuery<PiecesOAuth2AppsMap, Error>({
queryKey: ['oauth-apps'],
queryFn: async () => {
const apps =
edition === ApEdition.COMMUNITY
? {
data: [],
}
: await oauthAppsApi.listPlatformOAuth2Apps({
limit: 1000000,
cursor: undefined,
});
const cloudApps = !platform.cloudAuthEnabled
? {}
: await oauthAppsApi.listCloudOAuth2Apps(edition!);
const appsMap: PiecesOAuth2AppsMap = {};
Object.entries(cloudApps).forEach(([pieceName, app]) => {
appsMap[pieceName] = {
cloudOAuth2App: {
oauth2Type: AppConnectionType.CLOUD_OAUTH2,
clientId: app.clientId,
},
platformOAuth2App: null,
};
});
apps.data.forEach((app) => {
appsMap[app.pieceName] = {
platformOAuth2App: {
oauth2Type: AppConnectionType.PLATFORM_OAUTH2,
clientId: app.clientId,
},
cloudOAuth2App: appsMap[app.pieceName]?.cloudOAuth2App ?? null,
};
});
return appsMap;
},
staleTime: 0,
});
},
};
export type PieceToClientIdMap = {
[
pieceName: `${string}-${
| AppConnectionType.CLOUD_OAUTH2
| AppConnectionType.PLATFORM_OAUTH2}`
]: {
oauth2Type:
| AppConnectionType.CLOUD_OAUTH2
| AppConnectionType.PLATFORM_OAUTH2;
clientId: string;
};
};

View File

@@ -0,0 +1,257 @@
import { t } from 'i18next';
import { CheckIcon, UnplugIcon, XIcon } from 'lucide-react';
import { formUtils } from '@/features/pieces/lib/form-utils';
import { authenticationSession } from '@/lib/authentication-session';
import { OAuth2App } from '@/lib/oauth2-utils';
import {
CustomAuthProps,
OAuth2Props,
PieceAuthProperty,
PieceMetadataModel,
PieceMetadataModelSummary,
PropertyType,
} from '@activepieces/pieces-framework';
import {
AppConnectionType,
AppConnectionWithoutSensitiveData,
UpsertAppConnectionRequestBody,
assertNotNullOrUndefined,
isNil,
apId,
AppConnectionStatus,
OAuth2GrantType,
} from '@activepieces/shared';
import { appConnectionsApi } from './api/app-connections';
import { globalConnectionsApi } from './api/global-connections';
export class ConnectionNameAlreadyExists extends Error {
constructor() {
super(t('Connection name already used'));
this.name = 'ConnectionNameAlreadyExists';
}
}
export class NoProjectSelected extends Error {
constructor() {
super(t('Please select at least one project'));
this.name = 'NoProjectSelected';
}
}
export const appConnectionUtils = {
getStatusIcon(status: AppConnectionStatus): {
variant: 'default' | 'success' | 'error';
icon: React.ComponentType;
} {
switch (status) {
case AppConnectionStatus.ACTIVE:
return {
variant: 'success',
icon: CheckIcon,
};
case AppConnectionStatus.MISSING:
return {
variant: 'default',
icon: UnplugIcon,
};
case AppConnectionStatus.ERROR:
return {
variant: 'error',
icon: XIcon,
};
}
},
};
export const newConnectionUtils = {
getConnectionName(
piece: PieceMetadataModelSummary | PieceMetadataModel,
reconnectConnection: AppConnectionWithoutSensitiveData | null,
externalIdComingFromSdk?: string | null,
): {
externalId: string;
displayName: string;
} {
if (reconnectConnection) {
return {
externalId: reconnectConnection.externalId,
displayName: reconnectConnection.displayName,
};
}
if (externalIdComingFromSdk) {
return {
externalId: externalIdComingFromSdk,
displayName: externalIdComingFromSdk,
};
}
return {
externalId: apId(),
displayName: piece.displayName,
};
},
createDefaultValues({
auth,
suggestedExternalId,
suggestedDisplayName,
pieceName,
grantType,
oauth2App,
redirectUrl,
}: DefaultValuesParams): Partial<UpsertAppConnectionRequestBody> {
const projectId = authenticationSession.getProjectId();
assertNotNullOrUndefined(projectId, 'projectId');
if (!auth) {
throw new Error(`Unsupported property type: ${auth}`);
}
const commmonProps = {
externalId: suggestedExternalId,
displayName: suggestedDisplayName,
pieceName: pieceName,
projectId,
};
switch (auth.type) {
case PropertyType.SECRET_TEXT:
return {
...commmonProps,
type: AppConnectionType.SECRET_TEXT,
value: {
type: AppConnectionType.SECRET_TEXT,
secret_text: '',
},
};
case PropertyType.BASIC_AUTH:
return {
...commmonProps,
type: AppConnectionType.BASIC_AUTH,
value: {
type: AppConnectionType.BASIC_AUTH,
username: '',
password: '',
},
};
case PropertyType.CUSTOM_AUTH: {
return {
...commmonProps,
type: AppConnectionType.CUSTOM_AUTH,
value: {
type: AppConnectionType.CUSTOM_AUTH,
props: formUtils.getDefaultValueForProperties({
props: auth.props ?? {},
existingInput: {},
}),
},
};
}
case PropertyType.OAUTH2: {
switch (oauth2App?.oauth2Type) {
case AppConnectionType.CLOUD_OAUTH2:
return {
...commmonProps,
type: AppConnectionType.CLOUD_OAUTH2,
value: {
type: AppConnectionType.CLOUD_OAUTH2,
client_id: oauth2App.clientId,
code: '',
scope: auth.scope.join(' '),
authorization_method: auth.authorizationMethod,
props: formUtils.getDefaultValueForProperties({
props: auth.props ?? {},
existingInput: {},
}),
},
};
case AppConnectionType.PLATFORM_OAUTH2:
return {
...commmonProps,
type: AppConnectionType.PLATFORM_OAUTH2,
value: {
type: AppConnectionType.PLATFORM_OAUTH2,
client_id: oauth2App.clientId,
redirect_url: redirectUrl,
code: '',
scope: auth.scope.join(' '),
authorization_method: auth.authorizationMethod,
props: formUtils.getDefaultValueForProperties({
props: auth.props ?? {},
existingInput: {},
}),
},
};
default:
return {
...commmonProps,
type: AppConnectionType.OAUTH2,
value: {
type: AppConnectionType.OAUTH2,
client_id: '',
redirect_url: redirectUrl,
code: '',
scope: auth.scope.join(' '),
authorization_method: auth.authorizationMethod,
props: formUtils.getDefaultValueForProperties({
props: auth.props ?? {},
existingInput: {},
}),
client_secret: '',
grant_type: grantType ?? OAuth2GrantType.AUTHORIZATION_CODE,
},
};
}
}
}
},
extractDefaultPropsValues(props: CustomAuthProps | OAuth2Props | undefined) {
if (!props) {
return {};
}
return Object.entries(props).reduce((acc, [propName, prop]) => {
if (prop.defaultValue) {
return {
...acc,
[propName]: prop.defaultValue,
};
}
if (prop.type === PropertyType.CHECKBOX) {
return {
...acc,
[propName]: false,
};
}
return acc;
}, {});
},
};
export const isConnectionNameUnique = async (
isGlobalConnection: boolean,
displayName: string,
) => {
const connections = isGlobalConnection
? await globalConnectionsApi.list({
limit: 10000,
})
: await appConnectionsApi.list({
projectId: authenticationSession.getProjectId()!,
limit: 10000,
});
const existingConnection = connections.data.find(
(connection) => connection.displayName === displayName,
);
return isNil(existingConnection);
};
type DefaultValuesParams = {
suggestedExternalId: string;
suggestedDisplayName: string;
pieceName: string;
redirectUrl: string;
auth: PieceAuthProperty;
oauth2App: OAuth2App | null;
grantType: OAuth2GrantType | null;
};

View File

@@ -0,0 +1,146 @@
import { QuestionMarkIcon } from '@radix-ui/react-icons';
import { t } from 'i18next';
import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { flagsHooks } from '@/hooks/flags-hooks';
import { authenticationSession } from '@/lib/authentication-session';
import { cn, formatUtils } from '@/lib/utils';
import {
ApFlagId,
FlowRun,
FlowRunStatus,
Permission,
} from '@activepieces/shared';
import { useAuthorization } from '../../../hooks/authorization-hooks';
import { flowRunUtils } from '../lib/flow-run-utils';
type RunDetailsBarProps = {
run: FlowRun | null;
exitRun: (userHasPermissionToUpdateFlow: boolean) => void;
isLoading: boolean;
};
function getStatusText(
status: FlowRunStatus,
timeout: number,
memoryLimit: number,
) {
switch (status) {
case FlowRunStatus.SUCCEEDED:
return t('Run Succeeded');
case FlowRunStatus.FAILED:
return t('Run Failed');
case FlowRunStatus.PAUSED:
return t('Flow Run is paused');
case FlowRunStatus.QUOTA_EXCEEDED:
return t('Run Failed due to quota exceeded');
case FlowRunStatus.MEMORY_LIMIT_EXCEEDED:
return t(
'Run failed due to exceeding the memory limit of {memoryLimit} MB',
{
memoryLimit: Math.floor(memoryLimit / 1024),
},
);
case FlowRunStatus.QUEUED:
return t('Queued');
case FlowRunStatus.RUNNING:
return t('Running');
case FlowRunStatus.TIMEOUT:
return t('Run exceeded {timeout} seconds, try to optimize your steps.', {
timeout,
});
case FlowRunStatus.INTERNAL_ERROR:
return t('Run failed for an unknown reason, contact support.');
case FlowRunStatus.CANCELED:
return t('Run Cancelled');
}
}
const RunDetailsBar = React.memo(
({ run, exitRun, isLoading }: RunDetailsBarProps) => {
const { Icon, variant } = run
? flowRunUtils.getStatusIcon(run.status)
: { Icon: QuestionMarkIcon, variant: 'default' };
const navigate = useNavigate();
const isInRunsPage = useLocation().pathname.includes('/runs/');
const { data: timeoutSeconds } = flagsHooks.useFlag<number>(
ApFlagId.FLOW_RUN_TIME_SECONDS,
);
const { data: memoryLimit } = flagsHooks.useFlag<number>(
ApFlagId.FLOW_RUN_MEMORY_LIMIT_KB,
);
const { checkAccess } = useAuthorization();
const userHasPermissionToEditFlow = checkAccess(Permission.WRITE_FLOW);
if (!run) {
return <></>;
}
const handleSwitchToDraft = () => {
if (isInRunsPage) {
navigate(
authenticationSession.appendProjectRoutePrefix(
`/flows/${run.flowId}`,
),
);
} else {
exitRun(userHasPermissionToEditFlow);
}
};
return (
<div className="absolute bottom-4 p-4 left-1/2 transform -translate-x-1/2 w-[480px] bg-background shadow-lg border rounded-lg z-9999">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3 min-w-0 flex-1">
<Icon
className={cn('w-6 h-6 shrink-0', {
'text-foreground': variant === 'default',
'text-success': variant === 'success',
'text-destructive': variant === 'error',
})}
/>
<div className="flex flex-col gap-1 min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-medium text-foreground truncate">
{getStatusText(
run.status,
timeoutSeconds ?? -1,
memoryLimit ?? -1,
)}
</span>
</div>
{run.created && (
<span className="text-xs text-muted-foreground flex-shrink-0">
{formatUtils.formatDate(new Date(run.created))}
</span>
)}
</div>
<div className="text-xs text-muted-foreground">{run.id}</div>
</div>
</div>
{
<Button
variant={'outline'}
size="sm"
onClick={handleSwitchToDraft}
loading={isLoading}
onKeyboardShortcut={handleSwitchToDraft}
keyboardShortcut="Esc"
className="shrink-0"
data-testId="exit-run-button"
>
{t('Edit Flow')}
</Button>
}
</div>
</div>
);
},
);
RunDetailsBar.displayName = 'RunDetailsBar';
export { RunDetailsBar };

View File

@@ -0,0 +1,284 @@
import { ColumnDef } from '@tanstack/react-table';
import { t } from 'i18next';
import { Archive, ChevronDown, Hourglass } from 'lucide-react';
import { Dispatch, SetStateAction } from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { RowDataWithActions } from '@/components/ui/data-table';
import { DataTableColumnHeader } from '@/components/ui/data-table/data-table-column-header';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { FormattedDate } from '@/components/ui/formatted-date';
import { StatusIconWithText } from '@/components/ui/status-icon-with-text';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { flowRunUtils } from '@/features/flow-runs/lib/flow-run-utils';
import { formatUtils } from '@/lib/utils';
import { FlowRun, FlowRunStatus, isNil, SeekPage } from '@activepieces/shared';
type SelectedRow = {
id: string;
status: FlowRunStatus;
};
type RunsTableColumnsProps = {
data: SeekPage<FlowRun> | undefined;
selectedRows: SelectedRow[];
setSelectedRows: Dispatch<SetStateAction<SelectedRow[]>>;
selectedAll: boolean;
setSelectedAll: Dispatch<SetStateAction<boolean>>;
excludedRows: Set<string>;
setExcludedRows: Dispatch<SetStateAction<Set<string>>>;
};
export const runsTableColumns = ({
setSelectedRows,
selectedRows,
selectedAll,
setSelectedAll,
excludedRows,
setExcludedRows,
data,
}: RunsTableColumnsProps): ColumnDef<RowDataWithActions<FlowRun>>[] => [
{
id: 'select',
header: ({ table }) => (
<div className="flex items-center h-full w-8">
<Checkbox
checked={selectedAll || table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => {
const isChecked = !!value;
table.toggleAllPageRowsSelected(isChecked);
if (isChecked) {
const currentPageRows = table.getRowModel().rows.map((row) => ({
id: row.original.id,
status: row.original.status,
}));
setSelectedRows((prev) => {
const uniqueRows = new Map<string, SelectedRow>([
...prev.map((row) => [row.id, row] as [string, SelectedRow]),
...currentPageRows.map(
(row) => [row.id, row] as [string, SelectedRow],
),
]);
return Array.from(uniqueRows.values());
});
} else {
setSelectedAll(false);
setSelectedRows([]);
setExcludedRows(new Set());
}
}}
/>
{selectedRows.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="xs">
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="z-50">
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
const currentPageRows = table
.getRowModel()
.rows.map((row) => ({
id: row.original.id,
status: row.original.status,
}));
setSelectedRows(currentPageRows);
setSelectedAll(false);
setExcludedRows(new Set());
table.toggleAllPageRowsSelected(true);
}}
>
{t('Select shown')}
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
if (data?.data) {
const allRows = data.data.map((row) => ({
id: row.id,
status: row.status,
}));
setSelectedRows(allRows);
setSelectedAll(true);
setExcludedRows(new Set());
table.toggleAllPageRowsSelected(true);
}
}}
>
{t('Select all')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
),
cell: ({ row }) => {
const isExcluded = excludedRows.has(row.original.id);
const isSelected = selectedAll
? !isExcluded
: selectedRows.some(
(selectedRow) => selectedRow.id === row.original.id,
);
return (
<div className="flex items-center h-full">
<Checkbox
checked={isSelected}
onCheckedChange={(value) => {
const isChecked = !!value;
if (selectedAll) {
if (isChecked) {
const newExcluded = new Set(excludedRows);
newExcluded.delete(row.original.id);
setExcludedRows(newExcluded);
} else {
setExcludedRows(new Set([...excludedRows, row.original.id]));
}
} else {
if (isChecked) {
setSelectedRows((prev) => [
...prev,
{
id: row.original.id,
status: row.original.status,
},
]);
} else {
setSelectedRows((prev) =>
prev.filter(
(selectedRow) => selectedRow.id !== row.original.id,
),
);
}
}
row.toggleSelected(isChecked);
}}
/>
</div>
);
},
},
{
accessorKey: 'flowId',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Flow')} />
),
cell: ({ row }) => {
const { archivedAt, flowVersion } = row.original;
const displayName = flowVersion?.displayName ?? '—';
return (
<div className="flex items-center gap-2 text-left">
{!isNil(archivedAt) && (
<Archive className="size-4 text-muted-foreground" />
)}
<span>{displayName}</span>
</div>
);
},
},
{
accessorKey: 'status',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Status')} />
),
cell: ({ row }) => {
const status = row.original.status;
const { variant, Icon } = flowRunUtils.getStatusIcon(status);
return (
<div className="text-left">
<StatusIconWithText
icon={Icon}
text={formatUtils.convertEnumToReadable(status)}
variant={variant}
/>
</div>
);
},
},
{
accessorKey: 'created',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Started At')} />
),
cell: ({ row }) => {
return (
<div className="text-left">
<FormattedDate
date={new Date(row.original.created ?? new Date())}
className="text-left"
includeTime={true}
/>
</div>
);
},
},
{
accessorKey: 'duration',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Duration')} />
),
cell: ({ row }) => {
const duration =
row.original.startTime && row.original.finishTime
? new Date(row.original.finishTime).getTime() -
new Date(row.original.startTime).getTime()
: undefined;
const waitDuration =
row.original.startTime && row.original.created
? new Date(row.original.startTime).getTime() -
new Date(row.original.created).getTime()
: undefined;
return (
<Tooltip>
<TooltipTrigger>
<div className="text-left flex items-center gap-2">
{row.original.finishTime && (
<>
<Hourglass className="h-4 w-4 text-muted-foreground" />
{formatUtils.formatDuration(duration)}
</>
)}
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{t(
`Time waited before first execution attempt: ${formatUtils.formatDuration(
waitDuration,
)}`,
)}
</TooltipContent>
</Tooltip>
);
},
},
{
accessorKey: 'failedStep',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Failed Step')} />
),
cell: ({ row }) => {
return (
<div className="text-left">
{row.original.failedStep?.displayName ?? '-'}
</div>
);
},
},
];

View File

@@ -0,0 +1,518 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { t } from 'i18next';
import {
CheckIcon,
Redo,
RotateCw,
ChevronDown,
History,
X,
Archive,
} from 'lucide-react';
import { useMemo, useCallback, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { PermissionNeededTooltip } from '@/components/custom/permission-needed-tooltip';
import { Button } from '@/components/ui/button';
import {
BulkAction,
CURSOR_QUERY_PARAM,
LIMIT_QUERY_PARAM,
DataTable,
DataTableFilters,
} from '@/components/ui/data-table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { MessageTooltip } from '@/components/ui/message-tooltip';
import { flowRunUtils } from '@/features/flow-runs/lib/flow-run-utils';
import { flowRunsApi } from '@/features/flow-runs/lib/flow-runs-api';
import { flowHooks } from '@/features/flows/lib/flow-hooks';
import { useAuthorization } from '@/hooks/authorization-hooks';
import { authenticationSession } from '@/lib/authentication-session';
import { useNewWindow } from '@/lib/navigation-utils';
import { formatUtils } from '@/lib/utils';
import {
FlowRetryStrategy,
FlowRun,
FlowRunStatus,
isFailedState,
isFlowRunStateTerminal,
Permission,
} from '@activepieces/shared';
import { runsTableColumns } from './columns';
import {
RetriedRunsSnackbar,
RUN_IDS_QUERY_PARAM,
} from './retried-runs-snackbar';
type SelectedRow = {
id: string;
status: FlowRunStatus;
};
export const RunsTable = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [selectedRows, setSelectedRows] = useState<Array<SelectedRow>>([]);
const [selectedAll, setSelectedAll] = useState(false);
const [excludedRows, setExcludedRows] = useState<Set<string>>(new Set());
const projectId = authenticationSession.getProjectId()!;
const [retriedRunsIds, setRetriedRunsIds] = useState<string[]>([]);
const { data, isLoading, refetch } = useQuery({
queryKey: ['flow-run-table', searchParams.toString(), projectId],
staleTime: 0,
gcTime: 0,
queryFn: () => {
const status = searchParams.getAll('status') as FlowRunStatus[];
const flowId = searchParams.getAll('flowId');
const cursor = searchParams.get(CURSOR_QUERY_PARAM);
const flowRunIds = searchParams.getAll(RUN_IDS_QUERY_PARAM);
const failedStepName = searchParams.get('failedStepName') || undefined;
const limit = searchParams.get(LIMIT_QUERY_PARAM)
? parseInt(searchParams.get(LIMIT_QUERY_PARAM)!)
: 10;
const createdAfter = searchParams.get('createdAfter');
const createdBefore = searchParams.get('createdBefore');
const archivedParam = searchParams.get('archivedAt');
let archived: boolean;
if (archivedParam === 'true') archived = true;
else archived = false;
return flowRunsApi.list({
status: status ?? undefined,
projectId,
flowId,
cursor: cursor ?? undefined,
limit,
archived,
createdAfter: createdAfter ?? undefined,
createdBefore: createdBefore ?? undefined,
failedStepName,
flowRunIds,
});
},
refetchInterval: (query) => {
const allRuns = query.state.data?.data;
const runningRuns = allRuns?.filter(
(run) =>
!isFlowRunStateTerminal({
status: run.status,
ignoreInternalError: false,
}),
);
return runningRuns?.length ? 15 * 1000 : false;
},
});
const columns = runsTableColumns({
data,
selectedRows,
setSelectedRows,
selectedAll,
setSelectedAll,
excludedRows,
setExcludedRows,
});
const navigate = useNavigate();
const { data: flowsData, isFetching: isFetchingFlows } = flowHooks.useFlows({
limit: 1000,
cursor: undefined,
});
const openNewWindow = useNewWindow();
const flows = flowsData?.data;
const { checkAccess } = useAuthorization();
const userHasPermissionToRetryRun = checkAccess(Permission.WRITE_RUN);
const filters: DataTableFilters<keyof FlowRun>[] = useMemo(
() => [
{
type: 'select',
title: t('Flow name'),
accessorKey: 'flowId',
options:
flows?.map((flow) => ({
label: flow.version.displayName,
value: flow.id,
})) || [],
icon: CheckIcon,
},
{
type: 'select',
title: t('Status'),
accessorKey: 'status',
options: Object.values(FlowRunStatus).map((status) => {
return {
label: formatUtils.convertEnumToHumanReadable(status),
value: status,
icon: flowRunUtils.getStatusIcon(status).Icon,
};
}),
icon: CheckIcon,
},
{
type: 'date',
title: t('Created'),
accessorKey: 'created',
icon: CheckIcon,
defaultPresetName: '7days',
},
{
type: 'checkbox',
title: t('Show archived'),
accessorKey: 'archivedAt',
},
],
[flows],
);
const retryRuns = useMutation({
mutationFn: (retryParams: {
runIds: string[];
strategy: FlowRetryStrategy;
}) => {
const status = searchParams.getAll('status') as FlowRunStatus[];
const flowId = searchParams.getAll('flowId');
const createdAfter = searchParams.get('createdAfter') || undefined;
const createdBefore = searchParams.get('createdBefore') || undefined;
const failedStepName = searchParams.get('failedStepName') || undefined;
return flowRunsApi.bulkRetry({
projectId: authenticationSession.getProjectId()!,
flowRunIds: selectedAll ? undefined : retryParams.runIds,
strategy: retryParams.strategy,
excludeFlowRunIds: selectedAll ? Array.from(excludedRows) : undefined,
status,
flowId,
createdAfter,
createdBefore,
failedStepName,
});
},
onSuccess: (runs) => {
const runsIds = runs.map((run) => run.id);
setRetriedRunsIds(runsIds);
const isAlreadyViewingRetriedRuns = searchParams.get(RUN_IDS_QUERY_PARAM);
refetch();
if (isAlreadyViewingRetriedRuns) {
navigate(authenticationSession.appendProjectRoutePrefix(`/runs`));
setSearchParams({
[RUN_IDS_QUERY_PARAM]: runsIds,
[LIMIT_QUERY_PARAM]: runsIds.length.toString(),
});
}
},
});
const cancelRuns = useMutation({
mutationFn: (cancelParams: { runIds: string[] }) => {
const status = searchParams.getAll('status') as FlowRunStatus[];
const flowId = searchParams.getAll('flowId');
const createdAfter = searchParams.get('createdAfter') || undefined;
const createdBefore = searchParams.get('createdBefore') || undefined;
return flowRunsApi.bulkCancel({
projectId: authenticationSession.getProjectId()!,
flowRunIds: selectedAll ? undefined : cancelParams.runIds,
excludeFlowRunIds: selectedAll ? Array.from(excludedRows) : undefined,
status:
status.length > 0
? (status.filter(
(s) => s === FlowRunStatus.PAUSED || s === FlowRunStatus.QUEUED,
) as (
| typeof FlowRunStatus.PAUSED
| typeof FlowRunStatus.QUEUED
)[])
: undefined,
flowId,
createdAfter,
createdBefore,
});
},
onSuccess: () => {
refetch();
setSelectedRows([]);
setSelectedAll(false);
setExcludedRows(new Set());
},
});
const archiveRuns = useMutation({
mutationFn: (retryParams: { runIds: string[] }) => {
const status = searchParams.getAll('status') as FlowRunStatus[];
const flowId = searchParams.getAll('flowId');
const createdAfter = searchParams.get('createdAfter') || undefined;
const createdBefore = searchParams.get('createdBefore') || undefined;
const failedStepName = searchParams.get('failedStepName') || undefined;
return flowRunsApi.bulkArchive({
projectId: authenticationSession.getProjectId()!,
flowRunIds: selectedAll ? undefined : retryParams.runIds,
excludeFlowRunIds: selectedAll ? Array.from(excludedRows) : undefined,
status,
flowId,
createdAfter,
createdBefore,
failedStepName,
});
},
onSuccess: () => {
refetch();
},
});
const bulkActions: BulkAction<FlowRun>[] = useMemo(
() => [
{
render: (_, resetSelection) => {
const isDisabled =
selectedRows.length === 0 || !userHasPermissionToRetryRun;
return (
<div onClick={(e) => e.stopPropagation()}>
<Button
disabled={isDisabled}
variant="outline"
className="h-9 w-full"
loading={archiveRuns.isPending}
onClick={() => {
archiveRuns.mutate({
runIds: selectedRows.map((row) => row.id),
});
resetSelection();
setSelectedRows([]);
}}
>
<Archive className="size-4 mr-1" />
{selectedRows.length > 0
? `${t('Archive')} ${
!isDisabled
? selectedAll
? excludedRows.size > 0
? `${t('all except')} ${excludedRows.size}`
: t('all')
: `(${selectedRows.length})`
: ''
}`
: t('Archive')}
</Button>
</div>
);
},
},
{
render: (_, resetSelection) => {
const allCancellable = selectedRows.every(
(row) =>
row.status === FlowRunStatus.PAUSED ||
row.status === FlowRunStatus.QUEUED,
);
const isDisabled =
selectedRows.length === 0 ||
!userHasPermissionToRetryRun ||
!allCancellable;
return (
<div onClick={(e) => e.stopPropagation()}>
<PermissionNeededTooltip
hasPermission={userHasPermissionToRetryRun}
>
<MessageTooltip
message={t('Only paused or queued runs can be cancelled')}
isDisabled={allCancellable}
>
<Button
disabled={isDisabled}
variant="outline"
className="h-9 w-full"
loading={cancelRuns.isPending}
onClick={() => {
cancelRuns.mutate({
runIds: selectedRows.map((row) => row.id),
});
resetSelection();
}}
>
<X className="h-3 w-4 mr-1" />
{selectedRows.length > 0
? `${t('Cancel')} ${
selectedAll
? excludedRows.size > 0
? `${t('all except')} ${excludedRows.size}`
: t('all')
: `(${selectedRows.length})`
}`
: t('Cancel')}
</Button>
</MessageTooltip>
</PermissionNeededTooltip>
</div>
);
},
},
{
render: (_, resetSelection) => {
const allFailed = selectedRows.every((row) =>
isFailedState(row.status),
);
const isDisabled =
selectedRows.length === 0 || !userHasPermissionToRetryRun;
return (
<div onClick={(e) => e.stopPropagation()}>
<PermissionNeededTooltip
hasPermission={userHasPermissionToRetryRun}
>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild disabled={isDisabled}>
<Button
disabled={isDisabled}
className="h-9 w-full"
loading={retryRuns.isPending}
>
<RotateCw className="size-4 mr-1" />
{selectedRows.length > 0
? `${t('Retry')} ${
!isDisabled
? selectedAll
? excludedRows.size > 0
? `${t('all except')} ${excludedRows.size}`
: t('all')
: `(${selectedRows.length})`
: ''
}`
: t('Retry')}
<ChevronDown className="h-3 w-4 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<PermissionNeededTooltip
hasPermission={userHasPermissionToRetryRun}
>
<DropdownMenuItem
disabled={!userHasPermissionToRetryRun}
onClick={() => {
retryRuns.mutate({
runIds: selectedRows.map((row) => row.id),
strategy: FlowRetryStrategy.ON_LATEST_VERSION,
});
resetSelection();
setSelectedRows([]);
}}
className="cursor-pointer"
>
<div className="flex flex-row gap-2 items-center">
<RotateCw className="h-4 w-4" />
<span>{t('on latest version')}</span>
</div>
</DropdownMenuItem>
</PermissionNeededTooltip>
{selectedRows.some((row) => isFailedState(row.status)) && (
<MessageTooltip
message={t(
'Only failed runs can be retried from failed step',
)}
isDisabled={!allFailed}
>
<DropdownMenuItem
disabled={!userHasPermissionToRetryRun || !allFailed}
onClick={() => {
retryRuns.mutate({
runIds: selectedRows.map((row) => row.id),
strategy: FlowRetryStrategy.FROM_FAILED_STEP,
});
resetSelection();
setSelectedRows([]);
setSelectedAll(false);
setExcludedRows(new Set());
}}
className="cursor-pointer"
>
<div className="flex flex-row gap-2 items-center">
<Redo className="h-4 w-4" />
<span>{t('from failed step')}</span>
</div>
</DropdownMenuItem>
</MessageTooltip>
)}
</DropdownMenuContent>
</DropdownMenu>
</PermissionNeededTooltip>
</div>
);
},
},
],
[
retryRuns,
archiveRuns,
userHasPermissionToRetryRun,
selectedRows,
selectedAll,
excludedRows,
cancelRuns,
],
);
const handleRowClick = useCallback(
(row: FlowRun, newWindow: boolean) => {
if (newWindow) {
openNewWindow(
authenticationSession.appendProjectRoutePrefix(`/runs/${row.id}`),
);
} else {
navigate(
authenticationSession.appendProjectRoutePrefix(`/runs/${row.id}`),
);
}
},
[navigate, openNewWindow],
);
const retriedRunsInQueryParams = searchParams.getAll(RUN_IDS_QUERY_PARAM);
const customFilters =
retriedRunsInQueryParams.length > 0
? [
<Button
key="retried-runs-filter"
variant="outline"
onClick={() => {
navigate(authenticationSession.appendProjectRoutePrefix(`/runs`));
}}
>
<div className="flex flex-row gap-2 items-center">
{t('Viewing retried runs')} ({retriedRunsInQueryParams.length}){' '}
<X className="size-4" />
</div>
</Button>,
]
: [];
return (
<div className="relative">
<DataTable
emptyStateTextTitle={t('No flow runs found')}
emptyStateTextDescription={t(
'Come back later when your automations start running',
)}
emptyStateIcon={<History className="size-14" />}
columns={columns}
page={data}
isLoading={isLoading || isFetchingFlows}
filters={customFilters.length > 0 ? [] : filters}
bulkActions={bulkActions}
onRowClick={(row, newWindow) => handleRowClick(row, newWindow)}
customFilters={customFilters}
hidePagination={retriedRunsInQueryParams.length > 0}
/>
<RetriedRunsSnackbar
retriedRunsIds={retriedRunsIds}
clearRetriedRuns={() => setRetriedRunsIds([])}
/>
</div>
);
};

View File

@@ -0,0 +1,50 @@
import { InfoCircledIcon } from '@radix-ui/react-icons';
import { t } from 'i18next';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { LIMIT_QUERY_PARAM } from '@/components/ui/data-table';
import { authenticationSession } from '@/lib/authentication-session';
export const RUN_IDS_QUERY_PARAM = 'flowRunIds';
export const RetriedRunsSnackbar = ({
retriedRunsIds,
clearRetriedRuns,
}: {
retriedRunsIds: string[];
clearRetriedRuns: () => void;
}) => {
const [, setSearchParams] = useSearchParams();
const navigate = useNavigate();
if (retriedRunsIds.length === 0) {
return null;
}
return (
<div className="fixed bottom-5 p-4 left-1/2 transform -translate-x-1/2 w-[480px] animate-slide-in-from-bottom bg-background shadow-lg border rounded-lg z-9999">
<div className="flex items-center justify-between animate-fade">
<div className="flex items-center gap-2">
<InfoCircledIcon className="size-5" />
{t('runsRetriedNote', {
runsCount: retriedRunsIds.length,
})}
</div>
<Button
variant={'outline'}
size="sm"
onClick={() => {
navigate(authenticationSession.appendProjectRoutePrefix(`/runs`));
setSearchParams({
[RUN_IDS_QUERY_PARAM]: retriedRunsIds,
[LIMIT_QUERY_PARAM]: retriedRunsIds.length.toString(),
});
clearRetriedRuns();
}}
>
{t('View')}
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,60 @@
import { t } from 'i18next';
import React from 'react';
import { LoadingSpinner } from '@/components/ui/spinner';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { flowRunUtils } from '@/features/flow-runs/lib/flow-run-utils';
import { cn } from '@/lib/utils';
import { StepOutputStatus } from '@activepieces/shared';
type StepStatusIconProps = {
status: StepOutputStatus;
size: '3' | '4' | '5';
hideTooltip?: boolean;
};
const statusText = {
[StepOutputStatus.RUNNING]: t('Step running'),
[StepOutputStatus.PAUSED]: t('Step paused'),
[StepOutputStatus.STOPPED]: t('Step Stopped'),
[StepOutputStatus.SUCCEEDED]: t('Step Succeeded'),
[StepOutputStatus.FAILED]: t('Step Failed'),
};
const StepStatusIcon = React.memo(
({ status, size, hideTooltip = false }: StepStatusIconProps) => {
const { variant, Icon } = flowRunUtils.getStatusIconForStep(status);
if (status === StepOutputStatus.RUNNING) {
return <LoadingSpinner className="w-3 h-3 "></LoadingSpinner>;
}
return (
<Tooltip>
<TooltipTrigger asChild>
<Icon
className={cn('', {
'w-3': size === '3',
'w-4': size === '4',
'h-3': size === '3',
'h-4': size === '4',
'w-5': size === '5',
'h-5': size === '5',
'text-green-700': variant === 'success',
'text-red-700': variant === 'error',
'text-foreground': variant === 'default',
})}
></Icon>
</TooltipTrigger>
{!hideTooltip && (
<TooltipContent side="bottom">{statusText[status]}</TooltipContent>
)}
</Tooltip>
);
},
);
StepStatusIcon.displayName = 'StepStatusIcon';
export { StepStatusIcon };

View File

@@ -0,0 +1,319 @@
import { t } from 'i18next';
import {
Check,
CircleCheck,
CircleX,
PauseCircleIcon,
PauseIcon,
Play,
Timer,
X,
} from 'lucide-react';
import {
FlowActionType,
FlowRun,
FlowRunStatus,
flowStructureUtil,
FlowTrigger,
FlowVersion,
isFailedState,
isNil,
LoopOnItemsAction,
LoopStepOutput,
StepOutput,
StepOutputStatus,
} from '@activepieces/shared';
export const flowRunUtils = {
findLastStepWithStatus,
findLoopsState,
extractStepOutput: (
stepName: string,
loopsIndexes: Record<string, number>,
runOutput: Record<string, StepOutput>,
trigger: FlowTrigger,
): StepOutput | undefined => {
const stepOutput = runOutput[stepName];
if (!isNil(stepOutput)) {
return stepOutput;
}
const parents: LoopOnItemsAction[] = flowStructureUtil
.findPathToStep(trigger, stepName)
.filter(
(p) =>
p.type === FlowActionType.LOOP_ON_ITEMS &&
flowStructureUtil.isChildOf(p, stepName),
) as LoopOnItemsAction[];
if (parents.length > 0) {
return getLoopChildStepOutput(parents, loopsIndexes, stepName, runOutput);
}
return undefined;
},
getStatusIconForStep(stepOutput: StepOutputStatus): {
variant: 'default' | 'success' | 'error';
Icon:
| typeof Timer
| typeof CircleCheck
| typeof PauseCircleIcon
| typeof CircleX;
text: string;
} {
switch (stepOutput) {
case StepOutputStatus.RUNNING:
return {
variant: 'default',
Icon: Timer,
text: t('Running'),
};
case StepOutputStatus.PAUSED:
return {
variant: 'default',
Icon: PauseCircleIcon,
text: t('Paused'),
};
case StepOutputStatus.STOPPED:
case StepOutputStatus.SUCCEEDED:
return {
variant: 'success',
Icon: CircleCheck,
text: t('Succeeded'),
};
case StepOutputStatus.FAILED:
return {
variant: 'error',
Icon: CircleX,
text: t('Failed'),
};
}
},
getStatusIcon(status: FlowRunStatus): {
variant: 'default' | 'success' | 'error';
Icon: typeof Timer | typeof Check | typeof PauseIcon | typeof X;
} {
switch (status) {
case FlowRunStatus.QUEUED:
return {
variant: 'default',
Icon: Timer,
};
case FlowRunStatus.RUNNING:
return {
variant: 'default',
Icon: Play,
};
case FlowRunStatus.SUCCEEDED:
return {
variant: 'success',
Icon: Check,
};
case FlowRunStatus.FAILED:
return {
variant: 'error',
Icon: X,
};
case FlowRunStatus.PAUSED:
return {
variant: 'default',
Icon: PauseIcon,
};
case FlowRunStatus.CANCELED:
return {
variant: 'default',
Icon: X,
};
case FlowRunStatus.MEMORY_LIMIT_EXCEEDED:
return {
variant: 'error',
Icon: X,
};
case FlowRunStatus.QUOTA_EXCEEDED:
return {
variant: 'error',
Icon: X,
};
case FlowRunStatus.INTERNAL_ERROR:
return {
variant: 'error',
Icon: X,
};
case FlowRunStatus.TIMEOUT:
return {
variant: 'error',
Icon: X,
};
}
},
};
function findLoopsState(
flowVersion: FlowVersion,
run: FlowRun,
//runs get updated if they aren't terminated yet, so we shouldn't reset the loops state on each update
currentLoopsState: Record<string, number>,
) {
const loops = flowStructureUtil
.getAllSteps(flowVersion.trigger)
.filter((s) => s.type === FlowActionType.LOOP_ON_ITEMS);
const loopsOutputs = loops.map((loop) => {
//TODO: fix step outputs so we don't have to cast here
const output = run.steps
? (run.steps[loop.name] as LoopStepOutput | undefined)
: undefined;
return {
output,
step: loop,
};
});
const failedStep = run.steps
? findLastStepWithStatus(run.status, run.steps)
: null;
return loopsOutputs.reduce((res, { step, output }) => {
const doesLoopIncludeFailedStep =
failedStep && flowStructureUtil.isChildOf(step, failedStep);
if (isNil(output)) {
return {
...res,
[step.name]: 0,
};
}
if (doesLoopIncludeFailedStep && output.output) {
return {
...res,
[step.name]: output.output.iterations.length - 1,
};
}
return {
...res,
[step.name]: currentLoopsState[step.name] ?? 0,
};
}, currentLoopsState);
}
function findLastStepWithStatus(
runStatus: FlowRunStatus,
steps: Record<string, StepOutput> | undefined,
): string | null {
if (isNil(steps)) {
return null;
}
if (runStatus === FlowRunStatus.SUCCEEDED) {
return null;
}
const stepStatus = isFailedState(runStatus)
? StepOutputStatus.FAILED
: undefined;
return Object.entries(steps).reduce((res, [stepName, step]) => {
if (
step.type === FlowActionType.LOOP_ON_ITEMS &&
step.output &&
isNil(res)
) {
const latestStepInLoop = findLatestStepInLoop(
step as LoopStepOutput,
runStatus,
);
if (!isNil(latestStepInLoop)) {
return latestStepInLoop;
}
}
if (!isNil(stepStatus)) {
if (step.status === stepStatus) {
return stepName;
}
return null;
}
return stepName;
}, null as null | string);
}
function findLatestStepInLoop(
loopStepResult: LoopStepOutput,
runStatus: FlowRunStatus,
): string | null {
if (!loopStepResult.output) {
return null;
}
for (const iteration of loopStepResult.output.iterations) {
const lastStep = findLastStepWithStatus(runStatus, iteration);
if (!isNil(lastStep)) {
return lastStep;
}
}
return null;
}
function getLoopChildStepOutput(
parents: LoopOnItemsAction[],
loopsIndexes: Record<string, number>,
childName: string,
runOutput: Record<string, StepOutput>,
): StepOutput | undefined {
if (parents.length === 0) {
return undefined;
}
let currentStepOutput = runOutput[parents[0].name] as
| LoopStepOutput
| undefined;
for (let loopLevel = 0; loopLevel < parents.length; loopLevel++) {
const currentLoop = parents[loopLevel];
const targetStepName = getTargetStepName(parents, loopLevel, childName);
currentStepOutput = getStepOutputFromIteration({
loopStepOutput: currentStepOutput,
loopName: currentLoop.name,
targetStepName,
loopsIndexes,
});
if (!currentStepOutput) {
return undefined;
}
}
return currentStepOutput;
}
function getTargetStepName(
parents: LoopOnItemsAction[],
currentLoopLevel: number,
childName: string,
): string {
const hasMoreLevels = currentLoopLevel + 1 < parents.length;
return hasMoreLevels ? parents[currentLoopLevel + 1].name : childName;
}
function getStepOutputFromIteration({
loopStepOutput,
loopName,
targetStepName,
loopsIndexes,
}: {
loopStepOutput: LoopStepOutput | undefined;
loopName: string;
targetStepName: string;
loopsIndexes: Record<string, number>;
}): LoopStepOutput | undefined {
if (!loopStepOutput?.output) {
return undefined;
}
const iterationIndex = loopsIndexes[loopName];
const iterations = loopStepOutput.output.iterations;
if (iterationIndex < 0 || iterationIndex >= iterations.length) {
return undefined;
}
const targetIteration = iterations[iterationIndex];
if (isNil(targetIteration)) {
return undefined;
}
return targetIteration[targetStepName] as LoopStepOutput | undefined;
}

View File

@@ -0,0 +1,110 @@
import { Socket } from 'socket.io-client';
import { api } from '@/lib/api';
import {
FlowRun,
ListFlowRunsRequestQuery,
RetryFlowRequestBody,
TestFlowRunRequestBody,
WebsocketServerEvent,
WebsocketClientEvent,
CreateStepRunRequestBody,
StepRunResponse,
SeekPage,
BulkActionOnRunsRequestBody,
BulkArchiveActionOnRunsRequestBody,
BulkCancelFlowRequestBody,
} from '@activepieces/shared';
type TestStepParams = {
socket: Socket;
request: CreateStepRunRequestBody;
// optional callback for steps like agent and todo
onProgress?: (progress: StepRunResponse) => void;
onFinsih?: () => void;
};
export const flowRunsApi = {
list(request: ListFlowRunsRequestQuery): Promise<SeekPage<FlowRun>> {
return api.get<SeekPage<FlowRun>>('/v1/flow-runs', request);
},
getPopulated(id: string): Promise<FlowRun> {
return api.get<FlowRun>(`/v1/flow-runs/${id}`);
},
bulkRetry(request: BulkActionOnRunsRequestBody): Promise<FlowRun[]> {
return api.post<FlowRun[]>('/v1/flow-runs/retry', request);
},
bulkCancel(request: BulkCancelFlowRequestBody): Promise<FlowRun[]> {
return api.post<FlowRun[]>('/v1/flow-runs/cancel', request);
},
bulkArchive(request: BulkArchiveActionOnRunsRequestBody): Promise<void> {
return api.post<void>('/v1/flow-runs/archive', request);
},
retry(flowRunId: string, request: RetryFlowRequestBody): Promise<FlowRun> {
return api.post<FlowRun>(`/v1/flow-runs/${flowRunId}/retry`, request);
},
async testFlow(
socket: Socket,
request: TestFlowRunRequestBody,
onUpdate: (response: FlowRun) => void,
): Promise<void> {
socket.emit(WebsocketServerEvent.TEST_FLOW_RUN, request);
const initialRun = await getInitialRun(socket, request.flowVersionId);
onUpdate(initialRun);
},
async testStep(params: TestStepParams): Promise<StepRunResponse> {
const { socket, request, onProgress, onFinsih } = params;
const stepRun = await api.post<FlowRun>(
'/v1/sample-data/test-step',
request,
);
return new Promise<StepRunResponse>((resolve, reject) => {
const handleStepFinished = (response: StepRunResponse) => {
if (response.runId === stepRun.id) {
onFinsih?.();
socket.off(
WebsocketClientEvent.TEST_STEP_FINISHED,
handleStepFinished,
);
socket.off('error', handleError);
resolve(response);
}
};
const handleError = (error: any) => {
onFinsih?.();
socket.off(WebsocketClientEvent.TEST_STEP_FINISHED, handleStepFinished);
socket.off('error', handleError);
reject(error);
};
socket.on(WebsocketClientEvent.TEST_STEP_FINISHED, handleStepFinished);
socket.on('error', handleError);
if (onProgress) {
const handleOnProgress = (response: StepRunResponse) => {
if (response.runId === stepRun.id) {
onProgress(response);
}
};
socket.on(WebsocketClientEvent.TEST_STEP_PROGRESS, handleOnProgress);
}
});
},
};
function getInitialRun(
socket: Socket,
flowVersionId: string,
): Promise<FlowRun> {
return new Promise<FlowRun>((resolve) => {
const onRunStarted = (run: FlowRun) => {
if (run.flowVersionId !== flowVersionId) {
return;
}
socket.off(WebsocketClientEvent.TEST_FLOW_RUN_STARTED, onRunStarted);
resolve(run);
};
socket.on(WebsocketClientEvent.TEST_FLOW_RUN_STARTED, onRunStarted);
});
}

View File

@@ -0,0 +1,99 @@
import { t } from 'i18next';
import { useEffect, useState } from 'react';
import { ApErrorDialog } from '@/components/custom/ap-error-dialog/ap-error-dialog';
import { LoadingSpinner } from '@/components/ui/spinner';
import { useAuthorization } from '@/hooks/authorization-hooks';
import {
FlowStatus,
FlowStatusUpdatedResponse,
Permission,
PopulatedFlow,
isNil,
} from '@activepieces/shared';
import { Switch } from '../../../components/ui/switch';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '../../../components/ui/tooltip';
import { flowHooks } from '../lib/flow-hooks';
import { flowsUtils } from '../lib/flows-utils';
type FlowStatusToggleProps = {
flow: PopulatedFlow;
};
const FlowStatusToggle = ({ flow }: FlowStatusToggleProps) => {
const [isFlowPublished, setIsFlowPublished] = useState(
flow.status === FlowStatus.ENABLED,
);
useEffect(() => {
setIsFlowPublished(flow.status === FlowStatus.ENABLED);
}, [flow]);
const { checkAccess } = useAuthorization();
const userHasPermissionToToggleFlowStatus = checkAccess(
Permission.UPDATE_FLOW_STATUS,
);
const { mutate: changeStatus, isPending: isLoading } =
flowHooks.useChangeFlowStatus({
flowId: flow.id,
change: isFlowPublished ? FlowStatus.DISABLED : FlowStatus.ENABLED,
onSuccess: (response: FlowStatusUpdatedResponse) => {
setIsFlowPublished(response.flow.status === FlowStatus.ENABLED);
},
});
return (
<>
<ApErrorDialog />
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center justify-center">
<Switch
checked={isFlowPublished}
onCheckedChange={() => changeStatus()}
disabled={
isLoading ||
!userHasPermissionToToggleFlowStatus ||
isNil(flow.publishedVersionId)
}
/>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{userHasPermissionToToggleFlowStatus
? isNil(flow.publishedVersionId)
? t('Please publish flow first')
: isFlowPublished
? t('Flow is on')
: t('Flow is off')
: t('Permission Needed')}
</TooltipContent>
</Tooltip>
{isLoading ? (
<LoadingSpinner />
) : (
isFlowPublished && (
<Tooltip>
<TooltipTrigger asChild onClick={(e) => e.stopPropagation()}>
<div className="p-2 rounded-full ">
{flowsUtils.flowStatusIconRenderer(flow)}
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{flowsUtils.flowStatusToolTipRenderer(flow)}
</TooltipContent>
</Tooltip>
)
)}
</>
);
};
FlowStatusToggle.displayName = 'FlowStatusToggle';
export { FlowStatusToggle };

View File

@@ -0,0 +1,58 @@
import { t } from 'i18next';
import React from 'react';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { FlowVersionState } from '@activepieces/shared';
type FlowVersionStateProps = {
state: FlowVersionState;
publishedVersionId: string | undefined | null;
versionId: string;
};
const findVersionStateName: (
state: FlowVersionStateProps,
) => 'Draft' | 'Published' | 'Locked' = ({
state,
publishedVersionId,
versionId,
}) => {
if (state === FlowVersionState.DRAFT) {
return 'Draft';
}
if (publishedVersionId === versionId) {
return 'Published';
}
return 'Locked';
};
const FlowVersionStateDot = React.memo((state: FlowVersionStateProps) => {
const stateName = findVersionStateName(state);
if (stateName === 'Locked') {
return null;
}
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="size-8 flex justify-center items-center">
{stateName === 'Draft' && (
<span className="bg-warning size-1.5 rounded-full"></span>
)}
{stateName === 'Published' && (
<span className="bg-success size-1.5 rounded-full"></span>
)}
</div>
</TooltipTrigger>
<TooltipContent>
{stateName === 'Draft' && t('Draft')}
{stateName === 'Published' && t('Published')}
</TooltipContent>
</Tooltip>
);
});
FlowVersionStateDot.displayName = 'FlowVersionStateDot';
export { FlowVersionStateDot };

View File

@@ -0,0 +1,337 @@
import { useMutation } from '@tanstack/react-query';
import { HttpStatusCode } from 'axios';
import { t } from 'i18next';
import JSZip from 'jszip';
import { TriangleAlert } from 'lucide-react';
import React, { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { useTelemetry } from '@/components/telemetry-provider';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTrigger,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectGroup,
SelectLabel,
SelectItem,
} from '@/components/ui/select';
import { internalErrorToast } from '@/components/ui/sonner';
import { LoadingSpinner } from '@/components/ui/spinner';
import { foldersApi } from '@/features/folders/lib/folders-api';
import { foldersHooks } from '@/features/folders/lib/folders-hooks';
import { templatesApi } from '@/features/templates/lib/templates-api';
import { api } from '@/lib/api';
import { authenticationSession } from '@/lib/authentication-session';
import {
FlowOperationType,
isNil,
PopulatedFlow,
TelemetryEventName,
UncategorizedFolderId,
Template,
} from '@activepieces/shared';
import { FormError } from '../../../components/ui/form';
import { flowsApi } from '../lib/flows-api';
import { templateUtils } from '../lib/template-parser';
export type ImportFlowDialogProps =
| {
insideBuilder: false;
onRefresh: () => void;
folderId: string;
}
| {
insideBuilder: true;
flowId: string;
};
const readTemplateJson = async (
templateFile: File,
): Promise<Template | null> => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => {
const template = templateUtils.parseTemplate(reader.result as string);
resolve(template);
};
reader.readAsText(templateFile);
});
};
const ImportFlowDialog = (
props: ImportFlowDialogProps & { children: React.ReactNode },
) => {
const { capture } = useTelemetry();
const [templates, setTemplates] = useState<Template[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const [errorMessage, setErrorMessage] = useState('');
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [failedFiles, setFailedFiles] = useState<string[]>([]);
const [selectedFolderId, setSelectedFolderId] = useState<string | undefined>(
props.insideBuilder ? undefined : props.folderId,
);
const { folders, isLoading } = foldersHooks.useFolders();
const navigate = useNavigate();
const { mutate: importFlows, isPending } = useMutation<
PopulatedFlow[],
Error,
Template[]
>({
mutationFn: async (templates: Template[]) => {
const importPromises = templates.flatMap(async (template) => {
const flowImportPromises = (template.flows || []).map(
async (templateFlow) => {
let flow: PopulatedFlow | null = null;
if (props.insideBuilder) {
flow = await flowsApi.get(props.flowId);
} else {
const folder =
!isNil(selectedFolderId) &&
selectedFolderId !== UncategorizedFolderId
? await foldersApi.get(selectedFolderId)
: undefined;
flow = await flowsApi.create({
displayName: templateFlow.displayName,
projectId: authenticationSession.getProjectId()!,
folderName: folder?.displayName,
});
}
return await flowsApi.update(flow.id, {
type: FlowOperationType.IMPORT_FLOW,
request: {
displayName: templateFlow.displayName,
trigger: templateFlow.trigger,
schemaVersion: templateFlow.schemaVersion,
},
});
},
);
return Promise.all(flowImportPromises);
});
const results = await Promise.all(importPromises);
return results.flat();
},
onSuccess: (flows: PopulatedFlow[]) => {
capture({
name: TelemetryEventName.FLOW_IMPORTED_USING_FILE,
payload: {
location: props.insideBuilder
? 'inside the builder'
: 'inside dashboard',
multiple: flows.length > 1,
},
});
templatesApi.incrementUsageCount(templates[0].id);
toast.success(
t(`flowsImported`, {
flowsCount: flows.length,
}),
);
if (flows.length === 1) {
navigate(`/flows/${flows[0].id}`);
return;
}
setIsDialogOpen(false);
if (flows.length === 1 || props.insideBuilder) {
navigate(`/flow-import-redirect/${flows[0].id}`);
}
if (!props.insideBuilder) {
props.onRefresh();
}
},
onError: (err) => {
if (
api.isError(err) &&
err.response?.status === HttpStatusCode.BadRequest
) {
setErrorMessage(t('Template file is invalid'));
console.log(err);
} else {
internalErrorToast();
}
},
});
const handleSubmit = async () => {
if (templates.length === 0) {
setErrorMessage(
failedFiles.length
? t(
'No valid templates found. The following files failed to import: ',
) + failedFiles.join(', ')
: t('Please select a file first'),
);
} else {
setErrorMessage('');
importFlows(templates);
}
};
const handleFileChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const files = event.target.files;
if (!files?.[0]) return;
setTemplates([]);
setFailedFiles([]);
setErrorMessage('');
const file = files[0];
const newTemplates: Template[] = [];
const isZipFile =
file.type === 'application/zip' ||
file.type === 'application/x-zip-compressed';
if (isZipFile && !props.insideBuilder) {
const zip = new JSZip();
const zipContent = await zip.loadAsync(file);
const jsonFiles = Object.keys(zipContent.files).filter((fileName) =>
fileName.endsWith('.json'),
);
for (const fileName of jsonFiles) {
const fileData = await zipContent.files[fileName].async('string');
const template = await readTemplateJson(new File([fileData], fileName));
if (template) {
newTemplates.push(template);
} else {
setFailedFiles((prevFailedFiles) => [...prevFailedFiles, fileName]);
}
}
} else if (file.type === 'application/json') {
const template = await readTemplateJson(file);
if (template) {
newTemplates.push(template);
} else {
setFailedFiles((prevFailedFiles) => [...prevFailedFiles, file.name]);
}
} else {
setErrorMessage(t('Unsupported file type'));
return;
}
setTemplates(newTemplates);
};
return (
<Dialog
open={isDialogOpen}
onOpenChange={(open) => {
setIsDialogOpen(open);
if (!open) {
setErrorMessage('');
setTemplates([]);
setFailedFiles([]);
}
}}
>
<DialogTrigger asChild>{props.children}</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<div className="flex flex-col gap-3">
<DialogTitle>{t('Import Flow')}</DialogTitle>
{props.insideBuilder && (
<div className="flex gap-1 items-center text-muted-foreground">
<TriangleAlert className="w-5 h-5 stroke-warning"></TriangleAlert>
<div className="font-semibold">{t('Warning')}:</div>
<div>
{t('Importing a flow will overwrite your current one.')}
</div>
</div>
)}
</div>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="w-full flex flex-col gap-2 justify-between items-start">
<span className="w-16 text-sm font-medium text-gray-700">
{t('Flow')}
</span>
<Input
id="file-input"
type="file"
accept={props.insideBuilder ? '.json' : '.json,.zip'}
ref={fileInputRef}
onChange={handleFileChange}
/>
</div>
{!props.insideBuilder && (
<div className="w-full flex flex-col gap-2 justify-between items-start">
<span className="w-16 text-sm font-medium text-gray-700">
{t('Folder')}
</span>
{isLoading ? (
<div className="flex justify-center items-center w-full">
<LoadingSpinner />
</div>
) : (
<Select
onValueChange={(value) => setSelectedFolderId(value)}
defaultValue={selectedFolderId}
>
<SelectTrigger>
<SelectValue
defaultValue={selectedFolderId}
placeholder={t('Select a folder')}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>{t('Folders')}</SelectLabel>
<SelectItem value={UncategorizedFolderId}>
{t('Uncategorized')}
</SelectItem>
{folders?.map((folder) => (
<SelectItem key={folder.id} value={folder.id}>
{folder.displayName}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)}
</div>
)}
</div>
{errorMessage && (
<FormError formMessageId="import-flow-error-message" className="mt-4">
{errorMessage}
</FormError>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsDialogOpen(false)}
disabled={isPending}
>
{t('Cancel')}
</Button>
<Button onClick={handleSubmit} loading={isPending}>
{t('Import')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export { ImportFlowDialog };

View File

@@ -0,0 +1,133 @@
import { typeboxResolver } from '@hookform/resolvers/typebox';
import { Static, Type } from '@sinclair/typebox';
import { useMutation } from '@tanstack/react-query';
import { t } from 'i18next';
import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { FormField, FormItem, FormMessage } from '@/components/ui/form';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { foldersHooks } from '@/features/folders/lib/folders-hooks';
import { Flow, FlowOperationType, PopulatedFlow } from '@activepieces/shared';
import { flowsApi } from '../lib/flows-api';
const MoveFlowFormSchema = Type.Object({
folder: Type.String({
errorMessage: t('Please select a folder'),
}),
});
type MoveFlowFormSchema = Static<typeof MoveFlowFormSchema>;
type MoveFlowDialogProps = {
children: React.ReactNode;
flows: Flow[];
onMoveTo: (folderId: string) => void;
};
const MoveFlowDialog = ({ children, flows, onMoveTo }: MoveFlowDialogProps) => {
const form = useForm<MoveFlowFormSchema>({
resolver: typeboxResolver(MoveFlowFormSchema),
});
const { folders, isLoading } = foldersHooks.useFolders();
const [isDialogOpened, setIsDialogOpened] = useState(false);
const { mutate, isPending } = useMutation<
PopulatedFlow[],
Error,
MoveFlowFormSchema
>({
mutationFn: async (data) => {
const updatePromises = flows.map((flow) =>
flowsApi.update(flow.id, {
type: FlowOperationType.CHANGE_FOLDER,
request: {
folderId: data.folder,
},
}),
);
return await Promise.all(updatePromises);
},
onSuccess: () => {
onMoveTo(form.getValues().folder);
setIsDialogOpened(false);
toast.success(t('Moved flows successfully'));
},
});
return (
<Dialog onOpenChange={setIsDialogOpened} open={isDialogOpened}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Move Selected Flows')}</DialogTitle>
</DialogHeader>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit((data) => mutate(data))}>
<FormField
control={form.control}
name="folder"
render={({ field }) => (
<FormItem>
<Select
onValueChange={field.onChange}
disabled={isLoading || folders?.length === 0}
>
<SelectTrigger>
<SelectValue placeholder={t('Select Folder')} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{folders && folders.length === 0 && (
<SelectItem value="NULL">
{t('No Folders')}
</SelectItem>
)}
{folders &&
folders.map((folder) => (
<SelectItem key={folder.id} value={folder.id}>
{folder.displayName}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</FormItem>
)}
/>
{form?.formState?.errors?.root?.serverError && (
<FormMessage>
{form.formState.errors.root.serverError.message}
</FormMessage>
)}
<DialogFooter>
<Button type="submit" loading={isPending}>
{t('Confirm')}
</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);
};
export { MoveFlowDialog };

View File

@@ -0,0 +1,120 @@
import { typeboxResolver } from '@hookform/resolvers/typebox';
import { DialogTrigger } from '@radix-ui/react-dialog';
import { Static, Type } from '@sinclair/typebox';
import { useMutation } from '@tanstack/react-query';
import { t } from 'i18next';
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { flowsApi } from '@/features/flows/lib/flows-api';
import { FlowOperationType, PopulatedFlow } from '@activepieces/shared';
const RenameFlowSchema = Type.Object({
displayName: Type.String(),
});
type RenameFlowSchema = Static<typeof RenameFlowSchema>;
type RenameFlowDialogProps = {
children: React.ReactNode;
flowId: string;
onRename: (newName: string) => void;
flowName: string;
};
const RenameFlowDialog: React.FC<RenameFlowDialogProps> = ({
children,
flowId,
onRename,
flowName,
}) => {
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const renameFlowForm = useForm<RenameFlowSchema>({
resolver: typeboxResolver(RenameFlowSchema),
});
const { mutate, isPending } = useMutation<
PopulatedFlow,
Error,
{
flowId: string;
displayName: string;
}
>({
mutationFn: () =>
flowsApi.update(flowId, {
type: FlowOperationType.CHANGE_NAME,
request: renameFlowForm.getValues(),
}),
onSuccess: () => {
setIsRenameDialogOpen(false);
onRename(renameFlowForm.getValues().displayName);
toast.success(t('Flow has been renamed.'), {
duration: 3000,
});
},
});
return (
<Dialog
open={isRenameDialogOpen}
onOpenChange={(open) => setIsRenameDialogOpen(open)}
>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t('Rename')} {flowName}
</DialogTitle>
</DialogHeader>
<Form {...renameFlowForm}>
<form
className="grid space-y-4"
onSubmit={renameFlowForm.handleSubmit((data) =>
mutate({
flowId,
displayName: data.displayName,
}),
)}
>
<FormField
control={renameFlowForm.control}
name="displayName"
render={({ field }) => (
<FormItem className="grid space-y-2">
<Label htmlFor="displayName">{t('Name')}</Label>
<Input
{...field}
id="displayName"
placeholder={t('New Flow Name')}
className="rounded-sm"
defaultValue={flowName}
/>
<FormMessage />
</FormItem>
)}
/>
{renameFlowForm?.formState?.errors?.root?.serverError && (
<FormMessage>
{renameFlowForm.formState.errors.root.serverError.message}
</FormMessage>
)}
<Button loading={isPending}>{t('Confirm')}</Button>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
export { RenameFlowDialog };

View File

@@ -0,0 +1,127 @@
import { DialogDescription } from '@radix-ui/react-dialog';
import { t } from 'i18next';
import { ArrowLeft, Search, SearchX } from 'lucide-react';
import React, { useRef, useState } from 'react';
import { InputWithIcon } from '@/components/custom/input-with-icon';
import { Button } from '@/components/ui/button';
import {
Carousel,
CarouselApi,
CarouselContent,
CarouselItem,
} from '@/components/ui/carousel';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { LoadingSpinner } from '@/components/ui/spinner';
import { TemplateCard } from '@/features/templates/components/template-card';
import { TemplateDetailsView } from '@/features/templates/components/template-details-view';
import { useTemplates } from '@/features/templates/hooks/templates-hook';
import { Template, TemplateType } from '@activepieces/shared';
const SelectFlowTemplateDialog = ({
children,
folderId,
}: {
children: React.ReactNode;
folderId: string;
}) => {
const { filteredTemplates, isLoading, search, setSearch } = useTemplates({
type: TemplateType.CUSTOM,
});
const carousel = useRef<CarouselApi>();
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
null,
);
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearch(event.target.value);
};
const unselectTemplate = () => {
setSelectedTemplate(null);
carousel.current?.scrollPrev();
};
return (
<Dialog onOpenChange={unselectTemplate}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className=" lg:min-w-[850px] flex flex-col">
<DialogHeader>
<DialogTitle className="flex min-h-9 flex-row items-center justify-start gap-2 items-center h-full">
{selectedTemplate && (
<Button variant="ghost" size="sm" onClick={unselectTemplate}>
<ArrowLeft className="w-4 h-4" />
</Button>
)}
{t('Browse Templates')}
</DialogTitle>
</DialogHeader>
<Carousel setApi={(api) => (carousel.current = api)}>
<CarouselContent className="min-h-[300px] h-[70vh] max-h-[820px] ">
<CarouselItem key="templates">
<div>
<div className="p-1 ">
<InputWithIcon
icon={<Search className="w-4 h-4" />}
type="text"
value={search}
onChange={handleSearchChange}
placeholder={t('Search templates')}
className="mb-4"
/>
</div>
<DialogDescription>
{isLoading ? (
<div className="min-h-[300px] h-[70vh] max-h-[680px] o flex justify-center items-center">
<LoadingSpinner />
</div>
) : (
<>
{filteredTemplates?.length === 0 && (
<div className="flex flex-col items-center justify-center gap-2 text-center ">
<SearchX className="w-10 h-10" />
{t('No templates found, try adjusting your search')}
</div>
)}
<ScrollArea className="min-h-[260px] h-[calc(70vh-80px)] max-h-[740px] overflow-y-auto px-1">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredTemplates?.map((template) => (
<TemplateCard
key={template.id}
template={template}
folderId={folderId}
onSelectTemplate={(template) => {
setSelectedTemplate(template);
carousel.current?.scrollNext();
}}
/>
))}
</div>
</ScrollArea>
</>
)}
</DialogDescription>
</div>
</CarouselItem>
<CarouselItem key="template-details">
{selectedTemplate && (
<TemplateDetailsView template={selectedTemplate} />
)}
</CarouselItem>
</CarouselContent>
</Carousel>
</DialogContent>
</Dialog>
);
};
export { SelectFlowTemplateDialog };

View File

@@ -0,0 +1,145 @@
import { typeboxResolver } from '@hookform/resolvers/typebox';
import { DialogDescription, DialogTrigger } from '@radix-ui/react-dialog';
import { Static, Type } from '@sinclair/typebox';
import { useMutation } from '@tanstack/react-query';
import { t } from 'i18next';
import React, { useState } from 'react';
import { SubmitHandler, useForm } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { flowsApi } from '@/features/flows/lib/flows-api';
import { templatesApi } from '@/features/templates/lib/templates-api';
import { userHooks } from '@/hooks/user-hooks';
import { useNewWindow } from '@/lib/navigation-utils';
import { Template } from '@activepieces/shared';
const ShareTemplateSchema = Type.Object({
description: Type.String(),
blogUrl: Type.Optional(Type.String()),
tags: Type.Optional(Type.Array(Type.String())),
});
type ShareTemplateSchema = Static<typeof ShareTemplateSchema>;
const ShareTemplateDialog: React.FC<{
children: React.ReactNode;
flowId: string;
flowVersionId: string;
}> = ({ children, flowId, flowVersionId }) => {
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
const shareTemplateForm = useForm<ShareTemplateSchema>({
resolver: typeboxResolver(ShareTemplateSchema),
});
const openNewIndow = useNewWindow();
const { data: currentUser } = userHooks.useCurrentUser();
const { mutate, isPending } = useMutation<
Template,
Error,
{ flowId: string; description: string }
>({
mutationFn: async () => {
const template = await flowsApi.getTemplate(flowId, {
versionId: flowVersionId,
});
const author = currentUser
? `${currentUser.firstName} ${currentUser.lastName}`
: 'Unknown User';
const flowTemplate = await templatesApi.create({
name: template.name,
description: shareTemplateForm.getValues().description,
summary: template.summary,
tags: template.tags,
blogUrl: template.blogUrl ?? undefined,
metadata: null,
author,
categories: template.categories,
type: template.type,
flows: template.flows,
});
return flowTemplate;
},
onSuccess: (data) => {
openNewIndow(`/templates/${data.id}`);
setIsShareDialogOpen(false);
},
});
const onShareTemplateSubmit: SubmitHandler<{
description: string;
}> = (data) => {
mutate({
flowId,
description: data.description,
});
};
return (
<Dialog
open={isShareDialogOpen}
onOpenChange={(open) => setIsShareDialogOpen(open)}
>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Share Template')}</DialogTitle>
<DialogDescription className="flex flex-col gap-2">
<span>
{t(
'Generate or update a template link for the current flow to easily share it with others.',
)}
</span>
<span>
{t(
'The template will not have any credentials in connection fields, keeping sensitive information secure.',
)}
</span>
</DialogDescription>
</DialogHeader>
<Form {...shareTemplateForm}>
<form
className="grid space-y-4"
onSubmit={shareTemplateForm.handleSubmit(onShareTemplateSubmit)}
>
<FormField
control={shareTemplateForm.control}
name="description"
render={({ field }) => (
<FormItem className="grid space-y-2">
<Label htmlFor="description">{t('Description')}</Label>
<Input
{...field}
required
id="description"
placeholder={t('A short description of the template')}
className="rounded-sm"
/>
<FormMessage />
</FormItem>
)}
/>
{shareTemplateForm?.formState?.errors?.root?.serverError && (
<FormMessage>
{shareTemplateForm.formState.errors.root.serverError.message}
</FormMessage>
)}
<Button loading={isPending}>{t('Confirm')}</Button>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
export { ShareTemplateDialog };

View File

@@ -0,0 +1,154 @@
import { useMutation } from '@tanstack/react-query';
import { t } from 'i18next';
import { ChevronDown, Plus, Upload, Workflow } from 'lucide-react';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { PermissionNeededTooltip } from '@/components/custom/permission-needed-tooltip';
import { useEmbedding } from '@/components/embed-provider';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { foldersApi } from '@/features/folders/lib/folders-api';
import { useAuthorization } from '@/hooks/authorization-hooks';
import { authenticationSession } from '@/lib/authentication-session';
import { cn, NEW_FLOW_QUERY_PARAM } from '@/lib/utils';
import {
Permission,
PopulatedFlow,
UncategorizedFolderId,
} from '@activepieces/shared';
import { ImportFlowDialog } from '../components/import-flow-dialog';
import { SelectFlowTemplateDialog } from '../components/select-flow-template-dialog';
import { flowsApi } from './flows-api';
type CreateFlowDropdownProps = {
refetch: () => void | null;
variant?: 'default' | 'small';
className?: string;
folderId: string;
};
export const CreateFlowDropdown = ({
refetch,
variant = 'default',
className,
folderId,
}: CreateFlowDropdownProps) => {
const { checkAccess } = useAuthorization();
const doesUserHavePermissionToWriteFlow = checkAccess(Permission.WRITE_FLOW);
const [refresh, setRefresh] = useState(0);
const navigate = useNavigate();
const { embedState } = useEmbedding();
const { mutate: createFlow, isPending: isCreateFlowPending } = useMutation<
PopulatedFlow,
Error,
void
>({
mutationFn: async () => {
const folder =
folderId !== UncategorizedFolderId
? await foldersApi.get(folderId)
: undefined;
const flow = await flowsApi.create({
projectId: authenticationSession.getProjectId()!,
displayName: t('Untitled'),
folderName: folder?.displayName,
});
return flow;
},
onSuccess: (flow) => {
navigate(`/flows/${flow.id}?${NEW_FLOW_QUERY_PARAM}=true`);
},
});
return (
<PermissionNeededTooltip hasPermission={doesUserHavePermissionToWriteFlow}>
<DropdownMenu modal={false}>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<DropdownMenuTrigger
disabled={!doesUserHavePermissionToWriteFlow}
asChild
className={cn(className)}
>
<Button
disabled={!doesUserHavePermissionToWriteFlow}
variant={variant === 'small' ? 'ghost' : 'default'}
size={variant === 'small' ? 'icon' : 'default'}
className={cn(variant === 'small' ? '!bg-transparent' : '')}
loading={isCreateFlowPending}
onClick={(e) => e.stopPropagation()}
data-testid="new-flow-button"
>
{variant === 'small' ? (
<Plus className="h-4 w-4" />
) : (
<>
<span>{t('New Flow')}</span>
<ChevronDown className="h-4 w-4 ml-2 " />
</>
)}
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side={variant === 'small' ? 'right' : 'bottom'}>
{t('New flow')}
</TooltipContent>
</Tooltip>
<DropdownMenuContent onClick={(e) => e.stopPropagation()}>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
createFlow();
}}
disabled={isCreateFlowPending}
data-testid="new-flow-from-scratch-button"
>
<Plus className="h-4 w-4 me-2" />
<span>{t('From scratch')}</span>
</DropdownMenuItem>
<SelectFlowTemplateDialog folderId={folderId}>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
disabled={isCreateFlowPending}
>
<Workflow className="h-4 w-4 me-2" />
<span>{t('Use a template')}</span>
</DropdownMenuItem>
</SelectFlowTemplateDialog>
{!embedState.hideExportAndImportFlow && (
<ImportFlowDialog
insideBuilder={false}
onRefresh={() => {
setRefresh(refresh + 1);
if (refetch) refetch();
}}
folderId={folderId}
>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
disabled={!doesUserHavePermissionToWriteFlow}
>
<Upload className="h-4 w-4 me-2" />
{t('From local file')}
</DropdownMenuItem>
</ImportFlowDialog>
)}
</DropdownMenuContent>
</DropdownMenu>
</PermissionNeededTooltip>
);
};

View File

@@ -0,0 +1,292 @@
import { QueryClient, useMutation, useQuery } from '@tanstack/react-query';
import { t } from 'i18next';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { useApErrorDialogStore } from '@/components/custom/ap-error-dialog/ap-error-dialog-store';
import { useSocket } from '@/components/socket-provider';
import { internalErrorToast } from '@/components/ui/sonner';
import { flowRunsApi } from '@/features/flow-runs/lib/flow-runs-api';
import { pieceSelectorUtils } from '@/features/pieces/lib/piece-selector-utils';
import { piecesApi } from '@/features/pieces/lib/pieces-api';
import { stepUtils } from '@/features/pieces/lib/step-utils';
import { flagsHooks } from '@/hooks/flags-hooks';
import { authenticationSession } from '@/lib/authentication-session';
import { downloadFile } from '@/lib/utils';
import {
ApFlagId,
FlowOperationType,
FlowRun,
FlowStatus,
FlowVersion,
FlowVersionMetadata,
ListFlowsRequest,
PopulatedFlow,
FlowTrigger,
FlowTriggerType,
WebsocketClientEvent,
FlowStatusUpdatedResponse,
isNil,
ErrorCode,
} from '@activepieces/shared';
import { flowsApi } from './flows-api';
import { flowsUtils } from './flows-utils';
const createFlowsQueryKey = (projectId: string) => ['flows', projectId];
export const flowHooks = {
invalidateFlowsQuery: (queryClient: QueryClient) => {
queryClient.invalidateQueries({
queryKey: createFlowsQueryKey(authenticationSession.getProjectId()!),
});
},
useFlows: (request: Omit<ListFlowsRequest, 'projectId'>) => {
return useQuery({
queryKey: createFlowsQueryKey(authenticationSession.getProjectId()!),
queryFn: async () => {
return await flowsApi.list({
...request,
projectId: authenticationSession.getProjectId()!,
});
},
staleTime: 5 * 1000,
});
},
useChangeFlowStatus: ({
flowId,
change,
onSuccess,
setIsPublishing,
}: UseChangeFlowStatusParams) => {
const { data: enableFlowOnPublish } = flagsHooks.useFlag<boolean>(
ApFlagId.ENABLE_FLOW_ON_PUBLISH,
);
const socket = useSocket();
const { openDialog } = useApErrorDialogStore();
return useMutation({
mutationFn: async () => {
if (change === 'publish') {
setIsPublishing?.(true);
}
return await new Promise<FlowStatusUpdatedResponse>(
(resolve, reject) => {
const onUpdateFinish = (response: FlowStatusUpdatedResponse) => {
if (response.flow.id !== flowId) {
return;
}
socket.off(
WebsocketClientEvent.FLOW_STATUS_UPDATED,
onUpdateFinish,
);
resolve(response);
};
socket.on(WebsocketClientEvent.FLOW_STATUS_UPDATED, onUpdateFinish);
flowsApi
.update(flowId, {
type:
change === 'publish'
? FlowOperationType.LOCK_AND_PUBLISH
: FlowOperationType.CHANGE_STATUS,
request: {
status:
change === 'publish'
? enableFlowOnPublish
? FlowStatus.ENABLED
: FlowStatus.DISABLED
: change,
},
})
.then(() => {})
.catch((error) => {
reject(error);
});
},
);
},
onSuccess: (response: FlowStatusUpdatedResponse) => {
if (change === 'publish') {
setIsPublishing?.(false);
}
if (!isNil(response.error)) {
openDialog({
title:
change === 'publish'
? t('Publish failed')
: t('Status update failed'),
description: (
<p>
{t(
'An error occurred while changing the flow status. This may be due to an issue in the trigger piece or its settings.',
)}
</p>
),
error: {
standardError: response.error.params.standardError,
standardOutput: response.error.params.standardOutput || '',
},
});
return;
}
onSuccess?.(response);
},
onError: (error: unknown) => {
const errorCode = (error as any)?.response?.data?.code;
const errorMessage = (error as any)?.response?.data?.params?.message;
if (
errorCode === ErrorCode.FLOW_OPERATION_IN_PROGRESS &&
errorMessage
) {
toast.error(t('Flow Is Busy'), {
description: errorMessage,
duration: 5000,
});
} else {
internalErrorToast();
}
},
});
},
useExportFlows: () => {
return useMutation({
mutationFn: async (flows: PopulatedFlow[]) => {
if (flows.length === 0) {
return flows;
}
if (flows.length === 1) {
await flowsUtils.downloadFlow(flows[0].id);
return flows;
}
await downloadFile({
obj: await flowsUtils.zipFlows(flows),
fileName: 'flows',
extension: 'zip',
});
return flows;
},
onSuccess: (res) => {
if (res.length > 0) {
toast.success(
res.length === 1
? t(`${res[0].version.displayName} has been exported.`)
: t('Flows have been exported.'),
{
duration: 3000,
},
);
}
},
});
},
useFetchFlowVersion: ({
onSuccess,
}: {
onSuccess: (flowVersion: FlowVersion) => void;
}) => {
return useMutation<FlowVersion, Error, FlowVersionMetadata>({
mutationFn: async (flowVersion) => {
const result = await flowsApi.get(flowVersion.flowId, {
versionId: flowVersion.id,
});
return result.version;
},
onSuccess,
});
},
useOverWriteDraftWithVersion: ({
onSuccess,
}: {
onSuccess: (flowVersion: PopulatedFlow) => void;
}) => {
return useMutation<PopulatedFlow, Error, FlowVersionMetadata>({
mutationFn: async (flowVersion) => {
const result = await flowsApi.update(flowVersion.flowId, {
type: FlowOperationType.USE_AS_DRAFT,
request: {
versionId: flowVersion.id,
},
});
return result;
},
onSuccess,
});
},
useCreateMcpFlow: () => {
const navigate = useNavigate();
return useMutation({
mutationFn: async () => {
const flow = await flowsApi.create({
projectId: authenticationSession.getProjectId()!,
displayName: t('Untitled'),
});
const mcpPiece = await piecesApi.get({
name: '@activepieces/piece-mcp',
});
const trigger = mcpPiece.triggers['mcp_tool'];
if (!trigger) {
throw new Error('MCP trigger not found');
}
const stepData = pieceSelectorUtils.getDefaultStepValues({
stepName: 'trigger',
pieceSelectorItem: {
actionOrTrigger: trigger,
type: FlowTriggerType.PIECE,
pieceMetadata: stepUtils.mapPieceToMetadata({
piece: mcpPiece,
type: 'trigger',
}),
},
}) as FlowTrigger;
await flowsApi.update(flow.id, {
type: FlowOperationType.UPDATE_TRIGGER,
request: stepData,
});
return flow;
},
onSuccess: (flow) => {
navigate(`/flows/${flow.id}/`);
},
});
},
useGetFlow: (flowId: string) => {
return useQuery({
queryKey: ['flow', flowId],
queryFn: async () => {
try {
return await flowsApi.get(flowId);
} catch (err) {
console.error(err);
return null;
}
},
staleTime: 0,
});
},
useTestFlow: ({
flowVersionId,
onUpdateRun,
}: {
flowVersionId: string;
onUpdateRun: (run: FlowRun) => void;
}) => {
const socket = useSocket();
return useMutation<void>({
mutationFn: () =>
flowRunsApi.testFlow(
socket,
{
flowVersionId,
},
onUpdateRun,
),
});
},
};
type UseChangeFlowStatusParams = {
flowId: string;
change: 'publish' | FlowStatus;
onSuccess: (flow: FlowStatusUpdatedResponse) => void;
setIsPublishing?: (isPublishing: boolean) => void;
};

View File

@@ -0,0 +1,88 @@
import { t } from 'i18next';
import { toast } from 'sonner';
import { UNSAVED_CHANGES_TOAST } from '@/components/ui/sonner';
import { api } from '@/lib/api';
import { GetFlowTemplateRequestQuery } from '@activepieces/ee-shared';
import {
CreateFlowRequest,
ErrorCode,
FlowOperationRequest,
FlowVersion,
FlowVersionMetadata,
GetFlowQueryParamsRequest,
ListFlowVersionRequest,
ListFlowsRequest,
PopulatedFlow,
SharedTemplate,
SeekPage,
} from '@activepieces/shared';
export const flowsApi = {
list(request: ListFlowsRequest): Promise<SeekPage<PopulatedFlow>> {
return api.get<SeekPage<PopulatedFlow>>('/v1/flows', request);
},
create(request: CreateFlowRequest) {
return api.post<PopulatedFlow>('/v1/flows', request);
},
update(
flowId: string,
request: FlowOperationRequest,
showErrorToast = false,
) {
return api
.post<PopulatedFlow>(`/v1/flows/${flowId}`, request)
.catch((error) => {
if (showErrorToast) {
const errorCode: ErrorCode | undefined = (
error.response?.data as { code: ErrorCode }
)?.code;
if (errorCode === ErrorCode.FLOW_IN_USE) {
toast.error(t('Flow Is In Use'), {
description: t(
'Flow is being used by another user, please try again later.',
),
duration: Infinity,
action: {
label: t('Refresh'),
onClick: () => window.location.reload(),
},
});
} else {
toast.error(UNSAVED_CHANGES_TOAST.title, {
description: UNSAVED_CHANGES_TOAST.description,
duration: UNSAVED_CHANGES_TOAST.duration,
id: UNSAVED_CHANGES_TOAST.id,
});
}
}
throw error;
});
},
getTemplate(flowId: string, request: GetFlowTemplateRequestQuery) {
return api.get<SharedTemplate>(`/v1/flows/${flowId}/template`, {
params: request,
});
},
get(
flowId: string,
request?: GetFlowQueryParamsRequest,
): Promise<PopulatedFlow> {
return api.get<PopulatedFlow>(`/v1/flows/${flowId}`, request);
},
listVersions(
flowId: string,
request: ListFlowVersionRequest,
): Promise<SeekPage<FlowVersionMetadata>> {
return api.get<SeekPage<FlowVersion>>(
`/v1/flows/${flowId}/versions`,
request,
);
},
delete(flowId: string) {
return api.delete<void>(`/v1/flows/${flowId}`);
},
count() {
return api.get<number>('/v1/flows/count');
},
};

View File

@@ -0,0 +1,76 @@
import cronstrue from 'cronstrue/i18n';
import { t } from 'i18next';
import JSZip from 'jszip';
import { TimerReset, TriangleAlert, Zap } from 'lucide-react';
import { downloadFile } from '@/lib/utils';
import { PopulatedFlow, FlowTriggerType } from '@activepieces/shared';
import { flowsApi } from './flows-api';
const downloadFlow = async (flowId: string) => {
const template = await flowsApi.getTemplate(flowId, {});
downloadFile({
obj: JSON.stringify(template, null, 2),
fileName: template.name,
extension: 'json',
});
};
const zipFlows = async (flows: PopulatedFlow[]) => {
const zip = new JSZip();
for (const flow of flows) {
const template = await flowsApi.getTemplate(flow.id, {});
zip.file(
`${flow.version.displayName}_${flow.id}.json`,
JSON.stringify(template, null, 2),
);
}
return zip;
};
export const flowsUtils = {
downloadFlow,
zipFlows,
flowStatusToolTipRenderer: (flow: PopulatedFlow) => {
const trigger = flow.version.trigger;
switch (trigger?.type) {
case FlowTriggerType.PIECE: {
const cronExpression = flow.triggerSource?.schedule?.cronExpression;
return cronExpression
? `${t('Run')} ${cronstrue
.toString(cronExpression, { locale: 'en' })
.toLocaleLowerCase()}`
: t('Real time flow');
}
case FlowTriggerType.EMPTY:
console.error(
t("Flow can't be published with empty trigger {name}", {
name: flow.version.displayName,
}),
);
return t('Please contact support as your published flow has a problem');
}
},
flowStatusIconRenderer: (flow: PopulatedFlow) => {
const trigger = flow.version.trigger;
switch (trigger?.type) {
case FlowTriggerType.PIECE: {
const cronExpression = flow.triggerSource?.schedule?.cronExpression;
if (cronExpression) {
return <TimerReset className="h-4 w-4 text-foreground" />;
} else {
return <Zap className="h-4 w-4 text-foreground fill-foreground" />;
}
}
case FlowTriggerType.EMPTY: {
console.error(
t("Flow can't be published with empty trigger {name}", {
name: flow.version.displayName,
}),
);
return <TriangleAlert className="h-4 w-4 text-destructive" />;
}
}
},
};

View File

@@ -0,0 +1,8 @@
import { api } from '@/lib/api';
import { GetSampleDataRequest } from '@activepieces/shared';
export const sampleDataApi = {
get(request: GetSampleDataRequest) {
return api.get<unknown>(`/v1/sample-data`, request);
},
};

View File

@@ -0,0 +1,104 @@
import { useQuery, QueryClient } from '@tanstack/react-query';
import {
flowStructureUtil,
FlowVersion,
SampleDataFileType,
} from '@activepieces/shared';
import { sampleDataApi } from './sample-data-api';
export const sampleDataHooks = {
useSampleDataForFlow: (
flowVersion: FlowVersion | undefined,
projectId: string | undefined,
) => {
return useQuery({
queryKey: ['sampleData', flowVersion?.id],
enabled: !!flowVersion,
staleTime: 0,
retry: 4,
refetchOnWindowFocus: false,
queryFn: async () => {
const steps = flowStructureUtil.getAllSteps(flowVersion!.trigger);
const singleStepSampleData = await Promise.all(
steps.map(async (step) => {
return {
[step.name]: await getSampleData(
flowVersion!,
step.name,
projectId!,
SampleDataFileType.OUTPUT,
),
};
}),
);
const sampleData: Record<string, unknown> = {};
singleStepSampleData.forEach((stepData) => {
Object.assign(sampleData, stepData);
});
return sampleData;
},
});
},
useSampleDataInputForFlow: (
flowVersion: FlowVersion | undefined,
projectId: string | undefined,
) => {
return useQuery({
queryKey: ['sampleDataInput', flowVersion?.id],
enabled: !!flowVersion,
staleTime: 0,
retry: 4,
refetchOnWindowFocus: false,
queryFn: async () => {
const steps = flowStructureUtil.getAllSteps(flowVersion!.trigger);
const singleStepSampleDataInput = await Promise.all(
steps.map(async (step) => {
return {
[step.name]: step.settings.sampleData?.sampleDataInputFileId
? await getSampleData(
flowVersion!,
step.name,
projectId!,
SampleDataFileType.INPUT,
)
: undefined,
};
}),
);
const sampleDataInput: Record<string, unknown> = {};
singleStepSampleDataInput.forEach((stepData) => {
Object.assign(sampleDataInput, stepData);
});
return sampleDataInput;
},
});
},
invalidateSampleData: (flowVersionId: string, queryClient: QueryClient) => {
queryClient.invalidateQueries({ queryKey: ['sampleData', flowVersionId] });
queryClient.invalidateQueries({
queryKey: ['sampleDataInput', flowVersionId],
});
},
};
async function getSampleData(
flowVersion: FlowVersion,
stepName: string,
projectId: string,
type: SampleDataFileType,
): Promise<unknown> {
return sampleDataApi
.get({
flowId: flowVersion.flowId,
flowVersionId: flowVersion.id,
stepName,
projectId,
type,
})
.catch((error) => {
console.error(error);
return undefined;
});
}

View File

@@ -0,0 +1,55 @@
import { FlowVersionTemplate, Template } from '@activepieces/shared';
export const templateUtils = {
parseTemplate: (jsonString: string): Template | null => {
try {
const parsed = JSON.parse(jsonString);
let template: Template;
if (
parsed.flows &&
Array.isArray(parsed.flows) &&
parsed.flows.length > 0
) {
template = parsed as Template;
} else if (parsed.template && parsed.name) {
template = {
...parsed,
flows: [parsed.template],
} as Template;
delete (template as any).template;
} else {
return null;
}
const { flows, name } = template;
if (!flows?.[0] || !name || !flows[0].trigger) {
return null;
}
return template;
} catch {
return null;
}
},
extractFlow: (jsonString: string): FlowVersionTemplate | null => {
try {
const parsed = JSON.parse(jsonString);
if (
parsed.flows &&
Array.isArray(parsed.flows) &&
parsed.flows.length > 0
) {
return parsed.flows[0] as FlowVersionTemplate;
} else if (parsed.template) {
return parsed.template as FlowVersionTemplate;
}
return null;
} catch {
return null;
}
},
};

View File

@@ -0,0 +1,23 @@
import { useQuery } from '@tanstack/react-query';
import { SeekPage, TriggerEventWithPayload } from '@activepieces/shared';
import { triggerEventsApi } from './trigger-events-api';
export const triggerEventHooks = {
usePollResults: (flowVersionId: string, flowId: string) => {
const { data: pollResults, refetch } = useQuery<
SeekPage<TriggerEventWithPayload>
>({
queryKey: ['triggerEvents', flowVersionId],
queryFn: () =>
triggerEventsApi.list({
flowId: flowId,
limit: 5,
cursor: undefined,
}),
staleTime: 0,
});
return { pollResults, refetch };
},
};

View File

@@ -0,0 +1,31 @@
import { api } from '@/lib/api';
import {
ListTriggerEventsRequest,
SaveTriggerEventRequest,
SeekPage,
TestTriggerRequestBody,
TriggerEventWithPayload,
} from '@activepieces/shared';
export const triggerEventsApi = {
test(request: TestTriggerRequestBody) {
return api.post<SeekPage<TriggerEventWithPayload>>(
'/v1/test-trigger',
request,
);
},
list(
request: ListTriggerEventsRequest,
): Promise<SeekPage<TriggerEventWithPayload>> {
return api.get<SeekPage<TriggerEventWithPayload>>(
'/v1/trigger-events',
request,
);
},
saveTriggerMockdata(request: SaveTriggerEventRequest) {
return api.post<TriggerEventWithPayload>(`/v1/trigger-events`, {
flowId: request.flowId,
mockData: request.mockData,
});
},
};

View File

@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { TriggerStatusReport } from '@activepieces/shared';
export const triggerRunApi = {
getStatusReport: async (): Promise<TriggerStatusReport> => {
return api.get<TriggerStatusReport>('/v1/trigger-runs/status');
},
};
export const triggerRunHooks = {
useStatusReport: () => {
return useQuery({
queryKey: ['trigger-status-report'],
queryFn: triggerRunApi.getStatusReport,
});
},
};

View File

@@ -0,0 +1,202 @@
import { t } from 'i18next';
import { CornerUpLeft, Download, Trash2, UploadCloud } from 'lucide-react';
import { useMemo } from 'react';
import { PermissionNeededTooltip } from '@/components/custom/permission-needed-tooltip';
import { ConfirmationDeleteDialog } from '@/components/delete-dialog';
import { useEmbedding } from '@/components/embed-provider';
import { Button } from '@/components/ui/button';
import { BulkAction } from '@/components/ui/data-table';
import { LoadingSpinner } from '@/components/ui/spinner';
import { PublishedNeededTooltip } from '@/features/project-releases/components/published-tooltip';
import { PushToGitDialog } from '@/features/project-releases/components/push-to-git-dialog';
import { gitSyncHooks } from '@/features/project-releases/lib/git-sync-hooks';
import { useAuthorization } from '@/hooks/authorization-hooks';
import { platformHooks } from '@/hooks/platform-hooks';
import { authenticationSession } from '@/lib/authentication-session';
import { GitBranchType } from '@activepieces/ee-shared';
import {
FlowVersionState,
Permission,
PopulatedFlow,
} from '@activepieces/shared';
import { MoveFlowDialog } from '../components/move-flow-dialog';
import { CreateFlowDropdown } from './create-flow-dropdown';
import { flowHooks } from './flow-hooks';
import { flowsApi } from './flows-api';
export const useFlowsBulkActions = ({
selectedRows,
refresh,
setSelectedRows,
setRefresh,
refetch,
folderId,
}: {
selectedRows: PopulatedFlow[];
refresh: number;
setSelectedRows: (selectedRows: PopulatedFlow[]) => void;
setRefresh: (refresh: number) => void;
refetch: () => void;
folderId: string;
}) => {
const userHasPermissionToUpdateFlow = useAuthorization().checkAccess(
Permission.WRITE_FLOW,
);
const userHasPermissionToWriteFolder = useAuthorization().checkAccess(
Permission.WRITE_FOLDER,
);
const userHasPermissionToWriteProjectRelease = useAuthorization().checkAccess(
Permission.WRITE_PROJECT_RELEASE,
);
const allowPush = selectedRows.every(
(flow) =>
flow.publishedVersionId !== null &&
flow.version.state === FlowVersionState.LOCKED,
);
const { embedState } = useEmbedding();
const { platform } = platformHooks.useCurrentPlatform();
const { gitSync } = gitSyncHooks.useGitSync(
authenticationSession.getProjectId()!,
platform.plan.environmentsEnabled,
);
const isDevelopmentBranch =
gitSync && gitSync.branchType === GitBranchType.DEVELOPMENT;
const { mutate: exportFlows, isPending: isExportPending } =
flowHooks.useExportFlows();
return useMemo(() => {
const showMoveFlow =
!embedState.hideFolders &&
(userHasPermissionToUpdateFlow || userHasPermissionToWriteFolder);
const bulkActions: BulkAction<PopulatedFlow>[] = [
{
render: (_, resetSelection) => {
return (
<div
className="flex gap-2 items-center"
onClick={(e) => e.stopPropagation()}
>
{userHasPermissionToWriteProjectRelease &&
allowPush &&
selectedRows.length > 0 && (
<PermissionNeededTooltip
hasPermission={userHasPermissionToWriteProjectRelease}
>
<PublishedNeededTooltip allowPush={allowPush}>
<PushToGitDialog type="flow" flows={selectedRows}>
<Button variant="outline">
<UploadCloud className="h-4 w-4 mr-2" />
{t('Push to Git')}
</Button>
</PushToGitDialog>
</PublishedNeededTooltip>
</PermissionNeededTooltip>
)}
{showMoveFlow && selectedRows.length > 0 && (
<PermissionNeededTooltip
hasPermission={
userHasPermissionToUpdateFlow ||
userHasPermissionToWriteFolder
}
>
<MoveFlowDialog
flows={selectedRows}
onMoveTo={() => {
setRefresh(refresh + 1);
resetSelection();
setSelectedRows([]);
refetch();
}}
>
<Button variant="outline">
<CornerUpLeft className="size-4 mr-2" />
{t('Move To')}
</Button>
</MoveFlowDialog>
</PermissionNeededTooltip>
)}
{selectedRows.length > 0 && (
<Button
variant="outline"
onClick={() => {
exportFlows(selectedRows);
resetSelection();
setSelectedRows([]);
}}
>
{isExportPending ? (
<LoadingSpinner className="size-4 mr-2" />
) : (
<Download className="size-4 mr-2" />
)}
{isExportPending ? t('Exporting') : t('Export')}
</Button>
)}
{userHasPermissionToUpdateFlow && selectedRows.length > 0 && (
<PermissionNeededTooltip
hasPermission={userHasPermissionToUpdateFlow}
>
<ConfirmationDeleteDialog
title={`${t('Delete')} Selected Flows`}
message={
<>
<div>
{t(
'Are you sure you want to delete these flows? This will permanently delete the flows, all their data and any background runs.',
)}
</div>
{isDevelopmentBranch && (
<div className="font-bold mt-2">
{t(
'You are on a development branch, this will not delete the flows from the remote repository.',
)}
</div>
)}
</>
}
mutationFn={async () => {
await Promise.all(
selectedRows.map((flow) => flowsApi.delete(flow.id)),
);
setRefresh(refresh + 1);
resetSelection();
setSelectedRows([]);
refetch();
}}
entityName={t('flow')}
>
<Button variant="destructive">
<Trash2 className="h-4 w-4 mr-2" />
{t('Delete')}
</Button>
</ConfirmationDeleteDialog>
</PermissionNeededTooltip>
)}
<CreateFlowDropdown refetch={refetch} folderId={folderId} />
</div>
);
},
},
];
return bulkActions;
}, [
userHasPermissionToUpdateFlow,
userHasPermissionToWriteFolder,
userHasPermissionToWriteProjectRelease,
selectedRows,
refresh,
allowPush,
embedState.hideFolders,
isDevelopmentBranch,
exportFlows,
isExportPending,
setRefresh,
setSelectedRows,
refetch,
]);
};

View File

@@ -0,0 +1,167 @@
import { typeboxResolver } from '@hookform/resolvers/typebox';
import { Static, Type } from '@sinclair/typebox';
import {
QueryObserverResult,
RefetchOptions,
useMutation,
} from '@tanstack/react-query';
import { HttpStatusCode } from 'axios';
import { t } from 'i18next';
import { FolderPlus } from 'lucide-react';
import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { FormField, FormItem, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { internalErrorToast } from '@/components/ui/sonner';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useAuthorization } from '@/hooks/authorization-hooks';
import { api } from '@/lib/api';
import { authenticationSession } from '@/lib/authentication-session';
import { cn } from '@/lib/utils';
import { FolderDto, Permission } from '@activepieces/shared';
import { foldersApi } from '../lib/folders-api';
type CreateFolderDialogProps = {
updateSearchParams: (_folderId?: string) => void;
refetchFolders: (
options?: RefetchOptions,
) => Promise<QueryObserverResult<FolderDto[], Error>>;
className?: string;
};
const CreateFolderFormSchema = Type.Object({
displayName: Type.String({
errorMessage: t('Please enter folder name'),
pattern: '.*\\S.*',
}),
});
type CreateFolderFormSchema = Static<typeof CreateFolderFormSchema>;
export const CreateFolderDialog = ({
updateSearchParams,
refetchFolders,
className,
}: CreateFolderDialogProps) => {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const form = useForm<CreateFolderFormSchema>({
resolver: typeboxResolver(CreateFolderFormSchema),
});
const { checkAccess } = useAuthorization();
const userHasPermissionToUpdateFolders = checkAccess(Permission.WRITE_FOLDER);
const { mutate, isPending } = useMutation<
FolderDto,
Error,
CreateFolderFormSchema
>({
mutationFn: async (data) => {
return await foldersApi.create({
displayName: data.displayName.trim(),
projectId: authenticationSession.getProjectId()!,
});
},
onSuccess: (folder) => {
form.reset();
setIsDialogOpen(false);
updateSearchParams(folder.id);
refetchFolders();
toast.success(t('Added folder successfully'));
},
onError: (error) => {
if (api.isError(error)) {
switch (error.response?.status) {
case HttpStatusCode.Conflict: {
form.setError('root.serverError', {
message: t('The folder name already exists.'),
});
break;
}
default: {
internalErrorToast();
break;
}
}
}
},
});
return (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
variant="ghost"
disabled={!userHasPermissionToUpdateFolders}
size="icon"
className={cn(className)}
>
<FolderPlus />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent side="right">{t('New folder')}</TooltipContent>
</Tooltip>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('New Folder')}</DialogTitle>
</DialogHeader>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit((data) => mutate(data))}>
<FormField
control={form.control}
name="displayName"
render={({ field }) => (
<FormItem>
<Input
{...field}
required
id="folder"
placeholder={t('Folder Name')}
className="rounded-sm"
/>
<FormMessage />
</FormItem>
)}
/>
{form?.formState?.errors?.root?.serverError && (
<FormMessage>
{form.formState.errors.root.serverError.message}
</FormMessage>
)}
<DialogFooter>
<Button
variant={'outline'}
onClick={() => setIsDialogOpen(false)}
type="button"
>
{t('Cancel')}
</Button>
<Button type="submit" loading={isPending}>
{t('Confirm')}
</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,136 @@
import { t } from 'i18next';
import { EllipsisVertical, Pencil, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { PermissionNeededTooltip } from '@/components/custom/permission-needed-tooltip';
import { ConfirmationDeleteDialog } from '@/components/delete-dialog';
import { Button, buttonVariants } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAuthorization } from '@/hooks/authorization-hooks';
import { cn } from '@/lib/utils';
import { FolderDto, Permission } from '@activepieces/shared';
import { foldersApi } from '../lib/folders-api';
import { RenameFolderDialog } from './rename-folder-dialog';
type FolderActionsProps = {
folder: FolderDto;
hideFlowCount?: boolean;
refetch: () => void;
};
export const FolderActions = ({
folder,
refetch,
hideFlowCount,
}: FolderActionsProps) => {
const [isActionMenuOpen, setIsActionMenuOpen] = useState(false);
const { checkAccess } = useAuthorization();
const userHasPermissionToUpdateFolders = checkAccess(Permission.WRITE_FOLDER);
const showFlowCount = !hideFlowCount;
const showDropdown = userHasPermissionToUpdateFolders;
const hasOverlayBehavior = showFlowCount && showDropdown;
return (
<div
onClick={(e) => e.stopPropagation()}
className="flex items-center justify-center relative ml-auto"
>
{showFlowCount && (
<span
className={cn(
'text-muted-foreground text-xs! font-semibold! self-end transition-opacity duration-150',
buttonVariants({ size: 'icon', variant: 'ghost' }),
{
'opacity-100 group-hover/item:opacity-0':
hasOverlayBehavior && !isActionMenuOpen,
'opacity-0': hasOverlayBehavior && isActionMenuOpen,
'opacity-100': !hasOverlayBehavior,
},
)}
>
{folder.numberOfFlows}
</span>
)}
{showDropdown && (
<DropdownMenu onOpenChange={setIsActionMenuOpen} modal={true}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
'transition-opacity !bg-transparent duration-150',
hasOverlayBehavior ? 'absolute inset-0' : '',
{
'opacity-0 group-hover/item:opacity-100':
(hasOverlayBehavior && !isActionMenuOpen) ||
!hasOverlayBehavior,
'opacity-100': hasOverlayBehavior && isActionMenuOpen,
},
)}
>
<EllipsisVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<PermissionNeededTooltip
hasPermission={userHasPermissionToUpdateFolders}
>
<RenameFolderDialog
folderId={folder.id}
name={folder.displayName}
onRename={() => refetch()}
>
<DropdownMenuItem
disabled={!userHasPermissionToUpdateFolders}
onSelect={(e) => e.preventDefault()}
>
<div className="flex flex-row gap-2 items-center">
<Pencil className="h-4 w-4" />
<span>{t('Rename')}</span>
</div>
</DropdownMenuItem>
</RenameFolderDialog>
</PermissionNeededTooltip>
<PermissionNeededTooltip
hasPermission={userHasPermissionToUpdateFolders}
>
<ConfirmationDeleteDialog
title={t('Delete {folderName}', {
folderName: folder.displayName,
})}
message={t(
'If you delete this folder, we will keep its flows and move them to Uncategorized.',
)}
mutationFn={async () => {
await foldersApi.delete(folder.id);
refetch();
}}
entityName={folder.displayName}
>
<DropdownMenuItem
disabled={!userHasPermissionToUpdateFolders}
onSelect={(e) => e.preventDefault()}
>
<div className="flex flex-row gap-2 items-center">
<Trash2 className="h-4 w-4 text-destructive" />
<span className="text-destructive">{t('Delete')}</span>
</div>
</DropdownMenuItem>
</ConfirmationDeleteDialog>
</PermissionNeededTooltip>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
};

View File

@@ -0,0 +1,27 @@
import { t } from 'i18next';
import { Skeleton } from '@/components/ui/skeleton';
import { foldersHooks } from '../lib/folders-hooks';
type FolderBadgeProps = {
folderId: string;
};
const FolderBadge = ({ folderId }: FolderBadgeProps) => {
const { data } = foldersHooks.useFolder(folderId);
return (
<div>
{data ? (
<span>{data.displayName}</span>
) : (
<Skeleton
className="rounded-full h-6 w-24"
aria-label={t('Loading...')}
/>
)}
</div>
);
};
export { FolderBadge };

View File

@@ -0,0 +1,213 @@
import { useQuery } from '@tanstack/react-query';
import { t } from 'i18next';
import { Folder, Shapes, TableProperties } from 'lucide-react';
import { useEffect } from 'react';
import { useLocation, useSearchParams } from 'react-router-dom';
import { PermissionNeededTooltip } from '@/components/custom/permission-needed-tooltip';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Skeleton } from '@/components/ui/skeleton';
import { TextWithIcon } from '@/components/ui/text-with-icon';
import { flowsApi } from '@/features/flows/lib/flows-api';
import { useAuthorization } from '@/hooks/authorization-hooks';
import { authenticationSession } from '@/lib/authentication-session';
import { cn } from '@/lib/utils';
import {
FolderDto,
isNil,
Permission,
UncategorizedFolderId,
} from '@activepieces/shared';
import { foldersHooks } from '../lib/folders-hooks';
import { foldersUtils } from '../lib/folders-utils';
import { CreateFolderDialog } from './create-folder-dialog';
import { FolderActions } from './folder-actions';
const FolderIcon = () => {
return <Folder className="w-4 h-4" />;
};
type FolderItemProps = {
folder: FolderDto;
refetch: () => void;
updateSearchParams: (folderId: string | undefined) => void;
selectedFolderId: string | null;
};
const FolderItem = ({
folder,
refetch,
updateSearchParams,
selectedFolderId,
}: FolderItemProps) => {
return (
<div key={folder.id} className="group">
<Button
variant="ghost"
className={cn(
'w-full items-center justify-start group/item gap-2 pl-4 pr-0',
{
'bg-accent dark:bg-accent/50': selectedFolderId === folder.id,
},
)}
onClick={() => updateSearchParams(folder.id)}
>
<TextWithIcon
className="grow"
icon={<FolderIcon />}
text={
<div
className={cn(
'grow max-w-[150px] text-start truncate whitespace-nowrap overflow-hidden',
{
'font-medium': selectedFolderId === folder.id,
},
)}
>
{folder.displayName}
</div>
}
>
<FolderActions folder={folder} refetch={refetch} />
</TextWithIcon>
</Button>
</div>
);
};
const FolderFilterList = ({ refresh }: { refresh: number }) => {
const location = useLocation();
const { checkAccess } = useAuthorization();
const userHasPermissionToUpdateFolders = checkAccess(Permission.WRITE_FOLDER);
const [searchParams, setSearchParams] = useSearchParams(location.search);
const selectedFolderId = searchParams.get(folderIdParamName);
const updateSearchParams = (folderId: string | undefined) => {
const newQueryParameters: URLSearchParams = new URLSearchParams(
searchParams,
);
if (folderId) {
newQueryParameters.set(folderIdParamName, folderId);
} else {
newQueryParameters.delete(folderIdParamName);
}
newQueryParameters.delete('cursor');
setSearchParams(newQueryParameters);
};
const {
folders,
isLoading,
refetch: refetchFolders,
} = foldersHooks.useFolders();
const { data: allFlowsCount, refetch: refetchAllFlowsCount } = useQuery({
queryKey: ['flowsCount', authenticationSession.getProjectId()],
queryFn: flowsApi.count,
});
useEffect(() => {
refetchFolders();
refetchAllFlowsCount();
}, [refresh]);
const isInUncategorized = selectedFolderId === UncategorizedFolderId;
const isInAllFlows = isNil(selectedFolderId);
return (
<div className="mt-4">
<div className="flex flex-row items-center mb-2">
<span className="flex">{t('Folders')}</span>
<div className="grow"></div>
<div className="flex items-center justify-center">
<PermissionNeededTooltip
hasPermission={userHasPermissionToUpdateFolders}
>
<CreateFolderDialog
refetchFolders={refetchFolders}
updateSearchParams={updateSearchParams}
/>
</PermissionNeededTooltip>
</div>
</div>
<div className="flex w-[250px] h-full flex-col gap-y-1">
<Button
variant="accent"
className={cn('flex w-full justify-start bg-background pl-4 pr-0', {
'bg-muted': isInAllFlows,
})}
onClick={() => updateSearchParams(undefined)}
>
<TextWithIcon
icon={<TableProperties className="w-4 h-4"></TableProperties>}
text={
<div className="grow whitespace-break-spaces break-all text-start truncate">
{t('All flows')}
</div>
}
/>
<div className="grow"></div>
<div className="flex flex-row -space-x-4">
<span className="size-9 flex items-center justify-center text-muted-foreground">
{allFlowsCount}
</span>
</div>
</Button>
<Button
variant="ghost"
className={cn('flex w-full justify-start bg-background pl-4 pr-0', {
'bg-accent dark:bg-accent/50': isInUncategorized,
})}
onClick={() => updateSearchParams(UncategorizedFolderId)}
>
<TextWithIcon
icon={<Shapes className="w-4 h-4"></Shapes>}
text={
<div className="grow whitespace-break-spaces break-all text-start truncate">
{t('Uncategorized')}
</div>
}
/>
<div className="grow"></div>
<div className="flex flex-row -space-x-4">
<span className="size-9 flex items-center justify-center text-muted-foreground">
{foldersUtils.extractUncategorizedFlows(allFlowsCount, folders)}
</span>
</div>
</Button>
<Separator />
<ScrollArea type="auto">
<div className="flex flex-col w-full gap-y-1 max-h-[590px]">
{isLoading && (
<div className="flex flex-col gap-2">
{Array.from(Array(5)).map((_, index) => (
<Skeleton key={index} className="rounded-md w-full h-8" />
))}
</div>
)}
{folders &&
folders.map((folder) => {
return (
<FolderItem
key={folder.id}
folder={folder}
refetch={refetchFolders}
selectedFolderId={selectedFolderId}
updateSearchParams={updateSearchParams}
/>
);
})}
</div>
</ScrollArea>
</div>
</div>
);
};
const folderIdParamName = 'folderId';
export { FolderFilterList, folderIdParamName };

View File

@@ -0,0 +1,134 @@
import { typeboxResolver } from '@hookform/resolvers/typebox';
import { Static, Type } from '@sinclair/typebox';
import { useMutation } from '@tanstack/react-query';
import { t } from 'i18next';
import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { FormField, FormItem, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { internalErrorToast } from '@/components/ui/sonner';
import { validationUtils } from '@/lib/utils';
import { Folder } from '@activepieces/shared';
import { foldersApi } from '../lib/folders-api';
const RenameFolderSchema = Type.Object({
displayName: Type.String({
errorMessage: t('Please enter a folder name'),
pattern: '.*\\S.*',
}),
});
type RenameFolderSchema = Static<typeof RenameFolderSchema>;
const RenameFolderDialog = ({
children,
folderId,
onRename,
name,
}: {
children: React.ReactNode;
folderId: string;
onRename: () => void;
name: string;
}) => {
const [isOpen, setIsOpen] = useState(false);
const form = useForm<RenameFolderSchema>({
resolver: typeboxResolver(RenameFolderSchema),
});
const { mutate, isPending } = useMutation<Folder, Error, RenameFolderSchema>({
mutationFn: async (data) => {
return await foldersApi.renameFolder(folderId, {
displayName: data.displayName.trim(),
});
},
onSuccess: () => {
setIsOpen(false);
onRename();
toast.success(t('Renamed flow successfully'));
},
onError: (err) => {
if (validationUtils.isValidationError(err)) {
form.setError('displayName', {
message: t('Folder name already used'),
});
} else {
internalErrorToast();
}
},
});
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="w-full" asChild>
{children}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t('Rename')} {name}
</DialogTitle>
</DialogHeader>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit((data) => mutate(data))}>
<FormField
name="displayName"
render={({ field }) => (
<FormItem>
<Input
{...field}
required
id="displayName"
placeholder={t('New Folder Name')}
className="rounded-sm"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
form.handleSubmit((data) => mutate(data))();
}
}}
/>
<FormMessage />
</FormItem>
)}
/>
{form?.formState?.errors?.root?.serverError && (
<FormMessage>
{form.formState.errors.root.serverError.message}
</FormMessage>
)}
<DialogFooter>
<Button
variant={'outline'}
type="button"
onClick={(e) => {
e.preventDefault();
setIsOpen(false);
}}
>
{t('Cancel')}
</Button>
<Button type="submit" loading={isPending}>
{t('Confirm')}
</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);
};
export { RenameFolderDialog };

View File

@@ -0,0 +1,34 @@
import { api } from '@/lib/api';
import { authenticationSession } from '@/lib/authentication-session';
import {
CreateFolderRequest,
Folder,
FolderDto,
ListFolderRequest,
UpdateFolderRequest,
} from '@activepieces/shared';
export const foldersApi = {
async list(): Promise<FolderDto[]> {
const request: ListFolderRequest = {
cursor: undefined,
limit: 1000000,
projectId: authenticationSession.getProjectId()!,
};
const response = await api.get<any>('/v1/folders', request);
return response.data || [];
},
get(folderId: string) {
return api.get<Folder>(`/v1/folders/${folderId}`);
},
create(req: CreateFolderRequest) {
return api.post<FolderDto>('/v1/folders', req);
},
delete(folderId: string) {
return api.delete<void>(`/v1/folders/${folderId}`);
},
renameFolder(folderId: string, req: UpdateFolderRequest) {
return api.post<Folder>(`/v1/folders/${folderId}`, req);
},
};

View File

@@ -0,0 +1,34 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { authenticationSession } from '@/lib/authentication-session';
import { UncategorizedFolderId } from '@activepieces/shared';
import { foldersApi } from './folders-api';
const folderListQueryKey = ['folders', authenticationSession.getProjectId()];
export const foldersHooks = {
folderListQueryKey,
useQueryClient: null as any,
useFolders: () => {
foldersHooks.useQueryClient = useQueryClient();
const folderQuery = useQuery({
queryKey: folderListQueryKey,
queryFn: () => foldersApi.list(),
});
return {
folders: folderQuery.data,
isLoading: folderQuery.isLoading,
refetch: folderQuery.refetch,
};
},
useFolder: (folderId: string) => {
return useQuery({
queryKey: ['folder', folderId],
queryFn: () => foldersApi.get(folderId),
enabled: folderId !== UncategorizedFolderId,
});
},
};

View File

@@ -0,0 +1,16 @@
import { FolderDto } from '@activepieces/shared';
export const foldersUtils = {
extractUncategorizedFlows: (
allFlowsCount?: number,
folders?: FolderDto[],
) => {
let uncategorizedCount = allFlowsCount ?? 0;
folders?.forEach((folder) => {
uncategorizedCount = uncategorizedCount - folder.numberOfFlows;
});
return uncategorizedCount;
},
};

View File

@@ -0,0 +1,325 @@
import { typeboxResolver } from '@hookform/resolvers/typebox';
import { Separator } from '@radix-ui/react-dropdown-menu';
import { TSchema, Type } from '@sinclair/typebox';
import { useMutation } from '@tanstack/react-query';
import { t } from 'i18next';
import { useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useLocation } from 'react-router-dom';
import { toast } from 'sonner';
import { ApMarkdown } from '@/components/custom/markdown';
import { ShowPoweredBy } from '@/components/show-powered-by';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Form,
FormControl,
FormField,
FormLabel,
FormItem,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { ReadMoreDescription } from '@/components/ui/read-more-description';
import { Textarea } from '@/components/ui/textarea';
import { flagsHooks } from '@/hooks/flags-hooks';
import { api } from '@/lib/api';
import {
ApFlagId,
FileResponseInterface,
FormInput,
FormInputType,
FormResponse,
HumanInputFormResultTypes,
HumanInputFormResult,
createKeyForFormInput,
} from '@activepieces/shared';
import { Checkbox } from '../../../components/ui/checkbox';
import { humanInputApi } from '../lib/human-input-api';
type ApFormProps = {
form: FormResponse;
useDraft: boolean;
};
type FormInputWithName = FormInput & {
name: string;
};
/**We do this because it was the behaviour in previous versions of Activepieces.*/
const putBackQuotesForInputNames = (
value: Record<string, unknown>,
inputs: FormInputWithName[],
) => {
return inputs.reduce((acc, input) => {
const key = createKeyForFormInput(input.displayName);
acc[key] = value[key];
return acc;
}, {} as Record<string, unknown>);
};
const requiredPropertySettings = {
minLength: 1,
errorMessage: t('This field is required'),
};
const createPropertySchema = (input: FormInputWithName) => {
const schemaSettings = input.required ? requiredPropertySettings : {};
switch (input.type) {
case FormInputType.TOGGLE:
return Type.Boolean(schemaSettings);
case FormInputType.TEXT:
case FormInputType.TEXT_AREA:
return Type.String(schemaSettings);
case FormInputType.FILE:
return Type.Unknown(schemaSettings);
}
};
function buildSchema(inputs: FormInputWithName[]) {
return {
properties: Type.Object(
inputs.reduce<Record<string, TSchema>>((acc, input) => {
acc[input.name] = createPropertySchema(input);
return acc;
}, {}),
),
defaultValues: inputs.reduce<Record<string, string | boolean>>(
(acc, input) => {
acc[input.name] = input.type === FormInputType.TOGGLE ? false : '';
return acc;
},
{},
),
};
}
const handleDownloadFile = (fileBase: FileResponseInterface) => {
const link = document.createElement('a');
if ('url' in fileBase) {
link.href = fileBase.url;
} else {
link.download = fileBase.fileName;
link.href = fileBase.base64Url;
URL.revokeObjectURL(fileBase.base64Url);
}
link.target = '_blank';
link.rel = 'noreferrer noopener';
link.click();
};
const ApForm = ({ form, useDraft }: ApFormProps) => {
const location = useLocation();
const queryParams = new URLSearchParams(location.search);
const queryParamsLowerCase = Array.from(queryParams.entries()).reduce(
(acc, [key, value]) => {
acc[key.toLowerCase()] = value;
return acc;
},
{} as Record<string, string>,
);
const inputs = useRef<FormInputWithName[]>(
form.props.inputs.map((input) => {
return {
...input,
name: createKeyForFormInput(input.displayName),
};
}),
);
const schema = buildSchema(inputs.current);
const defaultValues = { ...schema.defaultValues };
inputs.current.forEach((input) => {
const queryValue = queryParamsLowerCase[input.name.toLowerCase()];
if (queryValue !== undefined) {
defaultValues[input.name] = queryValue;
}
});
const [markdownResponse, setMarkdownResponse] = useState<string | null>(null);
const { data: showPoweredBy } = flagsHooks.useFlag<boolean>(
ApFlagId.SHOW_POWERED_BY_IN_FORM,
);
const reactForm = useForm({
defaultValues,
resolver: typeboxResolver(schema.properties),
});
const { mutate, isPending } = useMutation<HumanInputFormResult | null, Error>(
{
mutationFn: async () =>
humanInputApi.submitForm(
form,
useDraft,
putBackQuotesForInputNames(reactForm.getValues(), inputs.current),
),
onSuccess: (formResult) => {
switch (formResult?.type) {
case HumanInputFormResultTypes.MARKDOWN: {
setMarkdownResponse(formResult.value as string);
if (formResult.files) {
formResult.files.forEach((file) => {
handleDownloadFile(file as FileResponseInterface);
});
}
break;
}
case HumanInputFormResultTypes.FILE:
handleDownloadFile(formResult.value as FileResponseInterface);
break;
default:
toast.success(t('Your submission was successfully received.'), {
duration: 3000,
});
break;
}
},
onError: (error) => {
if (api.isError(error)) {
const status = error.response?.status;
if (status === 404) {
toast.error(t('Flow not found'), {
description: t(
'The flow you are trying to submit to does not exist.',
),
duration: 3000,
});
} else {
toast.error(t('The flow failed to execute.'), {
duration: 3000,
});
}
}
console.error(error);
},
},
);
return (
<div className="w-full h-full flex">
<div className="container py-20">
<Form {...reactForm}>
<form onSubmit={(e) => reactForm.handleSubmit(() => mutate())(e)}>
<Card className="w-[500px] mx-auto">
<CardHeader>
<CardTitle className="text-center">{form?.title}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid w-full items-center gap-3">
{inputs.current.map((input) => {
return (
<FormField
key={input.name}
control={reactForm.control}
name={input.name}
render={({ field }) => (
<>
{input.type === FormInputType.TOGGLE && (
<>
<FormItem className="flex items-center gap-2 h-full">
<FormControl>
<Checkbox
onCheckedChange={(e) => field.onChange(e)}
checked={field.value as boolean}
></Checkbox>
</FormControl>
<FormLabel
htmlFor={input.name}
className="flex items-center"
>
{input.displayName}
</FormLabel>
</FormItem>
<ReadMoreDescription
text={input.description ?? ''}
/>
</>
)}
{input.type !== FormInputType.TOGGLE && (
<FormItem className="flex flex-col gap-1">
<FormLabel
htmlFor={input.name}
className="flex items-center justify-between"
>
{input.displayName} {input.required && '*'}
</FormLabel>
<FormControl className="flex flex-col gap-1">
<>
{input.type === FormInputType.TEXT_AREA && (
<Textarea
{...field}
name={input.name}
id={input.name}
onChange={field.onChange}
value={
field.value as string | undefined
}
/>
)}
{input.type === FormInputType.TEXT && (
<Input
{...field}
onChange={field.onChange}
id={input.name}
name={input.name}
value={
field.value as string | undefined
}
/>
)}
{input.type === FormInputType.FILE && (
<Input
name={input.name}
id={input.name}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
field.onChange(file);
}
}}
placeholder={input.displayName}
type="file"
/>
)}
<ReadMoreDescription
text={input.description ?? ''}
/>
</>
</FormControl>
</FormItem>
)}
</>
)}
/>
);
})}
</div>
<Button
type="submit"
className="w-full mt-4"
loading={isPending}
>
{t('Submit')}
</Button>
{markdownResponse && (
<>
<Separator className="my-4" />
<ApMarkdown markdown={markdownResponse} />
</>
)}
</CardContent>
</Card>
<div className="mt-2">
<ShowPoweredBy position="static" show={showPoweredBy ?? false} />
</div>
</form>
</Form>
</div>
</div>
);
};
ApForm.displayName = 'ApForm';
export { ApForm };

Some files were not shown because too many files have changed in this diff Show More