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,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,
|
||||
]);
|
||||
};
|
||||
Reference in New Issue
Block a user