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:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -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}`);
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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> {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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 }),
|
||||
}),
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
@@ -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" />;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
]);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user