import-flow-dialog.tsx•10.1 kB
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 { 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 { LoadingSpinner } from '@/components/ui/spinner';
import { INTERNAL_ERROR_TOAST, toast } from '@/components/ui/use-toast';
import { foldersHooks } from '@/features/folders/lib/folders-hooks';
import { api } from '@/lib/api';
import { authenticationSession } from '@/lib/authentication-session';
import {
FlowOperationType,
FlowTemplate,
PopulatedFlow,
TelemetryEventName,
} from '@activepieces/shared';
import { FormError } from '../../../components/ui/form';
import { flowsApi } from '../lib/flows-api';
export type ImportFlowDialogProps =
| {
insideBuilder: false;
onRefresh: () => void;
}
| {
insideBuilder: true;
flowId: string;
};
const readTemplateJson = async (
templateFile: File,
): Promise<FlowTemplate | null> => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => {
try {
const template = JSON.parse(reader.result as string) as FlowTemplate;
const { template: tmpl, name } = template;
if (!tmpl || !name || !tmpl.trigger) {
resolve(null);
} else {
resolve(template);
}
} catch {
resolve(null);
}
};
reader.readAsText(templateFile);
});
};
const ImportFlowDialog = (
props: ImportFlowDialogProps & { children: React.ReactNode },
) => {
const { capture } = useTelemetry();
const [templates, setTemplates] = useState<FlowTemplate[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const [errorMessage, setErrorMessage] = useState('');
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [failedFiles, setFailedFiles] = useState<string[]>([]);
const [selectedFolderName, setSelectedFolderName] = useState<
string | undefined
>(undefined);
const { folders, isLoading } = foldersHooks.useFolders();
const navigate = useNavigate();
const { mutate: importFlows, isPending } = useMutation<
PopulatedFlow[],
Error,
FlowTemplate[]
>({
mutationFn: async (templates: FlowTemplate[]) => {
const importPromises = templates.map(async (template) => {
const flow = props.insideBuilder
? await flowsApi.get(props.flowId)
: await flowsApi.create({
displayName: template.name,
projectId: authenticationSession.getProjectId()!,
folderName:
selectedFolderName === undefined ||
selectedFolderName === 'Uncategorized'
? undefined
: selectedFolderName,
});
return await flowsApi.update(flow.id, {
type: FlowOperationType.IMPORT_FLOW,
request: {
displayName: template.name,
trigger: template.template.trigger,
schemaVersion: template.template.schemaVersion,
},
});
});
return Promise.all(importPromises);
},
onSuccess: (flows: PopulatedFlow[]) => {
capture({
name: TelemetryEventName.FLOW_IMPORTED_USING_FILE,
payload: {
location: props.insideBuilder
? 'inside the builder'
: 'inside dashboard',
multiple: flows.length > 1,
},
});
toast({
title: t(`flowsImported`, {
flowsCount: flows.length,
}),
variant: 'default',
});
if (flows.length === 1) {
navigate(`/flows/${flows[0].id}`, { replace: true });
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 {
toast(INTERNAL_ERROR_TOAST);
}
},
});
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: FlowTemplate[] = [];
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);
};
const handleFolderSelect = (folderName: string | undefined) => {
setSelectedFolderName(folderName);
};
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={handleFolderSelect}
defaultValue={selectedFolderName}
>
<SelectTrigger>
<SelectValue placeholder={t('Select a folder')} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>{t('Folders')}</SelectLabel>
<SelectItem value="Uncategorized">
{t('Uncategorized')}
</SelectItem>
{folders?.map((folder) => (
<SelectItem key={folder.id} value={folder.displayName}>
{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 };