index.tsx•17 kB
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation } from '@tanstack/react-query';
import { t } from 'i18next';
import { PencilIcon, Plus, TrashIcon } from 'lucide-react';
import { useState } from 'react';
import { useForm, UseFormReturn } from 'react-hook-form';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { LoadingSpinner } from '@/components/ui/spinner';
import { Textarea } from '@/components/ui/textarea';
import { gitSyncHooks } from '@/features/git-sync/lib/git-sync-hooks';
import { projectReleaseApi } from '@/features/project-version/lib/project-release-api';
import { platformHooks } from '@/hooks/platform-hooks';
import { authenticationSession } from '@/lib/authentication-session';
import {
AgentOperationType,
ConnectionOperationType,
DiffReleaseRequest,
ProjectReleaseType,
ProjectSyncPlan,
TableOperationType,
} from '@activepieces/shared';
import { OperationChange } from './operation-change';
type CreateReleaseDialogProps = {
open: boolean;
setOpen: (open: boolean) => void;
refetch: () => void;
loading: boolean;
diffRequest: DiffReleaseRequest;
plan: ProjectSyncPlan;
defaultName?: string;
};
const formSchema = z.object({
name: z.string().min(1, t('Name is required')),
description: z.string().optional(),
});
type FormData = z.infer<typeof formSchema>;
type CreateReleaseDialogContentProps = {
form: UseFormReturn<FormData>;
loading: boolean;
diffRequest: DiffReleaseRequest;
plan: ProjectSyncPlan;
setOpen: (open: boolean) => void;
refetch: () => void;
};
const CreateReleaseDialogContent = ({
loading,
diffRequest,
plan,
form,
setOpen,
refetch,
}: CreateReleaseDialogContentProps) => {
const isThereAnyChanges =
(plan?.flows && plan?.flows.length > 0) ||
(plan?.tables && plan?.tables.length > 0) ||
(plan?.agents && plan?.agents.length > 0);
const { platform } = platformHooks.useCurrentPlatform();
const { gitSync } = gitSyncHooks.useGitSync(
authenticationSession.getProjectId()!,
platform.plan.environmentsEnabled,
);
const { mutate: applyChanges, isPending } = useMutation({
mutationFn: async () => {
switch (diffRequest.type) {
case ProjectReleaseType.GIT:
if (!gitSync) {
throw new Error('Git sync is not connected');
}
await projectReleaseApi.create({
name: form.getValues('name'),
description: form.getValues('description'),
selectedFlowsIds: Array.from(selectedChanges),
type: diffRequest.type,
projectId: authenticationSession.getProjectId()!,
});
break;
case ProjectReleaseType.PROJECT:
if (!diffRequest.targetProjectId) {
throw new Error('Project ID is required');
}
await projectReleaseApi.create({
name: form.getValues('name'),
description: form.getValues('description'),
selectedFlowsIds: Array.from(selectedChanges),
targetProjectId: diffRequest.targetProjectId,
type: diffRequest.type,
projectId: authenticationSession.getProjectId()!,
});
break;
case ProjectReleaseType.ROLLBACK:
await projectReleaseApi.create({
name: form.getValues('name'),
description: form.getValues('description'),
selectedFlowsIds: Array.from(selectedChanges),
projectReleaseId: diffRequest.projectReleaseId,
type: diffRequest.type,
projectId: authenticationSession.getProjectId()!,
});
break;
}
},
onSuccess: () => {
refetch();
setOpen(false);
},
});
const [selectedChanges, setSelectedChanges] = useState<Set<string>>(
new Set(plan?.flows.map((op) => op.flow.id) || []),
);
const [errorMessage, setErrorMessage] = useState('');
const handleSelectAll = (checked: boolean) => {
if (!plan) return;
setSelectedChanges(
new Set(checked ? plan.flows.map((op) => op.flow.id) : []),
);
};
return (
<>
{loading && (
<div className="flex items-center justify-center h-24">
<LoadingSpinner />
</div>
)}
{!loading && isThereAnyChanges && (
<div className="space-y-4">
<div className="flex flex-col gap-2">
<Label className="text-sm" htmlFor="name">
{t('Name')}
</Label>
<Input
id="name"
{...form.register('name')}
onChange={(e) => {
if (e.target.value) {
form.setError('name', { message: '' });
}
}}
placeholder={t('Meeting Summary Flow')}
/>
{form.formState.errors.name && (
<p className="text-sm text-destructive">
{form.formState.errors.name.message}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label className="text-sm" htmlFor="description">
{t('Description')}
</Label>
<Textarea
id="description"
{...form.register('description')}
placeholder={t('Added new features and fixed bugs')}
/>
{form.formState.errors.description && (
<p className="text-sm text-destructive">
{form.formState.errors.description.message}
</p>
)}
</div>
{plan?.flows && plan?.flows.length > 0 && (
<div className="space-y-2 ">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 py-2 border-b">
<Checkbox
checked={selectedChanges.size === plan?.flows.length}
onCheckedChange={handleSelectAll}
/>
<Label className="text-sm font-medium">
{t('Flows Changes')} ({selectedChanges.size}/
{plan?.flows.length || 0})
</Label>
</div>
</div>
<ScrollArea viewPortClassName="max-h-[15vh]">
{plan?.flows.map((operation) => (
<OperationChange
key={operation.flow.id}
change={operation}
selected={selectedChanges.has(operation.flow.id)}
onSelect={(checked) => {
const newSelectedChanges = new Set(selectedChanges);
if (checked) {
newSelectedChanges.add(operation.flow.id);
} else {
newSelectedChanges.delete(operation.flow.id);
}
setErrorMessage('');
setSelectedChanges(newSelectedChanges);
}}
/>
))}
</ScrollArea>
</div>
)}
{plan?.connections && plan?.connections.length > 0 && (
<div className="space-y-2">
<div className="flex flex-col gap-2">
<div className="flex flex-col justify -center gap-1 py-2 border-b">
<Label className="text-sm font-medium">
{t('Connections Changes')} ({plan?.connections?.length || 0}
)
</Label>
<div className="flex items-center text-sm text-muted-foreground">
<span className="flex items-center gap-2">
{t(
'New connections are placeholders and need to be reconnected again',
)}
</span>
</div>
</div>
<ScrollArea viewPortClassName="max-h-[10vh]">
{plan?.connections.map((connection, index) => (
<div
key={connection.connectionState.externalId}
className="flex items-center gap-2 text-sm py-1"
>
{connection.type ===
ConnectionOperationType.UPDATE_CONNECTION && (
<div className="flex items-center gap-2">
<PencilIcon className="w-4 h-4 shrink-0" />
<div className="flex items-center gap-1">
<span>
{connection.connectionState.displayName}
</span>
<span> {t('renamed to')} </span>
<span>
{connection.newConnectionState.displayName}
</span>
</div>
</div>
)}
{connection.type ===
ConnectionOperationType.CREATE_CONNECTION && (
<div className="flex items-center gap-2">
<Plus className="w-4 h-4 shrink-0 text-success" />
<span className="text-success">
{connection.connectionState.displayName}
</span>
</div>
)}
</div>
))}
</ScrollArea>
</div>
</div>
)}
{plan?.tables && plan?.tables.length > 0 && (
<div className="space-y-2">
<div className="flex flex-col gap-2">
<div className="flex flex-col justify -center gap-1 py-2 border-b">
<Label className="text-sm font-medium">
{t('Tables Changes')} ({plan?.tables?.length || 0})
</Label>
</div>
<ScrollArea viewPortClassName="max-h-[10vh]">
{plan?.tables.map((table, index) => (
<div
key={table.tableState.externalId}
className="flex items-center gap-2 text-sm py-1"
>
{table.type === TableOperationType.UPDATE_TABLE && (
<div className="flex items-center gap-2">
<PencilIcon className="w-4 h-4 shrink-0" />
<div className="flex items-center gap-1">
<span>{table.tableState.name}</span>
</div>
</div>
)}
{table.type === TableOperationType.CREATE_TABLE && (
<div className="flex items-center gap-2">
<Plus className="w-4 h-4 shrink-0 text-success" />
<span className="text-success">
{table.tableState.name}
</span>
</div>
)}
{table.type === TableOperationType.DELETE_TABLE && (
<div className="flex items-center gap-2">
<TrashIcon className="w-4 h-4 shrink-0 text-destructive" />
<span className="text-destructive">
{table.tableState.name}
</span>
</div>
)}
</div>
))}
</ScrollArea>
</div>
</div>
)}
{plan?.agents && plan?.agents.length > 0 && (
<div className="space-y-2">
<div className="flex flex-col gap-2">
<div className="flex flex-col justify -center gap-1 py-2 border-b">
<Label className="text-sm font-medium">
{t('Agents Changes')} ({plan?.agents?.length || 0})
</Label>
</div>
<ScrollArea viewPortClassName="max-h-[10vh]">
{plan?.agents.map((agent, index) => (
<div
key={agent.agentState.externalId}
className="flex items-center gap-2 text-sm py-1"
>
{agent.type === AgentOperationType.UPDATE_AGENT && (
<div className="flex items-center gap-2">
<PencilIcon className="w-4 h-4 shrink-0" />
<div className="flex items-center gap-1">
<span>{agent.agentState.displayName}</span>
</div>
</div>
)}
{agent.type === AgentOperationType.CREATE_AGENT && (
<div className="flex items-center gap-2">
<Plus className="w-4 h-4 shrink-0 text-success" />
<span className="text-success">
{agent.agentState.displayName}
</span>
</div>
)}
{agent.type === AgentOperationType.DELETE_AGENT && (
<div className="flex items-center gap-2">
<TrashIcon className="w-4 h-4 shrink-0 text-destructive" />
<span className="text-destructive">
{agent.agentState.displayName}
</span>
</div>
)}
</div>
))}
</ScrollArea>
</div>
</div>
)}
{errorMessage && (
<p className="text-sm text-destructive">{errorMessage}</p>
)}
</div>
)}
{loading ||
(!loading && !isThereAnyChanges && (
<div className="text-sm py-2">{t('No changes to apply')}</div>
))}
{!loading && isThereAnyChanges && (
<DialogFooter className=" items-end gap-1 ">
<Button
size={'sm'}
variant={'outline'}
onClick={() => setOpen(false)}
>
{t('Cancel')}
</Button>
<Button
size={'sm'}
loading={isPending}
disabled={isPending}
onClick={() => {
let error = false;
if (form.getValues('name').trim() === '') {
form.setError('name', { message: 'Release name is required' });
error = true;
}
if (
selectedChanges.size === 0 &&
plan.tables.length === 0 &&
plan.agents.length === 0
) {
setErrorMessage(
'Please select at least one change to include in the release',
);
error = true;
}
if (error) {
return;
}
applyChanges();
}}
>
{t('Apply Changes')}
</Button>
</DialogFooter>
)}
</>
);
};
const CreateReleaseDialog = ({
open,
setOpen,
refetch,
plan,
loading,
defaultName = '',
diffRequest,
}: CreateReleaseDialogProps) => {
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
name: defaultName,
description: '',
},
});
return (
<Dialog
modal={true}
open={open}
onOpenChange={(newOpenState: boolean) => {
if (newOpenState) {
form.reset({
name: defaultName,
description: '',
});
}
setOpen(newOpenState);
}}
>
<DialogContent className="min-h-[100px] max-h-[850px] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>
{diffRequest.type === ProjectReleaseType.GIT
? t('Create Git Release')
: diffRequest.type === ProjectReleaseType.PROJECT
? t('Create Project Release')
: `${t('Create Rollback to')} ${form.getValues('name')}`}
</DialogTitle>
</DialogHeader>
<CreateReleaseDialogContent
key={`${loading}`}
loading={loading}
diffRequest={diffRequest}
plan={plan}
form={form}
setOpen={setOpen}
refetch={refetch}
/>
</DialogContent>
</Dialog>
);
};
export { CreateReleaseDialog };