Add Activepieces integration for workflow automation

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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