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:
75
activepieces-fork/packages/react-ui/src/app/app.tsx
Normal file
75
activepieces-fork/packages/react-ui/src/app/app.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
DefaultErrorFunction,
|
||||
SetErrorFunction,
|
||||
} from '@sinclair/typebox/errors';
|
||||
import {
|
||||
MutationCache,
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
} from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { EmbeddingProvider } from '@/components/embed-provider';
|
||||
import TelemetryProvider from '@/components/telemetry-provider';
|
||||
import { ThemeProvider } from '@/components/theme-provider';
|
||||
import { internalErrorToast, Toaster } from '@/components/ui/sonner';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { useManagePlanDialogStore } from '@/features/billing/lib/active-flows-addon-dialog-state';
|
||||
import { RefreshAnalyticsProvider } from '@/features/platform-admin/lib/refresh-analytics-context';
|
||||
import { api } from '@/lib/api';
|
||||
import { ErrorCode, isNil } from '@activepieces/shared';
|
||||
|
||||
import { EmbeddingFontLoader } from './components/embedding-font-loader';
|
||||
import { InitialDataGuard } from './components/initial-data-guard';
|
||||
import { ApRouter } from './guards';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
mutationCache: new MutationCache({
|
||||
onError: (err: Error, _, __, mutation) => {
|
||||
if (api.isApError(err, ErrorCode.QUOTA_EXCEEDED)) {
|
||||
const { openDialog } = useManagePlanDialogStore.getState();
|
||||
openDialog();
|
||||
} else if (isNil(mutation.options.onError)) {
|
||||
internalErrorToast();
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
let typesFormatsAdded = false;
|
||||
|
||||
if (!typesFormatsAdded) {
|
||||
SetErrorFunction((error) => {
|
||||
return error?.schema?.errorMessage ?? DefaultErrorFunction(error);
|
||||
});
|
||||
typesFormatsAdded = true;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const { i18n } = useTranslation();
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RefreshAnalyticsProvider>
|
||||
<EmbeddingProvider>
|
||||
<InitialDataGuard>
|
||||
<EmbeddingFontLoader>
|
||||
<TelemetryProvider>
|
||||
<TooltipProvider>
|
||||
<React.Fragment key={i18n.language}>
|
||||
<ThemeProvider storageKey="vite-ui-theme">
|
||||
<ApRouter />
|
||||
<Toaster position="bottom-right" />
|
||||
</ThemeProvider>
|
||||
</React.Fragment>
|
||||
</TooltipProvider>
|
||||
</TelemetryProvider>
|
||||
</EmbeddingFontLoader>
|
||||
</InitialDataGuard>
|
||||
</EmbeddingProvider>
|
||||
</RefreshAnalyticsProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,213 @@
|
||||
import { QuestionMarkCircledIcon } from '@radix-ui/react-icons';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { t } from 'i18next';
|
||||
import { ChevronDown, Logs } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
createSearchParams,
|
||||
useNavigate,
|
||||
useSearchParams,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import {
|
||||
LeftSideBarType,
|
||||
RightSideBarType,
|
||||
useBuilderStateContext,
|
||||
} from '@/app/builder/builder-hooks';
|
||||
import { PageHeader } from '@/components/custom/page-header';
|
||||
import { useEmbedding } from '@/components/embed-provider';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import EditableText from '@/components/ui/editable-text';
|
||||
import { HomeButton } from '@/components/ui/home-button';
|
||||
import { flowHooks } from '@/features/flows/lib/flow-hooks';
|
||||
import { foldersHooks } from '@/features/folders/lib/folders-hooks';
|
||||
import { useAuthorization } from '@/hooks/authorization-hooks';
|
||||
import { flagsHooks } from '@/hooks/flags-hooks';
|
||||
import { getProjectName, projectHooks } from '@/hooks/project-hooks';
|
||||
import { authenticationSession } from '@/lib/authentication-session';
|
||||
import { useNewWindow } from '@/lib/navigation-utils';
|
||||
import { NEW_FLOW_QUERY_PARAM } from '@/lib/utils';
|
||||
import {
|
||||
ApFlagId,
|
||||
FlowOperationType,
|
||||
FlowVersionState,
|
||||
Permission,
|
||||
supportUrl,
|
||||
UncategorizedFolderId,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import FlowActionMenu from '../../components/flow-actions-menu';
|
||||
|
||||
import { BuilderFlowStatusSection } from './flow-status';
|
||||
|
||||
export const BuilderHeader = () => {
|
||||
const [queryParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const openNewWindow = useNewWindow();
|
||||
const { data: showSupport } = flagsHooks.useFlag<boolean>(
|
||||
ApFlagId.SHOW_COMMUNITY,
|
||||
);
|
||||
|
||||
const hasPermissionToReadRuns = useAuthorization().checkAccess(
|
||||
Permission.READ_FLOW,
|
||||
);
|
||||
const [
|
||||
flow,
|
||||
flowVersion,
|
||||
setLeftSidebar,
|
||||
moveToFolderClientSide,
|
||||
applyOperation,
|
||||
setRightSidebar,
|
||||
] = useBuilderStateContext((state) => [
|
||||
state.flow,
|
||||
state.flowVersion,
|
||||
state.setLeftSidebar,
|
||||
state.moveToFolderClientSide,
|
||||
state.applyOperation,
|
||||
state.setRightSidebar,
|
||||
]);
|
||||
|
||||
const { embedState } = useEmbedding();
|
||||
const { project } = projectHooks.useCurrentProject();
|
||||
|
||||
const { data: folderData } = foldersHooks.useFolder(
|
||||
flow.folderId ?? UncategorizedFolderId,
|
||||
);
|
||||
|
||||
const isLatestVersion =
|
||||
flowVersion.state === FlowVersionState.DRAFT ||
|
||||
flowVersion.id === flow.publishedVersionId;
|
||||
const [isEditingFlowName, setIsEditingFlowName] = useState(false);
|
||||
useEffect(() => {
|
||||
setIsEditingFlowName(queryParams.get(NEW_FLOW_QUERY_PARAM) === 'true');
|
||||
}, []);
|
||||
|
||||
const goToFlowsPage = () => {
|
||||
navigate({
|
||||
pathname: authenticationSession.appendProjectRoutePrefix('/flows'),
|
||||
search: createSearchParams({
|
||||
folderId: folderData?.id ?? UncategorizedFolderId,
|
||||
}).toString(),
|
||||
});
|
||||
};
|
||||
|
||||
const titleContent = (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{!embedState.disableNavigationInBuilder && (
|
||||
<>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink
|
||||
onClick={goToFlowsPage}
|
||||
className="cursor-pointer text-base"
|
||||
>
|
||||
{getProjectName(project)}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
</>
|
||||
)}
|
||||
{!embedState.hideFlowNameInBuilder && (
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>
|
||||
<div className="flex items-center gap-1 text-base">
|
||||
<EditableText
|
||||
className="hover:cursor-text"
|
||||
value={flowVersion.displayName}
|
||||
readonly={!isLatestVersion}
|
||||
onValueChange={(value) => {
|
||||
applyOperation(
|
||||
{
|
||||
type: FlowOperationType.CHANGE_NAME,
|
||||
request: {
|
||||
displayName: value,
|
||||
},
|
||||
},
|
||||
() => {
|
||||
flowHooks.invalidateFlowsQuery(queryClient);
|
||||
},
|
||||
);
|
||||
}}
|
||||
isEditing={isEditingFlowName}
|
||||
setIsEditing={setIsEditingFlowName}
|
||||
tooltipContent=""
|
||||
/>
|
||||
<FlowActionMenu
|
||||
onVersionsListClick={() => {
|
||||
setRightSidebar(RightSideBarType.VERSIONS);
|
||||
}}
|
||||
insideBuilder={true}
|
||||
flow={flow}
|
||||
flowVersion={flowVersion}
|
||||
readonly={!isLatestVersion}
|
||||
onDelete={goToFlowsPage}
|
||||
onRename={() => {
|
||||
setIsEditingFlowName(true);
|
||||
}}
|
||||
onMoveTo={(folderId) => moveToFolderClientSide(folderId)}
|
||||
onDuplicate={() => {}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-6 flex items-center justify-center"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</FlowActionMenu>
|
||||
</div>
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
)}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
|
||||
const rightContent = (
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
{showSupport && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="gap-2 px-2"
|
||||
onClick={() => openNewWindow(supportUrl)}
|
||||
>
|
||||
<QuestionMarkCircledIcon className="w-4 h-4"></QuestionMarkCircledIcon>
|
||||
{t('Support')}
|
||||
</Button>
|
||||
)}
|
||||
{hasPermissionToReadRuns && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setLeftSidebar(LeftSideBarType.RUNS)}
|
||||
className="gap-2 px-2"
|
||||
>
|
||||
<Logs className="w-4 h-4" />
|
||||
{t('Runs')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<BuilderFlowStatusSection></BuilderFlowStatusSection>
|
||||
</div>
|
||||
);
|
||||
|
||||
const leftContent = embedState.isEmbedded ? <HomeButton /> : null;
|
||||
|
||||
return (
|
||||
<PageHeader
|
||||
title={titleContent}
|
||||
rightContent={rightContent}
|
||||
leftContent={leftContent}
|
||||
showBorder={true}
|
||||
className="select-none"
|
||||
hideSidebarTrigger={embedState.isEmbedded}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useBuilderStateContext } from '@/app/builder/builder-hooks';
|
||||
import { FlowStatusToggle } from '@/features/flows/components/flow-status-toggle';
|
||||
import { FlowVersionStateDot } from '@/features/flows/components/flow-version-state-dot';
|
||||
import { FlowVersionState } from '@activepieces/shared';
|
||||
|
||||
import { PublishButton } from './publish-button';
|
||||
import { EditFlowOrViewDraftButton } from './view-draft-or-edit-flow-button';
|
||||
const BuilderFlowStatusSection = React.memo(() => {
|
||||
const [flowVersion, flow] = useBuilderStateContext((state) => [
|
||||
state.flowVersion,
|
||||
state.flow,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center space-x-2">
|
||||
<FlowVersionStateDot
|
||||
state={flowVersion.state}
|
||||
versionId={flowVersion.id}
|
||||
publishedVersionId={flow.publishedVersionId}
|
||||
></FlowVersionStateDot>
|
||||
{(flow.publishedVersionId === flowVersion.id ||
|
||||
flowVersion.state === FlowVersionState.DRAFT) && (
|
||||
<FlowStatusToggle flow={flow}></FlowStatusToggle>
|
||||
)}
|
||||
</div>
|
||||
<EditFlowOrViewDraftButton />
|
||||
<PublishButton />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
BuilderFlowStatusSection.displayName = 'BuilderFlowStatusSection';
|
||||
export { BuilderFlowStatusSection };
|
||||
@@ -0,0 +1,88 @@
|
||||
import { t } from 'i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { flowHooks } from '@/features/flows/lib/flow-hooks';
|
||||
import { useAuthorization } from '@/hooks/authorization-hooks';
|
||||
import {
|
||||
FlowStatusUpdatedResponse,
|
||||
FlowVersionState,
|
||||
Permission,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { useBuilderStateContext } from '../../builder-hooks';
|
||||
|
||||
const PublishButton = () => {
|
||||
const { checkAccess } = useAuthorization();
|
||||
const [
|
||||
flowVersion,
|
||||
flow,
|
||||
setFlow,
|
||||
setVersion,
|
||||
isSaving,
|
||||
readonly,
|
||||
isPublishing,
|
||||
setIsPublishing,
|
||||
] = useBuilderStateContext((state) => [
|
||||
state.flowVersion,
|
||||
state.flow,
|
||||
state.setFlow,
|
||||
state.setVersion,
|
||||
state.saving,
|
||||
state.readonly,
|
||||
state.isPublishing,
|
||||
state.setIsPublishing,
|
||||
]);
|
||||
const isViewingDraft =
|
||||
flowVersion.state === FlowVersionState.DRAFT ||
|
||||
flowVersion.id === flow.publishedVersionId;
|
||||
const permissionToEditFlow = checkAccess(Permission.WRITE_FLOW);
|
||||
const isPublishedVersion = flowVersion.id === flow.publishedVersionId;
|
||||
const { mutate: publish } = flowHooks.useChangeFlowStatus({
|
||||
flowId: flow.id,
|
||||
change: 'publish',
|
||||
onSuccess: (response: FlowStatusUpdatedResponse) => {
|
||||
setFlow(response.flow);
|
||||
setVersion(response.flow.version);
|
||||
toast.success(t('Your flow is now published.'), {
|
||||
duration: 3000,
|
||||
});
|
||||
},
|
||||
setIsPublishing: setIsPublishing,
|
||||
});
|
||||
if (!permissionToEditFlow || !isViewingDraft || (readonly && !isPublishing)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild className="disabled:pointer-events-auto">
|
||||
<Button
|
||||
size={'sm'}
|
||||
loading={isSaving || isPublishing}
|
||||
disabled={isPublishedVersion || !flowVersion.valid}
|
||||
onClick={() => publish()}
|
||||
>
|
||||
{t('Publish')}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{isPublishedVersion
|
||||
? t('Latest version is published')
|
||||
: !flowVersion.valid
|
||||
? t('Your flow has incomplete steps')
|
||||
: t('Publish')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
PublishButton.displayName = 'PublishButton';
|
||||
export { PublishButton };
|
||||
@@ -0,0 +1,43 @@
|
||||
import { t } from 'i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLocation } from 'react-use';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAuthorization } from '@/hooks/authorization-hooks';
|
||||
import { FlowVersionState, Permission } from '@activepieces/shared';
|
||||
|
||||
import { useBuilderStateContext, useSwitchToDraft } from '../../builder-hooks';
|
||||
|
||||
const EditFlowOrViewDraftButton = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { checkAccess } = useAuthorization();
|
||||
const { switchToDraft, isSwitchingToDraftPending } = useSwitchToDraft();
|
||||
const [flowVersion, flowId, readonly, run] = useBuilderStateContext(
|
||||
(state) => [state.flowVersion, state.flow.id, state.readonly, state.run],
|
||||
);
|
||||
const isViewingDraft = flowVersion.state === FlowVersionState.DRAFT;
|
||||
const permissionToEditFlow = checkAccess(Permission.WRITE_FLOW);
|
||||
if (!readonly || (isViewingDraft && !run)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant={'outline'}
|
||||
loading={isSwitchingToDraftPending}
|
||||
onClick={() => {
|
||||
if (location.pathname?.includes('/runs')) {
|
||||
navigate(`/flows/${flowId}`);
|
||||
} else {
|
||||
switchToDraft();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{permissionToEditFlow ? t('Edit Flow') : t('View Draft')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
EditFlowOrViewDraftButton.displayName = 'EditFlowOrViewDraftButton';
|
||||
export { EditFlowOrViewDraftButton };
|
||||
1101
activepieces-fork/packages/react-ui/src/app/builder/builder-hooks.ts
Normal file
1101
activepieces-fork/packages/react-ui/src/app/builder/builder-hooks.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
import { useRef } from 'react';
|
||||
|
||||
import {
|
||||
BuilderInitialState,
|
||||
BuilderStateContext,
|
||||
BuilderStore,
|
||||
createBuilderStore,
|
||||
} from '@/app/builder/builder-hooks';
|
||||
import { useAuthorization } from '@/hooks/authorization-hooks';
|
||||
import { projectHooks } from '@/hooks/project-hooks';
|
||||
import { Permission } from '@activepieces/shared';
|
||||
|
||||
type BuilderStateProviderProps = React.PropsWithChildren<BuilderInitialState>;
|
||||
|
||||
export function BuilderStateProvider({
|
||||
children,
|
||||
outputSampleData: sampleData,
|
||||
inputSampleData: sampleDataInput,
|
||||
...props
|
||||
}: BuilderStateProviderProps) {
|
||||
const storeRef = useRef<BuilderStore>();
|
||||
const { checkAccess } = useAuthorization();
|
||||
const readonly = !checkAccess(Permission.WRITE_FLOW) || props.readonly;
|
||||
projectHooks.useReloadPageIfProjectIdChanged(props.flow.projectId);
|
||||
if (!storeRef.current) {
|
||||
storeRef.current = createBuilderStore({
|
||||
...props,
|
||||
readonly,
|
||||
outputSampleData: sampleData,
|
||||
inputSampleData: sampleDataInput,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<BuilderStateContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</BuilderStateContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { t } from 'i18next';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
import { flowStructureUtil } from '@activepieces/shared';
|
||||
|
||||
import { useApRipple } from '../../../components/theme-provider';
|
||||
import { Button } from '../../../components/ui/button';
|
||||
import { PieceIcon } from '../../../features/pieces/components/piece-icon';
|
||||
import { stepsHooks } from '../../../features/pieces/lib/steps-hooks';
|
||||
import { useBuilderStateContext } from '../builder-hooks';
|
||||
|
||||
import { DataSelectorTreeNode } from './type';
|
||||
|
||||
const ToggleIcon = ({ expanded }: { expanded: boolean }) => {
|
||||
const toggleIconSize = 15;
|
||||
return expanded ? (
|
||||
<ChevronUp height={toggleIconSize} width={toggleIconSize}></ChevronUp>
|
||||
) : (
|
||||
<ChevronDown height={toggleIconSize} width={toggleIconSize}></ChevronDown>
|
||||
);
|
||||
};
|
||||
|
||||
type DataSelectorNodeContentProps = {
|
||||
expanded: boolean;
|
||||
setExpanded: (expanded: boolean) => void;
|
||||
depth: number;
|
||||
node: DataSelectorTreeNode;
|
||||
};
|
||||
const handleKeyPress = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
if (event.target) {
|
||||
(event.target as HTMLDivElement).click();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const DataSelectorNodeContent = ({
|
||||
node,
|
||||
expanded,
|
||||
setExpanded,
|
||||
depth,
|
||||
}: DataSelectorNodeContentProps) => {
|
||||
const flowVersion = useBuilderStateContext((state) => state.flowVersion);
|
||||
const insertMention = useBuilderStateContext((state) => state.insertMention);
|
||||
|
||||
const [ripple, rippleEvent] = useApRipple();
|
||||
const step =
|
||||
node.data.type === 'value'
|
||||
? flowStructureUtil.getStep(node.data.propertyPath, flowVersion.trigger)
|
||||
: node.data.type === 'test'
|
||||
? flowStructureUtil.getStep(node.data.stepName, flowVersion.trigger)
|
||||
: undefined;
|
||||
const stepMetadata = step
|
||||
? stepsHooks.useStepMetadata({ step }).stepMetadata
|
||||
: undefined;
|
||||
const showInsertButton =
|
||||
node.data.type === 'value' && node.data.insertable && !node.isLoopStepNode;
|
||||
const showNodeValue = !node.children && node.data.type === 'value';
|
||||
const depthMultiplier = 23 / (1 + depth * 0.05);
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyPress}
|
||||
ref={ripple}
|
||||
onClick={(e) => {
|
||||
if (node.children && node.children.length > 0) {
|
||||
rippleEvent(e);
|
||||
setExpanded(!expanded);
|
||||
} else if (
|
||||
insertMention &&
|
||||
node.data.type === 'value' &&
|
||||
node.data.insertable
|
||||
) {
|
||||
rippleEvent(e);
|
||||
insertMention(node.data.propertyPath);
|
||||
}
|
||||
}}
|
||||
className="w-full max-w-full select-none focus:outline-hidden hover:bg-accent focus:bg-accent focus:bg-opacity-75 hover:bg-opacity-75 cursor-pointer group"
|
||||
>
|
||||
<div className="grow max-w-full flex items-center gap-2 min-h-[48px] pr-3 select-none">
|
||||
<div
|
||||
style={{
|
||||
minWidth: `${
|
||||
depth * depthMultiplier + (depth === 0 ? 0 : 12) + 18
|
||||
}px`,
|
||||
}}
|
||||
></div>
|
||||
{stepMetadata && (
|
||||
<div className="shrink-0">
|
||||
<PieceIcon
|
||||
displayName={stepMetadata.displayName}
|
||||
logoUrl={stepMetadata.logoUrl}
|
||||
showTooltip={false}
|
||||
circle={false}
|
||||
border={false}
|
||||
size="sm"
|
||||
></PieceIcon>
|
||||
</div>
|
||||
)}
|
||||
{node.data.type !== 'test' && (
|
||||
<div className=" truncate">{node.data.displayName}</div>
|
||||
)}
|
||||
|
||||
{showNodeValue && (
|
||||
<>
|
||||
<div className="shrink-0">:</div>
|
||||
<div className="flex-1 text-primary truncate">
|
||||
{`${node.data.type === 'value' ? node.data.value : ''}`}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex shrink-0 gap-2 items-center">
|
||||
{showInsertButton && (
|
||||
<Button
|
||||
className="z-50 hover:opacity-100 opacity-0 p-0 group-hover:p-1 group-hover:opacity-100 focus:opacity-100"
|
||||
variant="basic"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (insertMention) {
|
||||
insertMention(
|
||||
node.data.type === 'value' ? node.data.propertyPath : '',
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('Insert')}
|
||||
</Button>
|
||||
)}
|
||||
{node.children && node.children.length > 0 && (
|
||||
<div className="shrink-0 pr-5">
|
||||
<ToggleIcon expanded={expanded}></ToggleIcon>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
DataSelectorNodeContent.displayName = 'DataSelectorNodeContent';
|
||||
export { DataSelectorNodeContent };
|
||||
@@ -0,0 +1,70 @@
|
||||
import { CollapsibleContent } from '@radix-ui/react-collapsible';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleTrigger,
|
||||
} from '../../../components/ui/collapsible';
|
||||
|
||||
import { DataSelectorNodeContent } from './data-selector-node-content';
|
||||
import { TestStepSection } from './test-step-section';
|
||||
import { DataSelectorTreeNode } from './type';
|
||||
import { dataSelectorUtils } from './utils';
|
||||
|
||||
type DataSelectorNodeProps = {
|
||||
node: DataSelectorTreeNode;
|
||||
depth: number;
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
const DataSelectorNode = ({
|
||||
node,
|
||||
depth,
|
||||
searchTerm,
|
||||
}: DataSelectorNodeProps) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchTerm && depth <= 1) {
|
||||
setExpanded(true);
|
||||
} else if (!searchTerm) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [searchTerm, depth]);
|
||||
|
||||
const isTestStepNode = dataSelectorUtils.isTestStepNode(node);
|
||||
if (isTestStepNode) {
|
||||
return <TestStepSection stepName={node.data.stepName}></TestStepSection>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible className="w-full" open={expanded} onOpenChange={setExpanded}>
|
||||
<>
|
||||
<CollapsibleTrigger asChild={true} className="w-full relative">
|
||||
<DataSelectorNodeContent
|
||||
node={node}
|
||||
expanded={expanded}
|
||||
setExpanded={setExpanded}
|
||||
depth={depth}
|
||||
></DataSelectorNodeContent>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="w-full">
|
||||
{node.children && node.children.length > 0 && (
|
||||
<div className="flex flex-col ">
|
||||
{node.children.map((node) => (
|
||||
<DataSelectorNode
|
||||
depth={depth + 1}
|
||||
node={node}
|
||||
key={node.key}
|
||||
searchTerm={searchTerm}
|
||||
></DataSelectorNode>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
DataSelectorNode.displayName = 'DataSelectorNode';
|
||||
export { DataSelectorNode };
|
||||
@@ -0,0 +1,81 @@
|
||||
import { t } from 'i18next';
|
||||
import { ExpandIcon, MinusIcon, PanelRightDashedIcon } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
import { Button } from '../../../components/ui/button';
|
||||
import { cn } from '../../../lib/utils';
|
||||
|
||||
export enum DataSelectorSizeState {
|
||||
EXPANDED,
|
||||
COLLAPSED,
|
||||
DOCKED,
|
||||
}
|
||||
|
||||
type DataSelectorSizeTogglersProps = {
|
||||
state: DataSelectorSizeState;
|
||||
setListSizeState: (state: DataSelectorSizeState) => void;
|
||||
};
|
||||
|
||||
export const DataSelectorSizeTogglers = ({
|
||||
state,
|
||||
setListSizeState: setDataSelectorSizeState,
|
||||
}: DataSelectorSizeTogglersProps) => {
|
||||
const handleClick = (newState: DataSelectorSizeState) => {
|
||||
setDataSelectorSizeState(newState);
|
||||
};
|
||||
|
||||
const buttonClassName = (btnState: DataSelectorSizeState) =>
|
||||
cn('', {
|
||||
'text-outline': state === btnState,
|
||||
'text-outline opacity-50': state !== btnState,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
className={buttonClassName(DataSelectorSizeState.EXPANDED)}
|
||||
onClick={() => handleClick(DataSelectorSizeState.EXPANDED)}
|
||||
variant="basic"
|
||||
>
|
||||
<ExpandIcon className="size-5"></ExpandIcon>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('Expand')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
className={buttonClassName(DataSelectorSizeState.DOCKED)}
|
||||
onClick={() => handleClick(DataSelectorSizeState.DOCKED)}
|
||||
variant="basic"
|
||||
>
|
||||
<PanelRightDashedIcon className="size-5"></PanelRightDashedIcon>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('Dock')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
className={buttonClassName(DataSelectorSizeState.COLLAPSED)}
|
||||
onClick={() => handleClick(DataSelectorSizeState.COLLAPSED)}
|
||||
variant="basic"
|
||||
>
|
||||
<MinusIcon className="size-5"></MinusIcon>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('Minimize')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
import { t } from 'i18next';
|
||||
import { SearchXIcon } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { textMentionUtils } from '@/app/builder/piece-properties/text-input-with-mentions/text-input-utils';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { flowStructureUtil, isNil } from '@activepieces/shared';
|
||||
|
||||
import { ScrollArea } from '../../../components/ui/scroll-area';
|
||||
import { BuilderState, useBuilderStateContext } from '../builder-hooks';
|
||||
|
||||
import { DataSelectorNode } from './data-selector-node';
|
||||
import {
|
||||
DataSelectorSizeState,
|
||||
DataSelectorSizeTogglers,
|
||||
} from './data-selector-size-togglers';
|
||||
import { DataSelectorTreeNode } from './type';
|
||||
import { dataSelectorUtils } from './utils';
|
||||
|
||||
const getDataSelectorStructure: (
|
||||
state: BuilderState,
|
||||
) => DataSelectorTreeNode[] = (state) => {
|
||||
const { selectedStep, flowVersion } = state;
|
||||
if (!selectedStep || !flowVersion || !flowVersion.trigger) {
|
||||
return [];
|
||||
}
|
||||
const pathToTargetStep = flowStructureUtil.findPathToStep(
|
||||
flowVersion.trigger,
|
||||
selectedStep,
|
||||
);
|
||||
return pathToTargetStep.map((step) => {
|
||||
try {
|
||||
return dataSelectorUtils.traverseStep(
|
||||
step,
|
||||
state.outputSampleData,
|
||||
state.isFocusInsideListMapperModeInput,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to traverse step:', error);
|
||||
return {
|
||||
key: `error-${step.name}`,
|
||||
data: {
|
||||
type: 'chunk',
|
||||
displayName: `Error loading ${step.name}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
type DataSelectorProps = {
|
||||
parentHeight: number;
|
||||
parentWidth: number;
|
||||
};
|
||||
|
||||
const doesElementHaveAnInputThatUsesMentions = (
|
||||
element: Element | null,
|
||||
): boolean => {
|
||||
if (isNil(element)) {
|
||||
return false;
|
||||
}
|
||||
if (element.classList.contains(textMentionUtils.inputWithMentionsCssClass)) {
|
||||
return true;
|
||||
}
|
||||
const parent = element.parentElement;
|
||||
if (parent) {
|
||||
return parent && doesElementHaveAnInputThatUsesMentions(parent);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const DataSelector = ({ parentHeight, parentWidth }: DataSelectorProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [DataSelectorSize, setDataSelectorSize] =
|
||||
useState<DataSelectorSizeState>(DataSelectorSizeState.DOCKED);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const dataSelectorStructure = useBuilderStateContext(
|
||||
getDataSelectorStructure,
|
||||
);
|
||||
const filteredNodes = dataSelectorUtils.filterBy(
|
||||
dataSelectorStructure,
|
||||
searchTerm,
|
||||
);
|
||||
const [showDataSelector, setShowDataSelector] = useState(false);
|
||||
|
||||
const checkFocus = useCallback(() => {
|
||||
const isTextMentionInputFocused =
|
||||
(!isNil(containerRef.current) &&
|
||||
containerRef.current.contains(document.activeElement)) ||
|
||||
doesElementHaveAnInputThatUsesMentions(document.activeElement);
|
||||
setShowDataSelector(isTextMentionInputFocused);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('focusin', checkFocus);
|
||||
document.addEventListener('focusout', checkFocus);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('focusin', checkFocus);
|
||||
document.removeEventListener('focusout', checkFocus);
|
||||
};
|
||||
}, [checkFocus]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'absolute bottom-0 mr-5 mb-5 right-0 z-50 transition-all border border-solid border-outline overflow-x-hidden bg-background shadow-lg rounded-md',
|
||||
{
|
||||
'opacity-0 pointer-events-none': !showDataSelector,
|
||||
},
|
||||
textMentionUtils.dataSelectorCssClassSelector,
|
||||
)}
|
||||
>
|
||||
<div className="text-lg items-center px-5 py-2 flex gap-2">
|
||||
{t('Data Selector')} <div className="grow"></div>{' '}
|
||||
<DataSelectorSizeTogglers
|
||||
state={DataSelectorSize}
|
||||
setListSizeState={setDataSelectorSize}
|
||||
></DataSelectorSizeTogglers>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height:
|
||||
DataSelectorSize === DataSelectorSizeState.COLLAPSED
|
||||
? '0px'
|
||||
: DataSelectorSize === DataSelectorSizeState.DOCKED
|
||||
? '450px'
|
||||
: `${parentHeight - 100}px`,
|
||||
width:
|
||||
DataSelectorSize !== DataSelectorSizeState.EXPANDED
|
||||
? '450px'
|
||||
: `${parentWidth - 40}px`,
|
||||
}}
|
||||
className="transition-all overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-5 py-2">
|
||||
<Input
|
||||
placeholder={t('Search')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
></Input>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="transition-all h-[calc(100%-56px)] w-full ">
|
||||
{filteredNodes &&
|
||||
filteredNodes.map((node) => (
|
||||
<DataSelectorNode
|
||||
depth={0}
|
||||
key={node.key}
|
||||
node={node}
|
||||
searchTerm={searchTerm}
|
||||
></DataSelectorNode>
|
||||
))}
|
||||
{filteredNodes.length === 0 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-5 flex-col">
|
||||
<SearchXIcon className="w-[35px] h-[35px]"></SearchXIcon>
|
||||
<div className="text-center font-semibold text-md">
|
||||
{t('No matching data')}
|
||||
</div>
|
||||
<div className="text-center ">
|
||||
{t('Try adjusting your search')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
DataSelector.displayName = 'DataSelector';
|
||||
export { DataSelector };
|
||||
@@ -0,0 +1,32 @@
|
||||
import { t } from 'i18next';
|
||||
|
||||
import { Button } from '../../../components/ui/button';
|
||||
import { useBuilderStateContext } from '../builder-hooks';
|
||||
|
||||
export const TestStepSection = ({ stepName }: { stepName: string }) => {
|
||||
const isTrigger = stepName === 'trigger';
|
||||
const selectStepByName = useBuilderStateContext(
|
||||
(state) => state.selectStepByName,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 select-none text-center px-12 py-10 grow items-center justify-center ">
|
||||
<div>
|
||||
{isTrigger
|
||||
? t(
|
||||
'This trigger needs to have data loaded from your account, to use as sample data.',
|
||||
)
|
||||
: t('This step needs to be tested in order to view its data.')}
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => selectStepByName(stepName)}
|
||||
variant="default"
|
||||
size="default"
|
||||
>
|
||||
{isTrigger ? t('Go to Trigger') : t('Go to Step')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
export type DataSelectorTreeChunkNodeData = {
|
||||
type: 'chunk';
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
export type DataSelectorTreeNodeData = {
|
||||
type: 'value';
|
||||
value: string | unknown;
|
||||
displayName: string;
|
||||
propertyPath: string;
|
||||
insertable: boolean;
|
||||
};
|
||||
|
||||
export type DataSelectorTestNodeData = {
|
||||
type: 'test';
|
||||
stepName: string;
|
||||
parentDisplayName: string;
|
||||
};
|
||||
|
||||
export type DataSelectorTreeNodeDataUnion =
|
||||
| DataSelectorTreeNodeData
|
||||
| DataSelectorTreeChunkNodeData
|
||||
| DataSelectorTestNodeData;
|
||||
export type DataSelectorTreeNode<
|
||||
T extends DataSelectorTreeNodeDataUnion = DataSelectorTreeNodeDataUnion,
|
||||
> = {
|
||||
key: string;
|
||||
data: T;
|
||||
children?: DataSelectorTreeNode<DataSelectorTreeNodeDataUnion>[];
|
||||
isLoopStepNode?: boolean;
|
||||
};
|
||||
@@ -0,0 +1,389 @@
|
||||
import {
|
||||
isNil,
|
||||
isObject,
|
||||
FlowAction,
|
||||
FlowActionType,
|
||||
FlowTrigger,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import {
|
||||
DataSelectorTreeNode,
|
||||
DataSelectorTestNodeData,
|
||||
DataSelectorTreeNodeDataUnion,
|
||||
DataSelectorTreeNodeData,
|
||||
} from './type';
|
||||
|
||||
type PathSegment = string | number;
|
||||
|
||||
const MAX_CHUNK_LENGTH = 10;
|
||||
const JOINED_VALUES_MAX_LENGTH = 32;
|
||||
|
||||
function buildTestStepNode(
|
||||
displayName: string,
|
||||
stepName: string,
|
||||
): DataSelectorTreeNode<DataSelectorTreeNodeData> {
|
||||
return {
|
||||
key: stepName,
|
||||
data: {
|
||||
type: 'value',
|
||||
value: '',
|
||||
displayName,
|
||||
propertyPath: stepName,
|
||||
insertable: false,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
data: {
|
||||
type: 'test',
|
||||
stepName,
|
||||
parentDisplayName: displayName,
|
||||
},
|
||||
key: `test_${stepName}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildChunkNode(
|
||||
displayName: string,
|
||||
children: DataSelectorTreeNode<DataSelectorTreeNodeDataUnion>[] | undefined,
|
||||
): DataSelectorTreeNode<DataSelectorTreeNodeDataUnion> {
|
||||
return {
|
||||
key: displayName,
|
||||
data: {
|
||||
type: 'chunk',
|
||||
displayName,
|
||||
},
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
type Node = {
|
||||
values: unknown[];
|
||||
properties: Record<string, Node>;
|
||||
};
|
||||
|
||||
function mergeUniqueKeys(
|
||||
obj: Record<string, Node>,
|
||||
obj2: Record<string, Node>,
|
||||
): Record<string, Node> {
|
||||
const result: Record<string, Node> = { ...obj };
|
||||
for (const [key, values] of Object.entries(obj2)) {
|
||||
const properties = mergeUniqueKeys(
|
||||
result[key]?.properties || {},
|
||||
values.properties,
|
||||
);
|
||||
result[key] = {
|
||||
values: [...(result[key]?.values || []), ...values.values],
|
||||
properties,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractUniqueKeys(obj: unknown): Record<string, Node> {
|
||||
let result: Record<string, Node> = {};
|
||||
if (isObject(obj)) {
|
||||
for (const [entryKey, entryValue] of Object.entries(obj)) {
|
||||
const resultValue = result[entryKey]?.values || [];
|
||||
if (Array.isArray(entryValue)) {
|
||||
const filteredValues = entryValue.filter(
|
||||
(v) => !isObject(v) && !Array.isArray(v),
|
||||
);
|
||||
resultValue.push(...filteredValues);
|
||||
} else if (!isObject(entryValue)) {
|
||||
resultValue.push(entryValue);
|
||||
}
|
||||
const properties = extractUniqueKeys(entryValue);
|
||||
result[entryKey] = {
|
||||
values: resultValue,
|
||||
properties,
|
||||
};
|
||||
}
|
||||
} else if (Array.isArray(obj)) {
|
||||
for (const value of obj) {
|
||||
const properties = extractUniqueKeys(value);
|
||||
result = mergeUniqueKeys(result, properties);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function convertArrayToZippedView(
|
||||
obj: Record<string, Node>,
|
||||
propertyPath: PathSegment[],
|
||||
): DataSelectorTreeNode<DataSelectorTreeNodeDataUnion>[] {
|
||||
const result: DataSelectorTreeNode<DataSelectorTreeNodeDataUnion>[] = [];
|
||||
for (const [key, node] of Object.entries(obj)) {
|
||||
const stepName = propertyPath[0];
|
||||
const subPath = [...propertyPath.slice(1), key];
|
||||
|
||||
const propertyPathWithFlattenArray = `flattenNestedKeys(${stepName}, ['${subPath
|
||||
.map((s) => String(s))
|
||||
.join("', '")}'])`;
|
||||
const joinedValues = node.values.join(', ');
|
||||
result.push({
|
||||
key: key,
|
||||
data: {
|
||||
type: 'value',
|
||||
value:
|
||||
joinedValues.length > JOINED_VALUES_MAX_LENGTH
|
||||
? `${joinedValues.slice(0, JOINED_VALUES_MAX_LENGTH)}...`
|
||||
: joinedValues,
|
||||
displayName: key,
|
||||
propertyPath: propertyPathWithFlattenArray,
|
||||
insertable: true,
|
||||
},
|
||||
children:
|
||||
Object.keys(node.properties).length > 0
|
||||
? convertArrayToZippedView(node.properties, [...propertyPath, key])
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildJsonPath(propertyPath: PathSegment[]): string {
|
||||
const propertyPathWithoutStepName = propertyPath.slice(1);
|
||||
//need array indexes to not be quoted so we can add 1 to them when displaying the path in mention
|
||||
return propertyPathWithoutStepName.reduce((acc, segment) => {
|
||||
return `${acc}[${
|
||||
typeof segment === 'string'
|
||||
? `'${escapeMentionKey(String(segment))}'`
|
||||
: segment
|
||||
}]`;
|
||||
}, `${propertyPath[0]}`) as string;
|
||||
}
|
||||
|
||||
function buildDataSelectorNode(
|
||||
displayName: string,
|
||||
propertyPath: PathSegment[],
|
||||
value: unknown,
|
||||
children: DataSelectorTreeNode<DataSelectorTreeNodeDataUnion>[] | undefined,
|
||||
insertable = true,
|
||||
): DataSelectorTreeNode<DataSelectorTreeNodeDataUnion> {
|
||||
const isEmptyArrayOrObject =
|
||||
(Array.isArray(value) && value.length === 0) ||
|
||||
(isObject(value) && Object.keys(value).length === 0);
|
||||
const jsonPath = buildJsonPath(propertyPath);
|
||||
|
||||
return {
|
||||
key: jsonPath,
|
||||
data: {
|
||||
type: 'value',
|
||||
value: isEmptyArrayOrObject ? 'Empty List' : value,
|
||||
displayName,
|
||||
propertyPath: jsonPath,
|
||||
insertable,
|
||||
},
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
function breakArrayIntoChunks<T>(
|
||||
array: T[],
|
||||
chunkSize: number,
|
||||
): { items: T[]; range: { start: number; end: number } }[] {
|
||||
return Array.from(
|
||||
{ length: Math.ceil(array.length / chunkSize) },
|
||||
(_, i) => ({
|
||||
items: array.slice(i * chunkSize, i * chunkSize + chunkSize),
|
||||
range: {
|
||||
start: i * chunkSize + 1,
|
||||
end: Math.min((i + 1) * chunkSize, array.length),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function traverseOutput(
|
||||
displayName: string,
|
||||
propertyPath: PathSegment[],
|
||||
node: unknown,
|
||||
zipArraysOfProperties: boolean,
|
||||
insertable = true,
|
||||
): DataSelectorTreeNode<DataSelectorTreeNodeDataUnion> {
|
||||
if (Array.isArray(node)) {
|
||||
const isArrayOfObjects = node.some((value) => isObject(value));
|
||||
if (!zipArraysOfProperties || !isArrayOfObjects) {
|
||||
const mentionNodes = node.map((value, idx) =>
|
||||
traverseOutput(
|
||||
`${displayName} [${idx + 1}]`,
|
||||
[...propertyPath, idx],
|
||||
value,
|
||||
zipArraysOfProperties,
|
||||
insertable,
|
||||
),
|
||||
);
|
||||
const chunks = breakArrayIntoChunks(mentionNodes, MAX_CHUNK_LENGTH);
|
||||
const isSingleChunk = chunks.length === 1;
|
||||
if (isSingleChunk) {
|
||||
return buildDataSelectorNode(
|
||||
displayName,
|
||||
propertyPath,
|
||||
node,
|
||||
mentionNodes,
|
||||
insertable,
|
||||
);
|
||||
}
|
||||
return buildDataSelectorNode(
|
||||
displayName,
|
||||
propertyPath,
|
||||
undefined,
|
||||
chunks.map((chunk) =>
|
||||
buildChunkNode(
|
||||
`${displayName} [${chunk.range.start}-${chunk.range.end}]`,
|
||||
chunk.items,
|
||||
),
|
||||
),
|
||||
insertable,
|
||||
);
|
||||
} else {
|
||||
return buildDataSelectorNode(
|
||||
displayName,
|
||||
propertyPath,
|
||||
node,
|
||||
convertArrayToZippedView(extractUniqueKeys(node), propertyPath),
|
||||
insertable,
|
||||
);
|
||||
}
|
||||
} else if (isObject(node)) {
|
||||
const children = Object.entries(node).map(([key, value]) => {
|
||||
if (zipArraysOfProperties) {
|
||||
return buildDataSelectorNode(
|
||||
key,
|
||||
[...propertyPath, key],
|
||||
value,
|
||||
convertArrayToZippedView(extractUniqueKeys(value), [
|
||||
...propertyPath,
|
||||
key,
|
||||
]),
|
||||
insertable,
|
||||
);
|
||||
}
|
||||
return traverseOutput(
|
||||
key,
|
||||
[...propertyPath, key],
|
||||
value,
|
||||
zipArraysOfProperties,
|
||||
insertable,
|
||||
);
|
||||
});
|
||||
return buildDataSelectorNode(
|
||||
displayName,
|
||||
propertyPath,
|
||||
node,
|
||||
children,
|
||||
insertable,
|
||||
);
|
||||
} else {
|
||||
return buildDataSelectorNode(
|
||||
displayName,
|
||||
propertyPath,
|
||||
node,
|
||||
undefined,
|
||||
insertable,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeMentionKey(key: string) {
|
||||
return key.replaceAll(/[\\"'\n\r\t’]/g, (char) => `\\${char}`);
|
||||
}
|
||||
|
||||
function getSearchableValue(
|
||||
item: DataSelectorTreeNode<DataSelectorTreeNodeDataUnion>,
|
||||
) {
|
||||
if (item.data.type === 'test') {
|
||||
return item.data.parentDisplayName;
|
||||
}
|
||||
if (item.data.type === 'chunk') {
|
||||
return item.data.displayName;
|
||||
}
|
||||
if (!isNil(item.data.value)) {
|
||||
return JSON.stringify(item.data.value).toLowerCase();
|
||||
} else if (item.data.value === null) {
|
||||
return 'null';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function traverseStep(
|
||||
step: (FlowAction | FlowTrigger) & { dfsIndex: number },
|
||||
sampleData: Record<string, unknown>,
|
||||
zipArraysOfProperties: boolean,
|
||||
): DataSelectorTreeNode<DataSelectorTreeNodeDataUnion> {
|
||||
const displayName = `${step.dfsIndex + 1}. ${step.displayName}`;
|
||||
const stepNeedsTesting = isNil(step.settings.sampleData?.lastTestDate);
|
||||
if (stepNeedsTesting) {
|
||||
return buildTestStepNode(displayName, step.name);
|
||||
}
|
||||
if (step.type === FlowActionType.LOOP_ON_ITEMS) {
|
||||
const copiedSampleData = JSON.parse(JSON.stringify(sampleData[step.name]));
|
||||
delete copiedSampleData['iterations'];
|
||||
const headNode = traverseOutput(
|
||||
displayName,
|
||||
[step.name],
|
||||
copiedSampleData,
|
||||
zipArraysOfProperties,
|
||||
true,
|
||||
);
|
||||
headNode.isLoopStepNode = true;
|
||||
return headNode;
|
||||
}
|
||||
|
||||
return traverseOutput(
|
||||
displayName,
|
||||
[step.name],
|
||||
sampleData[step.name],
|
||||
zipArraysOfProperties,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
function filterBy(
|
||||
mentions: DataSelectorTreeNode[],
|
||||
query: string | undefined,
|
||||
): DataSelectorTreeNode<DataSelectorTreeNodeDataUnion>[] {
|
||||
if (!query) {
|
||||
return mentions;
|
||||
}
|
||||
|
||||
const res = mentions
|
||||
.map((item) => {
|
||||
const filteredChildren = !isNil(item.children)
|
||||
? filterBy(item.children, query)
|
||||
: undefined;
|
||||
|
||||
if (filteredChildren && filteredChildren.length) {
|
||||
return {
|
||||
...item,
|
||||
children: filteredChildren,
|
||||
};
|
||||
}
|
||||
const searchableValue = getSearchableValue(item);
|
||||
|
||||
const displayName =
|
||||
item.data.type === 'value' ? item.data.displayName.toLowerCase() : '';
|
||||
const matchDisplayNameOrValue =
|
||||
displayName.toLowerCase().includes(query.toLowerCase()) ||
|
||||
searchableValue.toLowerCase().includes(query.toLowerCase());
|
||||
if (matchDisplayNameOrValue) {
|
||||
return item;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(
|
||||
(f) => !isNil(f),
|
||||
) as DataSelectorTreeNode<DataSelectorTreeNodeDataUnion>[];
|
||||
return res;
|
||||
}
|
||||
|
||||
export const dataSelectorUtils = {
|
||||
isTestStepNode: (
|
||||
node: DataSelectorTreeNode,
|
||||
): node is DataSelectorTreeNode<DataSelectorTestNodeData> =>
|
||||
node.data.type === 'test',
|
||||
traverseStep,
|
||||
filterBy,
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import { t } from 'i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
FlowAction,
|
||||
flowOperations,
|
||||
FlowOperationType,
|
||||
flowStructureUtil,
|
||||
FlowVersion,
|
||||
StepLocationRelativeToParent,
|
||||
PasteLocation,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { BuilderState } from '../builder-hooks';
|
||||
|
||||
type CopyActionsRequest = {
|
||||
type: 'COPY_ACTIONS';
|
||||
actions: FlowAction[];
|
||||
};
|
||||
|
||||
export function copySelectedNodes({
|
||||
selectedNodes,
|
||||
flowVersion,
|
||||
}: Pick<BuilderState, 'selectedNodes' | 'flowVersion'>) {
|
||||
const actionsToCopy = flowOperations.getActionsForCopy(
|
||||
selectedNodes,
|
||||
flowVersion,
|
||||
);
|
||||
const request: CopyActionsRequest = {
|
||||
type: 'COPY_ACTIONS',
|
||||
actions: actionsToCopy,
|
||||
};
|
||||
navigator.clipboard.writeText(JSON.stringify(request));
|
||||
}
|
||||
|
||||
export function deleteSelectedNodes({
|
||||
selectedNodes,
|
||||
applyOperation,
|
||||
selectedStep,
|
||||
exitStepSettings,
|
||||
}: Pick<
|
||||
BuilderState,
|
||||
'selectedNodes' | 'applyOperation' | 'selectedStep' | 'exitStepSettings'
|
||||
>) {
|
||||
applyOperation({
|
||||
type: FlowOperationType.DELETE_ACTION,
|
||||
request: {
|
||||
names: selectedNodes,
|
||||
},
|
||||
});
|
||||
if (selectedStep && selectedNodes.includes(selectedStep)) {
|
||||
exitStepSettings();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getActionsInClipboard(): Promise<FlowAction[]> {
|
||||
try {
|
||||
const clipboardText = await navigator.clipboard.readText();
|
||||
const request: CopyActionsRequest = JSON.parse(clipboardText);
|
||||
if (request && request.type === 'COPY_ACTIONS') {
|
||||
return request.actions;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting actions in clipboard', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function pasteNodes(
|
||||
flowVersion: BuilderState['flowVersion'],
|
||||
pastingDetails: PasteLocation,
|
||||
applyOperation: BuilderState['applyOperation'],
|
||||
) {
|
||||
const actions = await getActionsInClipboard();
|
||||
const addOperations = flowOperations.getOperationsForPaste(
|
||||
actions,
|
||||
flowVersion,
|
||||
pastingDetails,
|
||||
);
|
||||
addOperations.forEach((request) => {
|
||||
applyOperation(request);
|
||||
});
|
||||
if (addOperations.length === 0) {
|
||||
toast(t('No Steps Pasted'), {
|
||||
description: t(
|
||||
'Please make sure you have copied a step(s) and allowed permission to your clipboard',
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function getLastLocationAsPasteLocation(
|
||||
flowVersion: FlowVersion,
|
||||
): PasteLocation {
|
||||
const firstLevelParents = [
|
||||
flowVersion.trigger,
|
||||
...flowStructureUtil.getAllNextActionsWithoutChildren(flowVersion.trigger),
|
||||
];
|
||||
const lastAction = firstLevelParents[firstLevelParents.length - 1];
|
||||
return {
|
||||
parentStepName: lastAction.name,
|
||||
stepLocationRelativeToParent: StepLocationRelativeToParent.AFTER,
|
||||
};
|
||||
}
|
||||
|
||||
export function toggleSkipSelectedNodes({
|
||||
selectedNodes,
|
||||
flowVersion,
|
||||
applyOperation,
|
||||
}: Pick<BuilderState, 'selectedNodes' | 'flowVersion' | 'applyOperation'>) {
|
||||
const steps = selectedNodes.map((node) =>
|
||||
flowStructureUtil.getStepOrThrow(node, flowVersion.trigger),
|
||||
) as FlowAction[];
|
||||
const areAllStepsSkipped = steps.every((step) => !!step.skip);
|
||||
applyOperation({
|
||||
type: FlowOperationType.SET_SKIP_ACTION,
|
||||
request: {
|
||||
names: steps.map((step) => step.name),
|
||||
skip: !areAllStepsSkipped,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { Node, useKeyPress, useReactFlow } from '@xyflow/react';
|
||||
import { t } from 'i18next';
|
||||
import {
|
||||
Fullscreen,
|
||||
Hand,
|
||||
Minus,
|
||||
MousePointer,
|
||||
Plus,
|
||||
RotateCw,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { useBuilderStateContext } from '../builder-hooks';
|
||||
|
||||
import { flowUtilConsts } from './utils/consts';
|
||||
import { flowCanvasUtils } from './utils/flow-canvas-utils';
|
||||
import { ApNode } from './utils/types';
|
||||
const verticalPaddingOnFitView = 100;
|
||||
const duration = 500;
|
||||
// Calculate the node's position in relation to the canvas
|
||||
const calculateNodePositionInCanvas = (
|
||||
canvasWidth: number,
|
||||
node: Node,
|
||||
zoom: number,
|
||||
) => ({
|
||||
x:
|
||||
node.position.x +
|
||||
canvasWidth / 2 -
|
||||
(flowUtilConsts.AP_NODE_SIZE.STEP.width * zoom) / 2,
|
||||
y:
|
||||
node.position.y +
|
||||
flowUtilConsts.AP_NODE_SIZE.GRAPH_END_WIDGET.height +
|
||||
verticalPaddingOnFitView * zoom,
|
||||
});
|
||||
|
||||
// Check if the node is out of view
|
||||
const isNodeOutOfView = (
|
||||
nodePosition: { x: number; y: number },
|
||||
canvas: { width: number; height: number },
|
||||
) =>
|
||||
nodePosition.y > canvas.height ||
|
||||
nodePosition.x > canvas.width ||
|
||||
nodePosition.x < 0;
|
||||
|
||||
const calculateViewportDelta = (
|
||||
nodePosition: { x: number; y: number },
|
||||
canvas: { width: number; height: number },
|
||||
) => ({
|
||||
x:
|
||||
nodePosition.x > canvas.width
|
||||
? -1 *
|
||||
(nodePosition.x -
|
||||
canvas.width +
|
||||
flowUtilConsts.AP_NODE_SIZE.STEP.width * 2)
|
||||
: nodePosition.x < 0
|
||||
? -1 * nodePosition.x
|
||||
: 0,
|
||||
y:
|
||||
nodePosition.y > canvas.height
|
||||
? nodePosition.y - canvas.height + flowUtilConsts.AP_NODE_SIZE.STEP.height
|
||||
: 0,
|
||||
});
|
||||
|
||||
const PanningModeIndicator = ({ toggled }: { toggled: boolean }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute transition-all bg-primary/15 w-full h-full top-0 left-0',
|
||||
{
|
||||
'opacity-0': !toggled,
|
||||
},
|
||||
)}
|
||||
></div>
|
||||
);
|
||||
};
|
||||
|
||||
const CanvasControls = ({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
hasCanvasBeenInitialised,
|
||||
selectedStep,
|
||||
}: {
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
hasCanvasBeenInitialised: boolean;
|
||||
selectedStep: string | null;
|
||||
}) => {
|
||||
const {
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
zoomTo,
|
||||
setViewport,
|
||||
getNodes,
|
||||
getNode,
|
||||
getViewport,
|
||||
} = useReactFlow();
|
||||
const handleZoomIn = useCallback(() => {
|
||||
zoomIn({
|
||||
duration,
|
||||
});
|
||||
}, [zoomIn]);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
zoomOut({
|
||||
duration,
|
||||
});
|
||||
}, [zoomOut]);
|
||||
|
||||
const handleZoomReset = useCallback(() => {
|
||||
zoomTo(1, { duration });
|
||||
}, [zoomTo]);
|
||||
|
||||
const handleFitToView = useCallback(
|
||||
(isInitialRenderCall: boolean) => {
|
||||
const nodes = getNodes();
|
||||
if (nodes.length === 0) return;
|
||||
const graphHeight = flowCanvasUtils.calculateGraphBoundingBox({
|
||||
nodes: nodes as ApNode[],
|
||||
edges: [],
|
||||
}).height;
|
||||
const zoomRatio = Math.min(
|
||||
Math.max(canvasHeight / graphHeight, 0.9),
|
||||
1.25,
|
||||
);
|
||||
|
||||
setViewport(
|
||||
{
|
||||
x:
|
||||
canvasWidth / 2 -
|
||||
(flowUtilConsts.AP_NODE_SIZE.STEP.width * zoomRatio) / 2,
|
||||
y: nodes[0].position.y + verticalPaddingOnFitView * zoomRatio,
|
||||
zoom: zoomRatio,
|
||||
},
|
||||
{
|
||||
duration: isInitialRenderCall ? 0 : duration,
|
||||
},
|
||||
);
|
||||
},
|
||||
[getNodes, canvasHeight, setViewport, canvasWidth],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasCanvasBeenInitialised) return;
|
||||
|
||||
handleFitToView(true);
|
||||
|
||||
if (selectedStep) {
|
||||
adjustViewportForSelectedStep(selectedStep);
|
||||
}
|
||||
}, [hasCanvasBeenInitialised]);
|
||||
|
||||
// Helper function to adjust the viewport for the selected step
|
||||
const adjustViewportForSelectedStep = (stepId: string) => {
|
||||
const node = getNode(stepId);
|
||||
if (!node) return;
|
||||
|
||||
const viewport = getViewport();
|
||||
|
||||
const canvas = {
|
||||
height: canvasHeight / viewport.zoom,
|
||||
width: canvasWidth / viewport.zoom,
|
||||
};
|
||||
|
||||
const nodePositionInRelationToCanvas = calculateNodePositionInCanvas(
|
||||
canvasWidth,
|
||||
node,
|
||||
viewport.zoom,
|
||||
);
|
||||
|
||||
if (isNodeOutOfView(nodePositionInRelationToCanvas, canvas)) {
|
||||
const delta = calculateViewportDelta(
|
||||
nodePositionInRelationToCanvas,
|
||||
canvas,
|
||||
);
|
||||
|
||||
setViewport({
|
||||
x: viewport.x + delta.x,
|
||||
y: viewport.y - delta.y - flowUtilConsts.AP_NODE_SIZE.STEP.height,
|
||||
zoom: viewport.zoom,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const [setPanningMode, panningMode] = useBuilderStateContext((state) => {
|
||||
return [state.setPanningMode, state.panningMode];
|
||||
});
|
||||
const spacePressed = useKeyPress('Space');
|
||||
const shiftPressed = useKeyPress('Shift');
|
||||
const isInGrabMode =
|
||||
(spacePressed || panningMode === 'grab') && !shiftPressed;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-accent absolute left-[10px] bottom-[60px] z-50 flex flex-col gap-2 shadow-md">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="accent"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!spacePressed) {
|
||||
setPanningMode('pan');
|
||||
}
|
||||
}}
|
||||
className="relative focus:outline-0"
|
||||
>
|
||||
<PanningModeIndicator toggled={!isInGrabMode} />
|
||||
<MousePointer className="size-5"></MousePointer>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{t('Select Mode')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="accent"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!spacePressed) {
|
||||
setPanningMode('grab');
|
||||
}
|
||||
}}
|
||||
className="relative focus:outline-0"
|
||||
>
|
||||
<PanningModeIndicator toggled={isInGrabMode} />
|
||||
|
||||
<Hand className="size-5"></Hand>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{t('Move Mode')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="bg-accent absolute left-[10px] bottom-[10px] z-50 flex flex-row shadow-md">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="accent" size="sm" onClick={handleZoomReset}>
|
||||
<RotateCw className="size-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{t('Reset Zoom')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="accent" size="sm" onClick={handleZoomIn}>
|
||||
<Plus className="size-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{t('Zoom In')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="accent" size="sm" onClick={handleZoomOut}>
|
||||
<Minus className="size-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{t('Zoom Out')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="accent"
|
||||
size="sm"
|
||||
onClick={() => handleFitToView(false)}
|
||||
>
|
||||
<Fullscreen className="size-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{t('Fit to View')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { CanvasControls };
|
||||
@@ -0,0 +1,341 @@
|
||||
import { t } from 'i18next';
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
ClipboardPaste,
|
||||
ClipboardPlus,
|
||||
Copy,
|
||||
CopyPlus,
|
||||
Route,
|
||||
RouteOff,
|
||||
Trash,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
} from '@/components/ui/context-menu';
|
||||
import { Shortcut, ShortcutProps } from '@/components/ui/shortcut';
|
||||
import {
|
||||
FlowAction,
|
||||
FlowActionType,
|
||||
FlowOperationType,
|
||||
flowStructureUtil,
|
||||
StepLocationRelativeToParent,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { useBuilderStateContext } from '../../builder-hooks';
|
||||
import {
|
||||
copySelectedNodes,
|
||||
deleteSelectedNodes,
|
||||
getLastLocationAsPasteLocation,
|
||||
pasteNodes,
|
||||
toggleSkipSelectedNodes,
|
||||
} from '../bulk-actions';
|
||||
|
||||
import {
|
||||
CanvasContextMenuProps,
|
||||
CanvasShortcuts,
|
||||
ContextMenuType,
|
||||
} from './canvas-context-menu';
|
||||
|
||||
const ShortcutWrapper = ({
|
||||
children,
|
||||
shortcut,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
shortcut: ShortcutProps;
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 grow">
|
||||
<div className="flex gap-2 items-center">{children}</div>
|
||||
<Shortcut {...shortcut} className="text-end" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CanvasContextMenuContent = ({
|
||||
contextMenuType,
|
||||
}: CanvasContextMenuProps) => {
|
||||
const [
|
||||
selectedNodes,
|
||||
applyOperation,
|
||||
selectedStep,
|
||||
flowVersion,
|
||||
exitStepSettings,
|
||||
readonly,
|
||||
setOpenedPieceSelectorStepNameOrAddButtonId,
|
||||
] = useBuilderStateContext((state) => [
|
||||
state.selectedNodes,
|
||||
state.applyOperation,
|
||||
state.selectedStep,
|
||||
state.flowVersion,
|
||||
state.exitStepSettings,
|
||||
state.readonly,
|
||||
state.setOpenedPieceSelectorStepNameOrAddButtonId,
|
||||
]);
|
||||
const disabled = selectedNodes.length === 0;
|
||||
const areAllStepsSkipped = selectedNodes.every(
|
||||
(node) =>
|
||||
!!(flowStructureUtil.getStep(node, flowVersion.trigger) as FlowAction)
|
||||
?.skip,
|
||||
);
|
||||
const doSelectedNodesIncludeTrigger = selectedNodes.some(
|
||||
(node) => node === flowVersion.trigger.name,
|
||||
);
|
||||
|
||||
const firstSelectedStep = flowStructureUtil.getStep(
|
||||
selectedNodes[0],
|
||||
flowVersion.trigger,
|
||||
);
|
||||
const showPasteAfterLastStep =
|
||||
!readonly && contextMenuType === ContextMenuType.CANVAS;
|
||||
const showPasteAsFirstLoopAction =
|
||||
selectedNodes.length === 1 &&
|
||||
firstSelectedStep?.type === FlowActionType.LOOP_ON_ITEMS &&
|
||||
!readonly &&
|
||||
contextMenuType === ContextMenuType.STEP;
|
||||
const showPasteAsBranchChild =
|
||||
selectedNodes.length === 1 &&
|
||||
firstSelectedStep?.type === FlowActionType.ROUTER &&
|
||||
!readonly &&
|
||||
contextMenuType === ContextMenuType.STEP;
|
||||
const showPasteAfterCurrentStep =
|
||||
selectedNodes.length === 1 &&
|
||||
!readonly &&
|
||||
contextMenuType === ContextMenuType.STEP;
|
||||
const showReplace =
|
||||
selectedNodes.length === 1 &&
|
||||
!readonly &&
|
||||
contextMenuType === ContextMenuType.STEP;
|
||||
const showCopy =
|
||||
!doSelectedNodesIncludeTrigger && contextMenuType === ContextMenuType.STEP;
|
||||
const showDuplicate =
|
||||
selectedNodes.length === 1 &&
|
||||
!doSelectedNodesIncludeTrigger &&
|
||||
contextMenuType === ContextMenuType.STEP &&
|
||||
!readonly;
|
||||
const showSkip =
|
||||
!doSelectedNodesIncludeTrigger &&
|
||||
contextMenuType === ContextMenuType.STEP &&
|
||||
!readonly;
|
||||
const isTriggerTheOnlySelectedNode =
|
||||
selectedNodes.length === 1 && doSelectedNodesIncludeTrigger;
|
||||
const showDelete =
|
||||
!readonly &&
|
||||
contextMenuType === ContextMenuType.STEP &&
|
||||
!isTriggerTheOnlySelectedNode;
|
||||
|
||||
const duplicateStep = () => {
|
||||
applyOperation({
|
||||
type: FlowOperationType.DUPLICATE_ACTION,
|
||||
request: {
|
||||
stepName: selectedNodes[0],
|
||||
},
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{showReplace && (
|
||||
<ContextMenuItem
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setOpenedPieceSelectorStepNameOrAddButtonId(selectedNodes[0]);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowLeftRight className="w-4 h-4"></ArrowLeftRight> {t('Replace')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{showCopy && (
|
||||
<ContextMenuItem
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
copySelectedNodes({ selectedNodes, flowVersion });
|
||||
}}
|
||||
>
|
||||
<ShortcutWrapper shortcut={CanvasShortcuts['Copy']}>
|
||||
<Copy className="w-4 h-4"></Copy> {t('Copy')}
|
||||
</ShortcutWrapper>
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
|
||||
<>
|
||||
{showDuplicate && (
|
||||
<ContextMenuItem
|
||||
disabled={disabled}
|
||||
onClick={duplicateStep}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<CopyPlus className="w-4 h-4"></CopyPlus> {t('Duplicate')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
|
||||
{showSkip && (
|
||||
<ContextMenuItem
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
toggleSkipSelectedNodes({
|
||||
selectedNodes,
|
||||
flowVersion,
|
||||
applyOperation,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ShortcutWrapper shortcut={CanvasShortcuts['Skip']}>
|
||||
{areAllStepsSkipped ? (
|
||||
<Route className="h-4 w-4"></Route>
|
||||
) : (
|
||||
<RouteOff className="h-4 w-4"></RouteOff>
|
||||
)}
|
||||
{areAllStepsSkipped ? t('Unskip') : t('Skip')}
|
||||
</ShortcutWrapper>
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{(showPasteAsFirstLoopAction ||
|
||||
showPasteAsBranchChild ||
|
||||
showPasteAfterCurrentStep) && (
|
||||
<ContextMenuSeparator></ContextMenuSeparator>
|
||||
)}
|
||||
|
||||
{showPasteAfterLastStep && (
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
const pasteLocation = getLastLocationAsPasteLocation(flowVersion);
|
||||
if (pasteLocation) {
|
||||
pasteNodes(flowVersion, pasteLocation, applyOperation);
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ClipboardPlus className="w-4 h-4"></ClipboardPlus>{' '}
|
||||
{t('Paste After Last Step')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
|
||||
{showPasteAsFirstLoopAction && (
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
pasteNodes(
|
||||
flowVersion,
|
||||
{
|
||||
parentStepName: selectedNodes[0],
|
||||
stepLocationRelativeToParent:
|
||||
StepLocationRelativeToParent.INSIDE_LOOP,
|
||||
},
|
||||
applyOperation,
|
||||
);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ClipboardPaste className="w-4 h-4"></ClipboardPaste>{' '}
|
||||
{t('Paste Inside Loop')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
|
||||
{showPasteAfterCurrentStep && (
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
pasteNodes(
|
||||
flowVersion,
|
||||
{
|
||||
parentStepName: selectedNodes[0],
|
||||
stepLocationRelativeToParent:
|
||||
StepLocationRelativeToParent.AFTER,
|
||||
},
|
||||
applyOperation,
|
||||
);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ClipboardPlus className="w-4 h-4"></ClipboardPlus>{' '}
|
||||
{t('Paste After')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
|
||||
{showPasteAsBranchChild && (
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger className="flex items-center gap-2">
|
||||
<ClipboardPaste className="w-4 h-4"></ClipboardPaste>{' '}
|
||||
{t('Paste Inside...')}
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
{firstSelectedStep &&
|
||||
firstSelectedStep.settings.branches.map(
|
||||
(branch, branchIndex) => (
|
||||
<ContextMenuItem
|
||||
key={branch.branchName}
|
||||
onClick={() => {
|
||||
pasteNodes(
|
||||
flowVersion,
|
||||
{
|
||||
parentStepName: selectedNodes[0],
|
||||
stepLocationRelativeToParent:
|
||||
StepLocationRelativeToParent.INSIDE_BRANCH,
|
||||
branchIndex,
|
||||
},
|
||||
applyOperation,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{branch.branchName}
|
||||
</ContextMenuItem>
|
||||
),
|
||||
)}
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
applyOperation({
|
||||
type: FlowOperationType.ADD_BRANCH,
|
||||
request: {
|
||||
stepName: firstSelectedStep.name,
|
||||
branchIndex:
|
||||
firstSelectedStep.settings.branches.length - 1,
|
||||
branchName: `Branch ${firstSelectedStep.settings.branches.length}`,
|
||||
},
|
||||
});
|
||||
pasteNodes(
|
||||
flowVersion,
|
||||
{
|
||||
parentStepName: firstSelectedStep.name,
|
||||
stepLocationRelativeToParent:
|
||||
StepLocationRelativeToParent.INSIDE_BRANCH,
|
||||
branchIndex:
|
||||
firstSelectedStep.settings.branches.length - 1,
|
||||
},
|
||||
applyOperation,
|
||||
);
|
||||
}}
|
||||
>
|
||||
+ {t('New Branch')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
)}
|
||||
|
||||
{showDelete && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
deleteSelectedNodes({
|
||||
selectedNodes,
|
||||
applyOperation,
|
||||
selectedStep,
|
||||
exitStepSettings,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ShortcutWrapper shortcut={CanvasShortcuts['Delete']}>
|
||||
<Trash className="w-4 stroke-destructive h-4"></Trash>{' '}
|
||||
<div className="text-destructive">{t('Delete')}</div>
|
||||
</ShortcutWrapper>
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuTrigger,
|
||||
} from '@/components/ui/context-menu';
|
||||
import { ShortcutProps } from '@/components/ui/shortcut';
|
||||
|
||||
import { CanvasContextMenuContent } from './canvas-context-menu-content';
|
||||
|
||||
export type CanvasShortcutsProps = Record<
|
||||
'Paste' | 'Delete' | 'Copy' | 'Skip',
|
||||
ShortcutProps
|
||||
>;
|
||||
export const CanvasShortcuts: CanvasShortcutsProps = {
|
||||
Paste: {
|
||||
withCtrl: true,
|
||||
withShift: false,
|
||||
shortcutKey: 'v',
|
||||
},
|
||||
Delete: {
|
||||
withCtrl: false,
|
||||
withShift: true,
|
||||
shortcutKey: 'Delete',
|
||||
},
|
||||
Copy: {
|
||||
withCtrl: true,
|
||||
withShift: false,
|
||||
shortcutKey: 'c',
|
||||
shouldNotPreventDefault: true,
|
||||
},
|
||||
Skip: {
|
||||
withCtrl: true,
|
||||
withShift: false,
|
||||
shortcutKey: 'e',
|
||||
},
|
||||
};
|
||||
export enum ContextMenuType {
|
||||
CANVAS = 'CANVAS',
|
||||
STEP = 'STEP',
|
||||
}
|
||||
export type CanvasContextMenuProps = {
|
||||
children?: React.ReactNode;
|
||||
contextMenuType: ContextMenuType;
|
||||
};
|
||||
export const CanvasContextMenu = ({
|
||||
contextMenuType,
|
||||
children,
|
||||
}: CanvasContextMenuProps) => {
|
||||
return (
|
||||
<ContextMenu modal={false}>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<CanvasContextMenuContent
|
||||
contextMenuType={contextMenuType}
|
||||
></CanvasContextMenuContent>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useDndMonitor, useDroppable, DragMoveEvent } from '@dnd-kit/core';
|
||||
import { Plus } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { PieceSelector } from '@/app/builder/pieces-selector';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isNil } from '@activepieces/shared';
|
||||
|
||||
import { useBuilderStateContext } from '../../builder-hooks';
|
||||
import { flowUtilConsts } from '../utils/consts';
|
||||
import { flowCanvasUtils } from '../utils/flow-canvas-utils';
|
||||
import { ApButtonData } from '../utils/types';
|
||||
|
||||
const ApAddButton = React.memo((props: ApButtonData) => {
|
||||
const [isStepInsideDropZone, setIsStepInsideDropzone] = useState(false);
|
||||
const [activeDraggingStep, readonly, isPieceSelectorOpen] =
|
||||
useBuilderStateContext((state) => [
|
||||
state.activeDraggingStep,
|
||||
state.readonly,
|
||||
state.openedPieceSelectorStepNameOrAddButtonId === props.edgeId,
|
||||
]);
|
||||
|
||||
const { setNodeRef } = useDroppable({
|
||||
id: props.edgeId,
|
||||
data: {
|
||||
accepts: flowUtilConsts.DRAGGED_STEP_TAG,
|
||||
...props,
|
||||
},
|
||||
});
|
||||
|
||||
const showDropIndicator = !isNil(activeDraggingStep);
|
||||
|
||||
useDndMonitor({
|
||||
onDragMove(event: DragMoveEvent) {
|
||||
setIsStepInsideDropzone(event.collisions?.[0]?.id === props.edgeId);
|
||||
},
|
||||
onDragEnd() {
|
||||
setIsStepInsideDropzone(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{showDropIndicator && !readonly && (
|
||||
<div
|
||||
style={{
|
||||
width: flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.width + 'px',
|
||||
height: flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.height + 'px',
|
||||
}}
|
||||
className={cn('transition-all bg-primary/90 rounded-md', {
|
||||
'shadow-add-button': isStepInsideDropZone,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: flowUtilConsts.AP_NODE_SIZE.STEP.width + 'px',
|
||||
height: flowUtilConsts.AP_NODE_SIZE.STEP.height + 'px',
|
||||
left: `${-flowUtilConsts.AP_NODE_SIZE.STEP.width / 2}px`,
|
||||
top: `${-flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEPS / 2}px`,
|
||||
}}
|
||||
className={cn(' absolute rounded-md box-content ')}
|
||||
ref={setNodeRef}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
{!showDropIndicator && !readonly && (
|
||||
<PieceSelector
|
||||
operation={flowCanvasUtils.createAddOperationFromAddButtonData(props)}
|
||||
id={props.edgeId}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.width + 'px',
|
||||
height: flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.height + 'px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.width + 'px',
|
||||
height: flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.height + 'px',
|
||||
}}
|
||||
className={cn('rounded-md cursor-pointer transition-all z-50', {
|
||||
'shadow-add-button': isPieceSelectorOpen,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.width + 'px',
|
||||
height: flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.height + 'px',
|
||||
}}
|
||||
className={cn(
|
||||
'bg-background border border-border border-solid relative group overflow-visible rounded-md cursor-pointer flex items-center justify-center transition-all duration-300 ease-in-out',
|
||||
{
|
||||
'bg-primary border-primary': isPieceSelectorOpen,
|
||||
},
|
||||
)}
|
||||
data-testid="add-action-button"
|
||||
>
|
||||
{!isPieceSelectorOpen && (
|
||||
<Plus className="w-3 h-3 stroke-[3px] text-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PieceSelector>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
ApAddButton.displayName = 'ApAddButton';
|
||||
export { ApAddButton };
|
||||
@@ -0,0 +1,199 @@
|
||||
import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu';
|
||||
import { useReactFlow } from '@xyflow/react';
|
||||
import { t } from 'i18next';
|
||||
import { CopyPlus, EllipsisVertical, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
FlowActionType,
|
||||
BranchExecutionType,
|
||||
FlowOperationType,
|
||||
flowStructureUtil,
|
||||
isNil,
|
||||
StepLocationRelativeToParent,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from '../../../../components/ui/dropdown-menu';
|
||||
import { cn } from '../../../../lib/utils';
|
||||
import { useBuilderStateContext } from '../../builder-hooks';
|
||||
import { flowUtilConsts } from '../utils/consts';
|
||||
import { flowCanvasUtils } from '../utils/flow-canvas-utils';
|
||||
|
||||
type BaseBranchLabel = {
|
||||
label: string;
|
||||
targetNodeName: string;
|
||||
sourceNodeName: string;
|
||||
stepLocationRelativeToParent: StepLocationRelativeToParent.INSIDE_BRANCH;
|
||||
branchIndex: number;
|
||||
};
|
||||
|
||||
const BranchLabel = (props: BaseBranchLabel) => {
|
||||
const [
|
||||
selectedStep,
|
||||
selectedBranchIndex,
|
||||
selectStepByName,
|
||||
setSelectedBranchIndex,
|
||||
step,
|
||||
applyOperation,
|
||||
readonly,
|
||||
] = useBuilderStateContext((state) => [
|
||||
state.selectedStep,
|
||||
state.selectedBranchIndex,
|
||||
state.selectStepByName,
|
||||
state.setSelectedBranchIndex,
|
||||
flowStructureUtil.getStep(props.sourceNodeName, state.flowVersion.trigger),
|
||||
state.applyOperation,
|
||||
state.readonly,
|
||||
]);
|
||||
|
||||
const isFallbackBranch =
|
||||
props.stepLocationRelativeToParent ===
|
||||
StepLocationRelativeToParent.INSIDE_BRANCH &&
|
||||
step?.type === FlowActionType.ROUTER &&
|
||||
step?.settings.branches[props.branchIndex]?.branchType ===
|
||||
BranchExecutionType.FALLBACK;
|
||||
const isNotInsideRoute =
|
||||
props.stepLocationRelativeToParent !==
|
||||
StepLocationRelativeToParent.INSIDE_BRANCH;
|
||||
const isOtherwiseBranch = isNotInsideRoute || isFallbackBranch;
|
||||
const isBranchSelected =
|
||||
selectedStep === props.sourceNodeName &&
|
||||
props.stepLocationRelativeToParent ===
|
||||
StepLocationRelativeToParent.INSIDE_BRANCH &&
|
||||
props.branchIndex === selectedBranchIndex;
|
||||
const { fitView } = useReactFlow();
|
||||
const [isDropdownMenuOpen, setIsDropdownMenuOpen] = useState(false);
|
||||
|
||||
if (isNil(step) || step.type !== FlowActionType.ROUTER) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full flex items-center justify-center "
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDropdownMenuOpen(true);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-builder-background"
|
||||
style={{
|
||||
paddingTop: flowUtilConsts.LABEL_VERTICAL_PADDING / 2 + 'px',
|
||||
paddingBottom: flowUtilConsts.LABEL_VERTICAL_PADDING / 2 + 'px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-0.5 select-none transition-all rounded-md text-sm border border-solid bg-primary-100/30 dark:bg-primary-100/15 border-primary/50 px-2 text-primary/80 dark:text-primary/90 hover:text-primary hover:border-primary',
|
||||
{
|
||||
'border-primary text-primary': isBranchSelected,
|
||||
'bg-border/60 text-foreground/70 dark:text-foreground/70 border-border hover:text-foreground/70 hover:bg-border/60 hover:border-border cursor-default':
|
||||
isOtherwiseBranch,
|
||||
},
|
||||
)}
|
||||
style={{
|
||||
height: flowUtilConsts.LABEL_HEIGHT + 'px',
|
||||
maxWidth: flowUtilConsts.AP_NODE_SIZE.STEP.width - 10 + 'px',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (
|
||||
props.stepLocationRelativeToParent ===
|
||||
StepLocationRelativeToParent.INSIDE_BRANCH &&
|
||||
!isOtherwiseBranch
|
||||
) {
|
||||
selectStepByName(props.sourceNodeName);
|
||||
setSelectedBranchIndex(props.branchIndex);
|
||||
fitView(
|
||||
flowCanvasUtils.createFocusStepInGraphParams(
|
||||
props.targetNodeName,
|
||||
),
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="truncate">
|
||||
{props.label === 'Otherwise' ? t('Otherwise') : props.label}
|
||||
</div>
|
||||
|
||||
{!isOtherwiseBranch &&
|
||||
!readonly &&
|
||||
step.type === FlowActionType.ROUTER && (
|
||||
<DropdownMenu
|
||||
modal={true}
|
||||
open={isDropdownMenuOpen}
|
||||
onOpenChange={setIsDropdownMenuOpen}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
className="h-5 shrink-0 border border-transparent hover:border-solid hover:border-primary-300/50 transition-all rounded-full w-5 flex items-center justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVertical className="h-4 w-4" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
applyOperation({
|
||||
type: FlowOperationType.DUPLICATE_BRANCH,
|
||||
request: {
|
||||
stepName: props.sourceNodeName,
|
||||
branchIndex: props.branchIndex,
|
||||
},
|
||||
});
|
||||
setSelectedBranchIndex(props.branchIndex + 1);
|
||||
}}
|
||||
>
|
||||
<div className="flex cursor-pointer flex-row gap-2 items-center">
|
||||
<CopyPlus className="h-4 w-4" />
|
||||
<span>{t('Duplicate Branch')}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={step.settings.branches.length <= 2}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSelectedBranchIndex(null);
|
||||
applyOperation({
|
||||
type: FlowOperationType.DELETE_BRANCH,
|
||||
request: {
|
||||
stepName: props.sourceNodeName,
|
||||
branchIndex: props.branchIndex,
|
||||
},
|
||||
});
|
||||
selectStepByName(props.sourceNodeName);
|
||||
}}
|
||||
>
|
||||
<div className="flex cursor-pointer flex-row gap-2 items-center">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
<span className="text-destructive">
|
||||
{t('Delete Branch')}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { BranchLabel };
|
||||
@@ -0,0 +1,101 @@
|
||||
import { BaseEdge, EdgeProps } from '@xyflow/react';
|
||||
|
||||
import { StepLocationRelativeToParent } from '@activepieces/shared';
|
||||
|
||||
import { flowUtilConsts } from '../utils/consts';
|
||||
import { ApLoopReturnEdge } from '../utils/types';
|
||||
|
||||
import { ApAddButton } from './add-button';
|
||||
|
||||
export const ApLoopReturnLineCanvasEdge = ({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
data,
|
||||
id,
|
||||
}: EdgeProps & ApLoopReturnEdge) => {
|
||||
const horizontalLineLength =
|
||||
Math.abs(sourceX - targetX) - 2 * flowUtilConsts.ARC_LENGTH;
|
||||
|
||||
const verticalLineLength = data.verticalSpaceBetweenReturnNodeStartAndEnd;
|
||||
const ARROW_RIGHT = ` m-5 -6 l6 6 m-6 0 m6 0 l-6 6 m3 -6`;
|
||||
const endLineLength =
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEPS -
|
||||
2 * flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEP_AND_LINE +
|
||||
8;
|
||||
const path = `
|
||||
M ${sourceX - 0.5} ${
|
||||
sourceY - flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEP_AND_LINE
|
||||
}
|
||||
v 1
|
||||
${flowUtilConsts.ARC_LEFT_DOWN} h -${horizontalLineLength}
|
||||
${flowUtilConsts.ARC_RIGHT_UP} v -${verticalLineLength}
|
||||
a15,15 0 0,1 15,-15
|
||||
|
||||
h ${horizontalLineLength / 2 - 2 * flowUtilConsts.ARC_LENGTH}
|
||||
${ARROW_RIGHT}
|
||||
|
||||
M ${sourceX - flowUtilConsts.ARC_LENGTH - horizontalLineLength / 2} ${
|
||||
sourceY +
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEP_AND_LINE +
|
||||
flowUtilConsts.ARC_LENGTH / 2
|
||||
}
|
||||
v${endLineLength} ${
|
||||
data.drawArrowHeadAfterEnd ? flowUtilConsts.ARROW_DOWN : ''
|
||||
}
|
||||
`;
|
||||
const buttonPosition = {
|
||||
x:
|
||||
sourceX -
|
||||
horizontalLineLength / 2 -
|
||||
flowUtilConsts.ARC_LENGTH -
|
||||
flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.width / 2,
|
||||
y: sourceY + endLineLength / 2,
|
||||
};
|
||||
const showDebugForLineEndPoint = false;
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
path={path}
|
||||
style={{ strokeWidth: `${flowUtilConsts.LINE_WIDTH}px` }}
|
||||
className="relative"
|
||||
></BaseEdge>
|
||||
{showDebugForLineEndPoint && (
|
||||
<foreignObject
|
||||
x={targetX}
|
||||
y={targetY}
|
||||
className="w-[20px] h-[20px] rounded-full bg-[red] flex items-center justify-center absolute"
|
||||
>
|
||||
<div className=" w-[20px] h-[20px] rounded-full bg-[red] flex items-center justify-center"></div>
|
||||
</foreignObject>
|
||||
)}
|
||||
|
||||
{
|
||||
<foreignObject
|
||||
x={buttonPosition.x}
|
||||
y={buttonPosition.y}
|
||||
width={flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.width}
|
||||
height={flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.height}
|
||||
className="overflow-visible"
|
||||
>
|
||||
<ApAddButton
|
||||
edgeId={id}
|
||||
stepLocationRelativeToParent={StepLocationRelativeToParent.AFTER}
|
||||
parentStepName={data.parentStepName}
|
||||
></ApAddButton>
|
||||
</foreignObject>
|
||||
}
|
||||
|
||||
{showDebugForLineEndPoint && (
|
||||
<foreignObject
|
||||
x={sourceX}
|
||||
y={sourceY}
|
||||
className="w-[20px] h-[20px] rounded-full bg-[red] flex items-center justify-center absolute"
|
||||
>
|
||||
<div className=" w-[20px] h-[20px] rounded-full bg-[red] flex items-center justify-center"></div>
|
||||
</foreignObject>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { BaseEdge, EdgeProps } from '@xyflow/react';
|
||||
|
||||
import { StepLocationRelativeToParent } from '@activepieces/shared';
|
||||
|
||||
import { flowUtilConsts } from '../utils/consts';
|
||||
import { ApLoopStartEdge } from '../utils/types';
|
||||
|
||||
import { ApAddButton } from './add-button';
|
||||
|
||||
export const ApLoopStartLineCanvasEdge = ({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
data,
|
||||
source,
|
||||
id,
|
||||
}: EdgeProps & ApLoopStartEdge) => {
|
||||
const startY = sourceY + flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEP_AND_LINE;
|
||||
const verticalLineLength =
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEPS -
|
||||
2 * flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEP_AND_LINE;
|
||||
|
||||
const horizontalLineLength =
|
||||
Math.abs(targetX - sourceX) - 2 * flowUtilConsts.ARC_LENGTH;
|
||||
const path = `M ${sourceX} ${startY} v${verticalLineLength / 2}
|
||||
${flowUtilConsts.ARC_RIGHT_DOWN} h${horizontalLineLength}
|
||||
${flowUtilConsts.ARC_RIGHT} v${verticalLineLength}
|
||||
${!data.isLoopEmpty ? flowUtilConsts.ARROW_DOWN : ''}`;
|
||||
|
||||
const showDebugForLineEndPoint = false;
|
||||
const buttonPosition = {
|
||||
x:
|
||||
sourceX -
|
||||
flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.width / 2 +
|
||||
horizontalLineLength +
|
||||
flowUtilConsts.ARC_LENGTH * 2,
|
||||
y: startY + verticalLineLength + flowUtilConsts.ARC_LENGTH,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
path={path}
|
||||
style={{ strokeWidth: `${flowUtilConsts.LINE_WIDTH}px` }}
|
||||
className="relative"
|
||||
></BaseEdge>
|
||||
{!data.isLoopEmpty && (
|
||||
<foreignObject
|
||||
x={buttonPosition.x}
|
||||
y={buttonPosition.y}
|
||||
width={flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.width}
|
||||
height={flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.height}
|
||||
className="overflow-visible cursor-default"
|
||||
>
|
||||
<ApAddButton
|
||||
edgeId={id}
|
||||
stepLocationRelativeToParent={
|
||||
StepLocationRelativeToParent.INSIDE_LOOP
|
||||
}
|
||||
parentStepName={source}
|
||||
></ApAddButton>
|
||||
</foreignObject>
|
||||
)}
|
||||
|
||||
{showDebugForLineEndPoint && (
|
||||
<foreignObject
|
||||
x={sourceX}
|
||||
y={startY}
|
||||
className="w-[20px] h-[20px] rounded-full bg-[red] flex items-center justify-center absolute"
|
||||
>
|
||||
<div className=" w-[20px] h-[20px] rounded-full bg-[red] flex items-center justify-center"></div>
|
||||
</foreignObject>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { BaseEdge, EdgeProps } from '@xyflow/react';
|
||||
|
||||
import { StepLocationRelativeToParent } from '@activepieces/shared';
|
||||
|
||||
import { flowUtilConsts } from '../utils/consts';
|
||||
import { ApRouterEndEdge } from '../utils/types';
|
||||
|
||||
import { ApAddButton } from './add-button';
|
||||
|
||||
export const ApRouterEndCanvasEdge = ({
|
||||
sourceX,
|
||||
targetX,
|
||||
targetY,
|
||||
sourceY,
|
||||
data,
|
||||
id,
|
||||
}: EdgeProps & Omit<ApRouterEndEdge, 'position'>) => {
|
||||
const verticalLineLength =
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEPS -
|
||||
2 * flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEP_AND_LINE;
|
||||
|
||||
const horizontalLineLength =
|
||||
(Math.abs(targetX - sourceX) - 2 * flowUtilConsts.ARC_LENGTH) *
|
||||
(targetX > sourceX ? 1 : -1);
|
||||
|
||||
const distanceBetweenTargetAndSource = Math.abs(targetX - sourceX);
|
||||
|
||||
const generatePath = () => {
|
||||
// Start point
|
||||
let path = `M ${sourceX - 0.5} ${
|
||||
sourceY - flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEP_AND_LINE
|
||||
}`;
|
||||
|
||||
// Vertical line from start
|
||||
path += `v ${data.verticalSpaceBetweenLastNodeInBranchAndEndLine}`;
|
||||
|
||||
// Arc or vertical line based on distance
|
||||
if (distanceBetweenTargetAndSource >= flowUtilConsts.ARC_LENGTH) {
|
||||
path +=
|
||||
targetX > sourceX
|
||||
? flowUtilConsts.ARC_RIGHT_DOWN
|
||||
: flowUtilConsts.ARC_LEFT_DOWN;
|
||||
} else {
|
||||
path += `v ${
|
||||
flowUtilConsts.ARC_LENGTH +
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEP_AND_LINE +
|
||||
2
|
||||
}`;
|
||||
}
|
||||
|
||||
// Optional horizontal line
|
||||
if (data.drawHorizontalLine) {
|
||||
path += `h ${horizontalLineLength} ${
|
||||
targetX > sourceX ? flowUtilConsts.ARC_RIGHT : flowUtilConsts.ARC_LEFT
|
||||
}`;
|
||||
}
|
||||
|
||||
// Optional ending vertical line with arrow
|
||||
if (data.drawEndingVerticalLine) {
|
||||
path += `v${verticalLineLength}`;
|
||||
if (!data.isNextStepEmpty) {
|
||||
path += flowUtilConsts.ARROW_DOWN;
|
||||
}
|
||||
}
|
||||
|
||||
return path;
|
||||
};
|
||||
|
||||
const path = generatePath();
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
path={path}
|
||||
style={{ strokeWidth: `${flowUtilConsts.LINE_WIDTH}px` }}
|
||||
/>
|
||||
|
||||
{data.drawEndingVerticalLine && (
|
||||
<foreignObject
|
||||
x={
|
||||
targetX -
|
||||
flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.width / 2 -
|
||||
flowUtilConsts.LINE_WIDTH / 2
|
||||
}
|
||||
y={targetY - verticalLineLength}
|
||||
width={flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.width}
|
||||
height={flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.height}
|
||||
className="overflow-visible"
|
||||
>
|
||||
<ApAddButton
|
||||
edgeId={id}
|
||||
stepLocationRelativeToParent={StepLocationRelativeToParent.AFTER}
|
||||
parentStepName={data.routerOrBranchStepName}
|
||||
/>
|
||||
</foreignObject>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
import { BaseEdge, EdgeProps } from '@xyflow/react';
|
||||
|
||||
import { StepLocationRelativeToParent } from '@activepieces/shared';
|
||||
|
||||
import { flowUtilConsts } from '../utils/consts';
|
||||
import { ApRouterStartEdge } from '../utils/types';
|
||||
|
||||
import { ApAddButton } from './add-button';
|
||||
import { BranchLabel } from './branch-label';
|
||||
|
||||
export const ApRouterStartCanvasEdge = ({
|
||||
sourceX,
|
||||
targetX,
|
||||
targetY,
|
||||
data,
|
||||
source,
|
||||
target,
|
||||
id,
|
||||
}: EdgeProps & Omit<ApRouterStartEdge, 'position'>) => {
|
||||
const verticalLineLength =
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEPS -
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEP_AND_LINE +
|
||||
flowUtilConsts.LABEL_HEIGHT;
|
||||
|
||||
const distanceBetweenSourceAndTarget = Math.abs(targetX - sourceX);
|
||||
const generatePath = () => {
|
||||
// Start point and initial vertical line
|
||||
let path = `M ${targetX} ${
|
||||
targetY - flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEP_AND_LINE
|
||||
}`;
|
||||
|
||||
// Add arrow if branch is not empty
|
||||
if (!data.isBranchEmpty) {
|
||||
path += flowUtilConsts.ARROW_DOWN;
|
||||
}
|
||||
|
||||
// Vertical line up
|
||||
path += `v -${verticalLineLength}`;
|
||||
|
||||
// Arc or vertical line based on distance
|
||||
if (distanceBetweenSourceAndTarget >= flowUtilConsts.ARC_LENGTH) {
|
||||
// Add appropriate arc based on source position
|
||||
path +=
|
||||
sourceX > targetX ? ' a12,12 0 0,1 12,-12' : ' a-12,-12 0 0,0 -12,-12';
|
||||
|
||||
if (data.drawHorizontalLine) {
|
||||
// Calculate horizontal line length
|
||||
const horizontalLength =
|
||||
(Math.abs(targetX - sourceX) + 3 - 2 * flowUtilConsts.ARC_LENGTH) *
|
||||
(sourceX > targetX ? 1 : -1);
|
||||
|
||||
// Add horizontal line and arc
|
||||
path += `h ${horizontalLength}`;
|
||||
path +=
|
||||
sourceX > targetX
|
||||
? flowUtilConsts.ARC_LEFT_UP
|
||||
: flowUtilConsts.ARC_RIGHT_UP;
|
||||
}
|
||||
|
||||
if (data.drawStartingVerticalLine) {
|
||||
// Add final vertical line
|
||||
const finalVerticalLength =
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEPS / 2 -
|
||||
2 * flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEP_AND_LINE;
|
||||
path += `v -${finalVerticalLength}`;
|
||||
}
|
||||
} else {
|
||||
// If distance is small, just draw vertical line
|
||||
path += `v -${
|
||||
flowUtilConsts.ARC_LENGTH +
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEP_AND_LINE
|
||||
}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
};
|
||||
|
||||
const path = generatePath();
|
||||
|
||||
const branchLabelProps =
|
||||
data.stepLocationRelativeToParent ===
|
||||
StepLocationRelativeToParent.INSIDE_BRANCH
|
||||
? {
|
||||
label: data.label,
|
||||
sourceNodeName: source,
|
||||
targetNodeName: target,
|
||||
stepLocationRelativeToParent: data.stepLocationRelativeToParent,
|
||||
branchIndex: data.branchIndex,
|
||||
}
|
||||
: {
|
||||
label: data.label,
|
||||
sourceNodeName: source,
|
||||
targetNodeName: target,
|
||||
stepLocationRelativeToParent: data.stepLocationRelativeToParent,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
path={path}
|
||||
style={{ strokeWidth: `${flowUtilConsts.LINE_WIDTH}px` }}
|
||||
></BaseEdge>
|
||||
{!data.isBranchEmpty && (
|
||||
<foreignObject
|
||||
x={targetX - flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.width / 2}
|
||||
y={targetY - verticalLineLength / 2}
|
||||
width={flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.width}
|
||||
height={flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.height}
|
||||
className="overflow-visible"
|
||||
>
|
||||
{data.stepLocationRelativeToParent !==
|
||||
StepLocationRelativeToParent.INSIDE_BRANCH && (
|
||||
<ApAddButton
|
||||
edgeId={id}
|
||||
stepLocationRelativeToParent={data.stepLocationRelativeToParent}
|
||||
parentStepName={source}
|
||||
></ApAddButton>
|
||||
)}
|
||||
|
||||
{data.stepLocationRelativeToParent ===
|
||||
StepLocationRelativeToParent.INSIDE_BRANCH && (
|
||||
<ApAddButton
|
||||
edgeId={id}
|
||||
stepLocationRelativeToParent={data.stepLocationRelativeToParent}
|
||||
parentStepName={source}
|
||||
branchIndex={data.branchIndex}
|
||||
></ApAddButton>
|
||||
)}
|
||||
</foreignObject>
|
||||
)}
|
||||
|
||||
<foreignObject
|
||||
width={flowUtilConsts.AP_NODE_SIZE.STEP.width - 10 + 'px'}
|
||||
height={
|
||||
flowUtilConsts.LABEL_HEIGHT +
|
||||
flowUtilConsts.LABEL_VERTICAL_PADDING +
|
||||
'px'
|
||||
}
|
||||
x={targetX - (flowUtilConsts.AP_NODE_SIZE.STEP.width - 10) / 2}
|
||||
y={
|
||||
targetY -
|
||||
verticalLineLength / 2 -
|
||||
flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.height -
|
||||
30
|
||||
}
|
||||
className="flex items-center "
|
||||
>
|
||||
<BranchLabel
|
||||
key={branchLabelProps.label + branchLabelProps.targetNodeName}
|
||||
sourceNodeName={source}
|
||||
targetNodeName={target}
|
||||
stepLocationRelativeToParent={data.stepLocationRelativeToParent}
|
||||
branchIndex={data.branchIndex}
|
||||
label={data.label}
|
||||
/>
|
||||
</foreignObject>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { BaseEdge, EdgeProps } from '@xyflow/react';
|
||||
|
||||
import { StepLocationRelativeToParent } from '@activepieces/shared';
|
||||
|
||||
import { flowUtilConsts } from '../utils/consts';
|
||||
import { ApStraightLineEdge } from '../utils/types';
|
||||
|
||||
import { ApAddButton } from './add-button';
|
||||
|
||||
export const ApStraightLineCanvasEdge = ({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetY,
|
||||
data,
|
||||
id,
|
||||
source,
|
||||
}: EdgeProps & ApStraightLineEdge) => {
|
||||
const lineStartX = sourceX;
|
||||
const lineStartY = sourceY;
|
||||
const lineLength = targetY - sourceY;
|
||||
const path = `M ${lineStartX} ${lineStartY} v${lineLength}
|
||||
${data.drawArrowHead ? flowUtilConsts.ARROW_DOWN : ''}`;
|
||||
const showDebugForLineEndPoint = false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
path={path}
|
||||
style={{ strokeWidth: `${flowUtilConsts.LINE_WIDTH}px` }}
|
||||
/>
|
||||
{!data.hideAddButton && (
|
||||
<foreignObject
|
||||
x={lineStartX - flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.width / 2}
|
||||
y={
|
||||
lineStartY +
|
||||
(targetY - sourceY) / 2 -
|
||||
flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.height / 2
|
||||
}
|
||||
width={flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.width}
|
||||
height={flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.height}
|
||||
className="overflow-visible cursor-default"
|
||||
>
|
||||
<ApAddButton
|
||||
edgeId={id}
|
||||
parentStepName={source}
|
||||
stepLocationRelativeToParent={StepLocationRelativeToParent.AFTER}
|
||||
></ApAddButton>
|
||||
</foreignObject>
|
||||
)}
|
||||
|
||||
{showDebugForLineEndPoint && (
|
||||
<foreignObject
|
||||
x={lineStartX}
|
||||
y={lineStartY + targetY - sourceY}
|
||||
className="w-[20px] h-[20px] rounded-full bg-[red] flex items-center justify-center absolute"
|
||||
>
|
||||
<div className=" w-[20px] h-[20px] rounded-full bg-[red] flex items-center justify-center"></div>
|
||||
</foreignObject>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
rectIntersection,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { useViewport } from '@xyflow/react';
|
||||
import { t } from 'i18next';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
FlowOperationType,
|
||||
StepLocationRelativeToParent,
|
||||
flowStructureUtil,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { useBuilderStateContext } from '../builder-hooks';
|
||||
|
||||
import StepDragOverlay from './step-drag-overlay';
|
||||
import { ApButtonData } from './utils/types';
|
||||
|
||||
const FlowDragLayer = ({
|
||||
children,
|
||||
cursorPosition,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
cursorPosition: { x: number; y: number };
|
||||
}) => {
|
||||
const viewport = useViewport();
|
||||
const [previousViewPort, setPreviousViewPort] = useState(viewport);
|
||||
const [
|
||||
setActiveDraggingStep,
|
||||
applyOperation,
|
||||
flowVersion,
|
||||
activeDraggingStep,
|
||||
] = useBuilderStateContext((state) => [
|
||||
state.setActiveDraggingStep,
|
||||
state.applyOperation,
|
||||
state.flowVersion,
|
||||
state.activeDraggingStep,
|
||||
]);
|
||||
|
||||
const fixCursorSnapOffset = useCallback(
|
||||
(args: Parameters<typeof rectIntersection>[0]) => {
|
||||
// Bail out if keyboard activated
|
||||
if (!args.pointerCoordinates) {
|
||||
return rectIntersection(args);
|
||||
}
|
||||
const { x, y } = args.pointerCoordinates;
|
||||
const { width, height } = args.collisionRect;
|
||||
const deltaViewport = {
|
||||
x: previousViewPort.x - viewport.x,
|
||||
y: previousViewPort.y - viewport.y,
|
||||
};
|
||||
const updated = {
|
||||
...args,
|
||||
// The collision rectangle is broken when using snapCenterToCursor. Reset
|
||||
// the collision rectangle based on pointer location and overlay size.
|
||||
collisionRect: {
|
||||
width,
|
||||
height,
|
||||
bottom: y + height / 2 + deltaViewport.y,
|
||||
left: x - width / 2 + deltaViewport.x,
|
||||
right: x + width / 2 + deltaViewport.x,
|
||||
top: y - height / 2 + deltaViewport.y,
|
||||
},
|
||||
};
|
||||
return rectIntersection(updated);
|
||||
},
|
||||
[viewport.x, viewport.y, previousViewPort.x, previousViewPort.y],
|
||||
);
|
||||
const draggedStep = activeDraggingStep
|
||||
? flowStructureUtil.getStep(activeDraggingStep, flowVersion.trigger)
|
||||
: undefined;
|
||||
|
||||
const handleDragStart = (e: DragStartEvent) => {
|
||||
setActiveDraggingStep(e.active.id.toString());
|
||||
setPreviousViewPort(viewport);
|
||||
};
|
||||
|
||||
const handleDragCancel = () => {
|
||||
setActiveDraggingStep(null);
|
||||
};
|
||||
|
||||
const handleDragEnd = (e: DragEndEvent) => {
|
||||
setActiveDraggingStep(null);
|
||||
if (
|
||||
e.over &&
|
||||
e.over.data.current &&
|
||||
e.over.data.current.accepts === e.active.data?.current?.type
|
||||
) {
|
||||
const droppedAtNodeData: ApButtonData = e.over.data
|
||||
.current as ApButtonData;
|
||||
if (
|
||||
droppedAtNodeData &&
|
||||
droppedAtNodeData.parentStepName &&
|
||||
draggedStep &&
|
||||
draggedStep.name !== droppedAtNodeData.parentStepName
|
||||
) {
|
||||
const isPartOfInnerFlow = flowStructureUtil.isChildOf(
|
||||
draggedStep,
|
||||
droppedAtNodeData.parentStepName,
|
||||
);
|
||||
if (isPartOfInnerFlow) {
|
||||
toast(t('Invalid Move'), {
|
||||
description: t(
|
||||
'The destination location is a child of the dragged step',
|
||||
),
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
applyOperation({
|
||||
type: FlowOperationType.MOVE_ACTION,
|
||||
request: {
|
||||
name: draggedStep.name,
|
||||
newParentStep: droppedAtNodeData.parentStepName,
|
||||
stepLocationRelativeToNewParent:
|
||||
droppedAtNodeData.stepLocationRelativeToParent,
|
||||
branchIndex:
|
||||
droppedAtNodeData.stepLocationRelativeToParent ===
|
||||
StepLocationRelativeToParent.INSIDE_BRANCH
|
||||
? droppedAtNodeData.branchIndex
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 10,
|
||||
},
|
||||
}),
|
||||
useSensor(TouchSensor),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<DndContext
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
sensors={sensors}
|
||||
collisionDetection={fixCursorSnapOffset}
|
||||
>
|
||||
{children}
|
||||
<DragOverlay dropAnimation={{ duration: 0 }}></DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
{draggedStep && (
|
||||
<StepDragOverlay
|
||||
cursorPosition={cursorPosition}
|
||||
step={draggedStep}
|
||||
></StepDragOverlay>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { FlowDragLayer };
|
||||
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
SelectionMode,
|
||||
OnSelectionChangeParams,
|
||||
useStoreApi,
|
||||
PanOnScrollMode,
|
||||
useKeyPress,
|
||||
BackgroundVariant,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
FlowActionType,
|
||||
flowStructureUtil,
|
||||
FlowVersion,
|
||||
isNil,
|
||||
Step,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import {
|
||||
doesSelectionRectangleExist,
|
||||
NODE_SELECTION_RECT_CLASS_NAME,
|
||||
useBuilderStateContext,
|
||||
useFocusOnStep,
|
||||
useHandleKeyPressOnCanvas,
|
||||
useResizeCanvas,
|
||||
} from '../builder-hooks';
|
||||
|
||||
import {
|
||||
CanvasContextMenu,
|
||||
ContextMenuType,
|
||||
} from './context-menu/canvas-context-menu';
|
||||
import { FlowDragLayer } from './flow-drag-layer';
|
||||
import {
|
||||
flowUtilConsts,
|
||||
SELECTION_RECT_CHEVRON_ATTRIBUTE,
|
||||
STEP_CONTEXT_MENU_ATTRIBUTE,
|
||||
} from './utils/consts';
|
||||
import { flowCanvasUtils } from './utils/flow-canvas-utils';
|
||||
import { AboveFlowWidgets } from './widgets';
|
||||
import { useShowChevronNextToSelection } from './widgets/selection-chevron-button';
|
||||
const getChildrenKey = (step: Step) => {
|
||||
switch (step.type) {
|
||||
case FlowActionType.LOOP_ON_ITEMS:
|
||||
return step.firstLoopAction ? step.firstLoopAction.name : '';
|
||||
case FlowActionType.ROUTER:
|
||||
return step.children.reduce((routerKey, child) => {
|
||||
const childrenKey = child
|
||||
? flowStructureUtil
|
||||
.getAllSteps(child)
|
||||
.reduce(
|
||||
(childKey, grandChild) => `${childKey}-${grandChild.name}`,
|
||||
'',
|
||||
)
|
||||
: 'null';
|
||||
return `${routerKey}-${childrenKey}`;
|
||||
}, '');
|
||||
case FlowActionType.CODE:
|
||||
case FlowActionType.PIECE:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
const createGraphKey = (flowVersion: FlowVersion) => {
|
||||
return flowStructureUtil
|
||||
.getAllSteps(flowVersion.trigger)
|
||||
.reduce((acc, step) => {
|
||||
const branchesNames =
|
||||
step.type === FlowActionType.ROUTER
|
||||
? step.settings.branches.map((branch) => branch.branchName).join('-')
|
||||
: '0';
|
||||
const childrenKey = getChildrenKey(step);
|
||||
return `${acc}-${step.displayName}-${step.type}-${
|
||||
step.nextAction ? step.nextAction.name : ''
|
||||
}-${
|
||||
step.type === FlowActionType.PIECE ? step.settings.pieceName : ''
|
||||
}-${branchesNames}-${childrenKey}}`;
|
||||
}, '');
|
||||
};
|
||||
|
||||
export const FlowCanvas = React.memo(
|
||||
({
|
||||
setHasCanvasBeenInitialised,
|
||||
}: {
|
||||
setHasCanvasBeenInitialised: (value: boolean) => void;
|
||||
}) => {
|
||||
const [
|
||||
flowVersion,
|
||||
setSelectedNodes,
|
||||
selectedNodes,
|
||||
selectedStep,
|
||||
panningMode,
|
||||
selectStepByName,
|
||||
] = useBuilderStateContext((state) => {
|
||||
return [
|
||||
state.flowVersion,
|
||||
state.setSelectedNodes,
|
||||
state.selectedNodes,
|
||||
state.selectedStep,
|
||||
state.panningMode,
|
||||
state.selectStepByName,
|
||||
];
|
||||
});
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useShowChevronNextToSelection();
|
||||
useFocusOnStep();
|
||||
useHandleKeyPressOnCanvas();
|
||||
useResizeCanvas(containerRef, setHasCanvasBeenInitialised);
|
||||
const storeApi = useStoreApi();
|
||||
const isShiftKeyPressed = useKeyPress('Shift');
|
||||
const inGrabPanningMode = !isShiftKeyPressed && panningMode === 'grab';
|
||||
const onSelectionChange = useCallback(
|
||||
(ev: OnSelectionChangeParams) => {
|
||||
const selectedNodes = ev.nodes.map((n) => n.id);
|
||||
if (selectedNodes.length === 0 && selectedStep) {
|
||||
selectedNodes.push(selectedStep);
|
||||
}
|
||||
setSelectedNodes(selectedNodes);
|
||||
},
|
||||
[setSelectedNodes, selectedStep],
|
||||
);
|
||||
const graphKey = createGraphKey(flowVersion);
|
||||
const graph = useMemo(() => {
|
||||
return flowCanvasUtils.convertFlowVersionToGraph(flowVersion);
|
||||
}, [graphKey]);
|
||||
const [contextMenuType, setContextMenuType] = useState<ContextMenuType>(
|
||||
ContextMenuType.CANVAS,
|
||||
);
|
||||
const onContextMenu = useCallback(
|
||||
(ev: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
ev.target instanceof HTMLElement ||
|
||||
ev.target instanceof SVGElement
|
||||
) {
|
||||
const stepElement = ev.target.closest(
|
||||
`[data-${STEP_CONTEXT_MENU_ATTRIBUTE}]`,
|
||||
);
|
||||
const stepName = stepElement?.getAttribute(
|
||||
`data-${STEP_CONTEXT_MENU_ATTRIBUTE}`,
|
||||
);
|
||||
|
||||
if (stepElement && stepName) {
|
||||
selectStepByName(stepName);
|
||||
storeApi.getState().addSelectedNodes([stepName]);
|
||||
}
|
||||
const targetIsSelectionChevron = ev.target.closest(
|
||||
`[data-${SELECTION_RECT_CHEVRON_ATTRIBUTE}]`,
|
||||
);
|
||||
const targetIsSelectionRect = ev.target.classList.contains(
|
||||
NODE_SELECTION_RECT_CLASS_NAME,
|
||||
);
|
||||
const showStepContextMenu =
|
||||
stepElement || targetIsSelectionRect || targetIsSelectionChevron;
|
||||
if (showStepContextMenu) {
|
||||
setContextMenuType(ContextMenuType.STEP);
|
||||
} else {
|
||||
setContextMenuType(ContextMenuType.CANVAS);
|
||||
}
|
||||
const shouldRemoveSelectionRect =
|
||||
!targetIsSelectionRect && !targetIsSelectionChevron;
|
||||
if (shouldRemoveSelectionRect) {
|
||||
document
|
||||
.querySelector(`.${NODE_SELECTION_RECT_CLASS_NAME}`)
|
||||
?.remove();
|
||||
}
|
||||
}
|
||||
},
|
||||
[setSelectedNodes, selectedNodes, doesSelectionRectangleExist],
|
||||
);
|
||||
|
||||
const onSelectionEnd = useCallback(() => {
|
||||
const selectedSteps = selectedNodes.map((node) =>
|
||||
flowStructureUtil.getStepOrThrow(node, flowVersion.trigger),
|
||||
);
|
||||
selectedSteps.forEach((step) => {
|
||||
if (
|
||||
step.type === FlowActionType.LOOP_ON_ITEMS ||
|
||||
step.type === FlowActionType.ROUTER
|
||||
) {
|
||||
const childrenNotSelected = flowStructureUtil
|
||||
.getAllChildSteps(step)
|
||||
.filter((c) => isNil(selectedNodes.find((n) => n === c.name)));
|
||||
selectedSteps.push(...childrenNotSelected);
|
||||
}
|
||||
});
|
||||
const step = selectedStep
|
||||
? flowStructureUtil.getStep(selectedStep, flowVersion.trigger)
|
||||
: null;
|
||||
if (selectedNodes.length === 0 && step) {
|
||||
selectedSteps.push(step);
|
||||
}
|
||||
storeApi
|
||||
.getState()
|
||||
.addSelectedNodes(selectedSteps.map((step) => step.name));
|
||||
}, [selectedNodes, storeApi, selectedStep]);
|
||||
const [cursorPosition, setCursorPosition] = useState({ x: 0, y: 0 });
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="size-full relative overflow-hidden z-30 bg-builder-background"
|
||||
>
|
||||
<FlowDragLayer cursorPosition={cursorPosition}>
|
||||
<CanvasContextMenu contextMenuType={contextMenuType}>
|
||||
<ReactFlow
|
||||
className="bg-builder-background"
|
||||
onContextMenu={onContextMenu}
|
||||
onPaneClick={() => {
|
||||
storeApi.getState().unselectNodesAndEdges();
|
||||
}}
|
||||
nodeTypes={flowUtilConsts.nodeTypes}
|
||||
nodes={graph.nodes}
|
||||
edgeTypes={flowUtilConsts.edgeTypes}
|
||||
edges={graph.edges}
|
||||
draggable={false}
|
||||
edgesFocusable={false}
|
||||
elevateEdgesOnSelect={false}
|
||||
maxZoom={1.5}
|
||||
minZoom={0.5}
|
||||
panOnDrag={inGrabPanningMode ? [0, 1] : [1]}
|
||||
zoomOnDoubleClick={false}
|
||||
panOnScroll={true}
|
||||
panOnScrollMode={PanOnScrollMode.Free}
|
||||
fitView={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={true}
|
||||
nodesDraggable={false}
|
||||
nodesFocusable={false}
|
||||
onNodeDrag={(event) => {
|
||||
setCursorPosition({ x: event.clientX, y: event.clientY });
|
||||
}}
|
||||
selectionKeyCode={inGrabPanningMode ? 'Shift' : null}
|
||||
multiSelectionKeyCode={inGrabPanningMode ? 'Shift' : null}
|
||||
selectionOnDrag={inGrabPanningMode ? false : true}
|
||||
selectNodesOnDrag={true}
|
||||
selectionMode={SelectionMode.Partial}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onSelectionEnd={onSelectionEnd}
|
||||
>
|
||||
<AboveFlowWidgets></AboveFlowWidgets>
|
||||
<Background
|
||||
gap={10}
|
||||
size={1}
|
||||
variant={BackgroundVariant.Dots}
|
||||
bgColor={`var(--builder-background)`}
|
||||
color={`var(--builder-background-pattern)`}
|
||||
/>
|
||||
</ReactFlow>
|
||||
</CanvasContextMenu>
|
||||
</FlowDragLayer>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
FlowCanvas.displayName = 'FlowCanvas';
|
||||
@@ -0,0 +1,171 @@
|
||||
import { DragMoveEvent, useDndMonitor, useDroppable } from '@dnd-kit/core';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import React, { useId, useState } from 'react';
|
||||
|
||||
import { PieceSelector } from '@/app/builder/pieces-selector';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isNil } from '@activepieces/shared';
|
||||
|
||||
import { useBuilderStateContext } from '../../builder-hooks';
|
||||
import { flowUtilConsts } from '../utils/consts';
|
||||
import { flowCanvasUtils } from '../utils/flow-canvas-utils';
|
||||
import { ApBigAddButtonNode } from '../utils/types';
|
||||
|
||||
const ApBigAddButtonCanvasNode = React.memo(
|
||||
({ data, id }: Omit<ApBigAddButtonNode, 'position'>) => {
|
||||
const [isIsStepInsideDropzone, setIsStepInsideDropzone] = useState(false);
|
||||
const [readonly, activeDraggingStep, isPieceSelectorOpened] =
|
||||
useBuilderStateContext((state) => [
|
||||
state.readonly,
|
||||
state.activeDraggingStep,
|
||||
state.openedPieceSelectorStepNameOrAddButtonId === id,
|
||||
]);
|
||||
const draggableId = useId();
|
||||
const { setNodeRef } = useDroppable({
|
||||
id: draggableId,
|
||||
data: {
|
||||
accepts: flowUtilConsts.DRAGGED_STEP_TAG,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
const isShowingDropIndicator = !isNil(activeDraggingStep);
|
||||
useDndMonitor({
|
||||
onDragMove(event: DragMoveEvent) {
|
||||
setIsStepInsideDropzone(event.over?.id === draggableId);
|
||||
},
|
||||
onDragEnd() {
|
||||
setIsStepInsideDropzone(false);
|
||||
},
|
||||
});
|
||||
return (
|
||||
<>
|
||||
{
|
||||
<div
|
||||
style={{
|
||||
height: `${flowUtilConsts.AP_NODE_SIZE.STEP.height}px`,
|
||||
width: `${flowUtilConsts.AP_NODE_SIZE.STEP.width}px`,
|
||||
}}
|
||||
className="flex justify-center items-center "
|
||||
>
|
||||
{!readonly && (
|
||||
//we use transparent colors when opening the piece selector, so to not show the pattern of the background inside the button, we wrap the big add button in a div with the background color
|
||||
<div className="bg-builder-background">
|
||||
<div
|
||||
style={{
|
||||
height: `${flowUtilConsts.AP_NODE_SIZE.BIG_ADD_BUTTON.height}px`,
|
||||
width: `${flowUtilConsts.AP_NODE_SIZE.BIG_ADD_BUTTON.width}px`,
|
||||
}}
|
||||
className=" cursor-auto border-none flex items-center justify-center relative "
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${flowUtilConsts.AP_NODE_SIZE.BIG_ADD_BUTTON.height}px`,
|
||||
width: `${flowUtilConsts.AP_NODE_SIZE.BIG_ADD_BUTTON.width}px`,
|
||||
}}
|
||||
id={id}
|
||||
className={cn('rounded-lg bg-background relative', {
|
||||
'bg-primary/80':
|
||||
isShowingDropIndicator || isPieceSelectorOpened,
|
||||
'shadow-add-button':
|
||||
isIsStepInsideDropzone || isPieceSelectorOpened,
|
||||
'transition-all':
|
||||
isIsStepInsideDropzone ||
|
||||
isPieceSelectorOpened ||
|
||||
isShowingDropIndicator,
|
||||
})}
|
||||
>
|
||||
{!isShowingDropIndicator && (
|
||||
<PieceSelector
|
||||
operation={flowCanvasUtils.createAddOperationFromAddButtonData(
|
||||
data,
|
||||
)}
|
||||
id={id}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
variant="transparent"
|
||||
className="w-full h-full flex items-center hover:bg-accent-foreground rounded-lg border-border border-solid border"
|
||||
>
|
||||
<Plus
|
||||
className={cn('w-6 h-6 text-foreground ', {
|
||||
'opacity-0':
|
||||
isShowingDropIndicator ||
|
||||
isPieceSelectorOpened,
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
</span>
|
||||
</PieceSelector>
|
||||
)}
|
||||
</div>
|
||||
{isShowingDropIndicator && (
|
||||
//this is an invisible div that is used to show the drop indicator when the step is being dragged over the big add button, it is a rectangle so there is more leanancy to drop the step on the big add button
|
||||
<div
|
||||
style={{
|
||||
height: `${flowUtilConsts.AP_NODE_SIZE.STEP.height}px`,
|
||||
width: `${flowUtilConsts.AP_NODE_SIZE.STEP.width}px`,
|
||||
top: `-${
|
||||
flowUtilConsts.AP_NODE_SIZE.STEP.height / 2 -
|
||||
flowUtilConsts.AP_NODE_SIZE.BIG_ADD_BUTTON.width / 2
|
||||
}px`,
|
||||
}}
|
||||
className=" absolute "
|
||||
ref={setNodeRef}
|
||||
>
|
||||
{' '}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{readonly && (
|
||||
<div
|
||||
style={{
|
||||
height: `${flowUtilConsts.AP_NODE_SIZE.STEP.height}px`,
|
||||
width: `${flowUtilConsts.AP_NODE_SIZE.STEP.width}px`,
|
||||
}}
|
||||
className=" cursor-auto flex items-center justify-center relative "
|
||||
>
|
||||
<svg
|
||||
height={flowUtilConsts.AP_NODE_SIZE.STEP.height}
|
||||
width={flowUtilConsts.AP_NODE_SIZE.STEP.width}
|
||||
className="overflow-visible border-transparent "
|
||||
style={{
|
||||
stroke: 'var(--xy-edge-stroke, var(--xy-edge-stroke))',
|
||||
}}
|
||||
shapeRendering="auto"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d={`M ${
|
||||
flowUtilConsts.AP_NODE_SIZE.STEP.width / 2
|
||||
} -10 v ${flowUtilConsts.AP_NODE_SIZE.STEP.height + 14}`}
|
||||
fill="transparent"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
style={flowUtilConsts.HANDLE_STYLING}
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
style={flowUtilConsts.HANDLE_STYLING}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ApBigAddButtonCanvasNode.displayName = 'ApBigAddButtonCanvasNode';
|
||||
export { ApBigAddButtonCanvasNode };
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
|
||||
import { flowUtilConsts } from '../utils/consts';
|
||||
import { ApGraphEndNode } from '../utils/types';
|
||||
import FlowEndWidget from '../widgets/flow-end-widget';
|
||||
|
||||
const ApGraphEndWidgetNode = ({ data }: Omit<ApGraphEndNode, 'position'>) => {
|
||||
return (
|
||||
<>
|
||||
<div className="h-px w-px relative ">
|
||||
{data.showWidget && <FlowEndWidget></FlowEndWidget>}
|
||||
</div>
|
||||
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
style={flowUtilConsts.HANDLE_STYLING}
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
style={flowUtilConsts.HANDLE_STYLING}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ApGraphEndWidgetNode.displayName = 'ApGraphEndWidgetNode';
|
||||
export default ApGraphEndWidgetNode;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
|
||||
import { flowUtilConsts } from '../utils/consts';
|
||||
|
||||
//used purely to help calculate the loop graph width
|
||||
const ApLoopReturnCanvasNode = () => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="h-px bg-transparent pointer-events-none "
|
||||
style={{
|
||||
width: flowUtilConsts.AP_NODE_SIZE.LOOP_RETURN_NODE.width,
|
||||
}}
|
||||
></div>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Top}
|
||||
style={flowUtilConsts.HANDLE_STYLING}
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Bottom}
|
||||
style={flowUtilConsts.HANDLE_STYLING}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ApLoopReturnCanvasNode.displayName = 'EmptyLoopReturnCanvasNode';
|
||||
export default ApLoopReturnCanvasNode;
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { Handle, NodeProps, Position } from '@xyflow/react';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { useBuilderStateContext } from '@/app/builder/builder-hooks';
|
||||
import { PieceSelector } from '@/app/builder/pieces-selector';
|
||||
import { stepsHooks } from '@/features/pieces/lib/steps-hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
FlowOperationType,
|
||||
Step,
|
||||
FlowTriggerType,
|
||||
flowStructureUtil,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import {
|
||||
flowUtilConsts,
|
||||
STEP_CONTEXT_MENU_ATTRIBUTE,
|
||||
} from '../../utils/consts';
|
||||
import { flowCanvasUtils } from '../../utils/flow-canvas-utils';
|
||||
import { ApStepNode } from '../../utils/types';
|
||||
|
||||
import { StepInvalidOrSkippedIcon } from './step-invalid-or-skipped-icon';
|
||||
import { StepNodeChevron } from './step-node-chevron';
|
||||
import { StepNodeDisplayName } from './step-node-display-name';
|
||||
import { StepNodeLogo } from './step-node-logo';
|
||||
import { StepNodeName } from './step-node-name';
|
||||
import { ApStepNodeStatus } from './step-node-status';
|
||||
import { TriggerWidget } from './trigger-widget';
|
||||
|
||||
const ApStepCanvasNode = React.memo(
|
||||
({ data: { step } }: NodeProps & Omit<ApStepNode, 'position'>) => {
|
||||
const [
|
||||
selectStepByName,
|
||||
isSelected,
|
||||
isDragging,
|
||||
readonly,
|
||||
flowVersion,
|
||||
setSelectedBranchIndex,
|
||||
isPieceSelectorOpened,
|
||||
setOpenedPieceSelectorStepNameOrAddButtonId,
|
||||
isStepValid,
|
||||
] = useBuilderStateContext((state) => [
|
||||
state.selectStepByName,
|
||||
state.selectedStep === step.name,
|
||||
state.activeDraggingStep === step.name,
|
||||
state.readonly,
|
||||
state.flowVersion,
|
||||
state.setSelectedBranchIndex,
|
||||
state.openedPieceSelectorStepNameOrAddButtonId === step.name,
|
||||
state.setOpenedPieceSelectorStepNameOrAddButtonId,
|
||||
flowStructureUtil.getStep(step.name, state.flowVersion.trigger)?.valid,
|
||||
]);
|
||||
const { stepMetadata } = stepsHooks.useStepMetadata({
|
||||
step,
|
||||
});
|
||||
const stepIndex = useMemo(() => {
|
||||
const steps = flowStructureUtil.getAllSteps(flowVersion.trigger);
|
||||
return steps.findIndex((s) => s.name === step.name) + 1;
|
||||
}, [step, flowVersion]);
|
||||
const isTrigger = flowStructureUtil.isTrigger(step.type);
|
||||
const isSkipped = flowCanvasUtils.isSkipped(step.name, flowVersion.trigger);
|
||||
|
||||
const { attributes, listeners, setNodeRef } = useDraggable({
|
||||
id: step.name,
|
||||
disabled: isTrigger || readonly,
|
||||
data: {
|
||||
type: flowUtilConsts.DRAGGED_STEP_TAG,
|
||||
},
|
||||
});
|
||||
|
||||
const handleStepClick = (
|
||||
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||
) => {
|
||||
selectStepByName(step.name);
|
||||
setSelectedBranchIndex(null);
|
||||
if (step.type === FlowTriggerType.EMPTY) {
|
||||
setOpenedPieceSelectorStepNameOrAddButtonId(step.name);
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
const stepNodeDivAttributes = isPieceSelectorOpened ? {} : attributes;
|
||||
const stepNodeDivListeners = isPieceSelectorOpened ? {} : listeners;
|
||||
return (
|
||||
<div
|
||||
{...{ [`data-${STEP_CONTEXT_MENU_ATTRIBUTE}`]: step.name }}
|
||||
style={{
|
||||
height: `${flowUtilConsts.AP_NODE_SIZE.STEP.height}px`,
|
||||
width: `${flowUtilConsts.AP_NODE_SIZE.STEP.width}px`,
|
||||
maxWidth: `${flowUtilConsts.AP_NODE_SIZE.STEP.width}px`,
|
||||
}}
|
||||
className={cn(
|
||||
'transition-all border-box rounded-md border border-solid border-border relative overflow-show group',
|
||||
{
|
||||
'border-primary': isSelected,
|
||||
'bg-background': !isDragging,
|
||||
'border-none': isDragging,
|
||||
'shadow-none': isDragging,
|
||||
'bg-accent': isSkipped,
|
||||
'rounded-tl-none': isTrigger,
|
||||
'hover:border-ring': !isSelected,
|
||||
},
|
||||
)}
|
||||
onClick={(e) => handleStepClick(e)}
|
||||
key={step.name}
|
||||
ref={isPieceSelectorOpened ? null : setNodeRef}
|
||||
{...stepNodeDivAttributes}
|
||||
{...stepNodeDivListeners}
|
||||
>
|
||||
{isTrigger && <TriggerWidget isSelected={isSelected} />}
|
||||
<StepInvalidOrSkippedIcon
|
||||
isValid={!!isStepValid}
|
||||
isSkipped={isSkipped}
|
||||
/>
|
||||
<ApStepNodeStatus stepName={step.name} />
|
||||
<StepNodeName stepName={step.name} />
|
||||
<div className="px-3 h-full w-full overflow-hidden">
|
||||
{!isDragging && (
|
||||
<PieceSelector
|
||||
operation={{
|
||||
type: getPieceSelectorOperationType(step),
|
||||
stepName: step.name,
|
||||
}}
|
||||
id={step.name}
|
||||
openSelectorOnClick={false}
|
||||
stepToReplacePieceDisplayName={stepMetadata?.displayName}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-center h-full w-full gap-[10px]"
|
||||
onClick={(e) => {
|
||||
if (!isPieceSelectorOpened) {
|
||||
handleStepClick(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StepNodeLogo
|
||||
isSkipped={isSkipped}
|
||||
logoUrl={stepMetadata?.logoUrl ?? ''}
|
||||
displayName={stepMetadata?.displayName ?? ''}
|
||||
/>
|
||||
<StepNodeDisplayName
|
||||
stepDisplayName={step.displayName}
|
||||
stepIndex={stepIndex}
|
||||
isSkipped={isSkipped}
|
||||
pieceDisplayName={stepMetadata?.displayName ?? ''}
|
||||
/>
|
||||
{!readonly && <StepNodeChevron />}
|
||||
</div>
|
||||
</PieceSelector>
|
||||
)}
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
style={flowUtilConsts.HANDLE_STYLING}
|
||||
position={Position.Bottom}
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
style={flowUtilConsts.HANDLE_STYLING}
|
||||
position={Position.Top}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ApStepCanvasNode.displayName = 'ApStepCanvasNode';
|
||||
export { ApStepCanvasNode };
|
||||
|
||||
function getPieceSelectorOperationType(step: Step) {
|
||||
if (flowStructureUtil.isTrigger(step.type)) {
|
||||
return FlowOperationType.UPDATE_TRIGGER;
|
||||
}
|
||||
return FlowOperationType.UPDATE_ACTION;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { t } from 'i18next';
|
||||
import { RouteOff } from 'lucide-react';
|
||||
|
||||
import { InvalidStepIcon } from '@/components/custom/alert-icon';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
import { flowUtilConsts } from '../../utils/consts';
|
||||
|
||||
const StepInvalidOrSkippedIcon = ({
|
||||
isValid,
|
||||
isSkipped,
|
||||
}: {
|
||||
isValid: boolean;
|
||||
isSkipped: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="absolute flex items-center -left-[22px] bg-builder-background "
|
||||
style={{ height: `${flowUtilConsts.AP_NODE_SIZE.STEP.height}px` }}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
{!isValid && !isSkipped && (
|
||||
<InvalidStepIcon className="h-4 w-4"></InvalidStepIcon>
|
||||
)}
|
||||
{isSkipped && (
|
||||
<RouteOff className="w-3.5 h-3.5 animate-fade text-muted-foreground"></RouteOff>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{(!isValid || isSkipped) && (
|
||||
<TooltipContent>
|
||||
<div>
|
||||
{!isValid && !isSkipped && t('Incomplete step')}
|
||||
{isSkipped && t('Skipped')}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { StepInvalidOrSkippedIcon };
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const StepNodeChevron = () => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-1 size-7 "
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (e.target) {
|
||||
const rightClickEvent = new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
button: 2,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
});
|
||||
e.target.dispatchEvent(rightClickEvent);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronDown className="w-4 h-4 stroke-muted-foreground" />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export { StepNodeChevron };
|
||||
@@ -0,0 +1,37 @@
|
||||
import { TextWithTooltip } from '@/components/custom/text-with-tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const StepNodeDisplayName = ({
|
||||
stepDisplayName,
|
||||
stepIndex,
|
||||
isSkipped,
|
||||
pieceDisplayName,
|
||||
}: {
|
||||
stepDisplayName: string;
|
||||
stepIndex: number;
|
||||
isSkipped: boolean;
|
||||
pieceDisplayName: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className="grow flex flex-col items-start justify-center min-w-0 w-full">
|
||||
<div className=" flex items-center justify-between min-w-0 w-full">
|
||||
<TextWithTooltip tooltipMessage={stepDisplayName}>
|
||||
<div
|
||||
className={cn('text-sm truncate grow shrink ', {
|
||||
'text-accent-foreground/70': isSkipped,
|
||||
})}
|
||||
>
|
||||
{stepIndex}. {stepDisplayName}
|
||||
</div>
|
||||
</TextWithTooltip>
|
||||
</div>
|
||||
<div className="flex justify-between mt-0.5 w-full items-center">
|
||||
<div className="text-xs text-muted-foreground text-ellipsis overflow-hidden whitespace-nowrap w-full">
|
||||
{pieceDisplayName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { StepNodeDisplayName };
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ImageWithColorBackground } from '@/components/ui/image-with-color-background';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const StepNodeLogo = ({
|
||||
isSkipped,
|
||||
logoUrl,
|
||||
displayName,
|
||||
}: {
|
||||
isSkipped: boolean;
|
||||
logoUrl: string;
|
||||
displayName: string;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-center rounded-sm', {
|
||||
'opacity-80': isSkipped,
|
||||
})}
|
||||
>
|
||||
<ImageWithColorBackground
|
||||
src={logoUrl}
|
||||
alt={displayName}
|
||||
key={logoUrl + displayName}
|
||||
border={true}
|
||||
className="w-9 h-9 p-2"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { StepNodeLogo };
|
||||
@@ -0,0 +1,16 @@
|
||||
import { flowUtilConsts } from '../../utils/consts';
|
||||
|
||||
const StepNodeName = ({ stepName }: { stepName: string }) => {
|
||||
return (
|
||||
<div
|
||||
className="absolute left-full bg-builder-background ml-3 text-accent-foreground text-xs opacity-0 transition-all duration-300 group-hover:opacity-100 "
|
||||
style={{
|
||||
top: `${flowUtilConsts.AP_NODE_SIZE.STEP.height / 2 - 12}px`,
|
||||
}}
|
||||
>
|
||||
{stepName}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { StepNodeName };
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { StepStatusIcon } from '@/features/flow-runs/components/step-status-icon';
|
||||
import { flowRunUtils } from '@/features/flow-runs/lib/flow-run-utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { useBuilderStateContext } from '../../../builder-hooks';
|
||||
import { flowCanvasUtils } from '../../utils/flow-canvas-utils';
|
||||
|
||||
const ApStepNodeStatus = ({ stepName }: { stepName: string }) => {
|
||||
const [run, loopIndexes, flowVersion] = useBuilderStateContext((state) => [
|
||||
state.run,
|
||||
state.loopsIndexes,
|
||||
state.flowVersion,
|
||||
]);
|
||||
const stepStatusInRun = useMemo(() => {
|
||||
return flowCanvasUtils.getStepStatus(
|
||||
stepName,
|
||||
run,
|
||||
loopIndexes,
|
||||
flowVersion,
|
||||
);
|
||||
}, [stepName, run, loopIndexes, flowVersion]);
|
||||
if (!stepStatusInRun) {
|
||||
return null;
|
||||
}
|
||||
const { variant, text } = flowRunUtils.getStatusIconForStep(stepStatusInRun);
|
||||
return (
|
||||
<div className="absolute right-[1px] text-sm h-[20px] -top-[28px]">
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-1 animate-in fade-in slide-in-from-bottom-2 duration-500 items-center justify-center px-2 rounded-md ',
|
||||
{
|
||||
hidden: !stepStatusInRun,
|
||||
'text-green-800 bg-green-50 border border-green-200':
|
||||
variant === 'success',
|
||||
'text-red-800 bg-red-50 border border-red-200': variant === 'error',
|
||||
'bg-background border border-border text-foreground':
|
||||
variant === 'default',
|
||||
},
|
||||
)}
|
||||
>
|
||||
<StepStatusIcon
|
||||
status={stepStatusInRun}
|
||||
size="3"
|
||||
hideTooltip={true}
|
||||
></StepStatusIcon>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
ApStepNodeStatus.displayName = 'ApStepNodeStatus';
|
||||
|
||||
export { ApStepNodeStatus };
|
||||
@@ -0,0 +1,22 @@
|
||||
import { t } from 'i18next';
|
||||
import { Goal } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const TriggerWidget = ({ isSelected }: { isSelected: boolean }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center absolute transition-all -translate-y-[26px] -translate-x-[1px] border-border border border-1 border-b-transparent justify-center gap-1 rounded-t-md bg-background text-muted-foreground text-xs py-1 px-2 ',
|
||||
{
|
||||
'border-primary text-primary ': isSelected,
|
||||
'group-hover:border-ring ': !isSelected,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<Goal className="w-[10px] h-[10px]"></Goal> {t('Trigger')}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { TriggerWidget };
|
||||
@@ -0,0 +1,66 @@
|
||||
import { t } from 'i18next';
|
||||
|
||||
import { useSidebar } from '@/components/ui/sidebar-shadcn';
|
||||
import { stepsHooks } from '@/features/pieces/lib/steps-hooks';
|
||||
import { FlowAction, FlowTrigger } from '@activepieces/shared';
|
||||
|
||||
import {
|
||||
BUILDER_NAVIGATION_SIDEBAR_ID,
|
||||
flowUtilConsts,
|
||||
LEFT_SIDEBAR_ID,
|
||||
} from './utils/consts';
|
||||
|
||||
const StepDragOverlay = ({
|
||||
step,
|
||||
cursorPosition,
|
||||
}: {
|
||||
step: FlowAction | FlowTrigger;
|
||||
cursorPosition: { x: number; y: number };
|
||||
}) => {
|
||||
//the overlay position is relatiive to the whole screen so when items that squeeze the canvas from the left are rendered, we need to adjust the position
|
||||
//so we need to get the width of the left sidebar and the navigation bar and subtract them from the cursor position
|
||||
const { open } = useSidebar();
|
||||
const builderLeftSidebar = document.getElementById(LEFT_SIDEBAR_ID);
|
||||
const builderLeftSidebarWidth = builderLeftSidebar?.clientWidth ?? 0;
|
||||
const builderNavigationBar = document.getElementById(
|
||||
BUILDER_NAVIGATION_SIDEBAR_ID,
|
||||
);
|
||||
const builderNavigationBarWidth = open
|
||||
? builderNavigationBar?.clientWidth ?? 0
|
||||
: 0;
|
||||
const left = `${
|
||||
cursorPosition.x -
|
||||
flowUtilConsts.STEP_DRAG_OVERLAY_WIDTH / 2 -
|
||||
builderLeftSidebarWidth -
|
||||
builderNavigationBarWidth
|
||||
}px`;
|
||||
const top = `${
|
||||
cursorPosition.y - flowUtilConsts.STEP_DRAG_OVERLAY_HEIGHT - 20
|
||||
}px`;
|
||||
const { stepMetadata } = stepsHooks.useStepMetadata({
|
||||
step,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'p-4 absolute left-0 top-0 opacity-75 flex items-center justify-center rounded-2xl border border-solid border bg-background'
|
||||
}
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
height: `${flowUtilConsts.STEP_DRAG_OVERLAY_HEIGHT}px`,
|
||||
width: `${flowUtilConsts.STEP_DRAG_OVERLAY_WIDTH}px`,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
id={t('logo')}
|
||||
className={'object-contain left-0 right-0 static'}
|
||||
src={step?.settings?.customLogoUrl ?? stepMetadata?.logoUrl}
|
||||
alt={t('Step Icon')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StepDragOverlay;
|
||||
@@ -0,0 +1,111 @@
|
||||
import { ApLoopReturnLineCanvasEdge as ApLoopReturnCanvasEdge } from '../edges/loop-return-edge';
|
||||
import { ApLoopStartLineCanvasEdge as ApLoopStartCanvasEdge } from '../edges/loop-start-edge';
|
||||
import { ApRouterEndCanvasEdge } from '../edges/router-end-edge';
|
||||
import { ApRouterStartCanvasEdge } from '../edges/router-start-edge';
|
||||
import { ApStraightLineCanvasEdge } from '../edges/straight-line-edge';
|
||||
import { ApBigAddButtonCanvasNode } from '../nodes/big-add-button-node';
|
||||
import ApGraphEndWidgetNode from '../nodes/flow-end-widget-node';
|
||||
import ApLoopReturnCanvasNode from '../nodes/loop-return-node';
|
||||
import { ApStepCanvasNode } from '../nodes/step-node';
|
||||
|
||||
import { ApEdgeType, ApNodeType } from './types';
|
||||
|
||||
const ARC_LENGTH = 15;
|
||||
const ARC_LEFT = `a${ARC_LENGTH},${ARC_LENGTH} 0 0,0 -${ARC_LENGTH},${ARC_LENGTH}`;
|
||||
const ARC_RIGHT = `a${ARC_LENGTH},${ARC_LENGTH} 0 0,1 ${ARC_LENGTH},${ARC_LENGTH}`;
|
||||
const ARC_LEFT_DOWN = `a${ARC_LENGTH},${ARC_LENGTH} 0 0,1 -${ARC_LENGTH},${ARC_LENGTH}`;
|
||||
const ARC_RIGHT_DOWN = `a${ARC_LENGTH},${ARC_LENGTH} 0 0,0 ${ARC_LENGTH},${ARC_LENGTH}`;
|
||||
const ARC_RIGHT_UP = `a${ARC_LENGTH},${ARC_LENGTH} 0 0,1 -${ARC_LENGTH},-${ARC_LENGTH}`;
|
||||
const ARC_LEFT_UP = `a-${ARC_LENGTH},-${ARC_LENGTH} 0 0,0 ${ARC_LENGTH},-${ARC_LENGTH}`;
|
||||
const ARROW_DOWN = 'm6 -6 l-6 6 m-6 -6 l6 6';
|
||||
const VERTICAL_SPACE_BETWEEN_STEP_AND_LINE = 7;
|
||||
const VERTICAL_SPACE_BETWEEN_STEPS = 60;
|
||||
const VERTICAL_OFFSET_BETWEEN_LOOP_AND_CHILD =
|
||||
VERTICAL_SPACE_BETWEEN_STEPS * 1.5 + 2 * ARC_LENGTH;
|
||||
const LABEL_HEIGHT = 30;
|
||||
const LABEL_VERTICAL_PADDING = 12;
|
||||
const STEP_DRAG_OVERLAY_WIDTH = 75;
|
||||
const STEP_DRAG_OVERLAY_HEIGHT = 75;
|
||||
const VERTICAL_OFFSET_BETWEEN_ROUTER_AND_CHILD =
|
||||
VERTICAL_OFFSET_BETWEEN_LOOP_AND_CHILD + LABEL_HEIGHT;
|
||||
const LINE_WIDTH = 1.5;
|
||||
const DRAGGED_STEP_TAG = 'dragged-step';
|
||||
const HORIZONTAL_SPACE_BETWEEN_NODES = 80;
|
||||
const AP_NODE_SIZE: Record<
|
||||
Exclude<ApNodeType, ApNodeType.GRAPH_START_WIDGET>,
|
||||
{ height: number; width: number }
|
||||
> = {
|
||||
[ApNodeType.BIG_ADD_BUTTON]: {
|
||||
height: 50,
|
||||
width: 50,
|
||||
},
|
||||
[ApNodeType.ADD_BUTTON]: {
|
||||
height: 20,
|
||||
width: 20,
|
||||
},
|
||||
[ApNodeType.STEP]: {
|
||||
height: 60,
|
||||
width: 232,
|
||||
},
|
||||
[ApNodeType.LOOP_RETURN_NODE]: {
|
||||
height: 60,
|
||||
width: 232,
|
||||
},
|
||||
[ApNodeType.GRAPH_END_WIDGET]: {
|
||||
height: 0,
|
||||
width: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const doesNodeAffectBoundingBoxWidth: (
|
||||
type: ApNodeType,
|
||||
) => type is
|
||||
| ApNodeType.BIG_ADD_BUTTON
|
||||
| ApNodeType.STEP
|
||||
| ApNodeType.LOOP_RETURN_NODE = (type) =>
|
||||
type === ApNodeType.BIG_ADD_BUTTON ||
|
||||
type === ApNodeType.STEP ||
|
||||
type === ApNodeType.LOOP_RETURN_NODE;
|
||||
export const flowUtilConsts = {
|
||||
ARC_LENGTH,
|
||||
ARC_LEFT,
|
||||
ARC_RIGHT,
|
||||
ARC_LEFT_DOWN,
|
||||
ARC_RIGHT_DOWN,
|
||||
VERTICAL_OFFSET_BETWEEN_LOOP_AND_CHILD,
|
||||
AP_NODE_SIZE,
|
||||
VERTICAL_SPACE_BETWEEN_STEP_AND_LINE,
|
||||
ARROW_DOWN,
|
||||
VERTICAL_SPACE_BETWEEN_STEPS,
|
||||
ARC_RIGHT_UP,
|
||||
LINE_WIDTH,
|
||||
LABEL_HEIGHT,
|
||||
ARC_LEFT_UP,
|
||||
VERTICAL_OFFSET_BETWEEN_ROUTER_AND_CHILD,
|
||||
doesNodeAffectBoundingBox: doesNodeAffectBoundingBoxWidth,
|
||||
edgeTypes: {
|
||||
[ApEdgeType.STRAIGHT_LINE]: ApStraightLineCanvasEdge,
|
||||
[ApEdgeType.LOOP_START_EDGE]: ApLoopStartCanvasEdge,
|
||||
[ApEdgeType.LOOP_RETURN_EDGE]: ApLoopReturnCanvasEdge,
|
||||
[ApEdgeType.ROUTER_START_EDGE]: ApRouterStartCanvasEdge,
|
||||
[ApEdgeType.ROUTER_END_EDGE]: ApRouterEndCanvasEdge,
|
||||
},
|
||||
nodeTypes: {
|
||||
[ApNodeType.STEP]: ApStepCanvasNode,
|
||||
[ApNodeType.LOOP_RETURN_NODE]: ApLoopReturnCanvasNode,
|
||||
[ApNodeType.BIG_ADD_BUTTON]: ApBigAddButtonCanvasNode,
|
||||
[ApNodeType.GRAPH_END_WIDGET]: ApGraphEndWidgetNode,
|
||||
},
|
||||
DRAGGED_STEP_TAG,
|
||||
HORIZONTAL_SPACE_BETWEEN_NODES,
|
||||
HANDLE_STYLING: { opacity: 0, cursor: 'default' },
|
||||
LABEL_VERTICAL_PADDING,
|
||||
STEP_DRAG_OVERLAY_WIDTH,
|
||||
STEP_DRAG_OVERLAY_HEIGHT,
|
||||
};
|
||||
|
||||
export const STEP_CONTEXT_MENU_ATTRIBUTE = 'step-context-menu';
|
||||
export const SELECTION_RECT_CHEVRON_ATTRIBUTE = 'selection-rect-chevron';
|
||||
export const EMPTY_STEP_PARENT_NAME = 'empty-step-parent';
|
||||
export const LEFT_SIDEBAR_ID = 'builder-left-sidebar';
|
||||
export const BUILDER_NAVIGATION_SIDEBAR_ID = 'builder-navigation-sidebar';
|
||||
@@ -0,0 +1,510 @@
|
||||
import { t } from 'i18next';
|
||||
|
||||
import { flowRunUtils } from '@/features/flow-runs/lib/flow-run-utils';
|
||||
import {
|
||||
FlowAction,
|
||||
FlowActionType,
|
||||
FlowOperationType,
|
||||
FlowRun,
|
||||
flowStructureUtil,
|
||||
FlowVersion,
|
||||
isNil,
|
||||
LoopOnItemsAction,
|
||||
RouterAction,
|
||||
StepLocationRelativeToParent,
|
||||
FlowTrigger,
|
||||
FlowTriggerType,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { flowUtilConsts } from './consts';
|
||||
import {
|
||||
ApBigAddButtonNode,
|
||||
ApButtonData,
|
||||
ApEdge,
|
||||
ApEdgeType,
|
||||
ApGraph,
|
||||
ApGraphEndNode,
|
||||
ApLoopReturnNode,
|
||||
ApNodeType,
|
||||
ApStepNode,
|
||||
ApStraightLineEdge,
|
||||
} from './types';
|
||||
|
||||
const createBigAddButtonGraph: (
|
||||
parentStep: LoopOnItemsAction | RouterAction,
|
||||
nodeData: ApBigAddButtonNode['data'],
|
||||
) => ApGraph = (parentStep, nodeData) => {
|
||||
const bigAddButtonNode: ApBigAddButtonNode = {
|
||||
id: `${parentStep.name}-big-add-button-${nodeData.edgeId}`,
|
||||
type: ApNodeType.BIG_ADD_BUTTON,
|
||||
position: { x: 0, y: 0 },
|
||||
data: nodeData,
|
||||
selectable: false,
|
||||
style: {
|
||||
pointerEvents: 'all',
|
||||
},
|
||||
};
|
||||
const graphEndNode: ApGraphEndNode = {
|
||||
id: `${parentStep.name}-subgraph-end-${nodeData.edgeId}`,
|
||||
type: ApNodeType.GRAPH_END_WIDGET as const,
|
||||
position: {
|
||||
x: flowUtilConsts.AP_NODE_SIZE.STEP.width / 2,
|
||||
y:
|
||||
flowUtilConsts.AP_NODE_SIZE.STEP.height +
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEPS,
|
||||
},
|
||||
data: {},
|
||||
selectable: false,
|
||||
};
|
||||
|
||||
const straightLineEdge: ApStraightLineEdge = {
|
||||
id: `big-button-straight-line-for${nodeData.edgeId}`,
|
||||
source: `${parentStep.name}-big-add-button-${nodeData.edgeId}`,
|
||||
target: `${parentStep.name}-subgraph-end-${nodeData.edgeId}`,
|
||||
type: ApEdgeType.STRAIGHT_LINE as const,
|
||||
data: {
|
||||
drawArrowHead: false,
|
||||
hideAddButton: true,
|
||||
parentStepName: parentStep.name,
|
||||
},
|
||||
};
|
||||
return {
|
||||
nodes: [bigAddButtonNode, graphEndNode],
|
||||
edges: [straightLineEdge],
|
||||
};
|
||||
};
|
||||
|
||||
const createStepGraph: (
|
||||
step: FlowAction | FlowTrigger,
|
||||
graphHeight: number,
|
||||
) => ApGraph = (step, graphHeight) => {
|
||||
const stepNode: ApStepNode = {
|
||||
id: step.name,
|
||||
type: ApNodeType.STEP as const,
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
step,
|
||||
},
|
||||
selectable: step.name !== 'trigger',
|
||||
draggable: true,
|
||||
style: {
|
||||
pointerEvents: 'all',
|
||||
},
|
||||
};
|
||||
|
||||
const graphEndNode: ApGraphEndNode = {
|
||||
id: `${step.name}-subgraph-end`,
|
||||
type: ApNodeType.GRAPH_END_WIDGET as const,
|
||||
position: {
|
||||
x: flowUtilConsts.AP_NODE_SIZE.STEP.width / 2,
|
||||
y: graphHeight,
|
||||
},
|
||||
data: {},
|
||||
selectable: false,
|
||||
};
|
||||
|
||||
const straightLineEdge: ApStraightLineEdge = {
|
||||
id: `${step.name}-${step.nextAction?.name ?? 'graph-end'}-edge`,
|
||||
source: step.name,
|
||||
target: `${step.name}-subgraph-end`,
|
||||
type: ApEdgeType.STRAIGHT_LINE as const,
|
||||
data: {
|
||||
drawArrowHead: !isNil(step.nextAction),
|
||||
parentStepName: step.name,
|
||||
},
|
||||
};
|
||||
return {
|
||||
nodes: [stepNode, graphEndNode],
|
||||
edges:
|
||||
step.type !== FlowActionType.LOOP_ON_ITEMS &&
|
||||
step.type !== FlowActionType.ROUTER
|
||||
? [straightLineEdge]
|
||||
: [],
|
||||
};
|
||||
};
|
||||
|
||||
const buildGraph: (step: FlowAction | FlowTrigger | undefined) => ApGraph = (
|
||||
step,
|
||||
) => {
|
||||
if (isNil(step)) {
|
||||
return {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
};
|
||||
}
|
||||
|
||||
const graph: ApGraph = createStepGraph(
|
||||
step,
|
||||
flowUtilConsts.AP_NODE_SIZE.STEP.height +
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEPS,
|
||||
);
|
||||
const childGraph =
|
||||
step.type === FlowActionType.LOOP_ON_ITEMS
|
||||
? buildLoopChildGraph(step)
|
||||
: step.type === FlowActionType.ROUTER
|
||||
? buildRouterChildGraph(step)
|
||||
: null;
|
||||
|
||||
const graphWithChild = childGraph ? mergeGraph(graph, childGraph) : graph;
|
||||
const nextStepGraph = buildGraph(step.nextAction);
|
||||
return mergeGraph(
|
||||
graphWithChild,
|
||||
offsetGraph(nextStepGraph, {
|
||||
x: 0,
|
||||
y: calculateGraphBoundingBox(graphWithChild).height,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
function offsetGraph(
|
||||
graph: ApGraph,
|
||||
offset: { x: number; y: number },
|
||||
): ApGraph {
|
||||
return {
|
||||
nodes: graph.nodes.map((node) => ({
|
||||
...node,
|
||||
position: {
|
||||
x: node.position.x + offset.x,
|
||||
y: node.position.y + offset.y,
|
||||
},
|
||||
})),
|
||||
edges: graph.edges,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeGraph(graph1: ApGraph, graph2: ApGraph): ApGraph {
|
||||
return {
|
||||
nodes: [...graph1.nodes, ...graph2.nodes],
|
||||
edges: [...graph1.edges, ...graph2.edges],
|
||||
};
|
||||
}
|
||||
|
||||
function createFocusStepInGraphParams(stepName: string) {
|
||||
return {
|
||||
nodes: [{ id: stepName }],
|
||||
duration: 1000,
|
||||
maxZoom: 1.25,
|
||||
minZoom: 1.25,
|
||||
};
|
||||
}
|
||||
|
||||
const calculateGraphBoundingBox = (graph: ApGraph) => {
|
||||
const minX = Math.min(
|
||||
...graph.nodes
|
||||
.filter((node) => flowUtilConsts.doesNodeAffectBoundingBox(node.type))
|
||||
.map((node) => node.position.x),
|
||||
);
|
||||
const minY = Math.min(...graph.nodes.map((node) => node.position.y));
|
||||
const maxX = Math.max(
|
||||
...graph.nodes
|
||||
.filter((node) => flowUtilConsts.doesNodeAffectBoundingBox(node.type))
|
||||
.map((node) => node.position.x + flowUtilConsts.AP_NODE_SIZE.STEP.width),
|
||||
);
|
||||
const maxY = Math.max(...graph.nodes.map((node) => node.position.y));
|
||||
const width = maxX - minX;
|
||||
const height = maxY - minY;
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
left: -minX + flowUtilConsts.AP_NODE_SIZE.STEP.width / 2,
|
||||
right: maxX - flowUtilConsts.AP_NODE_SIZE.STEP.width / 2,
|
||||
top: minY,
|
||||
bottom: maxY,
|
||||
};
|
||||
};
|
||||
|
||||
const buildLoopChildGraph: (step: LoopOnItemsAction) => ApGraph = (step) => {
|
||||
const childGraph = step.firstLoopAction
|
||||
? buildGraph(step.firstLoopAction)
|
||||
: createBigAddButtonGraph(step, {
|
||||
parentStepName: step.name,
|
||||
stepLocationRelativeToParent: StepLocationRelativeToParent.INSIDE_LOOP,
|
||||
edgeId: `${step.name}-loop-start-edge`,
|
||||
});
|
||||
|
||||
const childGraphBoundingBox = calculateGraphBoundingBox(childGraph);
|
||||
const deltaLeftX =
|
||||
-(
|
||||
childGraphBoundingBox.width +
|
||||
flowUtilConsts.AP_NODE_SIZE.STEP.width +
|
||||
flowUtilConsts.HORIZONTAL_SPACE_BETWEEN_NODES -
|
||||
flowUtilConsts.AP_NODE_SIZE.STEP.width / 2 -
|
||||
childGraphBoundingBox.right
|
||||
) /
|
||||
2 -
|
||||
flowUtilConsts.AP_NODE_SIZE.STEP.width / 2;
|
||||
|
||||
const loopReturnNode: ApLoopReturnNode = {
|
||||
id: `${step.name}-loop-return-node`,
|
||||
type: ApNodeType.LOOP_RETURN_NODE,
|
||||
position: {
|
||||
x: deltaLeftX + flowUtilConsts.AP_NODE_SIZE.STEP.width / 2,
|
||||
y:
|
||||
flowUtilConsts.AP_NODE_SIZE.STEP.height +
|
||||
flowUtilConsts.VERTICAL_OFFSET_BETWEEN_LOOP_AND_CHILD +
|
||||
childGraphBoundingBox.height / 2,
|
||||
},
|
||||
data: {},
|
||||
selectable: false,
|
||||
};
|
||||
const childGraphAfterOffset = offsetGraph(childGraph, {
|
||||
x:
|
||||
deltaLeftX +
|
||||
flowUtilConsts.AP_NODE_SIZE.STEP.width +
|
||||
flowUtilConsts.HORIZONTAL_SPACE_BETWEEN_NODES +
|
||||
childGraphBoundingBox.left,
|
||||
y:
|
||||
flowUtilConsts.VERTICAL_OFFSET_BETWEEN_LOOP_AND_CHILD +
|
||||
flowUtilConsts.AP_NODE_SIZE.STEP.height,
|
||||
});
|
||||
const edges: ApEdge[] = [
|
||||
{
|
||||
id: `${step.name}-loop-start-edge`,
|
||||
source: step.name,
|
||||
target: `${childGraph.nodes[0].id}`,
|
||||
type: ApEdgeType.LOOP_START_EDGE as const,
|
||||
data: {
|
||||
isLoopEmpty: isNil(step.firstLoopAction),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: `${step.name}-loop-return-node`,
|
||||
source: `${childGraph.nodes[childGraph.nodes.length - 1].id}`,
|
||||
target: `${step.name}-loop-return-node`,
|
||||
type: ApEdgeType.LOOP_RETURN_EDGE as const,
|
||||
data: {
|
||||
parentStepName: step.name,
|
||||
isLoopEmpty: isNil(step.firstLoopAction),
|
||||
drawArrowHeadAfterEnd: !isNil(step.nextAction),
|
||||
verticalSpaceBetweenReturnNodeStartAndEnd:
|
||||
childGraphBoundingBox.height +
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEPS,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const subgraphEndSubNode: ApGraphEndNode = {
|
||||
id: `${step.name}-loop-subgraph-end`,
|
||||
type: ApNodeType.GRAPH_END_WIDGET,
|
||||
position: {
|
||||
x: flowUtilConsts.AP_NODE_SIZE.STEP.width / 2,
|
||||
y:
|
||||
flowUtilConsts.AP_NODE_SIZE.STEP.height +
|
||||
flowUtilConsts.VERTICAL_OFFSET_BETWEEN_LOOP_AND_CHILD +
|
||||
childGraphBoundingBox.height +
|
||||
flowUtilConsts.ARC_LENGTH +
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEPS,
|
||||
},
|
||||
data: {},
|
||||
selectable: false,
|
||||
};
|
||||
|
||||
return {
|
||||
nodes: [loopReturnNode, ...childGraphAfterOffset.nodes, subgraphEndSubNode],
|
||||
edges: [...edges, ...childGraphAfterOffset.edges],
|
||||
};
|
||||
};
|
||||
|
||||
const buildRouterChildGraph = (step: RouterAction) => {
|
||||
const childGraphs = step.children.map((branch, index) => {
|
||||
return branch
|
||||
? buildGraph(branch)
|
||||
: createBigAddButtonGraph(step, {
|
||||
parentStepName: step.name,
|
||||
stepLocationRelativeToParent:
|
||||
StepLocationRelativeToParent.INSIDE_BRANCH,
|
||||
branchIndex: index,
|
||||
edgeId: `${step.name}-branch-${index}-start-edge`,
|
||||
});
|
||||
});
|
||||
|
||||
const childGraphsAfterOffset = offsetRouterChildSteps(childGraphs);
|
||||
|
||||
const maxHeight = Math.max(
|
||||
...childGraphsAfterOffset.map((cg) => calculateGraphBoundingBox(cg).height),
|
||||
);
|
||||
|
||||
const subgraphEndSubNode: ApGraphEndNode = {
|
||||
id: `${step.name}-branch-subgraph-end`,
|
||||
type: ApNodeType.GRAPH_END_WIDGET,
|
||||
position: {
|
||||
x: flowUtilConsts.AP_NODE_SIZE.STEP.width / 2,
|
||||
y:
|
||||
flowUtilConsts.AP_NODE_SIZE.STEP.height +
|
||||
flowUtilConsts.VERTICAL_OFFSET_BETWEEN_ROUTER_AND_CHILD +
|
||||
maxHeight +
|
||||
flowUtilConsts.ARC_LENGTH +
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEPS,
|
||||
},
|
||||
data: {},
|
||||
selectable: false,
|
||||
};
|
||||
const edges: ApEdge[] = childGraphsAfterOffset
|
||||
.map((childGraph, branchIndex) => {
|
||||
return [
|
||||
{
|
||||
id: `${step.name}-branch-${branchIndex}-start-edge`,
|
||||
source: step.name,
|
||||
target: `${childGraph.nodes[0].id}`,
|
||||
type: ApEdgeType.ROUTER_START_EDGE as const,
|
||||
data: {
|
||||
isBranchEmpty: isNil(step.children[branchIndex]),
|
||||
label:
|
||||
step.settings.branches[branchIndex]?.branchName ??
|
||||
`${t('Branch')} ${branchIndex + 1} (missing branch)`,
|
||||
branchIndex,
|
||||
stepLocationRelativeToParent:
|
||||
StepLocationRelativeToParent.INSIDE_BRANCH as const,
|
||||
drawHorizontalLine:
|
||||
branchIndex === 0 ||
|
||||
branchIndex === childGraphsAfterOffset.length - 1,
|
||||
drawStartingVerticalLine: branchIndex === 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: `${step.name}-branch-${branchIndex}-end-edge`,
|
||||
source: `${childGraph.nodes.at(-1)!.id}`,
|
||||
target: subgraphEndSubNode.id,
|
||||
type: ApEdgeType.ROUTER_END_EDGE as const,
|
||||
data: {
|
||||
drawEndingVerticalLine: branchIndex === 0,
|
||||
verticalSpaceBetweenLastNodeInBranchAndEndLine:
|
||||
subgraphEndSubNode.position.y -
|
||||
childGraph.nodes.at(-1)!.position.y -
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEPS -
|
||||
flowUtilConsts.ARC_LENGTH,
|
||||
drawHorizontalLine:
|
||||
branchIndex === 0 ||
|
||||
branchIndex === childGraphsAfterOffset.length - 1,
|
||||
routerOrBranchStepName: step.name,
|
||||
isNextStepEmpty: isNil(step.nextAction),
|
||||
},
|
||||
},
|
||||
];
|
||||
})
|
||||
.flat();
|
||||
|
||||
return {
|
||||
nodes: [
|
||||
...childGraphsAfterOffset.map((cg) => cg.nodes).flat(),
|
||||
subgraphEndSubNode,
|
||||
],
|
||||
edges: [...childGraphsAfterOffset.map((cg) => cg.edges).flat(), ...edges],
|
||||
};
|
||||
};
|
||||
|
||||
const offsetRouterChildSteps = (childGraphs: ApGraph[]) => {
|
||||
const childGraphsBoundingBoxes = childGraphs.map((childGraph) =>
|
||||
calculateGraphBoundingBox(childGraph),
|
||||
);
|
||||
const totalWidth =
|
||||
childGraphsBoundingBoxes.reduce((acc, current) => acc + current.width, 0) +
|
||||
flowUtilConsts.HORIZONTAL_SPACE_BETWEEN_NODES * (childGraphs.length - 1);
|
||||
let deltaLeftX =
|
||||
-(
|
||||
totalWidth -
|
||||
childGraphsBoundingBoxes[0].left -
|
||||
childGraphsBoundingBoxes[childGraphs.length - 1].right
|
||||
) /
|
||||
2 -
|
||||
childGraphsBoundingBoxes[0].left;
|
||||
|
||||
return childGraphsBoundingBoxes.map((childGraphBoundingBox, index) => {
|
||||
const x = deltaLeftX + childGraphBoundingBox.left;
|
||||
deltaLeftX +=
|
||||
childGraphBoundingBox.width +
|
||||
flowUtilConsts.HORIZONTAL_SPACE_BETWEEN_NODES;
|
||||
return offsetGraph(childGraphs[index], {
|
||||
x,
|
||||
y:
|
||||
flowUtilConsts.AP_NODE_SIZE.STEP.height +
|
||||
flowUtilConsts.VERTICAL_OFFSET_BETWEEN_ROUTER_AND_CHILD,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const createAddOperationFromAddButtonData = (data: ApButtonData) => {
|
||||
if (
|
||||
data.stepLocationRelativeToParent ===
|
||||
StepLocationRelativeToParent.INSIDE_BRANCH
|
||||
) {
|
||||
return {
|
||||
type: FlowOperationType.ADD_ACTION,
|
||||
actionLocation: {
|
||||
parentStep: data.parentStepName,
|
||||
stepLocationRelativeToParent: data.stepLocationRelativeToParent,
|
||||
branchIndex: data.branchIndex,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
return {
|
||||
type: FlowOperationType.ADD_ACTION,
|
||||
actionLocation: {
|
||||
parentStep: data.parentStepName,
|
||||
stepLocationRelativeToParent: data.stepLocationRelativeToParent,
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
|
||||
const isSkipped = (stepName: string, trigger: FlowTrigger) => {
|
||||
const step = flowStructureUtil.getStep(stepName, trigger);
|
||||
if (
|
||||
isNil(step) ||
|
||||
step.type === FlowTriggerType.EMPTY ||
|
||||
step.type === FlowTriggerType.PIECE
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const skippedParents = flowStructureUtil
|
||||
.findPathToStep(trigger, stepName)
|
||||
.filter(
|
||||
(stepInPath) =>
|
||||
stepInPath.type === FlowActionType.LOOP_ON_ITEMS ||
|
||||
stepInPath.type === FlowActionType.ROUTER,
|
||||
)
|
||||
.filter((routerOrLoop) =>
|
||||
flowStructureUtil.isChildOf(routerOrLoop, stepName),
|
||||
)
|
||||
.filter((parent) => parent.skip);
|
||||
|
||||
return skippedParents.length > 0 || !!step.skip;
|
||||
};
|
||||
|
||||
const getStepStatus = (
|
||||
stepName: string | undefined,
|
||||
run: FlowRun | null,
|
||||
loopIndexes: Record<string, number>,
|
||||
flowVersion: FlowVersion,
|
||||
) => {
|
||||
if (isNil(run) || isNil(stepName) || isNil(run.steps)) {
|
||||
return undefined;
|
||||
}
|
||||
const stepOutput = flowRunUtils.extractStepOutput(
|
||||
stepName,
|
||||
loopIndexes,
|
||||
run.steps,
|
||||
flowVersion.trigger,
|
||||
);
|
||||
return stepOutput?.status;
|
||||
};
|
||||
|
||||
export const flowCanvasUtils = {
|
||||
convertFlowVersionToGraph(version: FlowVersion): ApGraph {
|
||||
const graph = buildGraph(version.trigger);
|
||||
const graphEndWidget = graph.nodes.findLast(
|
||||
(node) => node.type === ApNodeType.GRAPH_END_WIDGET,
|
||||
) as ApGraphEndNode;
|
||||
if (graphEndWidget) {
|
||||
graphEndWidget.data.showWidget = true;
|
||||
} else {
|
||||
console.warn('Flow end widget not found');
|
||||
}
|
||||
return graph;
|
||||
},
|
||||
createFocusStepInGraphParams,
|
||||
calculateGraphBoundingBox,
|
||||
createAddOperationFromAddButtonData,
|
||||
isSkipped,
|
||||
getStepStatus,
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
import { Edge } from '@xyflow/react';
|
||||
|
||||
import {
|
||||
FlowAction,
|
||||
StepLocationRelativeToParent,
|
||||
FlowTrigger,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
export enum ApNodeType {
|
||||
STEP = 'STEP',
|
||||
ADD_BUTTON = 'ADD_BUTTON',
|
||||
BIG_ADD_BUTTON = 'BIG_ADD_BUTTON',
|
||||
GRAPH_END_WIDGET = 'GRAPH_END_WIDGET',
|
||||
GRAPH_START_WIDGET = 'GRAPH_START_WIDGET',
|
||||
/**Used for calculating the loop graph width */
|
||||
LOOP_RETURN_NODE = 'LOOP_RETURN_NODE',
|
||||
}
|
||||
export type ApBoundingBox = {
|
||||
width: number;
|
||||
height: number;
|
||||
left: number;
|
||||
right: number;
|
||||
};
|
||||
|
||||
export type ApStepNode = {
|
||||
id: string;
|
||||
type: ApNodeType.STEP;
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
data: {
|
||||
step: FlowAction | FlowTrigger;
|
||||
};
|
||||
selectable?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
draggable?: boolean;
|
||||
};
|
||||
|
||||
export type ApLoopReturnNode = {
|
||||
id: string;
|
||||
type: ApNodeType.LOOP_RETURN_NODE;
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
data: Record<string, never>;
|
||||
selectable?: boolean;
|
||||
};
|
||||
|
||||
export type ApButtonData = {
|
||||
edgeId: string;
|
||||
} & (
|
||||
| {
|
||||
parentStepName: string;
|
||||
stepLocationRelativeToParent:
|
||||
| StepLocationRelativeToParent.AFTER
|
||||
| StepLocationRelativeToParent.INSIDE_LOOP;
|
||||
}
|
||||
| {
|
||||
parentStepName: string;
|
||||
stepLocationRelativeToParent: StepLocationRelativeToParent.INSIDE_BRANCH;
|
||||
branchIndex: number;
|
||||
}
|
||||
);
|
||||
|
||||
export type ApBigAddButtonNode = {
|
||||
id: string;
|
||||
type: ApNodeType.BIG_ADD_BUTTON;
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
data: ApButtonData;
|
||||
selectable?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
export type ApGraphEndNode = {
|
||||
id: string;
|
||||
type: ApNodeType.GRAPH_END_WIDGET;
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
data: {
|
||||
showWidget?: boolean;
|
||||
};
|
||||
selectable?: boolean;
|
||||
};
|
||||
|
||||
export type ApNode =
|
||||
| ApStepNode
|
||||
| ApGraphEndNode
|
||||
| ApBigAddButtonNode
|
||||
| ApLoopReturnNode;
|
||||
|
||||
export enum ApEdgeType {
|
||||
STRAIGHT_LINE = 'ApStraightLineEdge',
|
||||
LOOP_START_EDGE = 'ApLoopStartEdge',
|
||||
LOOP_CLOSE_EDGE = 'ApLoopCloseEdge',
|
||||
LOOP_RETURN_EDGE = 'ApLoopReturnEdge',
|
||||
ROUTER_START_EDGE = 'ApRouterStartEdge',
|
||||
ROUTER_END_EDGE = 'ApRouterEndEdge',
|
||||
}
|
||||
|
||||
export type ApStraightLineEdge = Edge & {
|
||||
type: ApEdgeType.STRAIGHT_LINE;
|
||||
data: {
|
||||
drawArrowHead: boolean;
|
||||
hideAddButton?: boolean;
|
||||
parentStepName: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ApLoopStartEdge = Edge & {
|
||||
type: ApEdgeType.LOOP_START_EDGE;
|
||||
data: {
|
||||
isLoopEmpty: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type ApLoopCloseEdge = Edge & {
|
||||
type: ApEdgeType.LOOP_CLOSE_EDGE;
|
||||
};
|
||||
|
||||
export type ApLoopReturnEdge = Edge & {
|
||||
type: ApEdgeType.LOOP_RETURN_EDGE;
|
||||
data: {
|
||||
parentStepName: string;
|
||||
isLoopEmpty: boolean;
|
||||
drawArrowHeadAfterEnd: boolean;
|
||||
verticalSpaceBetweenReturnNodeStartAndEnd: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type ApRouterStartEdge = Edge & {
|
||||
type: ApEdgeType.ROUTER_START_EDGE;
|
||||
data: {
|
||||
isBranchEmpty: boolean;
|
||||
label: string;
|
||||
drawHorizontalLine: boolean;
|
||||
drawStartingVerticalLine: boolean;
|
||||
} & {
|
||||
stepLocationRelativeToParent: StepLocationRelativeToParent.INSIDE_BRANCH;
|
||||
branchIndex: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type ApRouterEndEdge = Edge & {
|
||||
type: ApEdgeType.ROUTER_END_EDGE;
|
||||
data: {
|
||||
drawHorizontalLine: boolean;
|
||||
verticalSpaceBetweenLastNodeInBranchAndEndLine: number;
|
||||
} & (
|
||||
| {
|
||||
routerOrBranchStepName: string;
|
||||
drawEndingVerticalLine: true;
|
||||
isNextStepEmpty: boolean;
|
||||
}
|
||||
| {
|
||||
drawEndingVerticalLine: false;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export type ApEdge =
|
||||
| ApLoopStartEdge
|
||||
| ApLoopReturnEdge
|
||||
| ApStraightLineEdge
|
||||
| ApRouterStartEdge
|
||||
| ApRouterEndEdge;
|
||||
export type ApGraph = {
|
||||
nodes: ApNode[];
|
||||
edges: ApEdge[];
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { t } from 'i18next';
|
||||
|
||||
const FlowEndWidget = () => {
|
||||
return (
|
||||
<div
|
||||
className=" text-center w-[50px] bg-builder-background text-foreground/70 rounded-lg animate-fade -ml-[25px]"
|
||||
key={'flow-end-button'}
|
||||
id="flow-end-button"
|
||||
>
|
||||
<div className="w-full px-2 py-1 text-center h-full bg-border/80 rounded-lg ">
|
||||
{t('End')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FlowEndWidget.displayName = 'FlowEndWidget';
|
||||
export default FlowEndWidget;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useReactFlow } from '@xyflow/react';
|
||||
import { t } from 'i18next';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { BuilderState } from '@/app/builder/builder-hooks';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
FlowAction,
|
||||
FlowVersion,
|
||||
Step,
|
||||
flowStructureUtil,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { flowCanvasUtils } from '../utils/flow-canvas-utils';
|
||||
|
||||
type IncompleteSettingsButtonProps = {
|
||||
flowVersion: FlowVersion;
|
||||
selectStepByName: BuilderState['selectStepByName'];
|
||||
};
|
||||
|
||||
const IncompleteSettingsButton: React.FC<IncompleteSettingsButtonProps> = ({
|
||||
flowVersion,
|
||||
selectStepByName,
|
||||
}) => {
|
||||
const invalidSteps = useMemo(
|
||||
() =>
|
||||
flowStructureUtil
|
||||
.getAllSteps(flowVersion.trigger)
|
||||
.filter(filterValidOrSkippedSteps).length,
|
||||
[flowVersion],
|
||||
);
|
||||
const { fitView } = useReactFlow();
|
||||
function onClick() {
|
||||
const invalidSteps = flowStructureUtil
|
||||
.getAllSteps(flowVersion.trigger)
|
||||
.filter(filterValidOrSkippedSteps);
|
||||
if (invalidSteps.length > 0) {
|
||||
selectStepByName(invalidSteps[0].name);
|
||||
fitView(
|
||||
flowCanvasUtils.createFocusStepInGraphParams(invalidSteps[0].name),
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
!flowVersion.valid && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-[28px] hover:bg-amber-50 p-2 dark:hover:bg-amber-950 dark:bg-amber-950 bg-amber-50 border border-solid border-amber-500 hover:border-amber-700 dark:hover:border-amber-600 dark:border-amber-900 dark:text-amber-600 text-amber-700 hover:text-amber-700 dark:hover:text-amber-600 animate-fade"
|
||||
key={'complete-flow-button'}
|
||||
onClick={(e) => {
|
||||
onClick();
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{t('incompleteSteps', { invalidSteps: invalidSteps })}
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
IncompleteSettingsButton.displayName = 'IncompleteSettingsButton';
|
||||
export default IncompleteSettingsButton;
|
||||
function filterValidOrSkippedSteps(step: Step) {
|
||||
if ((step as FlowAction).skip) return false;
|
||||
return !step.valid;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ViewportPortal } from '@xyflow/react';
|
||||
import React from 'react';
|
||||
|
||||
import FlowEndWidget from '@/app/builder/flow-canvas/widgets/flow-end-widget';
|
||||
import IncompleteSettingsButton from '@/app/builder/flow-canvas/widgets/incomplete-settings-widget';
|
||||
import { TestFlowWidget } from '@/app/builder/flow-canvas/widgets/test-flow-widget';
|
||||
|
||||
import { useBuilderStateContext } from '../../builder-hooks';
|
||||
import { flowUtilConsts } from '../utils/consts';
|
||||
|
||||
const AboveFlowWidgets = React.memo(() => {
|
||||
const [flowVersion, selectStepByName, readonly] = useBuilderStateContext(
|
||||
(state) => [state.flowVersion, state.selectStepByName, state.readonly],
|
||||
);
|
||||
return (
|
||||
<ViewportPortal>
|
||||
<WidgetWrapper>
|
||||
<div
|
||||
style={{
|
||||
transform: `translate(0px,-${flowUtilConsts.AP_NODE_SIZE.STEP.height}px )`,
|
||||
position: 'absolute',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<div className="justify-center items-center flex w-[260px]">
|
||||
<TestFlowWidget></TestFlowWidget>
|
||||
{!readonly && (
|
||||
<IncompleteSettingsButton
|
||||
flowVersion={flowVersion}
|
||||
selectStepByName={selectStepByName}
|
||||
></IncompleteSettingsButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</WidgetWrapper>
|
||||
</ViewportPortal>
|
||||
);
|
||||
});
|
||||
AboveFlowWidgets.displayName = 'AboveFlowWidgets';
|
||||
const BelowFlowWidget = React.memo(() => {
|
||||
return (
|
||||
<ViewportPortal>
|
||||
<WidgetWrapper>
|
||||
<div
|
||||
style={{
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-center gap-2"
|
||||
style={{ width: flowUtilConsts.AP_NODE_SIZE.STEP.width + 'px' }}
|
||||
>
|
||||
<FlowEndWidget></FlowEndWidget>
|
||||
</div>
|
||||
</div>
|
||||
</WidgetWrapper>
|
||||
</ViewportPortal>
|
||||
);
|
||||
});
|
||||
|
||||
const WidgetWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<div
|
||||
style={{ width: flowUtilConsts.AP_NODE_SIZE.STEP.width + 'px' }}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BelowFlowWidget.displayName = 'BelowFlowWidget';
|
||||
export { AboveFlowWidgets, BelowFlowWidget };
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import { NODE_SELECTION_RECT_CLASS_NAME } from '../../builder-hooks';
|
||||
import { SELECTION_RECT_CHEVRON_ATTRIBUTE } from '../utils/consts';
|
||||
|
||||
const showChevronNextToSelection = (targetDiv: HTMLElement) => {
|
||||
const container = document.createElement('div');
|
||||
targetDiv.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="absolute top-0 -left-10 z-50"
|
||||
{...{ [`data-${SELECTION_RECT_CHEVRON_ATTRIBUTE}`]: true }}
|
||||
onClick={(e) => {
|
||||
const rightClickEvent = new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
button: 2,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
});
|
||||
e.target.dispatchEvent(rightClickEvent);
|
||||
}}
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</Button>,
|
||||
);
|
||||
return root;
|
||||
};
|
||||
|
||||
export const useShowChevronNextToSelection = () => {
|
||||
useEffect(() => {
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (
|
||||
node instanceof HTMLElement &&
|
||||
node.children.length > 0 &&
|
||||
node.children[0].classList.contains(NODE_SELECTION_RECT_CLASS_NAME)
|
||||
) {
|
||||
root = showChevronNextToSelection(node.children[0] as HTMLElement);
|
||||
}
|
||||
});
|
||||
// Handle removed nodes
|
||||
mutation.removedNodes.forEach((node) => {
|
||||
if (
|
||||
node instanceof HTMLElement &&
|
||||
node.children.length > 0 &&
|
||||
node.children[0].classList.contains(NODE_SELECTION_RECT_CLASS_NAME)
|
||||
) {
|
||||
if (root) {
|
||||
root.unmount();
|
||||
root = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
// Unmount all roots on cleanup
|
||||
if (root) {
|
||||
root.unmount();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { t } from 'i18next';
|
||||
|
||||
import {
|
||||
ChatDrawerSource,
|
||||
useBuilderStateContext,
|
||||
} from '@/app/builder/builder-hooks';
|
||||
import { flowHooks } from '@/features/flows/lib/flow-hooks';
|
||||
import { pieceSelectorUtils } from '@/features/pieces/lib/piece-selector-utils';
|
||||
import { isNil, FlowTriggerType } from '@activepieces/shared';
|
||||
|
||||
import ViewOnlyWidget from '../view-only-widget';
|
||||
|
||||
import { TestButton } from './test-button';
|
||||
|
||||
const TestFlowWidget = () => {
|
||||
const [setChatDrawerOpenSource, flowVersion, readonly, setRun] =
|
||||
useBuilderStateContext((state) => [
|
||||
state.setChatDrawerOpenSource,
|
||||
state.flowVersion,
|
||||
state.readonly,
|
||||
state.setRun,
|
||||
]);
|
||||
|
||||
const triggerHasSampleData =
|
||||
flowVersion.trigger.type === FlowTriggerType.PIECE &&
|
||||
!isNil(flowVersion.trigger.settings.sampleData?.lastTestDate);
|
||||
|
||||
const isChatTrigger = pieceSelectorUtils.isChatTrigger(
|
||||
flowVersion.trigger.settings.pieceName,
|
||||
flowVersion.trigger.settings.triggerName,
|
||||
);
|
||||
|
||||
const { mutate: runFlow, isPending } = flowHooks.useTestFlow({
|
||||
flowVersionId: flowVersion.id,
|
||||
onUpdateRun: (run) => {
|
||||
setRun(run, flowVersion);
|
||||
},
|
||||
});
|
||||
|
||||
if (!flowVersion.valid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isChatTrigger) {
|
||||
return (
|
||||
<TestButton
|
||||
onClick={() => {
|
||||
setChatDrawerOpenSource(ChatDrawerSource.TEST_FLOW);
|
||||
}}
|
||||
text={t('Open Chat')}
|
||||
loading={isPending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (readonly) {
|
||||
return <ViewOnlyWidget />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TestButton
|
||||
onClick={() => {
|
||||
runFlow();
|
||||
}}
|
||||
text={t('Test Flow')}
|
||||
triggerHasNoSampleData={!triggerHasSampleData}
|
||||
loading={isPending}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
TestFlowWidget.displayName = 'TestFlowWidget';
|
||||
|
||||
export { TestFlowWidget };
|
||||
@@ -0,0 +1,83 @@
|
||||
import { t } from 'i18next';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
type TestButtonProps = {
|
||||
onClick: () => void;
|
||||
text: string;
|
||||
triggerHasNoSampleData?: boolean;
|
||||
loading?: boolean;
|
||||
showKeyboardShortcut?: boolean;
|
||||
};
|
||||
|
||||
const TestButton = ({
|
||||
onClick,
|
||||
text,
|
||||
triggerHasNoSampleData = false,
|
||||
loading = false,
|
||||
showKeyboardShortcut = true,
|
||||
}: TestButtonProps) => {
|
||||
const isMac = /(Mac)/i.test(navigator.userAgent);
|
||||
|
||||
useEffect(() => {
|
||||
const keydownHandler = (event: KeyboardEvent) => {
|
||||
if (
|
||||
(isMac && event.metaKey && event.key.toLocaleLowerCase() === 'd') ||
|
||||
(!isMac && event.ctrlKey && event.key.toLocaleLowerCase() === 'd')
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (!loading && !triggerHasNoSampleData) {
|
||||
onClick();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', keydownHandler, { capture: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', keydownHandler, { capture: true });
|
||||
};
|
||||
}, [isMac, loading, onClick]);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="bg-builder-background">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 bg-primary-100/50! dark:text-primary-foreground text-primary hover:text-primary disabled:pointer-events-auto hover:border-primary! border-primary/50 border border-solid rounded-lg animate-fade"
|
||||
loading={loading}
|
||||
disabled={triggerHasNoSampleData}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex justify-center items-center gap-2">
|
||||
{text}
|
||||
{showKeyboardShortcut && (
|
||||
<span className="text-[10px] bg-primary/13 h-[20px] flex items-center justify-center px-1 rounded-sm tracking-widest whitespace-nowrap">
|
||||
{isMac ? '⌘ + D' : 'Ctrl + D'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{triggerHasNoSampleData && (
|
||||
<TooltipContent side="bottom">
|
||||
{t('Please test the trigger first')}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
TestButton.displayName = 'TestButton';
|
||||
|
||||
export { TestButton };
|
||||
@@ -0,0 +1,15 @@
|
||||
import { t } from 'i18next';
|
||||
|
||||
const ViewOnlyWidget = () => {
|
||||
return (
|
||||
<div
|
||||
className="p-2 bg-border text-foreground/70 rounded-lg animate-fade"
|
||||
key={'view-only-widget'}
|
||||
>
|
||||
{t('View Only')}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ViewOnlyWidget.displayName = 'ViewOnlyWidget';
|
||||
export default ViewOnlyWidget;
|
||||
@@ -0,0 +1,226 @@
|
||||
import { DotsVerticalIcon } from '@radix-ui/react-icons';
|
||||
import { t } from 'i18next';
|
||||
import { Eye, EyeIcon, Pencil } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import {
|
||||
LeftSideBarType,
|
||||
useBuilderStateContext,
|
||||
} from '@/app/builder/builder-hooks';
|
||||
import { CardListItem } from '@/components/custom/card-list';
|
||||
import { PermissionNeededTooltip } from '@/components/custom/permission-needed-tooltip';
|
||||
import { useEmbedding } from '@/components/embed-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { LoadingSpinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { UserAvatar } from '@/components/ui/user-avatar';
|
||||
import { FlowVersionStateDot } from '@/features/flows/components/flow-version-state-dot';
|
||||
import { flowHooks } from '@/features/flows/lib/flow-hooks';
|
||||
import { useAuthorization } from '@/hooks/authorization-hooks';
|
||||
import { formatUtils } from '@/lib/utils';
|
||||
import {
|
||||
FlowVersionMetadata,
|
||||
FlowVersionState,
|
||||
Permission,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
type UseAsDraftOptionProps = {
|
||||
versionNumber: number;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
const UseAsDraftDropdownMenuOption = ({
|
||||
versionNumber,
|
||||
onConfirm,
|
||||
}: UseAsDraftOptionProps) => {
|
||||
const { checkAccess } = useAuthorization();
|
||||
const userHasPermissionToWriteFlow = checkAccess(Permission.WRITE_FLOW);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
disabled={!userHasPermissionToWriteFlow}
|
||||
className="w-full"
|
||||
>
|
||||
<PermissionNeededTooltip hasPermission={userHasPermissionToWriteFlow}>
|
||||
<DropdownMenuItem
|
||||
className="w-full"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
disabled={!userHasPermissionToWriteFlow}
|
||||
>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
<span>{t('Use as Draft')}</span>
|
||||
</DropdownMenuItem>
|
||||
</PermissionNeededTooltip>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Are you sure?')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Your current draft version will be overwritten with')}{' '}
|
||||
<span className="font-semibold">
|
||||
{t('version #')}
|
||||
{versionNumber}
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="justify-end">
|
||||
<DialogClose asChild>
|
||||
<Button variant={'outline'}>{t('Cancel')}</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button onClick={() => onConfirm()}>{t('Confirm')}</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
UseAsDraftDropdownMenuOption.displayName = 'UseAsDraftDropdownMenuOption';
|
||||
|
||||
type FlowVersionDetailsCardProps = {
|
||||
flowVersion: FlowVersionMetadata;
|
||||
selected: boolean;
|
||||
publishedVersionId: string | undefined | null;
|
||||
flowVersionNumber: number;
|
||||
};
|
||||
const FlowVersionDetailsCard = React.memo(
|
||||
({
|
||||
flowVersion,
|
||||
flowVersionNumber,
|
||||
selected,
|
||||
publishedVersionId,
|
||||
}: FlowVersionDetailsCardProps) => {
|
||||
const { checkAccess } = useAuthorization();
|
||||
const userHasPermissionToWriteFlow = checkAccess(Permission.WRITE_FLOW);
|
||||
const [setBuilderVersion, setLeftSidebar, setReadonly] =
|
||||
useBuilderStateContext((state) => [
|
||||
state.setVersion,
|
||||
state.setLeftSidebar,
|
||||
state.setReadOnly,
|
||||
]);
|
||||
const [dropdownMenuOpen, setDropdownMenuOpen] = useState(false);
|
||||
const { mutate: viewVersion, isPending } = flowHooks.useFetchFlowVersion({
|
||||
onSuccess: (populatedFlowVersion) => {
|
||||
setBuilderVersion(populatedFlowVersion);
|
||||
setReadonly(
|
||||
populatedFlowVersion.state === FlowVersionState.LOCKED ||
|
||||
!userHasPermissionToWriteFlow,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: overWriteDraftWithVersion, isPending: isDraftPending } =
|
||||
flowHooks.useOverWriteDraftWithVersion({
|
||||
onSuccess: (populatedFlowVersion) => {
|
||||
setBuilderVersion(populatedFlowVersion.version);
|
||||
setLeftSidebar(LeftSideBarType.NONE);
|
||||
},
|
||||
});
|
||||
|
||||
const handleOverwriteDraftWtihVersion = () => {
|
||||
overWriteDraftWithVersion(flowVersion);
|
||||
setDropdownMenuOpen(false);
|
||||
};
|
||||
|
||||
const showAvatar = !useEmbedding().embedState.isEmbedded;
|
||||
|
||||
return (
|
||||
<CardListItem interactive={false}>
|
||||
{showAvatar && flowVersion.updatedByUser && (
|
||||
<UserAvatar
|
||||
size={28}
|
||||
name={
|
||||
flowVersion.updatedByUser.firstName +
|
||||
' ' +
|
||||
flowVersion.updatedByUser.lastName
|
||||
}
|
||||
email={flowVersion.updatedByUser.email}
|
||||
/>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<p className="text-sm font-medium leading-none select-none pointer-events-none">
|
||||
{formatUtils.formatDate(new Date(flowVersion.created))}
|
||||
</p>
|
||||
<p className="flex gap-1 text-xs text-muted-foreground">
|
||||
{t('Version')} {flowVersionNumber}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grow"></div>
|
||||
<div className="flex font-medium gap-2 justify-center items-center">
|
||||
{selected && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="size-10 flex justify-center items-center">
|
||||
<EyeIcon className="w-5 h-5 "></EyeIcon>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('Viewing')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<FlowVersionStateDot
|
||||
state={flowVersion.state}
|
||||
versionId={flowVersion.id}
|
||||
publishedVersionId={publishedVersionId}
|
||||
></FlowVersionStateDot>
|
||||
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => setDropdownMenuOpen(open)}
|
||||
open={dropdownMenuOpen}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={isPending || isDraftPending}
|
||||
size={'icon'}
|
||||
>
|
||||
{(isPending || isDraftPending) && <LoadingSpinner />}
|
||||
{!isPending && !isDraftPending && <DotsVerticalIcon />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-40">
|
||||
<DropdownMenuItem
|
||||
onClick={() => viewVersion(flowVersion)}
|
||||
className="w-full"
|
||||
>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
<span>{t('View')}</span>
|
||||
</DropdownMenuItem>
|
||||
{flowVersion.state !== FlowVersionState.DRAFT && (
|
||||
<UseAsDraftDropdownMenuOption
|
||||
versionNumber={flowVersionNumber}
|
||||
onConfirm={handleOverwriteDraftWtihVersion}
|
||||
></UseAsDraftDropdownMenuOption>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardListItem>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
FlowVersionDetailsCard.displayName = 'FlowVersionDetailsCard';
|
||||
export { FlowVersionDetailsCard };
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { t } from 'i18next';
|
||||
|
||||
import {
|
||||
RightSideBarType,
|
||||
useBuilderStateContext,
|
||||
} from '@/app/builder/builder-hooks';
|
||||
import { CardList, CardListItemSkeleton } from '@/components/custom/card-list';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { flowsApi } from '@/features/flows/lib/flows-api';
|
||||
import { FlowVersionMetadata, SeekPage } from '@activepieces/shared';
|
||||
|
||||
import { SidebarHeader } from '../sidebar-header';
|
||||
|
||||
import { FlowVersionDetailsCard } from './flow-versions-card';
|
||||
|
||||
const FlowVersionsList = () => {
|
||||
const [flow, setRightSidebar, selectedFlowVersion] = useBuilderStateContext(
|
||||
(state) => [state.flow, state.setRightSidebar, state.flowVersion],
|
||||
);
|
||||
|
||||
const {
|
||||
data: flowVersionPage,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery<SeekPage<FlowVersionMetadata>, Error>({
|
||||
queryKey: ['flow-versions', flow.id],
|
||||
queryFn: () =>
|
||||
flowsApi.listVersions(flow.id, {
|
||||
limit: 1000,
|
||||
cursor: undefined,
|
||||
}),
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarHeader onClose={() => setRightSidebar(RightSideBarType.NONE)}>
|
||||
{t('Version History')}
|
||||
</SidebarHeader>
|
||||
<CardList>
|
||||
{isLoading && <CardListItemSkeleton numberOfCards={10} />}
|
||||
{isError && <div>{t('Error, please try again.')}</div>}
|
||||
{flowVersionPage && flowVersionPage.data && (
|
||||
<ScrollArea className="w-full h-full">
|
||||
{flowVersionPage.data.map((flowVersion, index) => (
|
||||
<FlowVersionDetailsCard
|
||||
selected={flowVersion.id === selectedFlowVersion?.id}
|
||||
publishedVersionId={flow.publishedVersionId}
|
||||
flowVersion={flowVersion}
|
||||
flowVersionNumber={flowVersionPage.data.length - index}
|
||||
key={flowVersion.id}
|
||||
/>
|
||||
))}
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardList>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
FlowVersionsList.displayName = 'FlowVersionsList';
|
||||
|
||||
export { FlowVersionsList };
|
||||
287
activepieces-fork/packages/react-ui/src/app/builder/index.tsx
Normal file
287
activepieces-fork/packages/react-ui/src/app/builder/index.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ImperativePanelHandle } from 'react-resizable-panels';
|
||||
|
||||
import {
|
||||
LeftSideBarType,
|
||||
RightSideBarType,
|
||||
useBuilderStateContext,
|
||||
useShowBuilderIsSavingWarningBeforeLeaving,
|
||||
useSwitchToDraft,
|
||||
} from '@/app/builder/builder-hooks';
|
||||
import { DataSelector } from '@/app/builder/data-selector';
|
||||
import { CanvasControls } from '@/app/builder/flow-canvas/canvas-controls';
|
||||
import { StepSettingsProvider } from '@/app/builder/step-settings/step-settings-context';
|
||||
import { ChatDrawer } from '@/app/routes/chat/chat-drawer';
|
||||
import { ShowPoweredBy } from '@/components/show-powered-by';
|
||||
import { useSocket } from '@/components/socket-provider';
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from '@/components/ui/resizable-panel';
|
||||
import { RunDetailsBar } from '@/features/flow-runs/components/run-details-bar';
|
||||
import { flowRunsApi } from '@/features/flow-runs/lib/flow-runs-api';
|
||||
import { piecesHooks } from '@/features/pieces/lib/pieces-hooks';
|
||||
import { platformHooks } from '@/hooks/platform-hooks';
|
||||
import {
|
||||
FlowAction,
|
||||
FlowActionType,
|
||||
FlowTrigger,
|
||||
FlowTriggerType,
|
||||
FlowVersionState,
|
||||
WebsocketClientEvent,
|
||||
flowStructureUtil,
|
||||
isNil,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { cn, useElementSize } from '../../lib/utils';
|
||||
|
||||
import { BuilderHeader } from './builder-header/builder-header';
|
||||
import { FlowCanvas } from './flow-canvas';
|
||||
import { LEFT_SIDEBAR_ID } from './flow-canvas/utils/consts';
|
||||
import { FlowVersionsList } from './flow-versions';
|
||||
import { FlowRunDetails } from './run-details';
|
||||
import { RunsList } from './run-list';
|
||||
import { StepSettingsContainer } from './step-settings';
|
||||
|
||||
const minWidthOfSidebar = 'min-w-[max(20vw,400px)]';
|
||||
const animateResizeClassName = `transition-all duration-200`;
|
||||
|
||||
const useAnimateSidebar = (
|
||||
sidebarValue: LeftSideBarType | RightSideBarType,
|
||||
) => {
|
||||
const handleRef = useRef<ImperativePanelHandle>(null);
|
||||
const sidebarClosed = [LeftSideBarType.NONE, RightSideBarType.NONE].includes(
|
||||
sidebarValue,
|
||||
);
|
||||
useEffect(() => {
|
||||
const sidebarSize = handleRef.current?.getSize() ?? 0;
|
||||
if (sidebarClosed) {
|
||||
handleRef.current?.resize(0);
|
||||
} else if (sidebarSize === 0) {
|
||||
handleRef.current?.resize(25);
|
||||
}
|
||||
}, [handleRef, sidebarValue, sidebarClosed]);
|
||||
return handleRef;
|
||||
};
|
||||
|
||||
const BuilderPage = () => {
|
||||
const { platform } = platformHooks.useCurrentPlatform();
|
||||
const [setRun, flowVersion, leftSidebar, rightSidebar, run, selectedStep] =
|
||||
useBuilderStateContext((state) => [
|
||||
state.setRun,
|
||||
state.flowVersion,
|
||||
state.leftSidebar,
|
||||
state.rightSidebar,
|
||||
state.run,
|
||||
state.selectedStep,
|
||||
]);
|
||||
|
||||
useShowBuilderIsSavingWarningBeforeLeaving();
|
||||
|
||||
const { memorizedSelectedStep } = useBuilderStateContext((state) => {
|
||||
const flowVersion = state.flowVersion;
|
||||
if (isNil(state.selectedStep) || isNil(flowVersion)) {
|
||||
return {
|
||||
memorizedSelectedStep: undefined,
|
||||
};
|
||||
}
|
||||
const step = flowStructureUtil.getStep(
|
||||
state.selectedStep,
|
||||
flowVersion.trigger,
|
||||
);
|
||||
|
||||
return {
|
||||
memorizedSelectedStep: step,
|
||||
};
|
||||
});
|
||||
const middlePanelRef = useRef<HTMLDivElement>(null);
|
||||
const middlePanelSize = useElementSize(middlePanelRef);
|
||||
const [isDraggingHandle, setIsDraggingHandle] = useState(false);
|
||||
const rightHandleRef = useAnimateSidebar(rightSidebar);
|
||||
const leftHandleRef = useAnimateSidebar(leftSidebar);
|
||||
const rightSidePanelRef = useRef<HTMLDivElement>(null);
|
||||
const { pieceModel, refetch: refetchPiece } =
|
||||
piecesHooks.usePieceModelForStepSettings({
|
||||
name: memorizedSelectedStep?.settings.pieceName,
|
||||
version: memorizedSelectedStep?.settings.pieceVersion,
|
||||
enabled:
|
||||
memorizedSelectedStep?.type === FlowActionType.PIECE ||
|
||||
memorizedSelectedStep?.type === FlowTriggerType.PIECE,
|
||||
getExactVersion: flowVersion.state === FlowVersionState.LOCKED,
|
||||
});
|
||||
const socket = useSocket();
|
||||
const { mutate: fetchAndUpdateRun } = useMutation({
|
||||
mutationFn: flowRunsApi.getPopulated,
|
||||
});
|
||||
useEffect(() => {
|
||||
socket.on(WebsocketClientEvent.REFRESH_PIECE, () => {
|
||||
refetchPiece();
|
||||
});
|
||||
socket.on(WebsocketClientEvent.FLOW_RUN_PROGRESS, (data) => {
|
||||
const runId = data?.runId;
|
||||
if (run && run?.id === runId) {
|
||||
fetchAndUpdateRun(runId, {
|
||||
onSuccess: (run) => {
|
||||
setRun(run, flowVersion);
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
socket.removeAllListeners(WebsocketClientEvent.REFRESH_PIECE);
|
||||
socket.removeAllListeners(WebsocketClientEvent.FLOW_RUN_PROGRESS);
|
||||
};
|
||||
}, [socket.id, run?.id]);
|
||||
|
||||
const { switchToDraft, isSwitchingToDraftPending } = useSwitchToDraft();
|
||||
const [hasCanvasBeenInitialised, setHasCanvasBeenInitialised] =
|
||||
useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col relative">
|
||||
<div className="z-50">
|
||||
<BuilderHeader />
|
||||
</div>
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
<ResizablePanel
|
||||
id="left-sidebar"
|
||||
defaultSize={0}
|
||||
minSize={0}
|
||||
maxSize={39}
|
||||
order={1}
|
||||
ref={leftHandleRef}
|
||||
className={cn('min-w-0 z-20 ', {
|
||||
[minWidthOfSidebar]: leftSidebar !== LeftSideBarType.NONE,
|
||||
[animateResizeClassName]: !isDraggingHandle,
|
||||
})}
|
||||
>
|
||||
<div id={LEFT_SIDEBAR_ID} className="w-full h-full">
|
||||
{leftSidebar === LeftSideBarType.RUNS && <RunsList />}
|
||||
{leftSidebar === LeftSideBarType.RUN_DETAILS && <FlowRunDetails />}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle
|
||||
onDragging={setIsDraggingHandle}
|
||||
withHandle={leftSidebar !== LeftSideBarType.NONE}
|
||||
className={
|
||||
leftSidebar === LeftSideBarType.NONE ? 'bg-transparent' : ''
|
||||
}
|
||||
/>
|
||||
|
||||
<ResizablePanel defaultSize={100} order={2} id="flow-canvas">
|
||||
<div ref={middlePanelRef} className="relative h-full w-full">
|
||||
<FlowCanvas
|
||||
setHasCanvasBeenInitialised={setHasCanvasBeenInitialised}
|
||||
></FlowCanvas>
|
||||
|
||||
<RunDetailsBar
|
||||
run={run}
|
||||
isLoading={isSwitchingToDraftPending}
|
||||
exitRun={() => {
|
||||
socket.removeAllListeners(
|
||||
WebsocketClientEvent.FLOW_RUN_PROGRESS,
|
||||
);
|
||||
switchToDraft();
|
||||
}}
|
||||
/>
|
||||
{middlePanelRef.current &&
|
||||
middlePanelRef.current.clientWidth > 0 && (
|
||||
<CanvasControls
|
||||
canvasHeight={middlePanelRef.current?.clientHeight ?? 0}
|
||||
canvasWidth={middlePanelRef.current?.clientWidth ?? 0}
|
||||
hasCanvasBeenInitialised={hasCanvasBeenInitialised}
|
||||
selectedStep={selectedStep}
|
||||
></CanvasControls>
|
||||
)}
|
||||
|
||||
<ShowPoweredBy
|
||||
position="absolute"
|
||||
show={platform?.plan.showPoweredBy}
|
||||
/>
|
||||
<DataSelector
|
||||
parentHeight={middlePanelSize.height}
|
||||
parentWidth={middlePanelSize.width}
|
||||
></DataSelector>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle
|
||||
disabled={rightSidebar === RightSideBarType.NONE}
|
||||
withHandle={rightSidebar !== RightSideBarType.NONE}
|
||||
onDragging={setIsDraggingHandle}
|
||||
className={
|
||||
rightSidebar === RightSideBarType.NONE ? 'bg-transparent' : ''
|
||||
}
|
||||
/>
|
||||
|
||||
<ResizablePanel
|
||||
ref={rightHandleRef}
|
||||
id="right-sidebar"
|
||||
defaultSize={0}
|
||||
minSize={0}
|
||||
maxSize={60}
|
||||
order={3}
|
||||
className={cn('min-w-0 bg-background z-30', {
|
||||
[minWidthOfSidebar]: rightSidebar !== RightSideBarType.NONE,
|
||||
[animateResizeClassName]: !isDraggingHandle,
|
||||
})}
|
||||
>
|
||||
<div ref={rightSidePanelRef} className="h-full w-full">
|
||||
{rightSidebar === RightSideBarType.PIECE_SETTINGS &&
|
||||
memorizedSelectedStep && (
|
||||
<StepSettingsProvider
|
||||
pieceModel={pieceModel}
|
||||
selectedStep={memorizedSelectedStep}
|
||||
key={constructContainerKey({
|
||||
flowVersionId: flowVersion.id,
|
||||
step: memorizedSelectedStep,
|
||||
hasPieceModelLoaded: !!pieceModel,
|
||||
})}
|
||||
>
|
||||
<StepSettingsContainer />
|
||||
</StepSettingsProvider>
|
||||
)}
|
||||
{rightSidebar === RightSideBarType.VERSIONS && <FlowVersionsList />}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
<ChatDrawer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BuilderPage.displayName = 'BuilderPage';
|
||||
export { BuilderPage };
|
||||
|
||||
function constructContainerKey({
|
||||
flowVersionId,
|
||||
step,
|
||||
hasPieceModelLoaded,
|
||||
}: {
|
||||
flowVersionId: string;
|
||||
step?: FlowAction | FlowTrigger;
|
||||
hasPieceModelLoaded: boolean;
|
||||
}) {
|
||||
const stepName = step?.name;
|
||||
const triggerOrActionName =
|
||||
step?.type === FlowTriggerType.PIECE
|
||||
? step?.settings.triggerName
|
||||
: step?.settings.actionName;
|
||||
const pieceName =
|
||||
step?.type === FlowTriggerType.PIECE || step?.type === FlowActionType.PIECE
|
||||
? step?.settings.pieceName
|
||||
: undefined;
|
||||
//we need to re-render the step settings form when the step is skipped, so when the user edits the settings after setting it to skipped the changes are reflected in the update request
|
||||
const isSkipped =
|
||||
step?.type != FlowTriggerType.EMPTY &&
|
||||
step?.type != FlowTriggerType.PIECE &&
|
||||
step?.skip;
|
||||
return `${flowVersionId}-${stepName ?? ''}-${triggerOrActionName ?? ''}-${
|
||||
pieceName ?? ''
|
||||
}-${'skipped-' + !!isSkipped}-${
|
||||
hasPieceModelLoaded ? 'loaded' : 'not-loaded'
|
||||
}`;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { t } from 'i18next';
|
||||
import React from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
} from '@/components/ui/form';
|
||||
import { ReadMoreDescription } from '@/components/ui/read-more-description';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { FlowAction, FlowTrigger } from '@activepieces/shared';
|
||||
|
||||
type ActionErrorHandlingFormProps = {
|
||||
hideContinueOnFailure?: boolean;
|
||||
hideRetryOnFailure?: boolean;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
const ActionErrorHandlingForm = React.memo(
|
||||
({
|
||||
hideContinueOnFailure,
|
||||
hideRetryOnFailure,
|
||||
disabled,
|
||||
}: ActionErrorHandlingFormProps) => {
|
||||
const form = useFormContext<FlowAction | FlowTrigger>();
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{hideContinueOnFailure !== true && (
|
||||
<FormField
|
||||
name="settings.errorHandlingOptions.continueOnFailure.value"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start justify-between">
|
||||
<FormLabel
|
||||
htmlFor="continueOnFailure"
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<FormControl>
|
||||
<Switch
|
||||
disabled={disabled}
|
||||
id="continueOnFailure"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<span className="ml-3 grow">{t('Continue on Failure')}</span>
|
||||
</FormLabel>
|
||||
<ReadMoreDescription
|
||||
text={t(
|
||||
'Enable this option to skip this step and continue the flow normally if it fails.',
|
||||
)}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{hideRetryOnFailure !== true && (
|
||||
<FormField
|
||||
name="settings.errorHandlingOptions.retryOnFailure.value"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start justify-between">
|
||||
<FormLabel
|
||||
htmlFor="retryOnFailure"
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<FormControl>
|
||||
<Switch
|
||||
disabled={disabled}
|
||||
id="retryOnFailure"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<span className="ml-3 grow">{t('Retry on Failure')}</span>
|
||||
</FormLabel>
|
||||
<ReadMoreDescription
|
||||
text={t(
|
||||
'Automatically retry up to four attempts when failed.',
|
||||
)}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ActionErrorHandlingForm.displayName = 'ActionErrorHandlingForm';
|
||||
export { ActionErrorHandlingForm };
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
import { ArraySubProps } from '@activepieces/pieces-framework';
|
||||
|
||||
import {
|
||||
useBuilderStateContext,
|
||||
useIsFocusInsideListMapperModeInput,
|
||||
} from '../builder-hooks';
|
||||
|
||||
import { AutoPropertiesFormComponent } from './auto-properties-form';
|
||||
import { TextInputWithMentions } from './text-input-with-mentions';
|
||||
|
||||
type BaseArrayPropertyProps = {
|
||||
inputName: string;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
type ArrayPiecePropertyInInlineItemModeProps = BaseArrayPropertyProps &
|
||||
(
|
||||
| { arrayProperties: ArraySubProps<boolean> }
|
||||
| {
|
||||
arrayProperties: undefined;
|
||||
onChange: (value: string) => void;
|
||||
value: string;
|
||||
}
|
||||
);
|
||||
|
||||
const ArrayPiecePropertyInInlineItemMode = React.memo(
|
||||
(props: ArrayPiecePropertyInInlineItemModeProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [
|
||||
isFocusInsideListMapperModeInput,
|
||||
setIsFocusInsideListMapperModeInput,
|
||||
] = useBuilderStateContext((state) => [
|
||||
state.isFocusInsideListMapperModeInput,
|
||||
state.setIsFocusInsideListMapperModeInput,
|
||||
]);
|
||||
const { inputName, disabled } = props;
|
||||
useIsFocusInsideListMapperModeInput({
|
||||
containerRef,
|
||||
setIsFocusInsideListMapperModeInput,
|
||||
isFocusInsideListMapperModeInput,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full" ref={containerRef}>
|
||||
{props.arrayProperties ? (
|
||||
<div className="p-4 border rounded-md flex flex-col gap-4">
|
||||
<AutoPropertiesFormComponent
|
||||
prefixValue={inputName}
|
||||
props={props.arrayProperties}
|
||||
useMentionTextInput={true}
|
||||
allowDynamicValues={false}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<TextInputWithMentions
|
||||
disabled={disabled}
|
||||
onChange={props.onChange}
|
||||
initialValue={props.value ?? null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ArrayPiecePropertyInInlineItemMode.displayName =
|
||||
'ArrayPiecePropertyInInlineItemMode';
|
||||
export { ArrayPiecePropertyInInlineItemMode };
|
||||
@@ -0,0 +1,212 @@
|
||||
import { t } from 'i18next';
|
||||
import { Plus, TrashIcon } from 'lucide-react';
|
||||
import { nanoid } from 'nanoid';
|
||||
import React, { useState } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { ArrayInput } from '@/components/custom/array-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { TextWithIcon } from '@/components/ui/text-with-icon';
|
||||
import {
|
||||
ArrayProperty,
|
||||
ArraySubProps,
|
||||
PropertyType,
|
||||
} from '@activepieces/pieces-framework';
|
||||
|
||||
import { AutoPropertiesFormComponent } from './auto-properties-form';
|
||||
import { TextInputWithMentions } from './text-input-with-mentions';
|
||||
|
||||
type ArrayPropertyProps = {
|
||||
inputName: string;
|
||||
useMentionTextInput: boolean;
|
||||
arrayProperty: ArrayProperty<boolean>;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
type ArrayField = {
|
||||
id: string;
|
||||
value: string | Record<string, unknown>;
|
||||
};
|
||||
|
||||
const getDefaultValuesForInputs = (arrayProperties: ArraySubProps<boolean>) => {
|
||||
return Object.entries(arrayProperties).reduce((acc, [key, value]) => {
|
||||
switch (value.type) {
|
||||
case PropertyType.LONG_TEXT:
|
||||
case PropertyType.SHORT_TEXT:
|
||||
case PropertyType.NUMBER:
|
||||
case PropertyType.JSON:
|
||||
case PropertyType.COLOR:
|
||||
return {
|
||||
...acc,
|
||||
[key]: '',
|
||||
};
|
||||
case PropertyType.CHECKBOX:
|
||||
return {
|
||||
...acc,
|
||||
[key]: false,
|
||||
};
|
||||
case PropertyType.STATIC_DROPDOWN:
|
||||
case PropertyType.STATIC_MULTI_SELECT_DROPDOWN:
|
||||
case PropertyType.MULTI_SELECT_DROPDOWN:
|
||||
case PropertyType.DATE_TIME:
|
||||
return {
|
||||
...acc,
|
||||
[key]: null,
|
||||
};
|
||||
case PropertyType.FILE:
|
||||
return {
|
||||
...acc,
|
||||
[key]: null,
|
||||
};
|
||||
}
|
||||
}, {} as Record<string, unknown>);
|
||||
};
|
||||
const ArrayPieceProperty = React.memo(
|
||||
({
|
||||
inputName,
|
||||
useMentionTextInput,
|
||||
disabled,
|
||||
arrayProperty,
|
||||
}: ArrayPropertyProps) => {
|
||||
const form = useFormContext();
|
||||
|
||||
const [fields, setFields] = useState<ArrayField[]>(() => {
|
||||
const formValues = form.getValues(inputName);
|
||||
if (formValues) {
|
||||
return formValues.map((value: string | Record<string, unknown>) => ({
|
||||
id: nanoid(),
|
||||
value,
|
||||
}));
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const updateFormValue = (newFields: ArrayField[]) => {
|
||||
form.setValue(
|
||||
inputName,
|
||||
newFields.map((f) => f.value),
|
||||
{ shouldValidate: true },
|
||||
);
|
||||
};
|
||||
|
||||
const append = () => {
|
||||
//passing empty object will result in react form putting in the initial values when the user first started editing
|
||||
const value = arrayProperty.properties
|
||||
? getDefaultValuesForInputs(arrayProperty.properties)
|
||||
: '';
|
||||
const formValues = form.getValues(inputName) || [];
|
||||
const newFields = [
|
||||
...formValues.map((value: string | Record<string, unknown>) => ({
|
||||
id: nanoid(),
|
||||
value,
|
||||
})),
|
||||
{ id: nanoid(), value },
|
||||
];
|
||||
|
||||
setFields(newFields);
|
||||
updateFormValue(newFields);
|
||||
};
|
||||
|
||||
const remove = (index: number) => {
|
||||
const currentFields: ArrayField[] = form
|
||||
.getValues(inputName)
|
||||
.map((value: string | Record<string, unknown>) => ({
|
||||
id: nanoid(),
|
||||
value,
|
||||
}));
|
||||
const newFields = currentFields.filter((_, i) => i !== index);
|
||||
setFields(newFields);
|
||||
updateFormValue(newFields);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{arrayProperty.properties && (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
className="p-4 border rounded-md flex flex-col gap-4"
|
||||
key={'array-item-' + field.id}
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<div className="font-semibold"> #{index + 1}</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-8 shrink-0"
|
||||
onClick={() => {
|
||||
remove(index);
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<TrashIcon
|
||||
className="size-4 text-destructive"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">{t('Remove')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<AutoPropertiesFormComponent
|
||||
prefixValue={`${inputName}.[${index}]`}
|
||||
props={arrayProperty.properties!}
|
||||
useMentionTextInput={useMentionTextInput}
|
||||
allowDynamicValues={false}
|
||||
disabled={disabled}
|
||||
onValueChange={() => {
|
||||
form.trigger(inputName);
|
||||
}}
|
||||
></AutoPropertiesFormComponent>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!disabled && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => {
|
||||
append();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<TextWithIcon icon={<Plus size={18} />} text={t('Add Item')} />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!arrayProperty.properties && (
|
||||
<ArrayInput
|
||||
inputName={inputName}
|
||||
disabled={disabled}
|
||||
required={arrayProperty.required}
|
||||
customInputNode={(onChange, value, disabled) => {
|
||||
if (!useMentionTextInput) {
|
||||
return (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TextInputWithMentions
|
||||
initialValue={value}
|
||||
onChange={(newValue) => onChange(newValue)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ArrayPieceProperty.displayName = 'ArrayPieceProperty';
|
||||
export { ArrayPieceProperty };
|
||||
@@ -0,0 +1,341 @@
|
||||
import { t } from 'i18next';
|
||||
import { Calendar, SquareFunction, File } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { ControllerRenderProps, useFormContext } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormItem, FormLabel } from '@/components/ui/form';
|
||||
import { ReadMoreDescription } from '@/components/ui/read-more-description';
|
||||
import { Toggle } from '@/components/ui/toggle';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { formUtils } from '@/features/pieces/lib/form-utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
PieceAuthProperty,
|
||||
PieceProperty,
|
||||
PropertyType,
|
||||
} from '@activepieces/pieces-framework';
|
||||
import {
|
||||
FlowAction,
|
||||
FlowTrigger,
|
||||
PropertyExecutionType,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { ArrayPiecePropertyInInlineItemMode } from './array-property-in-inline-item-mode';
|
||||
import { TextInputWithMentions } from './text-input-with-mentions';
|
||||
|
||||
function AutoFormFieldWrapper({
|
||||
placeBeforeLabelText = false,
|
||||
children,
|
||||
allowDynamicValues,
|
||||
propertyName,
|
||||
inputName,
|
||||
property,
|
||||
disabled,
|
||||
field,
|
||||
dynamicInputModeToggled,
|
||||
//we have to pass this prop, because props inside custom auth can be secret text, which means their labels will become (Connection)
|
||||
isForConnectionSelect = false,
|
||||
}: AutoFormFieldWrapperProps) {
|
||||
const isArrayProperty =
|
||||
!isPieceAuthProperty(property) && property.type === PropertyType.ARRAY;
|
||||
const isAuthProperty = isForConnectionSelect || Array.isArray(property);
|
||||
return (
|
||||
<AutoFormFielWrapperErrorBoundary
|
||||
field={field}
|
||||
property={property ?? null}
|
||||
dynamicInputModeToggled={dynamicInputModeToggled}
|
||||
>
|
||||
<FormItem className="flex flex-col gap-1">
|
||||
<FormLabel className="flex items-center gap-1 ">
|
||||
{placeBeforeLabelText && !dynamicInputModeToggled && children}
|
||||
<div className="pt-1">
|
||||
<span>
|
||||
{isAuthProperty ? t('Connection') : property.displayName}
|
||||
</span>{' '}
|
||||
{(isAuthProperty || property.required) && (
|
||||
<span className="text-destructive">*</span>
|
||||
)}
|
||||
</div>
|
||||
{property && !isAuthProperty && (
|
||||
<PropertyTypeTooltip property={property} />
|
||||
)}
|
||||
<span className="grow"></span>
|
||||
{allowDynamicValues && (
|
||||
<DynamicValueToggle
|
||||
propertyName={propertyName}
|
||||
inputName={inputName}
|
||||
property={property}
|
||||
disabled={disabled}
|
||||
isToggled={dynamicInputModeToggled ?? false}
|
||||
/>
|
||||
)}
|
||||
</FormLabel>
|
||||
|
||||
{dynamicInputModeToggled && !isArrayProperty && (
|
||||
<TextInputWithMentions
|
||||
disabled={disabled}
|
||||
onChange={field.onChange}
|
||||
initialValue={field.value ?? null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isArrayProperty && dynamicInputModeToggled && (
|
||||
<ArrayPiecePropertyInInlineItemMode
|
||||
disabled={disabled}
|
||||
arrayProperties={property.properties}
|
||||
inputName={inputName}
|
||||
onChange={field.onChange}
|
||||
value={field.value ?? null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!placeBeforeLabelText && !dynamicInputModeToggled && (
|
||||
<div>{children}</div>
|
||||
)}
|
||||
|
||||
{!isForConnectionSelect &&
|
||||
!Array.isArray(property) &&
|
||||
property.description && (
|
||||
<ReadMoreDescription text={t(property.description)} />
|
||||
)}
|
||||
</FormItem>
|
||||
</AutoFormFielWrapperErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function AutoFormFielWrapperErrorBoundary({
|
||||
children,
|
||||
field,
|
||||
property,
|
||||
dynamicInputModeToggled,
|
||||
}: AutoFormFielWrapperErrorBoundaryProps) {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallbackRender={() => (
|
||||
<div className="text-sm flex items-center justify-between">
|
||||
<div className="text-red-500">
|
||||
{t('input value is invalid, please contact support')}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
JSON.stringify({
|
||||
stringifiedValue: stringifyValue(field.value),
|
||||
property,
|
||||
dynamicInputModeToggled,
|
||||
disabled: field.disabled,
|
||||
}),
|
||||
);
|
||||
toast(t('Info copied to clipboard, please send it to support'), {
|
||||
duration: 3000,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('Info')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function getValueForInputOnDynamicToggleChange(
|
||||
property: PieceProperty | PieceAuthProperty[],
|
||||
newMode: PropertyExecutionType,
|
||||
currentValue: unknown,
|
||||
) {
|
||||
const isAuthProperty = isPieceAuthProperty(property);
|
||||
switch (newMode) {
|
||||
case PropertyExecutionType.DYNAMIC: {
|
||||
if (!isAuthProperty && property.type === PropertyType.ARRAY) {
|
||||
return formUtils.getDefaultPropertyValue({
|
||||
property,
|
||||
dynamicInputModeToggled: true,
|
||||
});
|
||||
}
|
||||
//to show what the selected value is for dropdowns
|
||||
if (
|
||||
typeof currentValue === 'string' ||
|
||||
typeof currentValue === 'number'
|
||||
) {
|
||||
return currentValue;
|
||||
}
|
||||
return JSON.stringify(currentValue);
|
||||
}
|
||||
case PropertyExecutionType.MANUAL:
|
||||
if (isAuthProperty) {
|
||||
return '';
|
||||
}
|
||||
return formUtils.getDefaultPropertyValue({
|
||||
property,
|
||||
dynamicInputModeToggled: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function DynamicValueToggle({
|
||||
propertyName,
|
||||
inputName,
|
||||
property,
|
||||
disabled,
|
||||
isToggled,
|
||||
}: DynamicValueToggleProps) {
|
||||
const form = useFormContext<FlowAction | FlowTrigger>();
|
||||
function updatePropertySettings(mode: PropertyExecutionType) {
|
||||
const propertySettingsForSingleProperty = {
|
||||
...form.getValues().settings?.propertySettings?.[propertyName],
|
||||
type: mode,
|
||||
};
|
||||
form.setValue(
|
||||
`settings.propertySettings.${propertyName}`,
|
||||
propertySettingsForSingleProperty,
|
||||
);
|
||||
}
|
||||
function handleDynamicValueToggleChange(mode: PropertyExecutionType) {
|
||||
updatePropertySettings(mode);
|
||||
if (isInputNameLiteral(inputName)) {
|
||||
const currentValue = form.getValues(inputName);
|
||||
const newValue = getValueForInputOnDynamicToggleChange(
|
||||
property,
|
||||
mode,
|
||||
currentValue,
|
||||
);
|
||||
form.setValue(inputName, newValue, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
} else {
|
||||
throw new Error(
|
||||
'inputName is not a member of step settings input, you might be using dynamic properties where you should not',
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="flex gap-2 items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Toggle
|
||||
pressed={isToggled}
|
||||
onPressedChange={(newIsToggled) =>
|
||||
handleDynamicValueToggleChange(
|
||||
newIsToggled
|
||||
? PropertyExecutionType.DYNAMIC
|
||||
: PropertyExecutionType.MANUAL,
|
||||
)
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SquareFunction
|
||||
className={cn('size-5', {
|
||||
'text-foreground': isToggled,
|
||||
'text-muted-foreground': !isToggled,
|
||||
})}
|
||||
/>
|
||||
</Toggle>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{t('Dynamic value')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function PropertyTypeTooltip({ property }: { property: PieceProperty }) {
|
||||
if (
|
||||
property.type !== PropertyType.FILE &&
|
||||
property.type !== PropertyType.DATE_TIME
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{property.type === PropertyType.FILE ? (
|
||||
<File className="w-4 h-4 stroke-foreground/55"></File>
|
||||
) : (
|
||||
property.type === PropertyType.DATE_TIME && (
|
||||
<Calendar className="w-4 h-4 stroke-foreground/55"></Calendar>
|
||||
)
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<>
|
||||
{property.type === PropertyType.FILE &&
|
||||
t('File Input i.e a url or file passed from a previous step')}
|
||||
{property.type === PropertyType.DATE_TIME &&
|
||||
t('Date Input must comply with ISO 8601 format')}
|
||||
</>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
function stringifyValue(value: unknown) {
|
||||
try {
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
} catch (e) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
AutoFormFieldWrapper.displayName = 'AutoFormFieldWrapper';
|
||||
|
||||
export { AutoFormFieldWrapper };
|
||||
|
||||
type DynamicValueToggleProps = {
|
||||
propertyName: string;
|
||||
inputName: string;
|
||||
property: PieceProperty | PieceAuthProperty[];
|
||||
disabled: boolean;
|
||||
isToggled: boolean;
|
||||
};
|
||||
|
||||
type AutoFormFieldWrapperProps = {
|
||||
children: React.ReactNode;
|
||||
allowDynamicValues: boolean;
|
||||
propertyName: string;
|
||||
hideDescription?: boolean;
|
||||
placeBeforeLabelText?: boolean;
|
||||
disabled: boolean;
|
||||
field: ControllerRenderProps<any, string>;
|
||||
inputName: string;
|
||||
dynamicInputModeToggled?: boolean;
|
||||
property: PieceProperty | PieceAuthProperty[];
|
||||
isForConnectionSelect?: boolean;
|
||||
};
|
||||
type AutoFormFielWrapperErrorBoundaryProps = {
|
||||
children: React.ReactNode;
|
||||
field: ControllerRenderProps;
|
||||
property: PieceProperty | PieceAuthProperty[] | null;
|
||||
dynamicInputModeToggled?: boolean;
|
||||
};
|
||||
function isInputNameLiteral(
|
||||
inputName: string,
|
||||
): inputName is `settings.input.${string}` {
|
||||
return inputName.match(/settings\.input\./) !== null;
|
||||
}
|
||||
function isPieceAuthProperty(
|
||||
property: PieceProperty | PieceAuthProperty[],
|
||||
): property is PieceAuthProperty[] {
|
||||
const authPropertyTypes = [
|
||||
PropertyType.SECRET_TEXT,
|
||||
PropertyType.BASIC_AUTH,
|
||||
PropertyType.OAUTH2,
|
||||
PropertyType.CUSTOM_AUTH,
|
||||
];
|
||||
return (
|
||||
Array.isArray(property) ||
|
||||
authPropertyTypes.some((authType) => property.type === authType)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import { t } from 'i18next';
|
||||
import React from 'react';
|
||||
import { ControllerRenderProps, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { JsonEditor } from '@/components/custom/json-editor';
|
||||
import { ApMarkdown } from '@/components/custom/markdown';
|
||||
import { SearchableSelect } from '@/components/custom/searchable-select';
|
||||
import { ColorPicker } from '@/components/ui/color-picker';
|
||||
import { FormControl, FormField } from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { AgentTools } from '@/features/agents/agent-tools';
|
||||
import { AgentStructuredOutput } from '@/features/agents/structured-output';
|
||||
import {
|
||||
OAuth2Props,
|
||||
PieceProperty,
|
||||
PiecePropertyMap,
|
||||
PropertyType,
|
||||
ArraySubProps,
|
||||
} from '@activepieces/pieces-framework';
|
||||
import {
|
||||
AgentPieceProps,
|
||||
FlowActionType,
|
||||
FlowTriggerType,
|
||||
isNil,
|
||||
PropertyExecutionType,
|
||||
Step,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { MultiSelectPieceProperty } from '../../../components/custom/multi-select-piece-property';
|
||||
|
||||
import { ArrayPieceProperty } from './array-property';
|
||||
import { AutoFormFieldWrapper } from './auto-form-field-wrapper';
|
||||
import { BuilderJsonEditorWrapper } from './builder-json-wrapper';
|
||||
import CustomProperty from './custom-property';
|
||||
import { DictionaryProperty } from './dictionary-property';
|
||||
import { DynamicDropdownPieceProperty } from './dynamic-dropdown-piece-property';
|
||||
import { DynamicProperties } from './dynamic-piece-property';
|
||||
import { TextInputWithMentions } from './text-input-with-mentions';
|
||||
|
||||
type AutoFormProps = {
|
||||
props: PiecePropertyMap | OAuth2Props | ArraySubProps<boolean>;
|
||||
allowDynamicValues: boolean;
|
||||
prefixValue: string;
|
||||
markdownVariables?: Record<string, string>;
|
||||
useMentionTextInput: boolean;
|
||||
disabled?: boolean;
|
||||
onValueChange?: (val: { value: unknown; propertyName: string }) => void;
|
||||
};
|
||||
|
||||
const AutoPropertiesFormComponent = React.memo(
|
||||
({
|
||||
markdownVariables,
|
||||
props,
|
||||
allowDynamicValues,
|
||||
prefixValue,
|
||||
disabled,
|
||||
useMentionTextInput,
|
||||
onValueChange,
|
||||
}: AutoFormProps) => {
|
||||
const form = useFormContext();
|
||||
const step = form.getValues() as Step;
|
||||
|
||||
return (
|
||||
Object.keys(props).length > 0 && (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{Object.entries(props).map(([propertyName]) => {
|
||||
const isPieceStep =
|
||||
step.type === FlowActionType.PIECE ||
|
||||
step.type === FlowTriggerType.PIECE;
|
||||
const dynamicInputModeToggled = isPieceStep
|
||||
? step.settings.propertySettings[propertyName]?.type ===
|
||||
PropertyExecutionType.DYNAMIC
|
||||
: false;
|
||||
return (
|
||||
<FormField
|
||||
key={propertyName}
|
||||
name={`${prefixValue}.${propertyName}`}
|
||||
control={form.control}
|
||||
render={({ field }) =>
|
||||
selectFormComponentForProperty({
|
||||
field: {
|
||||
...field,
|
||||
onChange: (value) => {
|
||||
field.onChange(value);
|
||||
//must come after because the form value won't be updated yet otherwise
|
||||
onValueChange?.({
|
||||
value,
|
||||
propertyName,
|
||||
});
|
||||
},
|
||||
},
|
||||
propertyName,
|
||||
inputName: `${prefixValue}.${propertyName}`,
|
||||
property: props[propertyName],
|
||||
allowDynamicValues,
|
||||
markdownVariables: markdownVariables ?? {},
|
||||
useMentionTextInput: useMentionTextInput,
|
||||
disabled: disabled ?? false,
|
||||
dynamicInputModeToggled,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
type selectFormComponentForPropertyParams = {
|
||||
field: ControllerRenderProps<Record<string, any>, string>;
|
||||
propertyName: string;
|
||||
inputName: string;
|
||||
property: PieceProperty;
|
||||
allowDynamicValues: boolean;
|
||||
markdownVariables: Record<string, string>;
|
||||
useMentionTextInput: boolean;
|
||||
disabled: boolean;
|
||||
dynamicInputModeToggled: boolean;
|
||||
};
|
||||
|
||||
export const selectFormComponentForProperty = ({
|
||||
field,
|
||||
propertyName,
|
||||
inputName,
|
||||
property,
|
||||
allowDynamicValues,
|
||||
markdownVariables,
|
||||
useMentionTextInput,
|
||||
disabled,
|
||||
dynamicInputModeToggled,
|
||||
}: selectFormComponentForPropertyParams) => {
|
||||
if (propertyName === AgentPieceProps.AGENT_TOOLS) {
|
||||
return <AgentTools disabled={disabled} agentToolsField={field} />;
|
||||
} else if (propertyName === AgentPieceProps.STRUCTURED_OUTPUT) {
|
||||
return (
|
||||
<AgentStructuredOutput
|
||||
disabled={disabled}
|
||||
structuredOutputField={field}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
switch (property.type) {
|
||||
case PropertyType.ARRAY:
|
||||
return (
|
||||
<AutoFormFieldWrapper
|
||||
property={property}
|
||||
propertyName={propertyName}
|
||||
field={field}
|
||||
disabled={disabled}
|
||||
inputName={inputName}
|
||||
allowDynamicValues={allowDynamicValues}
|
||||
dynamicInputModeToggled={dynamicInputModeToggled}
|
||||
>
|
||||
<ArrayPieceProperty
|
||||
disabled={disabled}
|
||||
arrayProperty={property}
|
||||
inputName={inputName}
|
||||
useMentionTextInput={useMentionTextInput}
|
||||
></ArrayPieceProperty>
|
||||
</AutoFormFieldWrapper>
|
||||
);
|
||||
case PropertyType.OBJECT:
|
||||
return (
|
||||
<AutoFormFieldWrapper
|
||||
property={property}
|
||||
propertyName={propertyName}
|
||||
field={field}
|
||||
inputName={inputName}
|
||||
disabled={disabled}
|
||||
allowDynamicValues={allowDynamicValues}
|
||||
dynamicInputModeToggled={dynamicInputModeToggled}
|
||||
>
|
||||
<DictionaryProperty
|
||||
disabled={disabled}
|
||||
values={field.value}
|
||||
onChange={field.onChange}
|
||||
useMentionTextInput={useMentionTextInput}
|
||||
></DictionaryProperty>
|
||||
</AutoFormFieldWrapper>
|
||||
);
|
||||
case PropertyType.CHECKBOX:
|
||||
return (
|
||||
<AutoFormFieldWrapper
|
||||
property={property}
|
||||
propertyName={propertyName}
|
||||
disabled={disabled}
|
||||
field={field}
|
||||
inputName={inputName}
|
||||
allowDynamicValues={allowDynamicValues}
|
||||
placeBeforeLabelText={true}
|
||||
dynamicInputModeToggled={dynamicInputModeToggled}
|
||||
>
|
||||
<FormControl>
|
||||
<Switch
|
||||
id={propertyName}
|
||||
checked={field.value}
|
||||
disabled={disabled}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</AutoFormFieldWrapper>
|
||||
);
|
||||
case PropertyType.MARKDOWN:
|
||||
return (
|
||||
<ApMarkdown
|
||||
markdown={property.description}
|
||||
variables={markdownVariables}
|
||||
variant={property.variant}
|
||||
/>
|
||||
);
|
||||
case PropertyType.STATIC_DROPDOWN:
|
||||
return (
|
||||
<AutoFormFieldWrapper
|
||||
property={property}
|
||||
propertyName={propertyName}
|
||||
inputName={inputName}
|
||||
field={field}
|
||||
disabled={disabled}
|
||||
allowDynamicValues={allowDynamicValues}
|
||||
dynamicInputModeToggled={dynamicInputModeToggled}
|
||||
>
|
||||
<SearchableSelect
|
||||
options={property.options.options}
|
||||
onChange={field.onChange}
|
||||
value={field.value}
|
||||
disabled={disabled}
|
||||
placeholder={property.options.placeholder ?? t('Select an option')}
|
||||
showDeselect={!property.required}
|
||||
></SearchableSelect>
|
||||
</AutoFormFieldWrapper>
|
||||
);
|
||||
case PropertyType.JSON:
|
||||
return (
|
||||
<AutoFormFieldWrapper
|
||||
propertyName={propertyName}
|
||||
inputName={inputName}
|
||||
property={property}
|
||||
field={field}
|
||||
disabled={disabled}
|
||||
allowDynamicValues={allowDynamicValues}
|
||||
dynamicInputModeToggled={dynamicInputModeToggled}
|
||||
>
|
||||
{useMentionTextInput ? (
|
||||
<BuilderJsonEditorWrapper
|
||||
field={field}
|
||||
disabled={disabled}
|
||||
></BuilderJsonEditorWrapper>
|
||||
) : (
|
||||
<JsonEditor field={field} readonly={disabled}></JsonEditor>
|
||||
)}
|
||||
</AutoFormFieldWrapper>
|
||||
);
|
||||
case PropertyType.STATIC_MULTI_SELECT_DROPDOWN:
|
||||
return (
|
||||
<AutoFormFieldWrapper
|
||||
property={property}
|
||||
inputName={inputName}
|
||||
propertyName={propertyName}
|
||||
field={field}
|
||||
disabled={disabled}
|
||||
allowDynamicValues={allowDynamicValues}
|
||||
dynamicInputModeToggled={dynamicInputModeToggled}
|
||||
>
|
||||
<MultiSelectPieceProperty
|
||||
placeholder={property.options.placeholder ?? t('Select an option')}
|
||||
options={property.options.options}
|
||||
onChange={field.onChange}
|
||||
initialValues={field.value}
|
||||
disabled={disabled}
|
||||
showDeselect={
|
||||
!isNil(field.value) &&
|
||||
field.value.length > 0 &&
|
||||
!property.required
|
||||
}
|
||||
></MultiSelectPieceProperty>
|
||||
</AutoFormFieldWrapper>
|
||||
);
|
||||
case PropertyType.MULTI_SELECT_DROPDOWN:
|
||||
case PropertyType.DROPDOWN:
|
||||
return (
|
||||
<AutoFormFieldWrapper
|
||||
inputName={inputName}
|
||||
property={property}
|
||||
propertyName={propertyName}
|
||||
field={field}
|
||||
disabled={disabled}
|
||||
allowDynamicValues={allowDynamicValues}
|
||||
dynamicInputModeToggled={dynamicInputModeToggled}
|
||||
>
|
||||
<DynamicDropdownPieceProperty
|
||||
refreshers={property.refreshers}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
disabled={disabled}
|
||||
propertyName={propertyName}
|
||||
multiple={property.type === PropertyType.MULTI_SELECT_DROPDOWN}
|
||||
showDeselect={!property.required}
|
||||
shouldRefreshOnSearch={property.refreshOnSearch ?? false}
|
||||
></DynamicDropdownPieceProperty>
|
||||
</AutoFormFieldWrapper>
|
||||
);
|
||||
case PropertyType.DATE_TIME:
|
||||
case PropertyType.SHORT_TEXT:
|
||||
case PropertyType.LONG_TEXT:
|
||||
case PropertyType.FILE:
|
||||
case PropertyType.NUMBER:
|
||||
case PropertyType.SECRET_TEXT:
|
||||
return (
|
||||
<AutoFormFieldWrapper
|
||||
property={property}
|
||||
inputName={inputName}
|
||||
field={field}
|
||||
propertyName={propertyName}
|
||||
disabled={disabled}
|
||||
allowDynamicValues={false}
|
||||
dynamicInputModeToggled={dynamicInputModeToggled}
|
||||
>
|
||||
{useMentionTextInput ? (
|
||||
<TextInputWithMentions
|
||||
disabled={disabled}
|
||||
initialValue={field.value}
|
||||
onChange={field.onChange}
|
||||
></TextInputWithMentions>
|
||||
) : (
|
||||
<Input
|
||||
ref={field.ref}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
disabled={disabled}
|
||||
type={
|
||||
property.type === PropertyType.SECRET_TEXT ? 'password' : 'text'
|
||||
}
|
||||
></Input>
|
||||
)}
|
||||
</AutoFormFieldWrapper>
|
||||
);
|
||||
case PropertyType.DYNAMIC:
|
||||
return (
|
||||
<DynamicProperties
|
||||
refreshers={property.refreshers}
|
||||
propertyName={propertyName}
|
||||
disabled={disabled}
|
||||
></DynamicProperties>
|
||||
);
|
||||
case PropertyType.CUSTOM_AUTH:
|
||||
case PropertyType.BASIC_AUTH:
|
||||
case PropertyType.OAUTH2:
|
||||
return <></>;
|
||||
case PropertyType.CUSTOM:
|
||||
return (
|
||||
<CustomProperty
|
||||
code={property.code}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
disabled={disabled}
|
||||
property={property}
|
||||
></CustomProperty>
|
||||
);
|
||||
case PropertyType.COLOR:
|
||||
return (
|
||||
<AutoFormFieldWrapper
|
||||
property={property}
|
||||
inputName={inputName}
|
||||
propertyName={propertyName}
|
||||
field={field}
|
||||
disabled={disabled}
|
||||
allowDynamicValues={allowDynamicValues}
|
||||
dynamicInputModeToggled={dynamicInputModeToggled}
|
||||
>
|
||||
<ColorPicker value={field.value} onChange={field.onChange} />
|
||||
</AutoFormFieldWrapper>
|
||||
);
|
||||
}
|
||||
};
|
||||
AutoPropertiesFormComponent.displayName = 'AutoFormComponent';
|
||||
export { AutoPropertiesFormComponent };
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ControllerRenderProps } from 'react-hook-form';
|
||||
|
||||
import { useBuilderStateContext } from '@/app/builder/builder-hooks';
|
||||
import { textMentionUtils } from '@/app/builder/piece-properties/text-input-with-mentions/text-input-utils';
|
||||
import { JsonEditor } from '@/components/custom/json-editor';
|
||||
|
||||
interface BuilderJsonEditorWrapperProps {
|
||||
field: ControllerRenderProps<Record<string, any>, string>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const BuilderJsonEditorWrapper = ({
|
||||
field,
|
||||
disabled,
|
||||
}: BuilderJsonEditorWrapperProps) => {
|
||||
const [setInsertStateHandler] = useBuilderStateContext((state) => [
|
||||
state.setInsertMentionHandler,
|
||||
]);
|
||||
|
||||
return (
|
||||
<JsonEditor
|
||||
field={field}
|
||||
readonly={disabled ?? false}
|
||||
onFocus={(ref) => {
|
||||
setInsertStateHandler((propertyPath) => {
|
||||
ref.current?.view?.dispatch({
|
||||
changes: {
|
||||
from: ref.current.view.state.selection.main.head,
|
||||
insert: `{{${propertyPath}}}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
}}
|
||||
className={textMentionUtils.inputWithMentionsCssClass}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
BuilderJsonEditorWrapper.displayName = 'BuilderJsonEditorWrapper';
|
||||
export { BuilderJsonEditorWrapper };
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useId } from 'react';
|
||||
|
||||
import { useEmbedding } from '@/components/embed-provider';
|
||||
import { projectHooks } from '@/hooks/project-hooks';
|
||||
import { CustomProperty as CustomPropertyType } from '@activepieces/pieces-framework';
|
||||
const CUSTOM_PROPERTY_CONTAINER_ID = 'custom-property-container';
|
||||
|
||||
type CustomPropertyParams = {
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
code: string;
|
||||
disabled: boolean;
|
||||
property: CustomPropertyType<boolean>;
|
||||
};
|
||||
|
||||
const parseFunctionString = (code: string) => {
|
||||
return new Function(
|
||||
'params',
|
||||
`
|
||||
return (${code})(params);
|
||||
`,
|
||||
);
|
||||
};
|
||||
const CustomProperty = ({
|
||||
value,
|
||||
onChange,
|
||||
code,
|
||||
disabled,
|
||||
property,
|
||||
}: CustomPropertyParams) => {
|
||||
const { project } = projectHooks.useCurrentProject();
|
||||
const { embedState } = useEmbedding();
|
||||
const id = useId();
|
||||
const containerId = CUSTOM_PROPERTY_CONTAINER_ID + '-' + id;
|
||||
useEffect(() => {
|
||||
try {
|
||||
const params = {
|
||||
containerId,
|
||||
value,
|
||||
onChange,
|
||||
isEmbedded: embedState.isEmbedded,
|
||||
projectId: project.id,
|
||||
disabled,
|
||||
property,
|
||||
};
|
||||
// Create function that takes a params object
|
||||
const fn = parseFunctionString(code);
|
||||
// Execute the function with args as the params object
|
||||
const cleanUpFunction = fn(params);
|
||||
if (cleanUpFunction && typeof cleanUpFunction === 'function') {
|
||||
return cleanUpFunction;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error executing custom code:', error);
|
||||
}
|
||||
}, []);
|
||||
return <div id={containerId}></div>;
|
||||
};
|
||||
|
||||
CustomProperty.displayName = 'CustomProperty';
|
||||
export default CustomProperty;
|
||||
@@ -0,0 +1,155 @@
|
||||
import { t } from 'i18next';
|
||||
import { Plus, TrashIcon } from 'lucide-react';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { TextWithIcon } from '@/components/ui/text-with-icon';
|
||||
|
||||
import { TextInputWithMentions } from './text-input-with-mentions';
|
||||
|
||||
type DictionaryInputItem = {
|
||||
key: string;
|
||||
value: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
type DictionaryInputProps = {
|
||||
values: Record<string, string> | undefined;
|
||||
onChange: (values: Record<string, string>) => void;
|
||||
disabled?: boolean;
|
||||
useMentionTextInput?: boolean;
|
||||
};
|
||||
|
||||
export const DictionaryProperty = ({
|
||||
values,
|
||||
onChange,
|
||||
disabled,
|
||||
useMentionTextInput,
|
||||
}: DictionaryInputProps) => {
|
||||
const id = useRef(1);
|
||||
const valuesArray = Object.entries(values ?? {}).map((el) => {
|
||||
id.current++;
|
||||
return {
|
||||
key: el[0],
|
||||
value: el[1],
|
||||
id: `${id.current}`,
|
||||
};
|
||||
});
|
||||
const valuesArrayRef = useRef(valuesArray);
|
||||
// To allow keys that have the same prefix to be added in any order
|
||||
const valuesArrayRefUnique = valuesArrayRef.current
|
||||
.toReversed()
|
||||
.filter(
|
||||
(el, index, self) => self.findIndex((t) => t.key === el.key) === index,
|
||||
)
|
||||
.toReversed();
|
||||
const haveValuesChangedFromOutside =
|
||||
valuesArrayRefUnique.length !== valuesArray.length ||
|
||||
valuesArray.reduce((acc, _, index) => {
|
||||
return (
|
||||
acc ||
|
||||
valuesArrayRefUnique[index].key !== valuesArray[index].key ||
|
||||
valuesArrayRefUnique[index].value !== valuesArray[index].value
|
||||
);
|
||||
}, false);
|
||||
|
||||
if (haveValuesChangedFromOutside) {
|
||||
valuesArrayRef.current = valuesArray;
|
||||
}
|
||||
|
||||
const remove = (index: number) => {
|
||||
const newValues = valuesArrayRef.current.filter((_, i) => i !== index);
|
||||
valuesArrayRef.current = newValues;
|
||||
updateValue(newValues);
|
||||
};
|
||||
const add = () => {
|
||||
id.current++;
|
||||
const newValues = [
|
||||
...valuesArrayRef.current,
|
||||
{ key: '', value: '', id: `${id.current}` },
|
||||
];
|
||||
valuesArrayRef.current = newValues;
|
||||
updateValue(newValues);
|
||||
};
|
||||
|
||||
const onChangeValue = (
|
||||
index: number,
|
||||
value: string | undefined,
|
||||
key: string | undefined,
|
||||
) => {
|
||||
const newValues = [...valuesArrayRef.current];
|
||||
if (value !== undefined) {
|
||||
newValues[index].value = value;
|
||||
}
|
||||
if (key !== undefined) {
|
||||
newValues[index].key = key;
|
||||
}
|
||||
valuesArrayRef.current = newValues;
|
||||
updateValue(newValues);
|
||||
};
|
||||
|
||||
const updateValue = (items: DictionaryInputItem[]) => {
|
||||
onChange(
|
||||
items.reduce((acc, current) => {
|
||||
return { ...acc, [current.key]: current.value };
|
||||
}, {}),
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{valuesArrayRef.current.map(({ key, value, id }, index) => (
|
||||
<div
|
||||
key={'dictionary-input-' + id}
|
||||
className="flex items-center gap-3 items-center"
|
||||
>
|
||||
<Input
|
||||
value={key}
|
||||
disabled={disabled}
|
||||
className="basis-[50%] h-full max-w-[50%] h-[38px]"
|
||||
onChange={(e) => onChangeValue(index, undefined, e.target.value)}
|
||||
/>
|
||||
<div className="basis-[50%] max-w-[50%]">
|
||||
{useMentionTextInput ? (
|
||||
<TextInputWithMentions
|
||||
initialValue={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChangeValue(index, e, undefined)}
|
||||
></TextInputWithMentions>
|
||||
) : (
|
||||
<Input
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
className="h-full"
|
||||
onChange={(e) =>
|
||||
onChangeValue(index, e.target.value, undefined)
|
||||
}
|
||||
></Input>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-8 shrink-0"
|
||||
disabled={disabled}
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<TrashIcon className="size-4 text-destructive" aria-hidden="true" />
|
||||
<span className="sr-only">{t('Remove')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={add}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
>
|
||||
<TextWithIcon icon={<Plus size={18} />} text={t('Add Item')} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
import deepEqual from 'deep-equal';
|
||||
import { t } from 'i18next';
|
||||
import React, { useContext, useEffect, useRef, useState } from 'react';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { useBuilderStateContext } from '@/app/builder/builder-hooks';
|
||||
import { SearchableSelect } from '@/components/custom/searchable-select';
|
||||
import { piecesHooks } from '@/features/pieces/lib/pieces-hooks';
|
||||
import { DropdownState, PropertyType } from '@activepieces/pieces-framework';
|
||||
import { FlowAction, isNil, FlowTrigger } from '@activepieces/shared';
|
||||
|
||||
import { MultiSelectPieceProperty } from '../../../components/custom/multi-select-piece-property';
|
||||
|
||||
import { DynamicPropertiesErrorBoundary } from './dynamic-piece-properties-error-boundary';
|
||||
import { DynamicPropertiesContext } from './dynamic-properties-context';
|
||||
|
||||
type SelectPiecePropertyProps = {
|
||||
refreshers: string[];
|
||||
propertyName: string;
|
||||
value?: unknown;
|
||||
multiple?: boolean;
|
||||
disabled: boolean;
|
||||
onChange: (value: unknown | undefined) => void;
|
||||
showDeselect?: boolean;
|
||||
shouldRefreshOnSearch?: boolean;
|
||||
};
|
||||
const DynamicDropdownPiecePropertyImplementation = React.memo(
|
||||
(props: SelectPiecePropertyProps) => {
|
||||
const [flowVersion, readonly] = useBuilderStateContext((state) => [
|
||||
state.flowVersion,
|
||||
state.readonly,
|
||||
]);
|
||||
const form = useFormContext<FlowAction | FlowTrigger>();
|
||||
const isFirstRender = useRef(true);
|
||||
const previousValues = useRef<undefined | unknown[]>(undefined);
|
||||
const firstDropdownState = useRef<DropdownState<unknown> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const newRefreshers = [...props.refreshers, 'auth'];
|
||||
const [dropdownState, setDropdownState] = useState<DropdownState<unknown>>({
|
||||
disabled: false,
|
||||
placeholder: t('Select an option'),
|
||||
options: [],
|
||||
});
|
||||
const { propertyLoadingFinished, propertyLoadingStarted } = useContext(
|
||||
DynamicPropertiesContext,
|
||||
);
|
||||
const { mutate, isPending, error } = piecesHooks.usePieceOptions<
|
||||
PropertyType.DROPDOWN | PropertyType.MULTI_SELECT_DROPDOWN
|
||||
>({
|
||||
onMutate: () => {
|
||||
propertyLoadingStarted(props.propertyName);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
propertyLoadingFinished(props.propertyName);
|
||||
},
|
||||
onSuccess: () => {
|
||||
propertyLoadingFinished(props.propertyName);
|
||||
},
|
||||
});
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
const refresherValues = newRefreshers.map((refresher) =>
|
||||
useWatch({
|
||||
name: `settings.input.${refresher}` as const,
|
||||
control: form.control,
|
||||
}),
|
||||
);
|
||||
/* eslint-enable react-hooks/rules-of-hooks */
|
||||
const refresh = (term?: string) => {
|
||||
const input: Record<string, unknown> = {};
|
||||
newRefreshers.forEach((refresher, index) => {
|
||||
input[refresher] = refresherValues[index];
|
||||
});
|
||||
const { settings } = form.getValues();
|
||||
const actionOrTriggerName = settings.actionName ?? settings.triggerName;
|
||||
const { pieceName, pieceVersion } = settings;
|
||||
mutate(
|
||||
{
|
||||
request: {
|
||||
pieceName,
|
||||
pieceVersion,
|
||||
propertyName: props.propertyName,
|
||||
actionOrTriggerName: actionOrTriggerName,
|
||||
input,
|
||||
flowVersionId: flowVersion.id,
|
||||
flowId: flowVersion.flowId,
|
||||
searchValue: term,
|
||||
},
|
||||
propertyType: PropertyType.DROPDOWN,
|
||||
},
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
if (!firstDropdownState.current) {
|
||||
firstDropdownState.current = response.options;
|
||||
}
|
||||
setDropdownState(response.options);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isFirstRender.current &&
|
||||
!deepEqual(previousValues.current, refresherValues)
|
||||
) {
|
||||
props.onChange(null);
|
||||
}
|
||||
|
||||
previousValues.current = refresherValues;
|
||||
isFirstRender.current = false;
|
||||
refresh();
|
||||
}, refresherValues);
|
||||
|
||||
const selectOptions = dropdownState.options.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
}));
|
||||
const isDisabled = dropdownState.disabled || props.disabled;
|
||||
return props.multiple ? (
|
||||
<MultiSelectPieceProperty
|
||||
placeholder={dropdownState.placeholder ?? t('Select an option')}
|
||||
options={selectOptions}
|
||||
loading={isPending}
|
||||
onChange={(value) => props.onChange(value)}
|
||||
disabled={isDisabled}
|
||||
initialValues={props.value as unknown[]}
|
||||
showDeselect={
|
||||
props.showDeselect &&
|
||||
!isNil(props.value) &&
|
||||
Array.isArray(props.value) &&
|
||||
props.value.length > 0 &&
|
||||
!isDisabled
|
||||
}
|
||||
showRefresh={!isPending && !readonly}
|
||||
onRefresh={refresh}
|
||||
refreshOnSearch={props.shouldRefreshOnSearch ? refresh : undefined}
|
||||
cachedOptions={firstDropdownState.current?.options ?? []}
|
||||
/>
|
||||
) : (
|
||||
<SearchableSelect
|
||||
options={selectOptions}
|
||||
disabled={dropdownState.disabled || props.disabled}
|
||||
loading={isPending}
|
||||
placeholder={dropdownState.placeholder ?? t('Select an option')}
|
||||
value={props.value}
|
||||
onChange={(value) => props.onChange(value)}
|
||||
showDeselect={
|
||||
props.showDeselect && !isNil(props.value) && !props.disabled
|
||||
}
|
||||
onRefresh={refresh}
|
||||
showRefresh={!isPending && !readonly}
|
||||
refreshOnSearch={props.shouldRefreshOnSearch ? refresh : undefined}
|
||||
cachedOptions={firstDropdownState.current?.options ?? []}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const DynamicDropdownPieceProperty = React.memo(
|
||||
(props: SelectPiecePropertyProps) => {
|
||||
return (
|
||||
<DynamicPropertiesErrorBoundary>
|
||||
<DynamicDropdownPiecePropertyImplementation {...props} />
|
||||
</DynamicPropertiesErrorBoundary>
|
||||
);
|
||||
},
|
||||
);
|
||||
DynamicDropdownPieceProperty.displayName = 'DynamicDropdownPieceProperty';
|
||||
DynamicDropdownPiecePropertyImplementation.displayName =
|
||||
'DynamicDropdownPiecePropertyImplementation';
|
||||
export { DynamicDropdownPieceProperty };
|
||||
@@ -0,0 +1,54 @@
|
||||
import { t } from 'i18next';
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const DynamicPropertiesErrorBoundary = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const [key, setKey] = useState(Date.now());
|
||||
const triedRerenderingRef = useRef(false);
|
||||
return (
|
||||
<ErrorBoundary
|
||||
key={key}
|
||||
fallback={
|
||||
!triedRerenderingRef.current ? (
|
||||
<div className="text-sm text-red-500 italic flex justify-between items-center">
|
||||
{t('Unexpected error, please retry')}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setKey(Date.now());
|
||||
triedRerenderingRef.current = true;
|
||||
}}
|
||||
>
|
||||
{<RefreshCcw className="w-4 h-4 text-foreground!"></RefreshCcw>}{' '}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-red-500 italic flex justify-between items-center">
|
||||
{t('Unexpected error, please refresh the page or contact support')}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
{<RefreshCcw className="w-4 h-4 text-foreground!"></RefreshCcw>}{' '}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
DynamicPropertiesErrorBoundary.displayName = 'DynamicPropertiesErrorBoundary';
|
||||
export { DynamicPropertiesErrorBoundary };
|
||||
@@ -0,0 +1,204 @@
|
||||
import deepEqual from 'deep-equal';
|
||||
import React, { useState, useRef, useContext } from 'react';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { useDeepCompareEffectNoCheck } from 'use-deep-compare-effect';
|
||||
|
||||
import { useBuilderStateContext } from '@/app/builder/builder-hooks';
|
||||
import { SkeletonList } from '@/components/ui/skeleton';
|
||||
import { formUtils } from '@/features/pieces/lib/form-utils';
|
||||
import { piecesHooks } from '@/features/pieces/lib/pieces-hooks';
|
||||
import { PiecePropertyMap, PropertyType } from '@activepieces/pieces-framework';
|
||||
import {
|
||||
FlowAction,
|
||||
FlowTrigger,
|
||||
PropertyExecutionType,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { useStepSettingsContext } from '../step-settings/step-settings-context';
|
||||
|
||||
import { AutoPropertiesFormComponent } from './auto-properties-form';
|
||||
import { DynamicPropertiesErrorBoundary } from './dynamic-piece-properties-error-boundary';
|
||||
import { DynamicPropertiesContext } from './dynamic-properties-context';
|
||||
type DynamicPropertiesProps = {
|
||||
refreshers: string[];
|
||||
propertyName: string;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
const removeOptionsFromDropdownPropertiesSchema = (
|
||||
schema: PiecePropertyMap,
|
||||
) => {
|
||||
return Object.fromEntries(
|
||||
Object.entries(schema).map(([key, value]) => {
|
||||
if (
|
||||
value.type === PropertyType.STATIC_DROPDOWN ||
|
||||
value.type === PropertyType.STATIC_MULTI_SELECT_DROPDOWN
|
||||
) {
|
||||
return [key, { ...value, options: { disabled: false, options: [] } }];
|
||||
}
|
||||
return [key, value];
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const DynamicPropertiesImplementation = React.memo(
|
||||
(props: DynamicPropertiesProps) => {
|
||||
const [flowVersion, readonly] = useBuilderStateContext((state) => [
|
||||
state.flowVersion,
|
||||
state.readonly,
|
||||
]);
|
||||
const form = useFormContext<FlowAction | FlowTrigger>();
|
||||
const { updateFormSchema } = useStepSettingsContext();
|
||||
const allInputValues = useWatch({
|
||||
name: `settings.input`,
|
||||
control: form.control,
|
||||
});
|
||||
const refreshersPropertiesNames = [...props.refreshers, 'auth'];
|
||||
const refresherValues = refreshersPropertiesNames.reduce<
|
||||
Record<string, unknown>
|
||||
>((acc, refresher) => {
|
||||
acc[refresher] = allInputValues[refresher];
|
||||
return acc;
|
||||
}, {});
|
||||
const previousValues = useRef<Record<string, unknown>>(refresherValues);
|
||||
const { propertyLoadingFinished, propertyLoadingStarted } = useContext(
|
||||
DynamicPropertiesContext,
|
||||
);
|
||||
const [propertyMap, setPropertyMap] = useState<
|
||||
PiecePropertyMap | undefined
|
||||
>(undefined);
|
||||
|
||||
const { mutate, isPending } =
|
||||
piecesHooks.usePieceOptions<PropertyType.DYNAMIC>({
|
||||
onMutate: () => {
|
||||
propertyLoadingStarted(props.propertyName);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
propertyLoadingFinished(props.propertyName);
|
||||
},
|
||||
onSuccess: () => {
|
||||
propertyLoadingFinished(props.propertyName);
|
||||
},
|
||||
});
|
||||
|
||||
useDeepCompareEffectNoCheck(() => {
|
||||
if (!deepEqual(previousValues.current, refresherValues)) {
|
||||
// the field state won't be cleared if you only unset the parent prop value
|
||||
if (propertyMap) {
|
||||
Object.keys(propertyMap).forEach((childPropName) => {
|
||||
form.setValue(
|
||||
`settings.input.${props.propertyName}.${childPropName}` as const,
|
||||
null,
|
||||
{
|
||||
//never validate for each prop, it can be a long list of props and cause the browser to freeze
|
||||
shouldValidate: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
form.setValue(`settings.input.${props.propertyName}` as const, null, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
|
||||
previousValues.current = refresherValues;
|
||||
const { settings } = form.getValues();
|
||||
const actionOrTriggerName = settings.actionName ?? settings.triggerName;
|
||||
const { pieceName, pieceVersion } = settings;
|
||||
mutate(
|
||||
{
|
||||
request: {
|
||||
pieceName,
|
||||
pieceVersion,
|
||||
propertyName: props.propertyName,
|
||||
actionOrTriggerName: actionOrTriggerName,
|
||||
input: refresherValues,
|
||||
flowVersionId: flowVersion.id,
|
||||
flowId: flowVersion.flowId,
|
||||
},
|
||||
propertyType: PropertyType.DYNAMIC,
|
||||
},
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
const currentValue = form.getValues(
|
||||
`settings.input.${props.propertyName}`,
|
||||
);
|
||||
const defaultValue = formUtils.getDefaultValueForProperties({
|
||||
props: response.options,
|
||||
existingInput: currentValue ?? {},
|
||||
propertySettings:
|
||||
form.getValues().settings?.propertySettings?.[
|
||||
props.propertyName
|
||||
],
|
||||
});
|
||||
setPropertyMap(response.options);
|
||||
const schemaWithoutDropdownOptions =
|
||||
removeOptionsFromDropdownPropertiesSchema(response.options);
|
||||
updateFormSchema(
|
||||
`settings.input.${props.propertyName}`,
|
||||
schemaWithoutDropdownOptions,
|
||||
);
|
||||
|
||||
if (!readonly) {
|
||||
// previously the schema didn't have this property, so we need to set it
|
||||
// we can't always set it to MANUAL, because some sub properties might be dynamic and have the same name as the dynamic property
|
||||
// which will override the sub property exectuion type
|
||||
if (
|
||||
!form.getValues().settings?.propertySettings?.[
|
||||
props.propertyName
|
||||
]
|
||||
) {
|
||||
form.setValue(
|
||||
`settings.propertySettings.${props.propertyName}.type`,
|
||||
PropertyExecutionType.MANUAL as unknown,
|
||||
);
|
||||
}
|
||||
form.setValue(
|
||||
`settings.propertySettings.${props.propertyName}.schema`,
|
||||
schemaWithoutDropdownOptions,
|
||||
);
|
||||
}
|
||||
|
||||
form.setValue(
|
||||
`settings.input.${props.propertyName}`,
|
||||
defaultValue,
|
||||
{
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [refresherValues]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isPending && (
|
||||
<SkeletonList numberOfItems={3} className="h-7"></SkeletonList>
|
||||
)}
|
||||
{!isPending && propertyMap && (
|
||||
<AutoPropertiesFormComponent
|
||||
prefixValue={`settings.input.${props.propertyName}`}
|
||||
props={propertyMap}
|
||||
useMentionTextInput={true}
|
||||
disabled={props.disabled}
|
||||
allowDynamicValues={true}
|
||||
></AutoPropertiesFormComponent>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const DynamicProperties = React.memo((props: DynamicPropertiesProps) => {
|
||||
return (
|
||||
<DynamicPropertiesErrorBoundary>
|
||||
<DynamicPropertiesImplementation {...props} />
|
||||
</DynamicPropertiesErrorBoundary>
|
||||
);
|
||||
});
|
||||
DynamicPropertiesImplementation.displayName = 'DynamicPropertiesImplementation';
|
||||
DynamicProperties.displayName = 'DynamicProperties';
|
||||
export { DynamicProperties };
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createContext, useState, useCallback, useMemo } from 'react';
|
||||
|
||||
export const DynamicPropertiesContext = createContext<{
|
||||
propertiesNamesStillLoading: string[];
|
||||
propertyLoadingFinished: (propertyName: string) => void;
|
||||
propertyLoadingStarted: (propertyName: string) => void;
|
||||
isLoadingDynamicProperties: boolean;
|
||||
}>({
|
||||
propertiesNamesStillLoading: [],
|
||||
propertyLoadingFinished: (propertyName: string) => {},
|
||||
propertyLoadingStarted: (propertyName: string) => {},
|
||||
isLoadingDynamicProperties: false,
|
||||
});
|
||||
|
||||
export const DynamicPropertiesProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const [propertiesNamesStillLoading, setPropertiesNamesStillLoading] =
|
||||
useState<string[]>([]);
|
||||
|
||||
const propertyLoadingFinished = useCallback((propertyName: string) => {
|
||||
setPropertiesNamesStillLoading((prev) =>
|
||||
prev.filter((name) => name !== propertyName),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const propertyLoadingStarted = useCallback((propertyName: string) => {
|
||||
setPropertiesNamesStillLoading((prev) => [...prev, propertyName]);
|
||||
}, []);
|
||||
|
||||
const isLoadingDynamicProperties = useMemo(
|
||||
() => propertiesNamesStillLoading.length > 0,
|
||||
[propertiesNamesStillLoading],
|
||||
);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
propertiesNamesStillLoading,
|
||||
propertyLoadingFinished,
|
||||
propertyLoadingStarted,
|
||||
isLoadingDynamicProperties,
|
||||
}),
|
||||
[
|
||||
propertiesNamesStillLoading,
|
||||
propertyLoadingFinished,
|
||||
propertyLoadingStarted,
|
||||
isLoadingDynamicProperties,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<DynamicPropertiesContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</DynamicPropertiesContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import Document from '@tiptap/extension-document';
|
||||
import HardBreak from '@tiptap/extension-hard-break';
|
||||
import History from '@tiptap/extension-history';
|
||||
import Mention, { MentionNodeAttrs } from '@tiptap/extension-mention';
|
||||
import Paragraph from '@tiptap/extension-paragraph';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import Text from '@tiptap/extension-text';
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
|
||||
import './tip-tap.css';
|
||||
import { stepsHooks } from '@/features/pieces/lib/steps-hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { flowStructureUtil, isNil } from '@activepieces/shared';
|
||||
|
||||
import { useBuilderStateContext } from '../../builder-hooks';
|
||||
|
||||
import { textMentionUtils } from './text-input-utils';
|
||||
|
||||
type TextInputWithMentionsProps = {
|
||||
className?: string;
|
||||
initialValue?: unknown;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
const extensions = (placeholder?: string) => {
|
||||
return [
|
||||
Document,
|
||||
History,
|
||||
HardBreak,
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
Paragraph.configure({
|
||||
HTMLAttributes: {},
|
||||
}),
|
||||
Text,
|
||||
Mention.configure({
|
||||
suggestion: {
|
||||
char: '',
|
||||
},
|
||||
deleteTriggerWithBackspace: true,
|
||||
renderHTML({ node }) {
|
||||
const mentionAttrs: MentionNodeAttrs =
|
||||
node.attrs as unknown as MentionNodeAttrs;
|
||||
return textMentionUtils.generateMentionHtmlElement(mentionAttrs);
|
||||
},
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
function convertToText(value: unknown): string {
|
||||
if (isNil(value)) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value.toString();
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export const TextInputWithMentions = ({
|
||||
className,
|
||||
initialValue,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder,
|
||||
}: TextInputWithMentionsProps) => {
|
||||
const steps = useBuilderStateContext((state) =>
|
||||
flowStructureUtil.getAllSteps(state.flowVersion.trigger),
|
||||
);
|
||||
const stepsMetadata = stepsHooks
|
||||
.useStepsMetadata(steps)
|
||||
.map(({ data: metadata }, index) => {
|
||||
if (metadata) {
|
||||
return {
|
||||
...metadata,
|
||||
stepDisplayName: steps[index].displayName,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const setInsertMentionHandler = useBuilderStateContext(
|
||||
(state) => state.setInsertMentionHandler,
|
||||
);
|
||||
|
||||
const insertMention = (propertyPath: string) => {
|
||||
const mentionNode = textMentionUtils.createMentionNodeFromText(
|
||||
`{{${propertyPath}}}`,
|
||||
steps,
|
||||
stepsMetadata,
|
||||
);
|
||||
editor?.chain().focus().insertContent(mentionNode).run();
|
||||
};
|
||||
const editor = useEditor({
|
||||
editable: !disabled,
|
||||
extensions: extensions(placeholder),
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: textMentionUtils.convertTextToTipTapJsonContent(
|
||||
convertToText(initialValue),
|
||||
steps,
|
||||
stepsMetadata,
|
||||
),
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: cn(
|
||||
className ??
|
||||
' w-full rounded-sm border shadow-xs border-input bg-background px-3 min-h-9 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
textMentionUtils.inputWithMentionsCssClass,
|
||||
{
|
||||
'cursor-not-allowed opacity-50': disabled,
|
||||
},
|
||||
),
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
const editorContent = editor.getJSON();
|
||||
const textResult =
|
||||
textMentionUtils.convertTiptapJsonToText(editorContent);
|
||||
if (onChange) {
|
||||
onChange(textResult);
|
||||
}
|
||||
},
|
||||
onFocus: () => {
|
||||
setInsertMentionHandler(insertMention);
|
||||
},
|
||||
});
|
||||
|
||||
return <EditorContent editor={editor} />;
|
||||
};
|
||||
@@ -0,0 +1,292 @@
|
||||
import { MentionNodeAttrs } from '@tiptap/extension-mention';
|
||||
import { JSONContent } from '@tiptap/react';
|
||||
|
||||
import { StepMetadata } from '@/lib/types';
|
||||
import {
|
||||
FlowAction,
|
||||
FlowTrigger,
|
||||
assertNotNullOrUndefined,
|
||||
isNil,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
const removeQuotes = (text: string) => {
|
||||
if (
|
||||
(text.startsWith('"') && text.endsWith('"')) ||
|
||||
(text.startsWith("'") && text.endsWith("'"))
|
||||
) {
|
||||
return text.slice(1, -1);
|
||||
}
|
||||
return text;
|
||||
};
|
||||
const incrementArrayIndexes = (text: string) => {
|
||||
const numberText = Number(text);
|
||||
if (Number.isNaN(numberText)) {
|
||||
return text;
|
||||
}
|
||||
return `${numberText + 1}`;
|
||||
};
|
||||
|
||||
const keysWithinPath = (path: string) => {
|
||||
return path
|
||||
.split(/\.|\[|\]/)
|
||||
.filter((key) => key && key.trim().length > 0)
|
||||
.map(incrementArrayIndexes)
|
||||
.map(removeQuotes);
|
||||
};
|
||||
|
||||
type ApMentionNodeAttrs = {
|
||||
logoUrl?: string;
|
||||
displayText: string;
|
||||
serverValue: string;
|
||||
};
|
||||
const flattenNestedKeysRegex = /^flattenNestedKeys\((\w+),\s*\[(.*?)\]\)$/;
|
||||
enum TipTapNodeTypes {
|
||||
paragraph = 'paragraph',
|
||||
text = 'text',
|
||||
hardBreak = 'hardBreak',
|
||||
mention = 'mention',
|
||||
}
|
||||
|
||||
const isMentionNodeText = (item: string) => {
|
||||
const itemIsToken = item.match(/^\{\{(.*)\}\}$/);
|
||||
if (itemIsToken) {
|
||||
const content = itemIsToken[1].trim();
|
||||
const itemIsFlattenedArray = content.match(flattenNestedKeysRegex);
|
||||
if (itemIsFlattenedArray) {
|
||||
return true;
|
||||
}
|
||||
return /^(step_\d+|trigger)/.test(content);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
type StepMetadataWithDisplayName = StepMetadata & { stepDisplayName: string };
|
||||
|
||||
function convertTextToTipTapJsonContent(
|
||||
userInputText: string,
|
||||
steps: (FlowAction | FlowTrigger)[],
|
||||
stepsMetadata: (StepMetadataWithDisplayName | undefined)[],
|
||||
): {
|
||||
type: TipTapNodeTypes.paragraph;
|
||||
content: JSONContent[];
|
||||
}[] {
|
||||
const inputSplitToNodesContent = userInputText
|
||||
.split(/(\{\{.*?\}\})/)
|
||||
.map((el) => el.split(new RegExp(`(\n)`)))
|
||||
.flat(1)
|
||||
.filter((el) => el);
|
||||
return inputSplitToNodesContent.reduce(
|
||||
(result, node) => {
|
||||
if (node === '\n') {
|
||||
result.push({
|
||||
type: TipTapNodeTypes.paragraph,
|
||||
content: [],
|
||||
});
|
||||
} else if (isMentionNodeText(node)) {
|
||||
result[result.length - 1].content.push(
|
||||
createMentionNodeFromText(node, steps, stepsMetadata),
|
||||
);
|
||||
} else {
|
||||
result[result.length - 1].content.push({
|
||||
type: TipTapNodeTypes.text,
|
||||
text: node,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
[
|
||||
{
|
||||
content: [],
|
||||
type: TipTapNodeTypes.paragraph,
|
||||
},
|
||||
] as {
|
||||
type: TipTapNodeTypes.paragraph;
|
||||
content: JSONContent[];
|
||||
}[],
|
||||
);
|
||||
}
|
||||
|
||||
function parseFlattenArrayPath(input: string): {
|
||||
isValid: boolean;
|
||||
stepName?: string;
|
||||
arrayPath?: string[];
|
||||
} {
|
||||
const match = input.match(flattenNestedKeysRegex);
|
||||
|
||||
if (!match) {
|
||||
return { isValid: false };
|
||||
}
|
||||
|
||||
const stepName = match[1];
|
||||
const arrayPath = match[2]
|
||||
.split(',')
|
||||
.map((item) => item.trim().replace(/['"]/g, ''));
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
stepName,
|
||||
arrayPath,
|
||||
};
|
||||
}
|
||||
|
||||
const removeIntroplationBrackets = (text: string) => {
|
||||
if (text.startsWith('{{') && text.endsWith('}}')) {
|
||||
return text.slice(2, text.length - 2).trim();
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
function parseStepAndNameFromMention(mention: string) {
|
||||
const mentionWithoutInterpolationBrackets =
|
||||
removeIntroplationBrackets(mention);
|
||||
const { isValid, stepName, arrayPath } = parseFlattenArrayPath(
|
||||
mentionWithoutInterpolationBrackets,
|
||||
);
|
||||
if (isValid) {
|
||||
return {
|
||||
stepName,
|
||||
path: arrayPath ?? [],
|
||||
};
|
||||
}
|
||||
const keys = keysWithinPath(mentionWithoutInterpolationBrackets);
|
||||
if (keys.length === 0) {
|
||||
return {
|
||||
stepName: null,
|
||||
path: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
stepName: keys[0],
|
||||
path: keys.slice(1),
|
||||
};
|
||||
}
|
||||
|
||||
function parseLabelFromMention(
|
||||
mention: string,
|
||||
steps: (FlowAction | FlowTrigger)[],
|
||||
stepsMetadata: (StepMetadataWithDisplayName | undefined)[],
|
||||
) {
|
||||
const { stepName, path } = parseStepAndNameFromMention(mention);
|
||||
const stepIdx = steps.findIndex((step) => step.name === stepName);
|
||||
if (stepIdx < 0) {
|
||||
return {
|
||||
displayText: `(Missing) ${stepName}`,
|
||||
serverValue: mention,
|
||||
logoUrl: '/src/assets/img/custom/incomplete.png',
|
||||
};
|
||||
}
|
||||
const stepMetadata = stepsMetadata[stepIdx];
|
||||
return {
|
||||
displayText: `${stepIdx + 1}. ${
|
||||
stepMetadata?.stepDisplayName ?? ''
|
||||
} ${path.join(' ')}`,
|
||||
serverValue: mention,
|
||||
logoUrl: stepMetadata?.logoUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function createMentionNodeFromText(
|
||||
mention: string,
|
||||
steps: (FlowAction | FlowTrigger)[],
|
||||
stepsMetadata: (StepMetadataWithDisplayName | undefined)[],
|
||||
) {
|
||||
return {
|
||||
type: TipTapNodeTypes.mention,
|
||||
attrs: {
|
||||
id: mention,
|
||||
label: JSON.stringify(
|
||||
parseLabelFromMention(mention, steps, stepsMetadata),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function convertTiptapJsonToText(nodes: JSONContent[]): string {
|
||||
const res = nodes.map((node, index) => {
|
||||
switch (node.type) {
|
||||
case TipTapNodeTypes.hardBreak:
|
||||
return '\n';
|
||||
case TipTapNodeTypes.text: {
|
||||
//replace with a normal space
|
||||
return node.text ? node.text.replaceAll('\u00A0', ' ') : '';
|
||||
}
|
||||
case TipTapNodeTypes.mention: {
|
||||
return node.attrs?.label
|
||||
? JSON.parse(node.attrs.label).serverValue
|
||||
: '';
|
||||
}
|
||||
case TipTapNodeTypes.paragraph: {
|
||||
return `${
|
||||
isNil(node.content) ? '' : convertTiptapJsonToText(node.content)
|
||||
}${index < nodes.length - 1 ? '\n' : ''}`;
|
||||
}
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
return res.join('');
|
||||
}
|
||||
|
||||
const generateMentionHtmlElement = (mentionAttrs: MentionNodeAttrs) => {
|
||||
const mentionElement = document.createElement('span');
|
||||
const apMentionNodeAttrs: ApMentionNodeAttrs = JSON.parse(
|
||||
mentionAttrs.label || '{}',
|
||||
);
|
||||
mentionElement.className =
|
||||
'inline-flex bg-muted/10 break-all my-1 mx-px border border-[#9e9e9e] border-solid items-center gap-2 py-1 px-2 rounded-[3px] text-muted-foreground ';
|
||||
assertNotNullOrUndefined(mentionAttrs.label, 'mentionAttrs.label');
|
||||
assertNotNullOrUndefined(mentionAttrs.id, 'mentionAttrs.id');
|
||||
assertNotNullOrUndefined(
|
||||
apMentionNodeAttrs.displayText,
|
||||
'apMentionNodeAttrs.displayText',
|
||||
);
|
||||
mentionElement.dataset.id = mentionAttrs.id;
|
||||
mentionElement.dataset.label = mentionAttrs.label;
|
||||
mentionElement.dataset.displayText = apMentionNodeAttrs.displayText;
|
||||
mentionElement.dataset.type = TipTapNodeTypes.mention;
|
||||
mentionElement.contentEditable = 'false';
|
||||
|
||||
if (apMentionNodeAttrs.logoUrl) {
|
||||
const imgElement = document.createElement('img');
|
||||
imgElement.src = apMentionNodeAttrs.logoUrl;
|
||||
imgElement.className = 'object-contain w-4 h-4';
|
||||
mentionElement.appendChild(imgElement);
|
||||
} else {
|
||||
const emptyImagePlaceHolder = document.createElement('span');
|
||||
emptyImagePlaceHolder.className = 'h-4 -mr-2';
|
||||
mentionElement.appendChild(emptyImagePlaceHolder);
|
||||
}
|
||||
|
||||
const mentiontextDiv = document.createTextNode(
|
||||
apMentionNodeAttrs.displayText,
|
||||
);
|
||||
mentionElement.setAttribute('serverValue', apMentionNodeAttrs.serverValue);
|
||||
|
||||
mentionElement.appendChild(mentiontextDiv);
|
||||
return mentionElement;
|
||||
};
|
||||
|
||||
const inputWithMentionsCssClass = 'ap-text-with-mentions';
|
||||
const dataSelectorCssClassSelector = 'ap-data-selector';
|
||||
const isDataSelectorOrChildOfDataSelector = (element: HTMLElement) => {
|
||||
return (
|
||||
element.classList.contains(dataSelectorCssClassSelector) ||
|
||||
!isNil(element.closest(`.${dataSelectorCssClassSelector}`))
|
||||
);
|
||||
};
|
||||
export const textMentionUtils = {
|
||||
convertTextToTipTapJsonContent,
|
||||
convertTiptapJsonToText: ({ content }: JSONContent) => {
|
||||
const nodes = content ?? [];
|
||||
const res =
|
||||
nodes.length === 1 && isNil(nodes[0].content)
|
||||
? ''
|
||||
: convertTiptapJsonToText(nodes);
|
||||
return res;
|
||||
},
|
||||
generateMentionHtmlElement,
|
||||
createMentionNodeFromText,
|
||||
inputWithMentionsCssClass,
|
||||
dataSelectorCssClassSelector,
|
||||
isDataSelectorOrChildOfDataSelector,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
.tiptap p.is-editor-empty:first-child::before {
|
||||
color: #adb5bd;
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { t } from 'i18next';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import ActivepiecesCreateTodoGuide from '@/assets/img/custom/ActivepiecesCreateTodoGuide.png';
|
||||
import ActivepiecesTodo from '@/assets/img/custom/ActivepiecesTodo.png';
|
||||
import ExternalChannelTodo from '@/assets/img/custom/External_Channel_Todo.png';
|
||||
import { RadioGroupList } from '@/components/custom/radio-group-list';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useNewWindow } from '@/lib/navigation-utils';
|
||||
import { PieceSelectorOperation, PieceSelectorPieceItem } from '@/lib/types';
|
||||
import { isNil, TodoType } from '@activepieces/shared';
|
||||
|
||||
import { useBuilderStateContext } from '../builder-hooks';
|
||||
|
||||
import {
|
||||
createRouterStep,
|
||||
createTodoStep,
|
||||
createWaitForApprovalStep,
|
||||
} from './custom-piece-selector-items-utils';
|
||||
import GenericActionOrTriggerItem from './generic-piece-selector-item';
|
||||
|
||||
type AddTodoStepDialogProps = {
|
||||
pieceSelectorItem: PieceSelectorPieceItem;
|
||||
operation: PieceSelectorOperation;
|
||||
hidePieceIconAndDescription: boolean;
|
||||
};
|
||||
|
||||
const AddTodoStepDialog = ({
|
||||
operation,
|
||||
pieceSelectorItem,
|
||||
hidePieceIconAndDescription,
|
||||
}: AddTodoStepDialogProps) => {
|
||||
const [todoType, setTodoType] = useState<TodoType>(TodoType.INTERNAL);
|
||||
const [hoveredTodoType, setHoveredTodoType] = useState<TodoType | null>(null);
|
||||
const [handleAddingOrUpdatingStep] = useBuilderStateContext((state) => [
|
||||
state.handleAddingOrUpdatingStep,
|
||||
]);
|
||||
|
||||
const handleAddCreateTodoAction = () => {
|
||||
const todoStepName = createTodoStep({
|
||||
pieceMetadata: pieceSelectorItem.pieceMetadata,
|
||||
operation,
|
||||
todoType,
|
||||
handleAddingOrUpdatingStep,
|
||||
});
|
||||
if (isNil(todoStepName)) {
|
||||
return;
|
||||
}
|
||||
switch (todoType) {
|
||||
case TodoType.INTERNAL: {
|
||||
createRouterStep({
|
||||
parentStepName: todoStepName,
|
||||
logoUrl: pieceSelectorItem.pieceMetadata.logoUrl,
|
||||
handleAddingOrUpdatingStep,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case TodoType.EXTERNAL: {
|
||||
const waitForApprovalStepName = createWaitForApprovalStep({
|
||||
pieceMetadata: pieceSelectorItem.pieceMetadata,
|
||||
parentStepName: todoStepName,
|
||||
handleAddingOrUpdatingStep,
|
||||
});
|
||||
if (!waitForApprovalStepName) {
|
||||
return;
|
||||
}
|
||||
createRouterStep({
|
||||
parentStepName: waitForApprovalStepName,
|
||||
logoUrl: pieceSelectorItem.pieceMetadata.logoUrl,
|
||||
handleAddingOrUpdatingStep,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<GenericActionOrTriggerItem
|
||||
item={pieceSelectorItem}
|
||||
hidePieceIconAndDescription={hidePieceIconAndDescription}
|
||||
stepMetadataWithSuggestions={pieceSelectorItem.pieceMetadata}
|
||||
onClick={() => setOpen(true)}
|
||||
></GenericActionOrTriggerItem>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-6xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle className="text-xl">{t('Create Todo')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-1">
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div className="md:w-1/2 space-y-6">
|
||||
<h3 className="text-lg font-medium">
|
||||
{t('Where would you like the todo to be reviewed?')}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<TodoRadioGroup
|
||||
setTodoType={setTodoType}
|
||||
setHoveredOption={setHoveredTodoType}
|
||||
selectedTodoType={todoType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:w-1/2 flex flex-col items-center justify-center">
|
||||
<PreviewImage todoType={hoveredTodoType || todoType} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0 mt-3 pt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
className="mr-2"
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleAddCreateTodoAction}>
|
||||
{t('Add Steps')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
AddTodoStepDialog.displayName = 'CreateTodoDialog';
|
||||
export { AddTodoStepDialog as CreateTodoDialog };
|
||||
const PreviewImage = ({ todoType }: { todoType: TodoType }) => {
|
||||
const image =
|
||||
todoType === TodoType.INTERNAL
|
||||
? ActivepiecesCreateTodoGuide
|
||||
: ExternalChannelTodo;
|
||||
const alt =
|
||||
todoType === TodoType.INTERNAL ? 'Todos flow' : 'External channel flow';
|
||||
const title =
|
||||
todoType === TodoType.INTERNAL
|
||||
? t('Preview (Activepieces Todos)')
|
||||
: t('Preview (External channel)');
|
||||
const description =
|
||||
todoType === TodoType.INTERNAL
|
||||
? t('Users will manage tasks directly in our interface')
|
||||
: t(
|
||||
'Send notifications with approval links via external channels like Slack, Teams or Email. Best for collaborating with external stakeholders.',
|
||||
);
|
||||
return (
|
||||
<div className="overflow-hidden p-3 w-full h-full">
|
||||
<div className="flex flex-col items-center h-[480px]">
|
||||
<h3 className="text-md font-medium mb-3 text-center">{title}</h3>
|
||||
|
||||
<div className="w-full h-[350px] rounded mb-2 flex items-center justify-center bg-muted/50 relative">
|
||||
<img src={image} alt={alt} className="w-full h-full object-contain" />
|
||||
<div className="absolute -bottom-1 left-0 right-0 h-28 bg-linear-to-t from-white dark:from-background to-transparent"></div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground italic text-center mb-2">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TodoRadioGroup = ({
|
||||
setTodoType,
|
||||
setHoveredOption,
|
||||
selectedTodoType,
|
||||
}: {
|
||||
setTodoType: (todoType: TodoType) => void;
|
||||
setHoveredOption: (todoType: TodoType | null) => void;
|
||||
selectedTodoType: TodoType;
|
||||
}) => {
|
||||
const openNewWindow = useNewWindow();
|
||||
|
||||
return (
|
||||
<RadioGroupList
|
||||
value={selectedTodoType}
|
||||
items={[
|
||||
{
|
||||
label: t('Internal Todos'),
|
||||
value: TodoType.INTERNAL,
|
||||
description: t('Users will manage tasks directly in our interface'),
|
||||
labelExtra: (
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon className="w-4 h-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="w-[550px]">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<span className="text-sm select-none">
|
||||
{t('Users will manage tasks directly in our interface')}
|
||||
</span>{' '}
|
||||
<span
|
||||
className="text-sm text-primary underline cursor-pointer"
|
||||
onClick={() => openNewWindow('/todos')}
|
||||
>
|
||||
{t('here')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted rounded p-1">
|
||||
<img
|
||||
src={ActivepiecesTodo}
|
||||
alt="Todo UI"
|
||||
className="w-full h-auto rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t('External Channel (Slack, Teams, Email, ...)'),
|
||||
value: TodoType.EXTERNAL,
|
||||
description: t(
|
||||
'Send notifications with approval links via external channels like Slack, Teams or Email. Best for collaborating with external stakeholders.',
|
||||
),
|
||||
},
|
||||
]}
|
||||
onChange={setTodoType}
|
||||
onHover={setHoveredOption}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { CardListItem } from '@/components/custom/card-list';
|
||||
import { PieceIcon } from '@/features/pieces/components/piece-icon';
|
||||
import { PieceSelectorItem, StepMetadataWithSuggestions } from '@/lib/types';
|
||||
import { FlowActionType, FlowTriggerType } from '@activepieces/shared';
|
||||
|
||||
type AIActionItemProps = {
|
||||
item: PieceSelectorItem;
|
||||
hidePieceIconAndDescription: boolean;
|
||||
stepMetadataWithSuggestions: StepMetadataWithSuggestions;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const getPieceSelectorItemInfo = (item: PieceSelectorItem) => {
|
||||
if (
|
||||
item.type === FlowActionType.PIECE ||
|
||||
item.type === FlowTriggerType.PIECE
|
||||
) {
|
||||
return {
|
||||
displayName: item.actionOrTrigger.displayName,
|
||||
description: item.actionOrTrigger.description,
|
||||
};
|
||||
}
|
||||
return {
|
||||
displayName: item.displayName,
|
||||
description: item.description,
|
||||
};
|
||||
};
|
||||
|
||||
const AIActionItem = ({
|
||||
item,
|
||||
stepMetadataWithSuggestions,
|
||||
onClick,
|
||||
}: AIActionItemProps) => {
|
||||
const pieceSelectorItemInfo = getPieceSelectorItemInfo(item);
|
||||
|
||||
return (
|
||||
<CardListItem
|
||||
className="p-4 w-full h-full rounded-md flex flex-col justify-between h-[125px]"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-center">
|
||||
<PieceIcon
|
||||
logoUrl={stepMetadataWithSuggestions.logoUrl}
|
||||
displayName={stepMetadataWithSuggestions.displayName}
|
||||
showTooltip={false}
|
||||
size={'lg'}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<div className="text-sm font-medium leading-tight">
|
||||
{pieceSelectorItemInfo.displayName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardListItem>
|
||||
);
|
||||
};
|
||||
|
||||
AIActionItem.displayName = 'AIActionItem';
|
||||
export default AIActionItem;
|
||||
@@ -0,0 +1,114 @@
|
||||
import { t } from 'i18next';
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useTelemetry } from '@/components/telemetry-provider';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { flagsHooks } from '@/hooks/flags-hooks';
|
||||
import {
|
||||
PieceSelectorOperation,
|
||||
StepMetadataWithSuggestions,
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
ApFlagId,
|
||||
FlowActionType,
|
||||
TelemetryEventName,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { usePieceSearchContext } from '../../../../features/pieces/lib/piece-search-context';
|
||||
import { useBuilderStateContext } from '../../builder-hooks';
|
||||
import { convertStepMetadataToPieceSelectorItems } from '../piece-actions-or-triggers-list';
|
||||
|
||||
import AIActionItem from './ai-action';
|
||||
|
||||
type AIPieceActionsListProps = {
|
||||
hidePieceIconAndDescription: boolean;
|
||||
stepMetadataWithSuggestions: StepMetadataWithSuggestions;
|
||||
operation: PieceSelectorOperation;
|
||||
};
|
||||
|
||||
const ACTION_ICON_MAP: Record<string, string> = {
|
||||
run_agent: 'https://cdn.activepieces.com/pieces/agent.png',
|
||||
generateImage: 'https://cdn.activepieces.com/pieces/image-ai.svg',
|
||||
askAi: 'https://cdn.activepieces.com/pieces/text-ai.svg',
|
||||
summarizeText: 'https://cdn.activepieces.com/pieces/text-ai.svg',
|
||||
classifyText: 'https://cdn.activepieces.com/pieces/text-ai.svg',
|
||||
extractStructuredData: 'https://cdn.activepieces.com/pieces/ai-utility.svg',
|
||||
};
|
||||
|
||||
export const AIPieceActionsList: React.FC<AIPieceActionsListProps> = ({
|
||||
stepMetadataWithSuggestions,
|
||||
hidePieceIconAndDescription,
|
||||
operation,
|
||||
}) => {
|
||||
const { capture } = useTelemetry();
|
||||
const { searchQuery } = usePieceSearchContext();
|
||||
const [handleAddingOrUpdatingStep] = useBuilderStateContext((state) => [
|
||||
state.handleAddingOrUpdatingStep,
|
||||
]);
|
||||
const { data: isAgentsConfigured } = flagsHooks.useFlag<boolean>(
|
||||
ApFlagId.AGENTS_CONFIGURED,
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const aiActions = convertStepMetadataToPieceSelectorItems(
|
||||
stepMetadataWithSuggestions,
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full" viewPortClassName="h-full">
|
||||
<div className="grid grid-cols-3 p-2 gap-3 min-w-[350px]">
|
||||
{aiActions.map((item, index) => {
|
||||
const actionIcon =
|
||||
item.type === FlowActionType.PIECE
|
||||
? ACTION_ICON_MAP[item.actionOrTrigger.name]
|
||||
: 'https://cdn.activepieces.com/pieces/image-ai.svg';
|
||||
return (
|
||||
<AIActionItem
|
||||
key={index}
|
||||
item={item}
|
||||
hidePieceIconAndDescription={hidePieceIconAndDescription}
|
||||
stepMetadataWithSuggestions={{
|
||||
...stepMetadataWithSuggestions,
|
||||
logoUrl: actionIcon,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!isAgentsConfigured) {
|
||||
toast('Connect to OpenAI', {
|
||||
description: t(
|
||||
"To create an agent, you'll first need to connect to OpenAI in platform settings.",
|
||||
),
|
||||
action: {
|
||||
label: 'Set Up',
|
||||
onClick: () => {
|
||||
navigate('/platform/setup/ai');
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.type === FlowActionType.PIECE) {
|
||||
capture({
|
||||
name: TelemetryEventName.PIECE_SELECTOR_SEARCH,
|
||||
payload: {
|
||||
search: searchQuery,
|
||||
isTrigger: false,
|
||||
selectedActionOrTriggerName: item.actionOrTrigger.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
handleAddingOrUpdatingStep({
|
||||
pieceSelectorItem: item,
|
||||
operation,
|
||||
selectStepAfter: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { CardListItemSkeleton } from '@/components/custom/card-list';
|
||||
import {
|
||||
PieceSelectorTabType,
|
||||
usePieceSelectorTabs,
|
||||
} from '@/features/pieces/lib/piece-selector-tabs-provider';
|
||||
import { piecesHooks } from '@/features/pieces/lib/pieces-hooks';
|
||||
import { stepUtils } from '@/features/pieces/lib/step-utils';
|
||||
import { PieceSelectorOperation } from '@/lib/types';
|
||||
import { FlowOperationType, isNil } from '@activepieces/shared';
|
||||
|
||||
import { AIPieceActionsList } from './ai-actions-list';
|
||||
|
||||
const AITabContent = ({ operation }: { operation: PieceSelectorOperation }) => {
|
||||
const { selectedTab } = usePieceSelectorTabs();
|
||||
const { pieceModel, isLoading } = piecesHooks.usePiece({
|
||||
name: '@activepieces/piece-ai',
|
||||
});
|
||||
|
||||
if (
|
||||
selectedTab !== PieceSelectorTabType.AI_AND_AGENTS ||
|
||||
operation.type !== FlowOperationType.ADD_ACTION
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isLoading || isNil(pieceModel)) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<CardListItemSkeleton numberOfCards={2} withCircle={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const metadata = stepUtils.mapPieceToMetadata({
|
||||
piece: pieceModel,
|
||||
type: 'action',
|
||||
});
|
||||
|
||||
const pieceMetadataWithSuggestion = {
|
||||
...metadata,
|
||||
suggestedActions: Object.values(pieceModel?.actions),
|
||||
suggestedTriggers: Object.values(pieceModel.triggers),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<AIPieceActionsList
|
||||
stepMetadataWithSuggestions={pieceMetadataWithSuggestion}
|
||||
hidePieceIconAndDescription={false}
|
||||
operation={operation}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { AITabContent };
|
||||
@@ -0,0 +1,210 @@
|
||||
import { t } from 'i18next';
|
||||
|
||||
import { internalErrorToast } from '@/components/ui/sonner';
|
||||
import { pieceSelectorUtils } from '@/features/pieces/lib/piece-selector-utils';
|
||||
import {
|
||||
CORE_STEP_METADATA,
|
||||
TODO_ACTIONS,
|
||||
} from '@/features/pieces/lib/step-utils';
|
||||
import {
|
||||
PieceSelectorItem,
|
||||
PieceSelectorOperation,
|
||||
PieceSelectorPieceItem,
|
||||
PieceStepMetadataWithSuggestions,
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
FlowActionType,
|
||||
BranchExecutionType,
|
||||
BranchOperator,
|
||||
FlowOperationType,
|
||||
isNil,
|
||||
RouterActionSettings,
|
||||
RouterExecutionType,
|
||||
StepLocationRelativeToParent,
|
||||
TodoType,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { BuilderState } from '../builder-hooks';
|
||||
|
||||
const getTodoActionName = (todoType: TodoType) => {
|
||||
switch (todoType) {
|
||||
case TodoType.INTERNAL:
|
||||
return TODO_ACTIONS.createTodoAndWait;
|
||||
case TodoType.EXTERNAL:
|
||||
return TODO_ACTIONS.createTodo;
|
||||
}
|
||||
};
|
||||
|
||||
const getActionFromPieceMetadata = (
|
||||
pieceMetadata: PieceStepMetadataWithSuggestions,
|
||||
actionName: string,
|
||||
) => {
|
||||
const result = pieceMetadata.suggestedActions?.find(
|
||||
(action) => action.name === actionName,
|
||||
);
|
||||
if (isNil(result)) {
|
||||
internalErrorToast();
|
||||
console.error(`Action ${actionName} not found in piece metadata`);
|
||||
return null;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const createRouterStep = ({
|
||||
parentStepName,
|
||||
logoUrl,
|
||||
handleAddingOrUpdatingStep,
|
||||
}: {
|
||||
parentStepName: string;
|
||||
logoUrl: string;
|
||||
handleAddingOrUpdatingStep: BuilderState['handleAddingOrUpdatingStep'];
|
||||
}) => {
|
||||
const routerOnApprovalSettings: RouterActionSettings = {
|
||||
branches: [
|
||||
{
|
||||
conditions: [
|
||||
[
|
||||
{
|
||||
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
|
||||
firstValue: `{{ ${parentStepName}['status'] }}`,
|
||||
secondValue: 'Accepted',
|
||||
caseSensitive: false,
|
||||
},
|
||||
],
|
||||
],
|
||||
branchType: BranchExecutionType.CONDITION,
|
||||
branchName: 'Accepted',
|
||||
},
|
||||
{
|
||||
branchType: BranchExecutionType.FALLBACK,
|
||||
branchName: 'Rejected',
|
||||
},
|
||||
],
|
||||
executionType: RouterExecutionType.EXECUTE_FIRST_MATCH,
|
||||
};
|
||||
return handleAddingOrUpdatingStep({
|
||||
pieceSelectorItem: {
|
||||
...CORE_STEP_METADATA[FlowActionType.ROUTER],
|
||||
displayName: t('Check Todo Status'),
|
||||
},
|
||||
operation: {
|
||||
type: FlowOperationType.ADD_ACTION,
|
||||
actionLocation: {
|
||||
parentStep: parentStepName,
|
||||
stepLocationRelativeToParent: StepLocationRelativeToParent.AFTER,
|
||||
},
|
||||
},
|
||||
selectStepAfter: false,
|
||||
overrideSettings: routerOnApprovalSettings,
|
||||
customLogoUrl: logoUrl,
|
||||
});
|
||||
};
|
||||
|
||||
export const createTodoStep = ({
|
||||
pieceMetadata,
|
||||
operation,
|
||||
todoType,
|
||||
handleAddingOrUpdatingStep,
|
||||
}: {
|
||||
pieceMetadata: PieceStepMetadataWithSuggestions;
|
||||
operation: PieceSelectorOperation;
|
||||
todoType: TodoType;
|
||||
handleAddingOrUpdatingStep: BuilderState['handleAddingOrUpdatingStep'];
|
||||
}) => {
|
||||
const actionName = getTodoActionName(todoType);
|
||||
const createTodoAction = getActionFromPieceMetadata(
|
||||
pieceMetadata,
|
||||
actionName,
|
||||
);
|
||||
if (isNil(createTodoAction)) {
|
||||
return null;
|
||||
}
|
||||
return handleAddingOrUpdatingStep({
|
||||
pieceSelectorItem: {
|
||||
actionOrTrigger: createTodoAction,
|
||||
type: FlowActionType.PIECE,
|
||||
pieceMetadata: pieceMetadata,
|
||||
},
|
||||
operation,
|
||||
selectStepAfter: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const createWaitForApprovalStep = ({
|
||||
pieceMetadata,
|
||||
parentStepName,
|
||||
handleAddingOrUpdatingStep,
|
||||
}: {
|
||||
pieceMetadata: PieceStepMetadataWithSuggestions;
|
||||
parentStepName: string;
|
||||
handleAddingOrUpdatingStep: BuilderState['handleAddingOrUpdatingStep'];
|
||||
}) => {
|
||||
const waitForApprovalAction = getActionFromPieceMetadata(
|
||||
pieceMetadata,
|
||||
TODO_ACTIONS.waitForApproval,
|
||||
);
|
||||
if (isNil(waitForApprovalAction)) {
|
||||
return null;
|
||||
}
|
||||
const pieceSelectorItem: PieceSelectorItem = {
|
||||
actionOrTrigger: waitForApprovalAction,
|
||||
type: FlowActionType.PIECE,
|
||||
pieceMetadata: pieceMetadata,
|
||||
};
|
||||
const waitForApprovalStep = {
|
||||
pieceSelectorItem,
|
||||
operation: {
|
||||
type: FlowOperationType.ADD_ACTION,
|
||||
actionLocation: {
|
||||
parentStep: parentStepName,
|
||||
stepLocationRelativeToParent: StepLocationRelativeToParent.AFTER,
|
||||
},
|
||||
},
|
||||
selectStepAfter: false,
|
||||
} as const;
|
||||
const waitForApprovalStepName =
|
||||
handleAddingOrUpdatingStep(waitForApprovalStep);
|
||||
const defaultValues = pieceSelectorUtils.getDefaultStepValues({
|
||||
stepName: waitForApprovalStepName,
|
||||
pieceSelectorItem: {
|
||||
actionOrTrigger: waitForApprovalAction,
|
||||
type: FlowActionType.PIECE,
|
||||
pieceMetadata: pieceMetadata,
|
||||
},
|
||||
});
|
||||
defaultValues.settings.input.taskId = `{{ ${parentStepName}['id'] }}`;
|
||||
return handleAddingOrUpdatingStep({
|
||||
pieceSelectorItem,
|
||||
operation: {
|
||||
type: FlowOperationType.UPDATE_ACTION,
|
||||
stepName: waitForApprovalStepName,
|
||||
},
|
||||
selectStepAfter: false,
|
||||
overrideSettings: defaultValues.settings,
|
||||
});
|
||||
};
|
||||
|
||||
export const handleAddingOrUpdatingCustomAgentPieceSelectorItem = (
|
||||
agentPieceSelectorItem: PieceSelectorPieceItem,
|
||||
operation: PieceSelectorOperation,
|
||||
handleAddingOrUpdatingStep: BuilderState['handleAddingOrUpdatingStep'],
|
||||
) => {
|
||||
const stepName = handleAddingOrUpdatingStep({
|
||||
pieceSelectorItem: agentPieceSelectorItem,
|
||||
operation,
|
||||
selectStepAfter: true,
|
||||
});
|
||||
const defaultValues = pieceSelectorUtils.getDefaultStepValues({
|
||||
stepName,
|
||||
pieceSelectorItem: agentPieceSelectorItem,
|
||||
});
|
||||
return handleAddingOrUpdatingStep({
|
||||
pieceSelectorItem: agentPieceSelectorItem,
|
||||
operation: {
|
||||
type: FlowOperationType.UPDATE_ACTION,
|
||||
stepName,
|
||||
},
|
||||
selectStepAfter: false,
|
||||
overrideSettings: defaultValues.settings,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
CardListItem,
|
||||
CardListItemSkeleton,
|
||||
} from '@/components/custom/card-list';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { PieceIcon } from '@/features/pieces/components/piece-icon';
|
||||
import {
|
||||
PieceSelectorTabType,
|
||||
usePieceSelectorTabs,
|
||||
} from '@/features/pieces/lib/piece-selector-tabs-provider';
|
||||
import { piecesHooks } from '@/features/pieces/lib/pieces-hooks';
|
||||
import { PieceSelectorOperation } from '@/lib/types';
|
||||
import { FlowOperationType } from '@activepieces/shared';
|
||||
|
||||
import { PieceActionsOrTriggersList } from './piece-actions-or-triggers-list';
|
||||
|
||||
const ExploreTabContent = ({
|
||||
operation,
|
||||
}: {
|
||||
operation: PieceSelectorOperation;
|
||||
}) => {
|
||||
const { selectedTab, selectedPieceInExplore, setSelectedPieceInExplore } =
|
||||
usePieceSelectorTabs();
|
||||
const { data: categories, isLoading: isLoadingPieces } =
|
||||
piecesHooks.usePiecesSearch({
|
||||
shouldCaptureEvent: false,
|
||||
searchQuery: '',
|
||||
type:
|
||||
operation.type === FlowOperationType.UPDATE_TRIGGER
|
||||
? 'trigger'
|
||||
: 'action',
|
||||
});
|
||||
if (selectedTab !== PieceSelectorTabType.EXPLORE) {
|
||||
return null;
|
||||
}
|
||||
if (isLoadingPieces) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<CardListItemSkeleton numberOfCards={2} withCircle={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedPieceInExplore) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<PieceActionsOrTriggersList
|
||||
stepMetadataWithSuggestions={selectedPieceInExplore}
|
||||
hidePieceIconAndDescription={false}
|
||||
operation={operation}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full w-full">
|
||||
<div className="flex p-2 ">
|
||||
{categories.map((category) => (
|
||||
<div key={category.title} className="flex w-[50%] flex-col gap-0.5 ">
|
||||
<div className="text-sm text-muted-foreground mb-1.5">
|
||||
{category.title}
|
||||
</div>
|
||||
|
||||
{category.metadata.map((pieceMetadata) => (
|
||||
<CardListItem
|
||||
className="rounded-sm py-3"
|
||||
key={pieceMetadata.displayName}
|
||||
onClick={() => setSelectedPieceInExplore(pieceMetadata)}
|
||||
>
|
||||
<div className="flex gap-2 items-center h-full">
|
||||
<PieceIcon
|
||||
logoUrl={pieceMetadata.logoUrl}
|
||||
displayName={pieceMetadata.displayName}
|
||||
showTooltip={false}
|
||||
size={'sm'}
|
||||
/>
|
||||
<div className="grow h-full flex items-center justify-left text-sm">
|
||||
{pieceMetadata.displayName}
|
||||
</div>
|
||||
</div>{' '}
|
||||
</CardListItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
export { ExploreTabContent };
|
||||
@@ -0,0 +1,83 @@
|
||||
import { CardListItem } from '@/components/custom/card-list';
|
||||
import { PieceIcon } from '@/features/pieces/components/piece-icon';
|
||||
import { PIECE_SELECTOR_ELEMENTS_HEIGHTS } from '@/features/pieces/lib/piece-selector-utils';
|
||||
import { PieceSelectorItem, StepMetadataWithSuggestions } from '@/lib/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FlowActionType, FlowTriggerType } from '@activepieces/shared';
|
||||
type GenericActionOrTriggerItemProps = {
|
||||
item: PieceSelectorItem;
|
||||
hidePieceIconAndDescription: boolean;
|
||||
stepMetadataWithSuggestions: StepMetadataWithSuggestions;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const getPieceSelectorItemInfo = (item: PieceSelectorItem) => {
|
||||
if (
|
||||
item.type === FlowActionType.PIECE ||
|
||||
item.type === FlowTriggerType.PIECE
|
||||
) {
|
||||
return {
|
||||
displayName: item.actionOrTrigger.displayName,
|
||||
description: item.actionOrTrigger.description,
|
||||
};
|
||||
}
|
||||
return {
|
||||
displayName: item.displayName,
|
||||
description: item.description,
|
||||
};
|
||||
};
|
||||
|
||||
const GenericActionOrTriggerItem = ({
|
||||
item,
|
||||
hidePieceIconAndDescription,
|
||||
stepMetadataWithSuggestions,
|
||||
onClick,
|
||||
}: GenericActionOrTriggerItemProps) => {
|
||||
// we add this style because we hide the piece icon and description when they are in a virtualized list
|
||||
const style = hidePieceIconAndDescription
|
||||
? {
|
||||
height: `${PIECE_SELECTOR_ELEMENTS_HEIGHTS.ACTION_OR_TRIGGER_ITEM_HEIGHT}px`,
|
||||
maxHeight: `${PIECE_SELECTOR_ELEMENTS_HEIGHTS.ACTION_OR_TRIGGER_ITEM_HEIGHT}px`,
|
||||
}
|
||||
: {
|
||||
minHeight: '54px',
|
||||
};
|
||||
const pieceSelectorItemInfo = getPieceSelectorItemInfo(item);
|
||||
return (
|
||||
<CardListItem
|
||||
className={cn('p-2 w-full ', {
|
||||
truncate: hidePieceIconAndDescription,
|
||||
})}
|
||||
onClick={onClick}
|
||||
style={style}
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div
|
||||
className={cn({
|
||||
'opacity-0': hidePieceIconAndDescription,
|
||||
})}
|
||||
>
|
||||
<PieceIcon
|
||||
logoUrl={stepMetadataWithSuggestions.logoUrl}
|
||||
displayName={stepMetadataWithSuggestions.displayName}
|
||||
showTooltip={false}
|
||||
size={'sm'}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="text-sm">{pieceSelectorItemInfo.displayName}</div>
|
||||
{!hidePieceIconAndDescription && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{pieceSelectorItemInfo.description.endsWith('.')
|
||||
? pieceSelectorItemInfo.description.slice(0, -1)
|
||||
: pieceSelectorItemInfo.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardListItem>
|
||||
);
|
||||
};
|
||||
|
||||
GenericActionOrTriggerItem.displayName = 'GenericActionOrTriggerItem';
|
||||
export default GenericActionOrTriggerItem;
|
||||
@@ -0,0 +1,214 @@
|
||||
import { t } from 'i18next';
|
||||
import {
|
||||
LayoutGridIcon,
|
||||
PuzzleIcon,
|
||||
SparklesIcon,
|
||||
WrenchIcon,
|
||||
} from 'lucide-react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
|
||||
import { useBuilderStateContext } from '@/app/builder/builder-hooks';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { PiecesSearchInput } from '@/features/pieces/components/piece-selector-search';
|
||||
import { PieceSelectorTabs } from '@/features/pieces/components/piece-selector-tabs';
|
||||
import {
|
||||
PieceSelectorTabsProvider,
|
||||
PieceSelectorTabType,
|
||||
} from '@/features/pieces/lib/piece-selector-tabs-provider';
|
||||
import { pieceSelectorUtils } from '@/features/pieces/lib/piece-selector-utils';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { PieceSelectorOperation } from '@/lib/types';
|
||||
import { FlowOperationType, FlowTriggerType } from '@activepieces/shared';
|
||||
|
||||
import {
|
||||
PieceSearchProvider,
|
||||
usePieceSearchContext,
|
||||
} from '../../../features/pieces/lib/piece-search-context';
|
||||
|
||||
import { AITabContent } from './ai-tab-content';
|
||||
import { ExploreTabContent } from './explore-tab-content';
|
||||
import { PiecesCardList } from './pieces-card-list';
|
||||
|
||||
const getTabsList = (operationType: FlowOperationType) => {
|
||||
const baseTabs = [
|
||||
{
|
||||
value: PieceSelectorTabType.EXPLORE,
|
||||
name: t('Explore'),
|
||||
icon: <LayoutGridIcon className="size-5" />,
|
||||
},
|
||||
{
|
||||
value: PieceSelectorTabType.APPS,
|
||||
name: t('Apps'),
|
||||
icon: <PuzzleIcon className="size-5" />,
|
||||
},
|
||||
{
|
||||
value: PieceSelectorTabType.UTILITY,
|
||||
name: t('Utility'),
|
||||
icon: <WrenchIcon className="size-5" />,
|
||||
},
|
||||
];
|
||||
|
||||
if (operationType === FlowOperationType.ADD_ACTION) {
|
||||
baseTabs.splice(1, 0, {
|
||||
value: PieceSelectorTabType.AI_AND_AGENTS,
|
||||
name: t('AI & Agents'),
|
||||
icon: <SparklesIcon className="size-5" />,
|
||||
});
|
||||
}
|
||||
|
||||
return baseTabs;
|
||||
};
|
||||
|
||||
type PieceSelectorProps = {
|
||||
children: React.ReactNode;
|
||||
id: string;
|
||||
operation: PieceSelectorOperation;
|
||||
openSelectorOnClick?: boolean;
|
||||
stepToReplacePieceDisplayName?: string;
|
||||
};
|
||||
|
||||
const PieceSelectorWrapper = (props: PieceSelectorProps) => {
|
||||
return (
|
||||
<PieceSearchProvider>
|
||||
<PieceSelectorContent {...props} />
|
||||
</PieceSearchProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const PieceSelectorContent = ({
|
||||
children,
|
||||
operation,
|
||||
id,
|
||||
openSelectorOnClick = true,
|
||||
stepToReplacePieceDisplayName,
|
||||
}: PieceSelectorProps) => {
|
||||
const [
|
||||
openedPieceSelectorStepNameOrAddButtonId,
|
||||
setOpenedPieceSelectorStepNameOrAddButtonId,
|
||||
setSelectedPieceMetadataInPieceSelector,
|
||||
isForEmptyTrigger,
|
||||
deselectStep,
|
||||
] = useBuilderStateContext((state) => [
|
||||
state.openedPieceSelectorStepNameOrAddButtonId,
|
||||
state.setOpenedPieceSelectorStepNameOrAddButtonId,
|
||||
state.setSelectedPieceMetadataInPieceSelector,
|
||||
state.flowVersion.trigger.type === FlowTriggerType.EMPTY &&
|
||||
id === 'trigger',
|
||||
state.deselectStep,
|
||||
]);
|
||||
const { searchQuery, setSearchQuery } = usePieceSearchContext();
|
||||
const isForReplace =
|
||||
operation.type === FlowOperationType.UPDATE_ACTION ||
|
||||
(operation.type === FlowOperationType.UPDATE_TRIGGER && !isForEmptyTrigger);
|
||||
const [debouncedQuery] = useDebounce(searchQuery, 300);
|
||||
const isOpen = openedPieceSelectorStepNameOrAddButtonId === id;
|
||||
const isMobile = useIsMobile();
|
||||
const { listHeightRef, popoverTriggerRef } =
|
||||
pieceSelectorUtils.useAdjustPieceListHeightToAvailableSpace();
|
||||
const listHeight = Math.min(listHeightRef.current, 300);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTimeout(() => {
|
||||
searchInputRef.current?.focus();
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchQuery('');
|
||||
setSelectedPieceMetadataInPieceSelector(null);
|
||||
};
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
modal={true}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
clearSearch();
|
||||
setOpenedPieceSelectorStepNameOrAddButtonId(null);
|
||||
if (isForEmptyTrigger) {
|
||||
deselectStep();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger
|
||||
ref={popoverTriggerRef}
|
||||
asChild={true}
|
||||
onClick={() => {
|
||||
if (openSelectorOnClick) {
|
||||
setOpenedPieceSelectorStepNameOrAddButtonId(id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PopoverTrigger>
|
||||
|
||||
<PieceSelectorTabsProvider
|
||||
initiallySelectedTab={
|
||||
isForReplace || isMobile
|
||||
? PieceSelectorTabType.NONE
|
||||
: PieceSelectorTabType.EXPLORE
|
||||
}
|
||||
onTabChange={clearSearch}
|
||||
key={isOpen ? 'open' : 'closed'}
|
||||
>
|
||||
<PopoverContent
|
||||
onContextMenu={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="w-[340px] md:w-[600px] p-0 shadow-lg"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<div>
|
||||
<PiecesSearchInput
|
||||
searchInputRef={searchInputRef}
|
||||
onSearchChange={(e) => {
|
||||
setSelectedPieceMetadataInPieceSelector(null);
|
||||
if (e === '') {
|
||||
clearSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{!isMobile && (
|
||||
<PieceSelectorTabs tabs={getTabsList(operation.type)} />
|
||||
)}
|
||||
<Separator orientation="horizontal" className="mt-1" />
|
||||
</div>
|
||||
<div
|
||||
className=" flex flex-row max-h-[300px]"
|
||||
style={{
|
||||
height: listHeight + 'px',
|
||||
}}
|
||||
>
|
||||
<ExploreTabContent operation={operation} />
|
||||
<AITabContent operation={operation} />
|
||||
|
||||
<PiecesCardList
|
||||
//this is done to avoid debounced results when user clears search
|
||||
searchQuery={searchQuery === '' ? '' : debouncedQuery}
|
||||
operation={operation}
|
||||
stepToReplacePieceDisplayName={
|
||||
isMobile ? undefined : stepToReplacePieceDisplayName
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</PopoverContent>
|
||||
</PieceSelectorTabsProvider>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export { PieceSelectorWrapper as PieceSelector };
|
||||
@@ -0,0 +1,36 @@
|
||||
import { t } from 'i18next';
|
||||
import { SearchX } from 'lucide-react';
|
||||
|
||||
import { useEmbedding } from '@/components/embed-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { flagsHooks } from '@/hooks/flags-hooks';
|
||||
import { ApFlagId, feedbackUrl } from '@activepieces/shared';
|
||||
|
||||
const NoResultsFound = () => {
|
||||
const { data: showCommunityLinks } = flagsHooks.useFlag<boolean>(
|
||||
ApFlagId.SHOW_COMMUNITY,
|
||||
);
|
||||
const isEmbedding = useEmbedding().embedState.isEmbedded;
|
||||
const showRequestPieceButton = showCommunityLinks && !isEmbedding;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 items-center justify-center h-full ">
|
||||
<SearchX className="w-14 h-14" />
|
||||
<div className="text-sm ">{t('No pieces found')}</div>
|
||||
<div className="text-sm ">{t('Try adjusting your search')}</div>
|
||||
{showRequestPieceButton && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
window.open(`${feedbackUrl}`, '_blank', 'noopener noreferrer');
|
||||
}}
|
||||
>
|
||||
{t('Request Piece')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { NoResultsFound };
|
||||
@@ -0,0 +1,145 @@
|
||||
import { t } from 'i18next';
|
||||
import { MoveLeft } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
import { CardList } from '@/components/custom/card-list';
|
||||
import { useTelemetry } from '@/components/telemetry-provider';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { pieceSelectorUtils } from '@/features/pieces/lib/piece-selector-utils';
|
||||
import { CORE_ACTIONS_METADATA } from '@/features/pieces/lib/step-utils';
|
||||
import {
|
||||
PieceSelectorItem,
|
||||
PieceSelectorOperation,
|
||||
StepMetadataWithSuggestions,
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
FlowActionType,
|
||||
isNil,
|
||||
FlowTriggerType,
|
||||
TelemetryEventName,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { usePieceSearchContext } from '../../../features/pieces/lib/piece-search-context';
|
||||
import { useBuilderStateContext } from '../builder-hooks';
|
||||
|
||||
import { CreateTodoDialog } from './add-todo-step-dialog';
|
||||
import GenericActionOrTriggerItem from './generic-piece-selector-item';
|
||||
type PieceActionsOrTriggersListProps = {
|
||||
hidePieceIconAndDescription: boolean;
|
||||
stepMetadataWithSuggestions: StepMetadataWithSuggestions | null;
|
||||
operation: PieceSelectorOperation;
|
||||
};
|
||||
export const convertStepMetadataToPieceSelectorItems = (
|
||||
stepMetadataWithSuggestions: StepMetadataWithSuggestions,
|
||||
): PieceSelectorItem[] => {
|
||||
switch (stepMetadataWithSuggestions.type) {
|
||||
case FlowActionType.PIECE: {
|
||||
const actions = pieceSelectorUtils.removeHiddenActions(
|
||||
stepMetadataWithSuggestions,
|
||||
);
|
||||
return actions.map((action) => ({
|
||||
actionOrTrigger: action,
|
||||
type: FlowActionType.PIECE,
|
||||
pieceMetadata: stepMetadataWithSuggestions,
|
||||
}));
|
||||
}
|
||||
case FlowTriggerType.PIECE: {
|
||||
const triggers = Object.values(
|
||||
stepMetadataWithSuggestions.suggestedTriggers ?? {},
|
||||
);
|
||||
return triggers.map((trigger) => ({
|
||||
actionOrTrigger: trigger,
|
||||
type: FlowTriggerType.PIECE,
|
||||
pieceMetadata: stepMetadataWithSuggestions,
|
||||
}));
|
||||
}
|
||||
case FlowActionType.CODE:
|
||||
case FlowActionType.LOOP_ON_ITEMS:
|
||||
case FlowActionType.ROUTER: {
|
||||
return CORE_ACTIONS_METADATA.filter(
|
||||
(step) => step.type === stepMetadataWithSuggestions.type,
|
||||
);
|
||||
}
|
||||
default: {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const PieceActionsOrTriggersList: React.FC<
|
||||
PieceActionsOrTriggersListProps
|
||||
> = ({
|
||||
stepMetadataWithSuggestions,
|
||||
hidePieceIconAndDescription,
|
||||
operation,
|
||||
}) => {
|
||||
const { capture } = useTelemetry();
|
||||
const { searchQuery } = usePieceSearchContext();
|
||||
const [handleAddingOrUpdatingStep] = useBuilderStateContext((state) => [
|
||||
state.handleAddingOrUpdatingStep,
|
||||
]);
|
||||
if (isNil(stepMetadataWithSuggestions)) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 items-center justify-center h-full w-full">
|
||||
<MoveLeft className="w-10 h-10 rtl:rotate-180" />
|
||||
<div className="text-sm">{t('Please select a piece first')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const actionsOrTriggers = convertStepMetadataToPieceSelectorItems(
|
||||
stepMetadataWithSuggestions,
|
||||
);
|
||||
return (
|
||||
<ScrollArea className="h-full" viewPortClassName="h-full">
|
||||
<CardList className="min-w-[350px] h-full gap-0" listClassName="gap-0">
|
||||
{actionsOrTriggers &&
|
||||
actionsOrTriggers.map((item, index) => {
|
||||
const isCreateTodoAction =
|
||||
item.type === FlowActionType.PIECE &&
|
||||
item.actionOrTrigger.name === 'createTodo';
|
||||
|
||||
if (isCreateTodoAction) {
|
||||
return (
|
||||
<CreateTodoDialog
|
||||
key={index}
|
||||
pieceSelectorItem={item}
|
||||
operation={operation}
|
||||
hidePieceIconAndDescription={hidePieceIconAndDescription}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<GenericActionOrTriggerItem
|
||||
key={index}
|
||||
item={item}
|
||||
hidePieceIconAndDescription={hidePieceIconAndDescription}
|
||||
stepMetadataWithSuggestions={stepMetadataWithSuggestions}
|
||||
onClick={() => {
|
||||
if (
|
||||
item.type === FlowActionType.PIECE ||
|
||||
item.type === FlowTriggerType.PIECE
|
||||
) {
|
||||
capture({
|
||||
name: TelemetryEventName.PIECE_SELECTOR_SEARCH,
|
||||
payload: {
|
||||
search: searchQuery,
|
||||
isTrigger: item.type === FlowTriggerType.PIECE,
|
||||
selectedActionOrTriggerName: item.actionOrTrigger.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
handleAddingOrUpdatingStep({
|
||||
pieceSelectorItem: item,
|
||||
operation,
|
||||
selectStepAfter: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</CardList>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { CardListItem } from '@/components/custom/card-list';
|
||||
import { PieceIcon } from '@/features/pieces/components/piece-icon';
|
||||
import { PIECE_SELECTOR_ELEMENTS_HEIGHTS } from '@/features/pieces/lib/piece-selector-utils';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import {
|
||||
PieceSelectorOperation,
|
||||
StepMetadataWithSuggestions,
|
||||
} from '@/lib/types';
|
||||
import { cn, wait } from '@/lib/utils';
|
||||
|
||||
import { useBuilderStateContext } from '../builder-hooks';
|
||||
|
||||
import { PieceActionsOrTriggersList } from './piece-actions-or-triggers-list';
|
||||
|
||||
type PieceCardListItemProps = {
|
||||
pieceMetadata: StepMetadataWithSuggestions;
|
||||
searchQuery: string;
|
||||
operation: PieceSelectorOperation;
|
||||
isTemporaryDisabledUntilNextCursorMove: boolean;
|
||||
};
|
||||
|
||||
const PieceCardListItem = ({
|
||||
pieceMetadata,
|
||||
searchQuery,
|
||||
operation,
|
||||
isTemporaryDisabledUntilNextCursorMove,
|
||||
}: PieceCardListItemProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
const showSuggestions = searchQuery.length > 0 || isMobile;
|
||||
const isMouseOver = useRef(false);
|
||||
const selectPieceMetatdata = async () => {
|
||||
if (isTemporaryDisabledUntilNextCursorMove || showSuggestions) {
|
||||
return;
|
||||
}
|
||||
isMouseOver.current = true;
|
||||
await wait(250);
|
||||
if (isMouseOver.current) {
|
||||
setSelectedPieceMetadataInPieceSelector(pieceMetadata);
|
||||
}
|
||||
};
|
||||
const [
|
||||
selectedPieceMetadataInPieceSelector,
|
||||
setSelectedPieceMetadataInPieceSelector,
|
||||
] = useBuilderStateContext((state) => [
|
||||
state.selectedPieceMetadataInPieceSelector,
|
||||
state.setSelectedPieceMetadataInPieceSelector,
|
||||
]);
|
||||
const itemHeight = PIECE_SELECTOR_ELEMENTS_HEIGHTS.PIECE_ITEM_HEIGHT;
|
||||
return (
|
||||
<>
|
||||
<CardListItem
|
||||
className={cn('flex-col p-3 gap-1 items-start truncate', {
|
||||
'hover:bg-transparent!': isTemporaryDisabledUntilNextCursorMove,
|
||||
})}
|
||||
style={{ height: `${itemHeight}px`, maxHeight: `${itemHeight}px` }}
|
||||
selected={
|
||||
selectedPieceMetadataInPieceSelector?.displayName ===
|
||||
pieceMetadata.displayName && searchQuery.length === 0
|
||||
}
|
||||
interactive={!showSuggestions}
|
||||
onMouseEnter={selectPieceMetatdata}
|
||||
onMouseMove={selectPieceMetatdata}
|
||||
onClick={() => {
|
||||
if (!showSuggestions) {
|
||||
setSelectedPieceMetadataInPieceSelector(pieceMetadata);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
isMouseOver.current = false;
|
||||
}}
|
||||
id={pieceMetadata.displayName}
|
||||
data-testid={pieceMetadata.displayName}
|
||||
>
|
||||
<div className="flex gap-2 items-center h-full">
|
||||
<PieceIcon
|
||||
logoUrl={pieceMetadata.logoUrl}
|
||||
displayName={pieceMetadata.displayName}
|
||||
showTooltip={false}
|
||||
size={'sm'}
|
||||
/>
|
||||
<div className="grow h-full flex items-center justify-left text-sm">
|
||||
{pieceMetadata.displayName}
|
||||
</div>
|
||||
</div>
|
||||
</CardListItem>
|
||||
|
||||
{showSuggestions && (
|
||||
<div>
|
||||
<PieceActionsOrTriggersList
|
||||
stepMetadataWithSuggestions={pieceMetadata}
|
||||
hidePieceIconAndDescription={true}
|
||||
operation={operation}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
PieceCardListItem.displayName = 'PieceCardListItem';
|
||||
export { PieceCardListItem };
|
||||
@@ -0,0 +1,228 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { CardListItemSkeleton } from '@/components/custom/card-list';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { VirtualizedScrollArea } from '@/components/ui/virtualized-scroll-area';
|
||||
import {
|
||||
PieceSelectorTabType,
|
||||
usePieceSelectorTabs,
|
||||
} from '@/features/pieces/lib/piece-selector-tabs-provider';
|
||||
import {
|
||||
PIECE_SELECTOR_ELEMENTS_HEIGHTS,
|
||||
pieceSelectorUtils,
|
||||
} from '@/features/pieces/lib/piece-selector-utils';
|
||||
import { piecesHooks } from '@/features/pieces/lib/pieces-hooks';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import {
|
||||
PieceSelectorOperation,
|
||||
StepMetadataWithSuggestions,
|
||||
CategorizedStepMetadataWithSuggestions,
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
FlowActionType,
|
||||
FlowOperationType,
|
||||
FlowTriggerType,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { useBuilderStateContext } from '../builder-hooks';
|
||||
|
||||
import { NoResultsFound } from './no-results-found';
|
||||
import { PieceActionsOrTriggersList } from './piece-actions-or-triggers-list';
|
||||
import { PieceCardListItem } from './piece-card-item';
|
||||
|
||||
type PiecesCardListProps = {
|
||||
searchQuery: string;
|
||||
operation: PieceSelectorOperation;
|
||||
stepToReplacePieceDisplayName?: string;
|
||||
};
|
||||
|
||||
export const PiecesCardList: React.FC<PiecesCardListProps> = ({
|
||||
searchQuery,
|
||||
operation,
|
||||
stepToReplacePieceDisplayName,
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [selectedPieceMetadataInPieceSelector] = useBuilderStateContext(
|
||||
(state) => [state.selectedPieceMetadataInPieceSelector],
|
||||
);
|
||||
const { isLoading: isLoadingPieces, data: categories } =
|
||||
piecesHooks.usePiecesSearch({
|
||||
shouldCaptureEvent: true,
|
||||
searchQuery,
|
||||
type:
|
||||
operation.type === FlowOperationType.UPDATE_TRIGGER
|
||||
? 'trigger'
|
||||
: 'action',
|
||||
});
|
||||
|
||||
const noResultsFound = !isLoadingPieces && categories.length === 0;
|
||||
const [mouseMoved, setMouseMoved] = useState(false);
|
||||
const showActionsOrTriggersInsidePiecesList =
|
||||
searchQuery.length > 0 || isMobile;
|
||||
const virtualizedItems = transformPiecesMetadataToVirtualizedItems(
|
||||
categories,
|
||||
showActionsOrTriggersInsidePiecesList,
|
||||
);
|
||||
|
||||
const initialIndexToScrollToInPiecesList = virtualizedItems.findIndex(
|
||||
(item) => item.displayName === stepToReplacePieceDisplayName,
|
||||
);
|
||||
const { selectedTab } = usePieceSelectorTabs();
|
||||
|
||||
const isLoading = isLoadingPieces;
|
||||
const showActionsOrTriggersList =
|
||||
searchQuery.length === 0 && !isMobile && !noResultsFound && !isLoading;
|
||||
const showPiecesList = !noResultsFound && !isLoading;
|
||||
if (
|
||||
[PieceSelectorTabType.EXPLORE, PieceSelectorTabType.AI_AND_AGENTS].includes(
|
||||
selectedTab,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onMouseMove={() => {
|
||||
setMouseMoved(!isLoadingPieces);
|
||||
}}
|
||||
className={cn('w-full md:w-[250px] md:min-w-[250px] transition-all ', {
|
||||
'w-full md:w-full': searchQuery.length > 0 || noResultsFound,
|
||||
})}
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<CardListItemSkeleton numberOfCards={2} withCircle={false} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPiecesList && (
|
||||
<VirtualizedScrollArea
|
||||
key={`${selectedTab}-${searchQuery}`}
|
||||
initialScroll={{
|
||||
index: initialIndexToScrollToInPiecesList,
|
||||
clickAfterScroll: true,
|
||||
}}
|
||||
items={virtualizedItems}
|
||||
estimateSize={(index) => virtualizedItems[index].height}
|
||||
getItemKey={(index) => virtualizedItems[index].id}
|
||||
renderItem={(item) => {
|
||||
if (item.isCategory) {
|
||||
return (
|
||||
<div
|
||||
className={cn('p-2 pb-0 text-sm text-muted-foreground')}
|
||||
id={item.displayName}
|
||||
>
|
||||
{item.displayName}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PieceCardListItem
|
||||
pieceMetadata={item.pieceMetadata}
|
||||
searchQuery={searchQuery}
|
||||
operation={operation}
|
||||
isTemporaryDisabledUntilNextCursorMove={!mouseMoved}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{noResultsFound && <NoResultsFound />}
|
||||
</div>
|
||||
|
||||
{showActionsOrTriggersList && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="h-full" />
|
||||
<PieceActionsOrTriggersList
|
||||
stepMetadataWithSuggestions={selectedPieceMetadataInPieceSelector}
|
||||
hidePieceIconAndDescription={false}
|
||||
operation={operation}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type VirtualizedItem = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
height: number;
|
||||
} & (
|
||||
| {
|
||||
isCategory: true;
|
||||
}
|
||||
| {
|
||||
isCategory: false;
|
||||
pieceMetadata: StepMetadataWithSuggestions;
|
||||
}
|
||||
);
|
||||
const transformPiecesMetadataToVirtualizedItems = (
|
||||
searchResult: CategorizedStepMetadataWithSuggestions[],
|
||||
showActionsOrTriggersInsidePiecesList: boolean,
|
||||
) => {
|
||||
return searchResult.reduce<VirtualizedItem[]>((result, category) => {
|
||||
if (!showActionsOrTriggersInsidePiecesList) {
|
||||
result.push({
|
||||
id: category.title,
|
||||
displayName: category.title,
|
||||
height: PIECE_SELECTOR_ELEMENTS_HEIGHTS.CATEGORY_ITEM_HEIGHT,
|
||||
isCategory: true,
|
||||
});
|
||||
}
|
||||
category.metadata.forEach((pieceMetadata, index) => {
|
||||
result.push({
|
||||
id: `${pieceMetadata.displayName}-${index}`,
|
||||
height: getItemHeight(
|
||||
pieceMetadata,
|
||||
showActionsOrTriggersInsidePiecesList,
|
||||
),
|
||||
isCategory: false,
|
||||
pieceMetadata,
|
||||
displayName: pieceMetadata.displayName,
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const getItemHeight = (
|
||||
pieceMetadata: StepMetadataWithSuggestions,
|
||||
showActionsOrTriggersInsidePiecesList: boolean,
|
||||
) => {
|
||||
const { ACTION_OR_TRIGGER_ITEM_HEIGHT, PIECE_ITEM_HEIGHT } =
|
||||
PIECE_SELECTOR_ELEMENTS_HEIGHTS;
|
||||
if (
|
||||
pieceMetadata.type === FlowActionType.PIECE &&
|
||||
showActionsOrTriggersInsidePiecesList
|
||||
) {
|
||||
const actionsListWithoutHiddenActions =
|
||||
pieceSelectorUtils.removeHiddenActions(pieceMetadata);
|
||||
return (
|
||||
ACTION_OR_TRIGGER_ITEM_HEIGHT *
|
||||
Object.values(actionsListWithoutHiddenActions).length +
|
||||
PIECE_ITEM_HEIGHT
|
||||
);
|
||||
}
|
||||
if (
|
||||
pieceMetadata.type === FlowTriggerType.PIECE &&
|
||||
showActionsOrTriggersInsidePiecesList
|
||||
) {
|
||||
return (
|
||||
ACTION_OR_TRIGGER_ITEM_HEIGHT *
|
||||
Object.values(pieceMetadata.suggestedTriggers ?? {}).length +
|
||||
PIECE_ITEM_HEIGHT
|
||||
);
|
||||
}
|
||||
const isCoreAction =
|
||||
pieceMetadata.type === FlowActionType.CODE ||
|
||||
pieceMetadata.type === FlowActionType.LOOP_ON_ITEMS ||
|
||||
pieceMetadata.type === FlowActionType.ROUTER;
|
||||
if (isCoreAction && showActionsOrTriggersInsidePiecesList) {
|
||||
return ACTION_OR_TRIGGER_ITEM_HEIGHT + PIECE_ITEM_HEIGHT;
|
||||
}
|
||||
return PIECE_ITEM_HEIGHT;
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import { t } from 'i18next';
|
||||
import { Timer } from 'lucide-react';
|
||||
|
||||
import { JsonViewer } from '@/components/json-viewer';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { AgentTimeline } from '@/features/agents/agent-timeline';
|
||||
import { StepStatusIcon } from '@/features/flow-runs/components/step-status-icon';
|
||||
import { formatUtils } from '@/lib/utils';
|
||||
import {
|
||||
FlowAction,
|
||||
StepOutput,
|
||||
StepOutputStatus,
|
||||
flowStructureUtil,
|
||||
AgentResult,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
type Props = {
|
||||
stepDetails: StepOutput;
|
||||
selectedStep: FlowAction;
|
||||
};
|
||||
|
||||
export const FlowStepInputOutput = ({ stepDetails, selectedStep }: Props) => {
|
||||
const isAgent = flowStructureUtil.isAgentPiece(selectedStep);
|
||||
const isRunning =
|
||||
stepDetails.status === StepOutputStatus.RUNNING ||
|
||||
stepDetails.status === StepOutputStatus.PAUSED;
|
||||
|
||||
const parsedOutput =
|
||||
stepDetails.errorMessage ?? stepDetails.output ?? 'No output';
|
||||
|
||||
const tabCount = isAgent ? 3 : 2;
|
||||
const gridCols = tabCount === 3 ? 'grid-cols-3' : 'grid-cols-2';
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2 text-base font-medium">
|
||||
<StepStatusIcon status={stepDetails.status} size="5" />
|
||||
<span>{selectedStep.displayName}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Timer className="w-4 h-4" />
|
||||
<span>
|
||||
{t('Duration')}:{' '}
|
||||
{formatUtils.formatDuration(stepDetails.duration ?? 0, false)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue={isAgent ? 'timeline' : 'input'} className="w-full">
|
||||
<TabsList className={`w-full grid ${gridCols}`}>
|
||||
<TabsTrigger value="input">{t('Input')}</TabsTrigger>
|
||||
{isAgent && (
|
||||
<TabsTrigger value="timeline">{t('Timeline')}</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="output">{t('Output')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="input">
|
||||
<JsonViewer json={stepDetails.input} title={t('Input')} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="timeline">
|
||||
<AgentTimeline agentResult={stepDetails.output as AgentResult} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="output">
|
||||
{isRunning ? (
|
||||
<div className="mt-4 space-y-4">
|
||||
<Skeleton className="h-8 w-1/3" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-4 w-1/4" />
|
||||
</div>
|
||||
) : (
|
||||
<JsonViewer json={parsedOutput} title={t('Output')} />
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useBuilderStateContext } from '@/app/builder/builder-hooks';
|
||||
import {
|
||||
flowStructureUtil,
|
||||
StepOutput,
|
||||
FlowAction,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { FlowStepInputOutput } from './flow-step-input-output';
|
||||
|
||||
type FlowStepIOProps = {
|
||||
stepDetails: StepOutput;
|
||||
};
|
||||
|
||||
const FlowStepIO = React.memo(({ stepDetails }: FlowStepIOProps) => {
|
||||
const [flowVersion, selectedStepName] = useBuilderStateContext((state) => [
|
||||
state.flowVersion,
|
||||
state.selectedStep,
|
||||
]);
|
||||
|
||||
const selectedStep = selectedStepName
|
||||
? (flowStructureUtil.getStep(
|
||||
selectedStepName,
|
||||
flowVersion.trigger,
|
||||
) as FlowAction)
|
||||
: undefined;
|
||||
|
||||
if (!selectedStep) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FlowStepInputOutput
|
||||
stepDetails={stepDetails}
|
||||
selectedStep={selectedStep}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
FlowStepIO.displayName = 'FlowStepIO';
|
||||
|
||||
export { FlowStepIO };
|
||||
@@ -0,0 +1,143 @@
|
||||
import { t } from 'i18next';
|
||||
import { ChevronLeft, Info } from 'lucide-react';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
LeftSideBarType,
|
||||
useBuilderStateContext,
|
||||
} from '@/app/builder/builder-hooks';
|
||||
import { CardList } from '@/components/custom/card-list';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from '@/components/ui/resizable-panel';
|
||||
import { LoadingSpinner } from '@/components/ui/spinner';
|
||||
import { flagsHooks } from '@/hooks/flags-hooks';
|
||||
import {
|
||||
ApFlagId,
|
||||
FlowRun,
|
||||
FlowRunStatus,
|
||||
isNil,
|
||||
RunEnvironment,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { flowRunUtils } from '../../../features/flow-runs/lib/flow-run-utils';
|
||||
import { SidebarHeader } from '../sidebar-header';
|
||||
|
||||
import { FlowStepIO } from './flow-step-io';
|
||||
import { FlowStepDetailsCardItem } from './run-step-card-item';
|
||||
|
||||
function getMessage(run: FlowRun | null, retentionDays: number | null) {
|
||||
if (
|
||||
!run ||
|
||||
[
|
||||
FlowRunStatus.RUNNING,
|
||||
FlowRunStatus.QUEUED,
|
||||
FlowRunStatus.SUCCEEDED,
|
||||
].includes(run.status)
|
||||
)
|
||||
return null;
|
||||
if ([FlowRunStatus.INTERNAL_ERROR].includes(run.status)) {
|
||||
return t('There are no logs captured for this run.');
|
||||
}
|
||||
if (isNil(run.logsFileId)) {
|
||||
return t(
|
||||
'Logs are kept for {days} days after execution and then deleted.',
|
||||
{ days: retentionDays },
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const FlowRunDetails = React.memo(() => {
|
||||
const { data: rententionDays } = flagsHooks.useFlag<number>(
|
||||
ApFlagId.EXECUTION_DATA_RETENTION_DAYS,
|
||||
);
|
||||
|
||||
const [setLeftSidebar, run, steps, loopsIndexes, flowVersion, selectedStep] =
|
||||
useBuilderStateContext((state) => {
|
||||
const steps =
|
||||
state.run && state.run.steps ? Object.keys(state.run.steps) : [];
|
||||
return [
|
||||
state.setLeftSidebar,
|
||||
state.run,
|
||||
steps,
|
||||
state.loopsIndexes,
|
||||
state.flowVersion,
|
||||
state.selectedStep,
|
||||
];
|
||||
});
|
||||
|
||||
const selectedStepOutput = useMemo(() => {
|
||||
return run && selectedStep && run.steps
|
||||
? flowRunUtils.extractStepOutput(
|
||||
selectedStep,
|
||||
loopsIndexes,
|
||||
run.steps,
|
||||
flowVersion.trigger,
|
||||
)
|
||||
: null;
|
||||
}, [run, selectedStep, loopsIndexes, flowVersion.trigger]);
|
||||
|
||||
const message = getMessage(run, rententionDays);
|
||||
|
||||
if (!isNil(message))
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center gap-4 w-full h-full">
|
||||
<Info size={36} className="text-muted-foreground" />
|
||||
<h4 className="px-6 text-sm text-center text-muted-foreground ">
|
||||
{message}
|
||||
</h4>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup direction="vertical">
|
||||
<SidebarHeader onClose={() => setLeftSidebar(LeftSideBarType.NONE)}>
|
||||
<div className="flex gap-2 items-center">
|
||||
{run && run.environment !== RunEnvironment.TESTING && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={'sm'}
|
||||
onClick={() => setLeftSidebar(LeftSideBarType.RUNS)}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</Button>
|
||||
)}
|
||||
<span>{t('Run Details')}</span>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<ResizablePanel className="h-full min-h-[80px]">
|
||||
<CardList className="pr-2 h-full" listClassName="gap-0.5">
|
||||
{steps.length > 0 &&
|
||||
steps
|
||||
.filter((path) => !isNil(path))
|
||||
.map((path) => (
|
||||
<FlowStepDetailsCardItem
|
||||
stepName={path}
|
||||
depth={0}
|
||||
key={path}
|
||||
></FlowStepDetailsCardItem>
|
||||
))}
|
||||
{steps.length === 0 && (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<LoadingSpinner></LoadingSpinner>
|
||||
</div>
|
||||
)}
|
||||
</CardList>
|
||||
</ResizablePanel>
|
||||
{selectedStepOutput && (
|
||||
<>
|
||||
<ResizableHandle withHandle={true} />
|
||||
<ResizablePanel defaultValue={25} className="min-h-[100px]">
|
||||
<FlowStepIO stepDetails={selectedStepOutput}></FlowStepIO>
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
});
|
||||
|
||||
FlowRunDetails.displayName = 'FlowRunDetails';
|
||||
export { FlowRunDetails };
|
||||
@@ -0,0 +1,142 @@
|
||||
import { t } from 'i18next';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FlowActionType, isNil } from '@activepieces/shared';
|
||||
|
||||
import { flowRunUtils } from '../../../features/flow-runs/lib/flow-run-utils';
|
||||
import { useBuilderStateContext } from '../builder-hooks';
|
||||
|
||||
const LoopIterationInput = ({ stepName }: { stepName: string }) => {
|
||||
const [setLoopIndex, currentIndex, run, flowVersion, loopsIndexes] =
|
||||
useBuilderStateContext((state) => [
|
||||
state.setLoopIndex,
|
||||
state.loopsIndexes[stepName] ?? 0,
|
||||
state.run,
|
||||
state.flowVersion,
|
||||
state.loopsIndexes,
|
||||
]);
|
||||
const stepOutput = useMemo(() => {
|
||||
return run && run.steps
|
||||
? flowRunUtils.extractStepOutput(
|
||||
stepName,
|
||||
loopsIndexes,
|
||||
run.steps,
|
||||
flowVersion.trigger,
|
||||
)
|
||||
: null;
|
||||
}, [run, stepName, loopsIndexes, flowVersion.trigger]);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const totalIterations =
|
||||
stepOutput &&
|
||||
stepOutput.output &&
|
||||
stepOutput.type === FlowActionType.LOOP_ON_ITEMS
|
||||
? stepOutput.output.iterations.length
|
||||
: 0;
|
||||
|
||||
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const value = e.target.value ?? '1';
|
||||
const parsedValue = Math.max(
|
||||
1,
|
||||
Math.min(parseInt(value) ?? 1, totalIterations),
|
||||
);
|
||||
setLoopIndex(stepName, parsedValue - 1);
|
||||
}
|
||||
|
||||
function removeFocus() {
|
||||
setIsFocused(false);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.blur();
|
||||
}
|
||||
if (!isNil(inputRef.current) && inputRef.current.value.length === 0) {
|
||||
setLoopIndex(stepName, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isFocused && (
|
||||
<div className="text-sm duration-300 animate-fade">
|
||||
{t('Iteration')}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
dir="rtl"
|
||||
className=" transition-all duration-300 ease-expand-out relative"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{
|
||||
width: isFocused
|
||||
? '100%'
|
||||
: (inputRef.current?.value.length || 1) * 2.6 + 1 + 'ch',
|
||||
minWidth: isFocused ? '100px' : undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-3 opacity-0 hidden pointer-events-none gap-2 justify-center items-center h-full text-sm text-muted-foreground transition-all duration-300',
|
||||
{
|
||||
flex: isFocused,
|
||||
'opacity-100': isFocused,
|
||||
},
|
||||
)}
|
||||
dir="ltr"
|
||||
>
|
||||
<div className="pointer-events-none">/{totalIterations}</div>
|
||||
<Button
|
||||
variant="transparent"
|
||||
className="p-1 text-xs rounded-xs h-auto pointer-events-auto "
|
||||
onClick={(e) => {
|
||||
inputRef.current?.blur();
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{t('Done')}
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
dir="ltr"
|
||||
ref={inputRef}
|
||||
className="h-7 grow-0 transition-all duration-300 ease-expand-out text-center focus:text-start rounded-sm focus:w-full p-1"
|
||||
style={{
|
||||
width: isFocused
|
||||
? '100%'
|
||||
: (inputRef.current?.value.length || 1) * 2.6 + 1 + 'ch',
|
||||
}}
|
||||
value={currentIndex + 1}
|
||||
type="number"
|
||||
min={1}
|
||||
max={totalIterations}
|
||||
onChange={onChange}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => {
|
||||
setIsFocused(false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onChange(e as unknown as React.ChangeEvent<HTMLInputElement>);
|
||||
removeFocus();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
removeFocus();
|
||||
}
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
LoopIterationInput.displayName = 'LoopIterationInput';
|
||||
export { LoopIterationInput };
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useReactFlow } from '@xyflow/react';
|
||||
import { t } from 'i18next';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { useBuilderStateContext } from '@/app/builder/builder-hooks';
|
||||
import { CardListItem } from '@/components/custom/card-list';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { stepsHooks } from '@/features/pieces/lib/steps-hooks';
|
||||
import { cn, formatUtils } from '@/lib/utils';
|
||||
import { FlowActionType, flowStructureUtil } from '@activepieces/shared';
|
||||
|
||||
import { StepStatusIcon } from '../../../features/flow-runs/components/step-status-icon';
|
||||
import { flowRunUtils } from '../../../features/flow-runs/lib/flow-run-utils';
|
||||
import { flowCanvasUtils } from '../flow-canvas/utils/flow-canvas-utils';
|
||||
|
||||
import { LoopIterationInput } from './loop-iteration-input';
|
||||
type RunStepCardProps = {
|
||||
stepName: string;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
const RunStepCardItem = ({ stepName, depth }: RunStepCardProps) => {
|
||||
const [
|
||||
loopsIndexes,
|
||||
step,
|
||||
selectedStep,
|
||||
stepIndex,
|
||||
selectStepByName,
|
||||
run,
|
||||
flowVersion,
|
||||
] = useBuilderStateContext((state) => {
|
||||
const step = flowStructureUtil.getStepOrThrow(
|
||||
stepName,
|
||||
state.flowVersion.trigger,
|
||||
);
|
||||
const stepIndex = flowStructureUtil
|
||||
.getAllSteps(state.flowVersion.trigger)
|
||||
.findIndex((s) => s.name === stepName);
|
||||
|
||||
return [
|
||||
state.loopsIndexes,
|
||||
step,
|
||||
state.selectedStep,
|
||||
stepIndex,
|
||||
state.selectStepByName,
|
||||
state.run,
|
||||
state.flowVersion,
|
||||
];
|
||||
});
|
||||
const { fitView } = useReactFlow();
|
||||
const isChildSelected = useMemo(() => {
|
||||
return step?.type === FlowActionType.LOOP_ON_ITEMS && selectedStep
|
||||
? flowStructureUtil.isChildOf(step, selectedStep)
|
||||
: false;
|
||||
}, [step, selectedStep]);
|
||||
|
||||
const stepOutput = useMemo(() => {
|
||||
return run && run.steps
|
||||
? flowRunUtils.extractStepOutput(
|
||||
stepName,
|
||||
loopsIndexes,
|
||||
run.steps,
|
||||
flowVersion.trigger,
|
||||
)
|
||||
: null;
|
||||
}, [loopsIndexes, run, stepName, flowVersion.trigger]);
|
||||
|
||||
const isStepSelected = selectedStep === stepName;
|
||||
|
||||
const children =
|
||||
stepOutput &&
|
||||
stepOutput.output &&
|
||||
stepOutput.type === FlowActionType.LOOP_ON_ITEMS &&
|
||||
stepOutput.output.iterations[loopsIndexes[stepName]]
|
||||
? Object.keys(stepOutput.output.iterations[loopsIndexes[stepName]])
|
||||
: [];
|
||||
const { stepMetadata } = stepsHooks.useStepMetadata({
|
||||
step: step,
|
||||
});
|
||||
const [isOpen, setIsOpen] = React.useState(true);
|
||||
|
||||
const isLoopStep =
|
||||
stepOutput && stepOutput.type === FlowActionType.LOOP_ON_ITEMS;
|
||||
const loopHasNoIterations =
|
||||
isLoopStep && stepOutput.output?.iterations.length === 0;
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} className="w-full">
|
||||
<CollapsibleTrigger asChild={true}>
|
||||
<CardListItem
|
||||
onClick={() => {
|
||||
if (!isStepSelected) {
|
||||
selectStepByName(stepName);
|
||||
fitView(flowCanvasUtils.createFocusStepInGraphParams(stepName));
|
||||
setIsOpen(true);
|
||||
} else {
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
}}
|
||||
className={cn('cursor-pointer select-none px-4 py-3 h-14', {
|
||||
'bg-accent text-accent-foreground': isStepSelected,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
minWidth: `${depth * 25}px`,
|
||||
display: depth === 0 ? 'none' : 'flex',
|
||||
}}
|
||||
></div>
|
||||
<div className="flex items-center w-full gap-3">
|
||||
{children.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={'icon'}
|
||||
className="w-4 h-4"
|
||||
onClick={(e) => {
|
||||
setIsOpen(!isOpen);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<ChevronRight
|
||||
size={16}
|
||||
className={cn('', { 'rotate-90': isOpen })}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
<img
|
||||
alt={stepMetadata?.displayName}
|
||||
className="w-6 h-6 object-contain"
|
||||
src={step?.settings?.customLogoUrl ?? stepMetadata?.logoUrl}
|
||||
/>
|
||||
<div className="break-all truncate min-w-0 grow shrink">{`${
|
||||
stepIndex + 1
|
||||
}. ${step?.displayName}`}</div>
|
||||
<div className="w-2"></div>
|
||||
<div className="flex gap-1 justify-end items-center grow">
|
||||
{isLoopStep && isStepSelected && (
|
||||
<span className="text-sm font-semibold animate-fade">
|
||||
{loopHasNoIterations
|
||||
? t('No Iterations')
|
||||
: t('All Iterations')}
|
||||
</span>
|
||||
)}
|
||||
{isLoopStep && !isStepSelected && (
|
||||
<div
|
||||
className={cn('flex gap-1 justify-end items-center grow', {
|
||||
hidden: !isChildSelected || loopHasNoIterations,
|
||||
})}
|
||||
>
|
||||
<LoopIterationInput stepName={stepName} />
|
||||
</div>
|
||||
)}
|
||||
{(!isLoopStep ||
|
||||
(isLoopStep && !isChildSelected && !isStepSelected)) && (
|
||||
<div className="flex gap-1 animate-fade">
|
||||
<span className="text-muted-foreground text-xs break-normal whitespace-nowrap">
|
||||
{formatUtils.formatDuration(
|
||||
stepOutput?.duration ?? 0,
|
||||
true,
|
||||
)}
|
||||
</span>
|
||||
{stepOutput && stepOutput.status && (
|
||||
<StepStatusIcon
|
||||
status={stepOutput.status}
|
||||
size="4"
|
||||
></StepStatusIcon>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardListItem>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="p-0">
|
||||
{children.map((stepName) => (
|
||||
<RunStepCardItem
|
||||
stepName={stepName}
|
||||
key={stepName}
|
||||
depth={depth + 1}
|
||||
></RunStepCardItem>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
RunStepCardItem.displayName = 'RunStepCardItem';
|
||||
export { RunStepCardItem as FlowStepDetailsCardItem };
|
||||
@@ -0,0 +1,279 @@
|
||||
import { StopwatchIcon } from '@radix-ui/react-icons';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { t } from 'i18next';
|
||||
import { Eye, Repeat } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import {
|
||||
LeftSideBarType,
|
||||
useBuilderStateContext,
|
||||
} from '@/app/builder/builder-hooks';
|
||||
import { CardListItem } from '@/components/custom/card-list';
|
||||
import { PermissionNeededTooltip } from '@/components/custom/permission-needed-tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { LoadingSpinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { flowRunUtils } from '@/features/flow-runs/lib/flow-run-utils';
|
||||
import { flowRunsApi } from '@/features/flow-runs/lib/flow-runs-api';
|
||||
import { flowsApi } from '@/features/flows/lib/flows-api';
|
||||
import { useAuthorization } from '@/hooks/authorization-hooks';
|
||||
import { authenticationSession } from '@/lib/authentication-session';
|
||||
import { cn, formatUtils } from '@/lib/utils';
|
||||
import {
|
||||
FlowRetryStrategy,
|
||||
FlowRun,
|
||||
FlowRunStatus,
|
||||
isFailedState,
|
||||
isFlowRunStateTerminal,
|
||||
Permission,
|
||||
PopulatedFlow,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
type FlowRunCardProps = {
|
||||
run: FlowRun;
|
||||
viewedRunId?: string;
|
||||
refetchRuns: () => void;
|
||||
};
|
||||
|
||||
export const FLOW_CARD_HEIGHT = 70;
|
||||
const FlowRunCard = React.memo(
|
||||
({ run, viewedRunId, refetchRuns }: FlowRunCardProps) => {
|
||||
const { Icon, variant } = flowRunUtils.getStatusIcon(run.status);
|
||||
const userHasPermissionToRetryRun = useAuthorization().checkAccess(
|
||||
Permission.WRITE_RUN,
|
||||
);
|
||||
const projectId = authenticationSession.getProjectId();
|
||||
|
||||
const [setLeftSidebar, setRun] = useBuilderStateContext((state) => [
|
||||
state.setLeftSidebar,
|
||||
state.setRun,
|
||||
]);
|
||||
const { mutate: viewRun, isPending: isFetchingRun } = useMutation<
|
||||
{
|
||||
run: FlowRun;
|
||||
populatedFlow: PopulatedFlow;
|
||||
},
|
||||
Error,
|
||||
string
|
||||
>({
|
||||
mutationFn: async (flowRunId) => {
|
||||
const run = await flowRunsApi.getPopulated(flowRunId);
|
||||
const populatedFlow = await flowsApi.get(run.flowId, {
|
||||
versionId: run.flowVersionId,
|
||||
});
|
||||
return {
|
||||
run,
|
||||
populatedFlow,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ run, populatedFlow }) => {
|
||||
setRun(run, populatedFlow.version);
|
||||
setLeftSidebar(LeftSideBarType.RUN_DETAILS);
|
||||
refetchRuns();
|
||||
},
|
||||
});
|
||||
const [isRetryDropdownOpen, setIsRetryDropdownOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const { mutate: retryRun, isPending: isRetryingRun } = useMutation<
|
||||
{
|
||||
run: FlowRun;
|
||||
populatedFlow: PopulatedFlow;
|
||||
},
|
||||
Error,
|
||||
{
|
||||
run: FlowRun;
|
||||
retryStrategy: FlowRetryStrategy;
|
||||
}
|
||||
>({
|
||||
mutationFn: async ({ run, retryStrategy }) => {
|
||||
if (projectId) {
|
||||
const updatedRun = await flowRunsApi.retry(run.id, {
|
||||
projectId,
|
||||
strategy: retryStrategy,
|
||||
});
|
||||
const populatedFlow = await flowsApi.get(run.flowId, {
|
||||
versionId: updatedRun.flowVersionId,
|
||||
});
|
||||
return {
|
||||
run: updatedRun,
|
||||
populatedFlow,
|
||||
};
|
||||
}
|
||||
throw Error("Project id isn't defined");
|
||||
},
|
||||
onSuccess: ({ populatedFlow, run }) => {
|
||||
refetchRuns();
|
||||
setRun(run, populatedFlow.version);
|
||||
setLeftSidebar(LeftSideBarType.RUN_DETAILS);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<CardListItem
|
||||
className={cn('px-3 group', {
|
||||
'bg-accent text-accent-foreground': run.id === viewedRunId,
|
||||
})}
|
||||
style={{ height: `${FLOW_CARD_HEIGHT}px` }}
|
||||
onClick={() => {
|
||||
if (!isFetchingRun) {
|
||||
viewRun(run.id);
|
||||
}
|
||||
}}
|
||||
key={run.id}
|
||||
>
|
||||
<div>
|
||||
<span>
|
||||
{run.status === FlowRunStatus.CANCELED ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Icon
|
||||
className={cn('w-5 h-5', {
|
||||
'text-success': variant === 'success',
|
||||
'text-destructive': variant === 'error',
|
||||
})}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('Canceled')}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Icon
|
||||
className={cn('w-5 h-5', {
|
||||
'text-success': variant === 'success',
|
||||
'text-destructive': variant === 'error',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="text-sm font-medium leading-none flex gap-2 items-center">
|
||||
{formatUtils.formatDateWithTime(
|
||||
new Date(run.created ?? new Date()),
|
||||
true,
|
||||
)}{' '}
|
||||
{run.id === viewedRunId && <Eye className="w-3.5 h-3.5"></Eye>}
|
||||
</div>
|
||||
{isFlowRunStateTerminal({
|
||||
status: run.status,
|
||||
ignoreInternalError: false,
|
||||
}) && (
|
||||
<p className="flex gap-1 text-xs text-muted-foreground">
|
||||
<StopwatchIcon className="h-3.5 w-3.5" />
|
||||
{t('Took')}{' '}
|
||||
{formatUtils.formatDuration(
|
||||
run.startTime && run.finishTime
|
||||
? new Date(run.finishTime).getTime() -
|
||||
new Date(run.startTime).getTime()
|
||||
: undefined,
|
||||
false,
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{run.status === FlowRunStatus.RUNNING && (
|
||||
<p className="flex gap-1 text-xs text-muted-foreground">
|
||||
{t('Running')}...
|
||||
</p>
|
||||
)}
|
||||
{run.status === FlowRunStatus.QUEUED && (
|
||||
<p className="flex gap-1 text-xs text-muted-foreground">
|
||||
{t('Queued')}...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto font-medium">
|
||||
{(isFetchingRun || isRetryingRun) && (
|
||||
<LoadingSpinner className="size-4"></LoadingSpinner>
|
||||
)}
|
||||
|
||||
{!isFetchingRun && !isRetryingRun && (
|
||||
<PermissionNeededTooltip
|
||||
hasPermission={userHasPermissionToRetryRun}
|
||||
>
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={isRetryDropdownOpen}
|
||||
onOpenChange={setIsRetryDropdownOpen}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={'icon'}
|
||||
className={cn(
|
||||
'group-hover:opacity-100 opacity-0 rounded-full bg-accent drop-shadow-md',
|
||||
{
|
||||
'opacity-100': isRetryDropdownOpen,
|
||||
},
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Repeat className="w-4 h-4"></Repeat>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('Retry run')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
disabled={!userHasPermissionToRetryRun}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
retryRun({
|
||||
run,
|
||||
retryStrategy: FlowRetryStrategy.ON_LATEST_VERSION,
|
||||
});
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<span>{t('On latest version')}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{isFailedState(run.status) && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!isRetryingRun) {
|
||||
retryRun({
|
||||
run,
|
||||
retryStrategy: FlowRetryStrategy.FROM_FAILED_STEP,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<span>{t('From failed step')}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</PermissionNeededTooltip>
|
||||
)}
|
||||
</div>
|
||||
</CardListItem>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
FlowRunCard.displayName = 'FlowRunCard';
|
||||
export { FlowRunCard };
|
||||
@@ -0,0 +1,142 @@
|
||||
import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { t } from 'i18next';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
LeftSideBarType,
|
||||
useBuilderStateContext,
|
||||
} from '@/app/builder/builder-hooks';
|
||||
import {
|
||||
CardListEmpty,
|
||||
CardListItemSkeleton,
|
||||
} from '@/components/custom/card-list';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { VirtualizedScrollArea } from '@/components/ui/virtualized-scroll-area';
|
||||
import { flowRunsApi } from '@/features/flow-runs/lib/flow-runs-api';
|
||||
import { authenticationSession } from '@/lib/authentication-session';
|
||||
import {
|
||||
FlowRun,
|
||||
isFlowRunStateTerminal,
|
||||
SeekPage,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { SidebarHeader } from '../sidebar-header';
|
||||
|
||||
import { FLOW_CARD_HEIGHT, FlowRunCard } from './flow-run-card';
|
||||
|
||||
type RunsListItem =
|
||||
| { type: 'flowRun'; run: FlowRun }
|
||||
| { type: 'loadMoreButton'; id: 'loadMoreButton' };
|
||||
const RunsList = React.memo(() => {
|
||||
const [flow, setLeftSidebar, run] = useBuilderStateContext((state) => [
|
||||
state.flow,
|
||||
state.setLeftSidebar,
|
||||
state.run,
|
||||
]);
|
||||
|
||||
const {
|
||||
data: runs,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
isRefetching,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useInfiniteQuery<
|
||||
SeekPage<FlowRun>,
|
||||
Error,
|
||||
InfiniteData<SeekPage<FlowRun>>
|
||||
>({
|
||||
queryKey: ['flow-runs', flow.id],
|
||||
getNextPageParam: (lastPage) => lastPage.next,
|
||||
initialPageParam: undefined,
|
||||
queryFn: ({ pageParam }) =>
|
||||
flowRunsApi.list({
|
||||
flowId: [flow.id],
|
||||
projectId: authenticationSession.getProjectId()!,
|
||||
limit: 15,
|
||||
cursor: pageParam as string | undefined,
|
||||
}),
|
||||
refetchOnMount: true,
|
||||
staleTime: 0,
|
||||
refetchInterval: (query) => {
|
||||
const allRuns = query.state.data?.pages.flatMap((page) => page.data);
|
||||
const runningRuns = allRuns?.filter(
|
||||
(run) =>
|
||||
!isFlowRunStateTerminal({
|
||||
status: run.status,
|
||||
ignoreInternalError: false,
|
||||
}),
|
||||
);
|
||||
return runningRuns?.length ? 15 * 1000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
const allViewedRuns: RunsListItem[] = useMemo(() => {
|
||||
const allRuns = (runs?.pages.flatMap((page) => page.data) ?? []).map(
|
||||
(run) => ({ type: 'flowRun' as const, run }),
|
||||
);
|
||||
if (hasNextPage) {
|
||||
return [
|
||||
...allRuns,
|
||||
{ type: 'loadMoreButton' as const, id: 'loadMoreButton' },
|
||||
];
|
||||
}
|
||||
return allRuns;
|
||||
}, [runs, hasNextPage]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<SidebarHeader onClose={() => setLeftSidebar(LeftSideBarType.NONE)}>
|
||||
{t('Recent Runs')}
|
||||
</SidebarHeader>
|
||||
{isLoading && <CardListItemSkeleton numberOfCards={10} />}
|
||||
|
||||
{isError && <div>{t('Error, please try again.')}</div>}
|
||||
|
||||
{runs &&
|
||||
runs.pages.flatMap((page) => page.data).length === 0 &&
|
||||
!isLoading &&
|
||||
!isRefetching && <CardListEmpty message={t('No runs found')} />}
|
||||
|
||||
{runs && runs.pages.flatMap((page) => page.data).length > 0 && (
|
||||
<VirtualizedScrollArea
|
||||
className="w-full grow max-w-[calc(100%-6px)]"
|
||||
items={allViewedRuns}
|
||||
estimateSize={() => FLOW_CARD_HEIGHT}
|
||||
getItemKey={(index) => index}
|
||||
renderItem={(item) => {
|
||||
if (item.type === 'flowRun') {
|
||||
return (
|
||||
<FlowRunCard
|
||||
refetchRuns={() => {
|
||||
refetch();
|
||||
}}
|
||||
run={item.run}
|
||||
key={item.run.id + item.run.status}
|
||||
viewedRunId={run?.id}
|
||||
></FlowRunCard>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="mx-5 h-full flex items-center ">
|
||||
<Button
|
||||
className="w-full"
|
||||
variant={'accent'}
|
||||
onClick={() => fetchNextPage()}
|
||||
loading={isFetchingNextPage}
|
||||
>
|
||||
{t('More...')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
></VirtualizedScrollArea>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
RunsList.displayName = 'RunsList';
|
||||
export { RunsList };
|
||||
@@ -0,0 +1,30 @@
|
||||
import { t } from 'i18next';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
type SidebarHeaderProps = {
|
||||
children: React.ReactNode;
|
||||
onClose: () => void;
|
||||
};
|
||||
const SidebarHeader = ({ children, onClose }: SidebarHeaderProps) => {
|
||||
return (
|
||||
<div className="flex p-4 w-full gap-2 text-lg items-center">
|
||||
{children}
|
||||
<div className="grow"></div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={'sm'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
aria-label={t('Close')}
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { SidebarHeader };
|
||||
@@ -0,0 +1,79 @@
|
||||
import { t } from 'i18next';
|
||||
import React from 'react';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { BranchConditionToolbar } from '@/app/builder/step-settings/branch-settings/branch-condition-toolbar';
|
||||
import { BranchSingleCondition } from '@/app/builder/step-settings/branch-settings/branch-single-condition';
|
||||
import { HorizontalSeparatorWithText } from '@/components/ui/separator';
|
||||
import { RouterAction } from '@activepieces/shared';
|
||||
|
||||
type BranchConditionGroupProps = {
|
||||
readonly: boolean;
|
||||
groupIndex: number;
|
||||
onAnd: () => void;
|
||||
onOr: () => void;
|
||||
numberOfGroups: number;
|
||||
handleDelete: (conditionIndex: number) => void;
|
||||
branchIndex: number;
|
||||
};
|
||||
|
||||
const BranchConditionGroup = React.memo(
|
||||
({
|
||||
readonly,
|
||||
groupIndex,
|
||||
onAnd,
|
||||
onOr,
|
||||
handleDelete,
|
||||
numberOfGroups,
|
||||
branchIndex,
|
||||
}: BranchConditionGroupProps) => {
|
||||
const form = useFormContext<RouterAction>();
|
||||
const { fields } = useFieldArray({
|
||||
control: form.control,
|
||||
name: `settings.branches.${branchIndex}.conditions.${groupIndex}` as const,
|
||||
});
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{groupIndex > 0 && (
|
||||
<HorizontalSeparatorWithText className="my-2">
|
||||
{t('OR')}
|
||||
</HorizontalSeparatorWithText>
|
||||
)}
|
||||
{fields.length === 0 && (
|
||||
<BranchConditionToolbar
|
||||
readonly={readonly}
|
||||
key={`toolbar-${groupIndex}`}
|
||||
onAnd={onAnd}
|
||||
onOr={onOr}
|
||||
showOr={true}
|
||||
showAnd={true}
|
||||
/>
|
||||
)}
|
||||
{fields.map((condition, conditionIndex) => (
|
||||
<React.Fragment key={condition.id}>
|
||||
{conditionIndex > 0 && <div>{t('And If')}</div>}
|
||||
<BranchSingleCondition
|
||||
groupIndex={groupIndex}
|
||||
readonly={readonly}
|
||||
conditionIndex={conditionIndex}
|
||||
deleteClick={() => handleDelete(conditionIndex)}
|
||||
showDelete={numberOfGroups !== 1 || fields.length !== 1}
|
||||
branchIndex={branchIndex}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<BranchConditionToolbar
|
||||
onAnd={onAnd}
|
||||
onOr={onOr}
|
||||
readonly={readonly}
|
||||
showOr={true}
|
||||
showAnd={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
BranchConditionGroup.displayName = 'ConditionGroup';
|
||||
|
||||
export { BranchConditionGroup };
|
||||
@@ -0,0 +1,42 @@
|
||||
import { t } from 'i18next';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
type BranchConditionToolbarProps = {
|
||||
onAnd: () => void;
|
||||
onOr: () => void;
|
||||
showOr: boolean;
|
||||
showAnd: boolean;
|
||||
readonly: boolean;
|
||||
};
|
||||
|
||||
const BranchConditionToolbar = (props: BranchConditionToolbarProps) => {
|
||||
return (
|
||||
<div className="flex gap-2 text-center justify-end">
|
||||
{props.showAnd && (
|
||||
<Button
|
||||
variant="basic"
|
||||
size="sm"
|
||||
onClick={props.onAnd}
|
||||
disabled={props.readonly}
|
||||
>
|
||||
{t('+ And')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{props.showOr && (
|
||||
<Button
|
||||
variant="basic"
|
||||
size="sm"
|
||||
onClick={props.onOr}
|
||||
disabled={props.readonly}
|
||||
>
|
||||
{t('+ Or')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BranchConditionToolbar.displayName = 'BranchConditionToolbar';
|
||||
export { BranchConditionToolbar };
|
||||
@@ -0,0 +1,223 @@
|
||||
import { t } from 'i18next';
|
||||
import { Trash } from 'lucide-react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { SearchableSelect } from '@/components/custom/searchable-select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormField, FormItem, FormMessage } from '@/components/ui/form';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
BranchOperator,
|
||||
textConditions,
|
||||
singleValueConditions,
|
||||
RouterAction,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { InvalidStepIcon } from '../../../../components/custom/alert-icon';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '../../../../components/ui/tooltip';
|
||||
import { TextInputWithMentions } from '../../piece-properties/text-input-with-mentions';
|
||||
|
||||
const textToBranchOperation: Record<BranchOperator, string> = {
|
||||
[BranchOperator.TEXT_CONTAINS]: t('(Text) Contains'),
|
||||
[BranchOperator.TEXT_DOES_NOT_CONTAIN]: t('(Text) Does not contain'),
|
||||
[BranchOperator.TEXT_EXACTLY_MATCHES]: t('(Text) Exactly matches'),
|
||||
[BranchOperator.TEXT_DOES_NOT_EXACTLY_MATCH]: t(
|
||||
'(Text) Does not exactly match',
|
||||
),
|
||||
[BranchOperator.TEXT_STARTS_WITH]: t('(Text) Starts with'),
|
||||
[BranchOperator.TEXT_DOES_NOT_START_WITH]: t('(Text) Does not start with'),
|
||||
[BranchOperator.TEXT_ENDS_WITH]: t('(Text) Ends with'),
|
||||
[BranchOperator.TEXT_DOES_NOT_END_WITH]: t('(Text) Does not end with'),
|
||||
[BranchOperator.LIST_CONTAINS]: t('(List) Contains'),
|
||||
[BranchOperator.LIST_DOES_NOT_CONTAIN]: t('(List) Does not contain'),
|
||||
[BranchOperator.NUMBER_IS_GREATER_THAN]: t('(Number) Is greater than'),
|
||||
[BranchOperator.NUMBER_IS_LESS_THAN]: t('(Number) Is less than'),
|
||||
[BranchOperator.NUMBER_IS_EQUAL_TO]: t('(Number) Is equal to'),
|
||||
[BranchOperator.DATE_IS_AFTER]: t('(Date/time) After'),
|
||||
[BranchOperator.DATE_IS_BEFORE]: t('(Date/time) Before'),
|
||||
[BranchOperator.DATE_IS_EQUAL]: t('(Date/time) Equals'),
|
||||
[BranchOperator.BOOLEAN_IS_TRUE]: t('(Boolean) Is true'),
|
||||
[BranchOperator.BOOLEAN_IS_FALSE]: t('(Boolean) Is false'),
|
||||
[BranchOperator.LIST_IS_EMPTY]: t('(List) Is empty'),
|
||||
[BranchOperator.LIST_IS_NOT_EMPTY]: t('(List) Is not empty'),
|
||||
[BranchOperator.EXISTS]: t('Exists'),
|
||||
[BranchOperator.DOES_NOT_EXIST]: t('Does not exist'),
|
||||
};
|
||||
const operationOptions = Object.keys(textToBranchOperation).map((operator) => {
|
||||
return {
|
||||
label: textToBranchOperation[operator as BranchOperator],
|
||||
value: operator,
|
||||
};
|
||||
});
|
||||
|
||||
type BranchSingleConditionProps = {
|
||||
showDelete: boolean;
|
||||
groupIndex: number;
|
||||
conditionIndex: number;
|
||||
readonly: boolean;
|
||||
deleteClick: () => void;
|
||||
branchIndex: number;
|
||||
};
|
||||
|
||||
const BranchSingleCondition = ({
|
||||
deleteClick,
|
||||
groupIndex,
|
||||
conditionIndex,
|
||||
showDelete,
|
||||
readonly,
|
||||
branchIndex,
|
||||
}: BranchSingleConditionProps) => {
|
||||
const form = useFormContext<RouterAction>();
|
||||
|
||||
const condition = form.getValues(
|
||||
`settings.branches.${branchIndex}.conditions.${groupIndex}.${conditionIndex}`,
|
||||
);
|
||||
|
||||
const isTextCondition =
|
||||
condition.operator && textConditions.includes(condition?.operator);
|
||||
const isSingleValueCondition =
|
||||
condition.operator && singleValueConditions.includes(condition?.operator);
|
||||
const isInvalid = isSingleValueCondition
|
||||
? condition.firstValue.length === 0
|
||||
: condition.firstValue.length === 0 ||
|
||||
('secondValue' in condition && condition.secondValue?.length === 0);
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{isInvalid && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InvalidStepIcon className="h-4 w-4 shrink-0"></InvalidStepIcon>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{t('Incomplete condition')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div
|
||||
className={cn('grid gap-2 grow', {
|
||||
'grid-cols-2': isSingleValueCondition,
|
||||
'grid-cols-3': !isSingleValueCondition,
|
||||
})}
|
||||
>
|
||||
<FormField
|
||||
name={`settings.branches.${branchIndex}.conditions.${groupIndex}.${conditionIndex}.firstValue`}
|
||||
control={form.control}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<TextInputWithMentions
|
||||
disabled={readonly}
|
||||
placeholder={t('First value')}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
form.trigger();
|
||||
}}
|
||||
initialValue={field.value}
|
||||
></TextInputWithMentions>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
name={`settings.branches.${branchIndex}.conditions.${groupIndex}.${conditionIndex}.operator`}
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<SearchableSelect
|
||||
disabled={readonly}
|
||||
value={field.value}
|
||||
options={operationOptions}
|
||||
placeholder={''}
|
||||
onChange={(e) => {
|
||||
if (
|
||||
isSingleValueCondition &&
|
||||
e !== null &&
|
||||
!singleValueConditions.includes(e as BranchOperator)
|
||||
) {
|
||||
//TODO: fix this
|
||||
//@ts-expect-ignore
|
||||
form.setValue(
|
||||
`settings.branches.${branchIndex}.conditions.${groupIndex}.${conditionIndex}.secondValue`,
|
||||
'' as any,
|
||||
);
|
||||
}
|
||||
field.onChange(e);
|
||||
form.trigger();
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{!isSingleValueCondition && (
|
||||
<FormField
|
||||
name={`settings.branches.${branchIndex}.conditions.${groupIndex}.${conditionIndex}.secondValue`}
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<TextInputWithMentions
|
||||
placeholder={t('Second value')}
|
||||
disabled={readonly}
|
||||
initialValue={field.value || ''}
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
form.trigger();
|
||||
}}
|
||||
></TextInputWithMentions>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-start items-center gap-2 mt-2">
|
||||
{isTextCondition && (
|
||||
<FormField
|
||||
name={`settings.branches.${branchIndex}.conditions.${groupIndex}.${conditionIndex}.caseSensitive`}
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2 p-1">
|
||||
<Switch
|
||||
disabled={readonly}
|
||||
id="case-sensitive"
|
||||
checked={field.value}
|
||||
onCheckedChange={(e) => field.onChange(e)}
|
||||
/>
|
||||
<Label htmlFor="case-sensitive">{t('Case sensitive')}</Label>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="grow"></div>
|
||||
<div>
|
||||
{showDelete && (
|
||||
<Button
|
||||
variant={'basic'}
|
||||
className="text-destructive gap-2 items-center"
|
||||
size={'sm'}
|
||||
onClick={deleteClick}
|
||||
>
|
||||
<Trash className="w-4 h-4"></Trash> {t('Remove')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
BranchSingleCondition.displayName = 'BranchSingleCondition';
|
||||
export { BranchSingleCondition };
|
||||
@@ -0,0 +1,74 @@
|
||||
import { t } from 'i18next';
|
||||
import React from 'react';
|
||||
import { useFormContext, useFieldArray } from 'react-hook-form';
|
||||
|
||||
import { BranchConditionGroup } from '@/app/builder/step-settings/branch-settings/branch-condition-group';
|
||||
import { emptyCondition } from '@activepieces/shared';
|
||||
|
||||
type BranchSettingsProps = {
|
||||
readonly: boolean;
|
||||
branchIndex: number;
|
||||
};
|
||||
|
||||
const BranchSettings = React.memo(
|
||||
({ readonly, branchIndex }: BranchSettingsProps) => {
|
||||
const form = useFormContext();
|
||||
const { fields, append, remove, update } = useFieldArray({
|
||||
control: form.control,
|
||||
name: `settings.branches.${branchIndex}.conditions`,
|
||||
});
|
||||
|
||||
const handleDelete = (groupIndex: number, conditionIndex: number) => {
|
||||
const conditions = form.getValues(
|
||||
`settings.branches.${branchIndex}.conditions`,
|
||||
);
|
||||
const newConditionsGroup = [...conditions[groupIndex]];
|
||||
const isSingleGroup = conditions.length === 1;
|
||||
const isSingleConditionInGroup = newConditionsGroup.length === 1;
|
||||
|
||||
if (isSingleGroup && isSingleConditionInGroup) {
|
||||
update(groupIndex, [emptyCondition]);
|
||||
} else if (isSingleConditionInGroup) {
|
||||
remove(groupIndex);
|
||||
} else {
|
||||
newConditionsGroup.splice(conditionIndex, 1);
|
||||
update(groupIndex, newConditionsGroup);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnd = (groupIndex: number) => {
|
||||
const conditions = form.getValues(
|
||||
`settings.branches.${branchIndex}.conditions`,
|
||||
);
|
||||
conditions[groupIndex] = [...conditions[groupIndex], emptyCondition];
|
||||
update(groupIndex, conditions[groupIndex]);
|
||||
};
|
||||
|
||||
const handleOr = () => {
|
||||
append([[emptyCondition]]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4" onSubmit={(e) => e.preventDefault()}>
|
||||
<div className="text-md ">{t('Execute If')}</div>
|
||||
{fields.map((fieldGroup, groupIndex) => (
|
||||
<BranchConditionGroup
|
||||
key={fieldGroup.id}
|
||||
readonly={readonly}
|
||||
branchIndex={branchIndex}
|
||||
numberOfGroups={fields.length}
|
||||
groupIndex={groupIndex}
|
||||
onAnd={() => handleAnd(groupIndex)}
|
||||
onOr={handleOr}
|
||||
handleDelete={(conditionIndex: number) =>
|
||||
handleDelete(groupIndex, conditionIndex)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
BranchSettings.displayName = 'BranchSettings';
|
||||
export { BranchSettings };
|
||||
@@ -0,0 +1,138 @@
|
||||
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 { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
const formSchema = Type.Object({
|
||||
packageName: Type.String({
|
||||
minLength: 1,
|
||||
errorMessage: t('The package name is required'),
|
||||
}),
|
||||
});
|
||||
|
||||
type AddNpmDialogProps = {
|
||||
children: React.ReactNode;
|
||||
onAdd: ({
|
||||
packageName,
|
||||
packageVersion,
|
||||
}: {
|
||||
packageName: string;
|
||||
packageVersion: string;
|
||||
}) => void;
|
||||
};
|
||||
const AddNpmDialog = ({ children, onAdd }: AddNpmDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const form = useForm<Static<typeof formSchema>>({
|
||||
defaultValues: {},
|
||||
resolver: typeboxResolver(formSchema),
|
||||
});
|
||||
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { packageName } = form.getValues();
|
||||
const response = await api.get<{ 'dist-tags': { latest: string } }>(
|
||||
`https://registry.npmjs.org/${packageName}`,
|
||||
);
|
||||
return {
|
||||
packageName,
|
||||
packageVersion: response['dist-tags'].latest,
|
||||
};
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
onAdd(response);
|
||||
setOpen(false);
|
||||
toast.success(t('Package added successfully'), {
|
||||
duration: 3000,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
form.setError('root.serverError', {
|
||||
message: t('Could not fetch package version'),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(open) => setOpen(open)}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Add NPM Package')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Type the name of the npm package you want to add.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={(e) => form.handleSubmit(() => mutate())(e)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="packageName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label htmlFor="packageName">{t('Package Name')}</Label>
|
||||
<Input
|
||||
{...field}
|
||||
id="packageName"
|
||||
type="text"
|
||||
placeholder="hello-world"
|
||||
className="rounded-sm"
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormDescription>
|
||||
{t('The latest version will be fetched and added')}
|
||||
</FormDescription>
|
||||
{form?.formState?.errors?.root?.serverError && (
|
||||
<FormMessage>
|
||||
{form.formState.errors.root.serverError.message}
|
||||
</FormMessage>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" loading={isPending} onClick={() => mutate()}>
|
||||
{t('Add')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
AddNpmDialog.displayName = 'AddNpmDialog';
|
||||
export { AddNpmDialog };
|
||||
@@ -0,0 +1,174 @@
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { githubDark, githubLight } from '@uiw/codemirror-theme-github';
|
||||
import CodeMirror, { EditorState, EditorView } from '@uiw/react-codemirror';
|
||||
import { t } from 'i18next';
|
||||
import { Code, Package } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import { useTheme } from '@/components/theme-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { internalErrorToast } from '@/components/ui/sonner';
|
||||
import { flagsHooks } from '@/hooks/flags-hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ApFlagId, SourceCode, deepMergeAndCast } from '@activepieces/shared';
|
||||
|
||||
import { AddNpmDialog } from './add-npm-dialog';
|
||||
|
||||
const styleTheme = EditorView.baseTheme({
|
||||
'&.cm-editor.cm-focused': {
|
||||
outline: 'none',
|
||||
},
|
||||
});
|
||||
|
||||
type CodeEditorProps = {
|
||||
sourceCode: SourceCode;
|
||||
onChange: (sourceCode: SourceCode) => void;
|
||||
readonly: boolean;
|
||||
applyCodeToCurrentStep?: () => void;
|
||||
minHeight?: string;
|
||||
};
|
||||
|
||||
const CodeEditor = ({
|
||||
sourceCode,
|
||||
readonly,
|
||||
onChange,
|
||||
applyCodeToCurrentStep,
|
||||
minHeight,
|
||||
}: CodeEditorProps) => {
|
||||
const { code, packageJson } = sourceCode;
|
||||
const [activeTab, setActiveTab] = useState<keyof SourceCode>('code');
|
||||
const [language, setLanguage] = useState<'typescript' | 'json'>('typescript');
|
||||
const codeApplicationEnabled = typeof applyCodeToCurrentStep === 'function';
|
||||
const { theme } = useTheme();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const codeEditorTheme = theme === 'dark' ? githubDark : githubLight;
|
||||
|
||||
const { data: allowNpmPackagesInCodeStep } = flagsHooks.useFlag<boolean>(
|
||||
ApFlagId.ALLOW_NPM_PACKAGES_IN_CODE_STEP,
|
||||
);
|
||||
|
||||
const extensions = [
|
||||
styleTheme,
|
||||
EditorState.readOnly.of(readonly),
|
||||
EditorView.editable.of(!readonly),
|
||||
language === 'json' ? json() : javascript({ jsx: false, typescript: true }),
|
||||
];
|
||||
|
||||
function handlePackageClick() {
|
||||
setActiveTab('packageJson');
|
||||
setLanguage('json');
|
||||
}
|
||||
|
||||
function handleCodeClick() {
|
||||
setActiveTab('code');
|
||||
setLanguage('typescript');
|
||||
}
|
||||
|
||||
function handleAddPackages({
|
||||
packageName,
|
||||
packageVersion,
|
||||
}: {
|
||||
packageName: string;
|
||||
packageVersion: string;
|
||||
}) {
|
||||
try {
|
||||
const json = deepMergeAndCast(JSON.parse(packageJson), {
|
||||
dependencies: {
|
||||
[packageName]: packageVersion,
|
||||
},
|
||||
});
|
||||
setActiveTab('packageJson');
|
||||
onChange({ code, packageJson: JSON.stringify(json, null, 2) });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
internalErrorToast();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col gap-2 border rounded py-2 px-2 transition-all"
|
||||
ref={containerRef}
|
||||
>
|
||||
<div className="flex flex-row justify-center items-center h-full">
|
||||
<div className="flex justify-start gap-4 items-center">
|
||||
<div
|
||||
className={cn('text-sm cursor-pointer', {
|
||||
'font-bold': activeTab === 'code',
|
||||
})}
|
||||
onClick={() => handleCodeClick()}
|
||||
>
|
||||
{t('Code')}
|
||||
</div>
|
||||
{allowNpmPackagesInCodeStep && (
|
||||
<div
|
||||
className={cn('text-sm cursor-pointer', {
|
||||
'font-bold': activeTab === 'packageJson',
|
||||
})}
|
||||
onClick={() => handlePackageClick()}
|
||||
>
|
||||
{t('Dependencies')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex grow"></div>
|
||||
{codeApplicationEnabled ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex gap-2"
|
||||
size={'sm'}
|
||||
onClick={applyCodeToCurrentStep}
|
||||
>
|
||||
<Code className="w-3 h-3" />
|
||||
{t('Use code')}
|
||||
</Button>
|
||||
) : (
|
||||
allowNpmPackagesInCodeStep && (
|
||||
<AddNpmDialog onAdd={handleAddPackages}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex gap-2"
|
||||
size={'sm'}
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Package className="w-4 h-4" />
|
||||
{t('Add package')}
|
||||
</Button>
|
||||
</AddNpmDialog>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<CodeMirror
|
||||
value={activeTab === 'code' ? code : packageJson}
|
||||
className="border-none"
|
||||
minHeight={minHeight ?? '200px'}
|
||||
width="100%"
|
||||
height="100%"
|
||||
maxWidth="100%"
|
||||
basicSetup={{
|
||||
foldGutter: true,
|
||||
lineNumbers: true,
|
||||
searchKeymap: false,
|
||||
lintKeymap: true,
|
||||
autocompletion: true,
|
||||
foldKeymap: true,
|
||||
}}
|
||||
lang="typescript"
|
||||
onChange={(value) => {
|
||||
onChange(
|
||||
activeTab === 'code'
|
||||
? { code: value, packageJson }
|
||||
: { code, packageJson: value },
|
||||
);
|
||||
}}
|
||||
theme={codeEditorTheme}
|
||||
readOnly={readonly}
|
||||
extensions={[...extensions, EditorView.lineWrapping]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { CodeEditor };
|
||||
@@ -0,0 +1,84 @@
|
||||
import { t } from 'i18next';
|
||||
import React from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { ApMarkdown } from '@/components/custom/markdown';
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { CodeAction, MarkdownVariant } from '@activepieces/shared';
|
||||
|
||||
import { DictionaryProperty } from '../../piece-properties/dictionary-property';
|
||||
|
||||
import { CodeEditor } from './code-editor';
|
||||
|
||||
const markdown = `
|
||||
To use data from previous steps in your code, include them as pairs of keys and values below.
|
||||
|
||||
You can access these inputs in your code using \`inputs.key\`, where \`key\` is the name you assigned below.
|
||||
`;
|
||||
|
||||
const warningMarkdown = `
|
||||
**const code** is the entry to the code. If it is removed or renamed, your step will fail.
|
||||
`;
|
||||
|
||||
type CodeSettingsProps = {
|
||||
readonly: boolean;
|
||||
};
|
||||
|
||||
const CodeSettings = React.memo(({ readonly }: CodeSettingsProps) => {
|
||||
const form = useFormContext<CodeAction>();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settings.input"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="pb-4">
|
||||
<ApMarkdown markdown={markdown} variant={MarkdownVariant.INFO} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-2!">
|
||||
<FormLabel>{t('Inputs')}</FormLabel>
|
||||
</div>
|
||||
|
||||
<DictionaryProperty
|
||||
disabled={readonly}
|
||||
values={field.value}
|
||||
onChange={field.onChange}
|
||||
useMentionTextInput={true}
|
||||
></DictionaryProperty>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<ApMarkdown
|
||||
markdown={warningMarkdown}
|
||||
variant={MarkdownVariant.WARNING}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settings.sourceCode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<CodeEditor
|
||||
sourceCode={field.value}
|
||||
onChange={field.onChange}
|
||||
readonly={readonly}
|
||||
></CodeEditor>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
CodeSettings.displayName = 'CodeSettings';
|
||||
export { CodeSettings };
|
||||
@@ -0,0 +1,85 @@
|
||||
import { t } from 'i18next'; // Import t directly from i18next
|
||||
import { Pencil } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
import EditableText from '@/components/ui/editable-text';
|
||||
import { isNil } from '@activepieces/shared';
|
||||
|
||||
interface EditableStepNameProps {
|
||||
selectedBranchIndex: number | null;
|
||||
displayName: string;
|
||||
branchName: string | undefined;
|
||||
setDisplayName: (value: string) => void;
|
||||
setBranchName: (value: string) => void;
|
||||
readonly: boolean;
|
||||
isEditingStepOrBranchName: boolean;
|
||||
setIsEditingStepOrBranchName: (isEditing: boolean) => void;
|
||||
setSelectedBranchIndex: (index: number | null) => void;
|
||||
}
|
||||
|
||||
const EditableStepName: React.FC<EditableStepNameProps> = ({
|
||||
selectedBranchIndex,
|
||||
displayName,
|
||||
branchName,
|
||||
setDisplayName,
|
||||
setBranchName,
|
||||
readonly,
|
||||
isEditingStepOrBranchName,
|
||||
setIsEditingStepOrBranchName,
|
||||
setSelectedBranchIndex,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{isNil(selectedBranchIndex) ? (
|
||||
<EditableText
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
setDisplayName(value);
|
||||
}
|
||||
}}
|
||||
readonly={readonly}
|
||||
value={displayName}
|
||||
tooltipContent={readonly ? '' : t('Edit Step Name')}
|
||||
isEditing={isEditingStepOrBranchName}
|
||||
setIsEditing={setIsEditingStepOrBranchName}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="truncate cursor-pointer hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedBranchIndex(null);
|
||||
}}
|
||||
>
|
||||
{displayName}
|
||||
</div>
|
||||
/
|
||||
<EditableText
|
||||
key={branchName}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
setBranchName(value);
|
||||
}
|
||||
}}
|
||||
readonly={readonly}
|
||||
value={branchName}
|
||||
tooltipContent={readonly ? '' : t('Edit Branch Name')}
|
||||
isEditing={isEditingStepOrBranchName}
|
||||
setIsEditing={setIsEditingStepOrBranchName}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!isEditingStepOrBranchName && !readonly && (
|
||||
<Pencil
|
||||
className="h-4 w-4 shrink-0"
|
||||
onClick={() => {
|
||||
setIsEditingStepOrBranchName(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditableStepName;
|
||||
@@ -0,0 +1,250 @@
|
||||
import { typeboxResolver } from '@hookform/resolvers/typebox';
|
||||
import deepEqual from 'deep-equal';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { useBuilderStateContext } from '@/app/builder/builder-hooks';
|
||||
import { Form } from '@/components/ui/form';
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from '@/components/ui/resizable-panel';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { stepsHooks } from '@/features/pieces/lib/steps-hooks';
|
||||
import { projectHooks } from '@/hooks/project-hooks';
|
||||
import {
|
||||
FlowAction,
|
||||
FlowActionType,
|
||||
FlowOperationType,
|
||||
FlowTrigger,
|
||||
FlowTriggerType,
|
||||
isNil,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { formUtils } from '../../../features/pieces/lib/form-utils';
|
||||
import { ActionErrorHandlingForm } from '../piece-properties/action-error-handling';
|
||||
import { DynamicPropertiesProvider } from '../piece-properties/dynamic-properties-context';
|
||||
import { SidebarHeader } from '../sidebar-header';
|
||||
import { TestStepContainer } from '../test-step';
|
||||
|
||||
import { CodeSettings } from './code-settings';
|
||||
import EditableStepName from './editable-step-name';
|
||||
import { LoopsSettings } from './loops-settings';
|
||||
import { PieceSettings } from './piece-settings';
|
||||
import { RouterSettings } from './router-settings';
|
||||
import { StepInfo } from './step-info';
|
||||
import { useStepSettingsContext } from './step-settings-context';
|
||||
const StepSettingsContainer = () => {
|
||||
const { selectedStep, pieceModel, formSchema } = useStepSettingsContext();
|
||||
const { project } = projectHooks.useCurrentProject();
|
||||
const [
|
||||
readonly,
|
||||
exitStepSettings,
|
||||
applyOperation,
|
||||
saving,
|
||||
flowVersion,
|
||||
selectedBranchIndex,
|
||||
setSelectedBranchIndex,
|
||||
] = useBuilderStateContext((state) => [
|
||||
state.readonly,
|
||||
state.exitStepSettings,
|
||||
state.applyOperation,
|
||||
state.saving,
|
||||
state.flowVersion,
|
||||
state.selectedBranchIndex,
|
||||
state.setSelectedBranchIndex,
|
||||
]);
|
||||
|
||||
const { stepMetadata } = stepsHooks.useStepMetadata({
|
||||
step: selectedStep,
|
||||
});
|
||||
|
||||
const currentValuesRef = useRef<FlowAction | FlowTrigger>(selectedStep);
|
||||
const form = useForm<FlowAction | FlowTrigger>({
|
||||
mode: 'all',
|
||||
disabled: readonly,
|
||||
reValidateMode: 'onChange',
|
||||
defaultValues: selectedStep,
|
||||
resolver: async (values, context, options) => {
|
||||
const result = await typeboxResolver(formSchema)(
|
||||
values,
|
||||
context,
|
||||
options,
|
||||
);
|
||||
|
||||
const cleanedNewValues = formUtils.removeUndefinedFromInput(values);
|
||||
const cleanedCurrentValues = formUtils.removeUndefinedFromInput(
|
||||
currentValuesRef.current,
|
||||
);
|
||||
if (
|
||||
cleanedNewValues.type === FlowTriggerType.EMPTY ||
|
||||
(isNil(pieceModel) &&
|
||||
(cleanedNewValues.type === FlowActionType.PIECE ||
|
||||
cleanedNewValues.type === FlowTriggerType.PIECE))
|
||||
) {
|
||||
return result;
|
||||
}
|
||||
if (deepEqual(cleanedNewValues, cleanedCurrentValues)) {
|
||||
return result;
|
||||
}
|
||||
const valid = Object.keys(result.errors).length === 0;
|
||||
//We need to copy the object because the form is using the same object reference
|
||||
currentValuesRef.current = JSON.parse(JSON.stringify(cleanedNewValues));
|
||||
if (cleanedNewValues.type === FlowTriggerType.PIECE) {
|
||||
applyOperation({
|
||||
type: FlowOperationType.UPDATE_TRIGGER,
|
||||
request: {
|
||||
...cleanedNewValues,
|
||||
valid,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
applyOperation({
|
||||
type: FlowOperationType.UPDATE_ACTION,
|
||||
request: {
|
||||
...cleanedNewValues,
|
||||
valid,
|
||||
},
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
const sidebarHeaderContainerRef = useRef<HTMLDivElement>(null);
|
||||
const modifiedStep = form.getValues();
|
||||
const [isEditingStepOrBranchName, setIsEditingStepOrBranchName] =
|
||||
useState(false);
|
||||
const showActionErrorHandlingForm =
|
||||
[FlowActionType.CODE, FlowActionType.PIECE].includes(
|
||||
modifiedStep.type as FlowActionType,
|
||||
) && !isNil(stepMetadata);
|
||||
|
||||
useEffect(() => {
|
||||
//RHF doesn't automatically trigger validation when the form is rendered, so we need to trigger it manually
|
||||
form.trigger();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
onChange={(e) => e.preventDefault()}
|
||||
className="w-full h-full"
|
||||
>
|
||||
<div ref={sidebarHeaderContainerRef}>
|
||||
<SidebarHeader onClose={() => exitStepSettings()}>
|
||||
<EditableStepName
|
||||
selectedBranchIndex={selectedBranchIndex}
|
||||
setDisplayName={(value) => {
|
||||
form.setValue('displayName', value, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}}
|
||||
readonly={readonly}
|
||||
displayName={modifiedStep.displayName}
|
||||
branchName={
|
||||
!isNil(selectedBranchIndex)
|
||||
? modifiedStep.settings.branches?.[selectedBranchIndex]
|
||||
?.branchName
|
||||
: undefined
|
||||
}
|
||||
setBranchName={(value) => {
|
||||
if (!isNil(selectedBranchIndex)) {
|
||||
form.setValue(
|
||||
`settings.branches[${selectedBranchIndex}].branchName`,
|
||||
value,
|
||||
{
|
||||
shouldValidate: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
setSelectedBranchIndex={setSelectedBranchIndex}
|
||||
isEditingStepOrBranchName={isEditingStepOrBranchName}
|
||||
setIsEditingStepOrBranchName={setIsEditingStepOrBranchName}
|
||||
></EditableStepName>
|
||||
</SidebarHeader>
|
||||
</div>
|
||||
<DynamicPropertiesProvider
|
||||
key={`${selectedStep.name}-${selectedStep.type}`}
|
||||
>
|
||||
<ResizablePanelGroup direction="vertical">
|
||||
<ResizablePanel defaultSize={55} className="min-h-[80px]">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="flex flex-col gap-2 px-4 pb-6">
|
||||
<StepInfo step={modifiedStep}></StepInfo>
|
||||
|
||||
{modifiedStep.type === FlowActionType.LOOP_ON_ITEMS && (
|
||||
<LoopsSettings readonly={readonly}></LoopsSettings>
|
||||
)}
|
||||
{modifiedStep.type === FlowActionType.CODE && (
|
||||
<CodeSettings readonly={readonly}></CodeSettings>
|
||||
)}
|
||||
{modifiedStep.type === FlowActionType.PIECE &&
|
||||
modifiedStep && (
|
||||
<PieceSettings
|
||||
step={modifiedStep}
|
||||
flowId={flowVersion.flowId}
|
||||
readonly={readonly}
|
||||
></PieceSettings>
|
||||
)}
|
||||
{modifiedStep.type === FlowActionType.ROUTER &&
|
||||
modifiedStep && (
|
||||
<RouterSettings readonly={readonly}></RouterSettings>
|
||||
)}
|
||||
{modifiedStep.type === FlowTriggerType.PIECE &&
|
||||
modifiedStep && (
|
||||
<PieceSettings
|
||||
step={modifiedStep}
|
||||
flowId={flowVersion.flowId}
|
||||
readonly={readonly}
|
||||
></PieceSettings>
|
||||
)}
|
||||
{showActionErrorHandlingForm && (
|
||||
<ActionErrorHandlingForm
|
||||
hideContinueOnFailure={
|
||||
stepMetadata.type === FlowActionType.PIECE
|
||||
? stepMetadata.errorHandlingOptions?.continueOnFailure
|
||||
?.hide
|
||||
: false
|
||||
}
|
||||
disabled={readonly}
|
||||
hideRetryOnFailure={
|
||||
stepMetadata.type === FlowActionType.PIECE
|
||||
? stepMetadata.errorHandlingOptions?.retryOnFailure
|
||||
?.hide
|
||||
: false
|
||||
}
|
||||
></ActionErrorHandlingForm>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</ResizablePanel>
|
||||
{!readonly && (
|
||||
<>
|
||||
<ResizableHandle withHandle={true} />
|
||||
<ResizablePanel defaultSize={45} className="min-h-[130px]">
|
||||
<ScrollArea className="h-[calc(100%-35px)] p-4 pb-10 ">
|
||||
{modifiedStep.type && (
|
||||
<TestStepContainer
|
||||
type={modifiedStep.type}
|
||||
flowId={flowVersion.flowId}
|
||||
flowVersionId={flowVersion.id}
|
||||
projectId={project?.id}
|
||||
isSaving={saving}
|
||||
></TestStepContainer>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
</DynamicPropertiesProvider>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
StepSettingsContainer.displayName = 'StepSettingsContainer';
|
||||
export { StepSettingsContainer };
|
||||
@@ -0,0 +1,45 @@
|
||||
import { t } from 'i18next';
|
||||
import React from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { ApMarkdown } from '@/components/custom/markdown';
|
||||
import { FormField, FormItem, FormLabel } from '@/components/ui/form';
|
||||
import { LoopOnItemsAction } from '@activepieces/shared';
|
||||
|
||||
import { TextInputWithMentions } from '../piece-properties/text-input-with-mentions';
|
||||
|
||||
const markdown = t(
|
||||
'Select the items to iterate over from the previous step by clicking on the **Items** input, which should be a **list** of items.\n\nThe loop will iterate over each item in the list and execute the next step for every item.',
|
||||
);
|
||||
|
||||
type LoopsSettingsProps = {
|
||||
readonly: boolean;
|
||||
};
|
||||
|
||||
const LoopsSettings = React.memo(({ readonly }: LoopsSettingsProps) => {
|
||||
const form = useFormContext<LoopOnItemsAction>();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settings.items"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col gap-2">
|
||||
<ApMarkdown markdown={markdown} />
|
||||
<FormLabel>
|
||||
{t('Items')} <span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<TextInputWithMentions
|
||||
disabled={readonly}
|
||||
onChange={field.onChange}
|
||||
initialValue={field.value}
|
||||
placeholder={t('Select an array of items')}
|
||||
></TextInputWithMentions>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
LoopsSettings.displayName = 'LoopsSettings';
|
||||
export { LoopsSettings };
|
||||
@@ -0,0 +1,293 @@
|
||||
import { t } from 'i18next';
|
||||
import { Plus, Globe } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { AutoFormFieldWrapper } from '@/app/builder/piece-properties/auto-form-field-wrapper';
|
||||
import { CreateOrEditConnectionDialog } from '@/app/connections/create-edit-connection-dialog';
|
||||
import { PermissionNeededTooltip } from '@/components/custom/permission-needed-tooltip';
|
||||
import { SearchableSelect } from '@/components/custom/searchable-select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormField, FormLabel } from '@/components/ui/form';
|
||||
import {
|
||||
Select,
|
||||
SelectAction,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { appConnectionsQueries } from '@/features/connections/lib/app-connections-hooks';
|
||||
import { piecesHooks } from '@/features/pieces/lib/pieces-hooks';
|
||||
import {
|
||||
useAuthorization,
|
||||
useIsPlatformAdmin,
|
||||
} from '@/hooks/authorization-hooks';
|
||||
import { authenticationSession } from '@/lib/authentication-session';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
PieceMetadataModel,
|
||||
PieceMetadataModelSummary,
|
||||
} from '@activepieces/pieces-framework';
|
||||
import {
|
||||
AppConnectionScope,
|
||||
AppConnectionWithoutSensitiveData,
|
||||
Permission,
|
||||
PieceAction,
|
||||
PieceTrigger,
|
||||
PropertyExecutionType,
|
||||
isNil,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
function ConnectionSelect(params: ConnectionSelectProps) {
|
||||
const [connectionDialogOpen, setConnectionDialogOpen] = useState(false);
|
||||
const [selectConnectionOpen, setSelectConnectionOpen] = useState(false);
|
||||
const [reconnectConnection, setReconnectConnection] =
|
||||
useState<AppConnectionWithoutSensitiveData | null>(null);
|
||||
//in case of reconnection we need to use the piece version from the connection
|
||||
const { pieceModel: pieceWithCorrectVersion, isLoading: isLoadingPiece } =
|
||||
piecesHooks.usePiece({
|
||||
name: params.piece.name,
|
||||
version: reconnectConnection?.pieceVersion ?? params.piece.version,
|
||||
});
|
||||
const form = useFormContext<PieceAction | PieceTrigger>();
|
||||
const hasPermissionToCreateConnection = useAuthorization().checkAccess(
|
||||
Permission.WRITE_APP_CONNECTION,
|
||||
);
|
||||
const {
|
||||
data: connections,
|
||||
isLoading: isLoadingConnections,
|
||||
refetch,
|
||||
} = appConnectionsQueries.useAppConnections({
|
||||
request: {
|
||||
pieceName: params.piece.name,
|
||||
projectId: authenticationSession.getProjectId()!,
|
||||
limit: 1000,
|
||||
},
|
||||
pieceAuth: params.piece.auth,
|
||||
extraKeys: [params.piece.name, authenticationSession.getProjectId()!],
|
||||
staleTime: 0,
|
||||
});
|
||||
const selectedConnection = connections?.data?.find(
|
||||
(connection) =>
|
||||
connection.externalId ===
|
||||
removeBrackets(form.getValues().settings.input.auth ?? ''),
|
||||
);
|
||||
const isGlobalConnection =
|
||||
selectedConnection?.scope === AppConnectionScope.PLATFORM;
|
||||
const dynamicInputModeToggled =
|
||||
form.getValues().settings.propertySettings['auth']?.type ===
|
||||
PropertyExecutionType.DYNAMIC;
|
||||
const isPLatformAdmin = useIsPlatformAdmin();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
key={form.getValues().settings.input.auth}
|
||||
name={'settings.input.auth'}
|
||||
render={({ field }) => (
|
||||
<>
|
||||
{(isLoadingConnections || !pieceWithCorrectVersion) && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<FormLabel>
|
||||
{t('Connection')} <span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<SearchableSelect
|
||||
options={[]}
|
||||
disabled={true}
|
||||
loading={isLoadingConnections}
|
||||
placeholder={t('Select a connection')}
|
||||
value={field.value as React.Key}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
showDeselect={false}
|
||||
onRefresh={() => {}}
|
||||
showRefresh={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingConnections &&
|
||||
pieceWithCorrectVersion &&
|
||||
params.piece.auth && (
|
||||
<AutoFormFieldWrapper
|
||||
property={params.piece.auth}
|
||||
propertyName="auth"
|
||||
field={field}
|
||||
disabled={params.disabled}
|
||||
inputName="settings.input.auth"
|
||||
allowDynamicValues={!params.isTrigger}
|
||||
dynamicInputModeToggled={dynamicInputModeToggled}
|
||||
isForConnectionSelect={true}
|
||||
>
|
||||
<CreateOrEditConnectionDialog
|
||||
reconnectConnection={reconnectConnection}
|
||||
isGlobalConnection={isGlobalConnection}
|
||||
piece={pieceWithCorrectVersion}
|
||||
key={`CreateOrEditConnectionDialog-open-${connectionDialogOpen}`}
|
||||
open={connectionDialogOpen}
|
||||
setOpen={(open, connection) => {
|
||||
setConnectionDialogOpen(open);
|
||||
if (connection) {
|
||||
refetch();
|
||||
field.onChange(addBrackets(connection.externalId));
|
||||
}
|
||||
}}
|
||||
></CreateOrEditConnectionDialog>
|
||||
<Select
|
||||
open={selectConnectionOpen}
|
||||
onOpenChange={setSelectConnectionOpen}
|
||||
defaultValue={field.value as string | undefined}
|
||||
onValueChange={field.onChange}
|
||||
disabled={params.disabled}
|
||||
>
|
||||
<div className="relative">
|
||||
{field.value &&
|
||||
!field.disabled &&
|
||||
selectedConnection &&
|
||||
(!isGlobalConnection || isPLatformAdmin) && (
|
||||
<div className="z-50 absolute right-8 top-2 ">
|
||||
<PermissionNeededTooltip
|
||||
hasPermission={hasPermissionToCreateConnection}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
loading={isLoadingPiece}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setReconnectConnection(selectedConnection);
|
||||
setSelectConnectionOpen(false);
|
||||
setConnectionDialogOpen(true);
|
||||
}}
|
||||
disabled={!hasPermissionToCreateConnection}
|
||||
>
|
||||
{t('Reconnect')}
|
||||
</Button>
|
||||
</PermissionNeededTooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SelectTrigger className="flex gap-2 items-center">
|
||||
<SelectValue
|
||||
className="truncate grow shrink"
|
||||
placeholder={t('Select a connection')}
|
||||
data-testid="select-connection-value"
|
||||
>
|
||||
{!isNil(field.value) &&
|
||||
!isNil(
|
||||
connections?.data?.find(
|
||||
(connection) =>
|
||||
connection.externalId ===
|
||||
removeBrackets(field.value),
|
||||
),
|
||||
) ? (
|
||||
<div className="truncate grow shrink flex items-center gap-2">
|
||||
{connections?.data?.find(
|
||||
(connection) =>
|
||||
connection.externalId ===
|
||||
removeBrackets(field.value),
|
||||
)?.scope === AppConnectionScope.PLATFORM && (
|
||||
<Globe size={16} className="shrink-0" />
|
||||
)}
|
||||
{
|
||||
connections?.data?.find(
|
||||
(connection) =>
|
||||
connection.externalId ===
|
||||
removeBrackets(field.value),
|
||||
)?.displayName
|
||||
}
|
||||
</div>
|
||||
) : null}
|
||||
</SelectValue>
|
||||
<div className="grow"></div>
|
||||
{field.value &&
|
||||
connections?.data?.find(
|
||||
(connection) =>
|
||||
connection.externalId ===
|
||||
removeBrackets(field.value) &&
|
||||
connection.scope !== AppConnectionScope.PLATFORM,
|
||||
) && (
|
||||
<span
|
||||
role="button"
|
||||
className="z-50 opacity-0 pointer-events-none"
|
||||
>
|
||||
{t('Reconnect')}
|
||||
</span>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
</div>
|
||||
|
||||
<SelectContent>
|
||||
<PermissionNeededTooltip
|
||||
hasPermission={hasPermissionToCreateConnection}
|
||||
>
|
||||
<SelectAction
|
||||
onClick={() => {
|
||||
setSelectConnectionOpen(false);
|
||||
setReconnectConnection(null);
|
||||
setConnectionDialogOpen(true);
|
||||
}}
|
||||
disabled={!hasPermissionToCreateConnection}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-primary w-full',
|
||||
{
|
||||
'text-muted-foreground cursor-not-allowed':
|
||||
!hasPermissionToCreateConnection,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<Plus size={16} />
|
||||
{t('Create Connection')}
|
||||
</span>
|
||||
</SelectAction>
|
||||
</PermissionNeededTooltip>
|
||||
|
||||
{connections &&
|
||||
connections.data &&
|
||||
connections.data?.map((connection) => {
|
||||
return (
|
||||
<SelectItem
|
||||
value={addBrackets(connection.externalId)}
|
||||
key={connection.externalId}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{connection.scope ===
|
||||
AppConnectionScope.PLATFORM && (
|
||||
<Globe size={16} className="shrink-0" />
|
||||
)}
|
||||
{connection.displayName}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</AutoFormFieldWrapper>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
></FormField>
|
||||
);
|
||||
}
|
||||
|
||||
ConnectionSelect.displayName = 'ConnectionSelect';
|
||||
export { ConnectionSelect };
|
||||
|
||||
type ConnectionSelectProps = {
|
||||
disabled: boolean;
|
||||
piece: PieceMetadataModelSummary | PieceMetadataModel;
|
||||
isTrigger: boolean;
|
||||
};
|
||||
function addBrackets(str: string) {
|
||||
return `{{connections['${str}']}}`;
|
||||
}
|
||||
function removeBrackets(str: string | undefined) {
|
||||
if (isNil(str)) {
|
||||
return undefined;
|
||||
}
|
||||
return str.replace(
|
||||
/\{\{connections\['(.*?)'\]\}\}/g,
|
||||
(_, connectionName) => connectionName,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { flagsHooks } from '@/hooks/flags-hooks';
|
||||
import {
|
||||
ApFlagId,
|
||||
isNil,
|
||||
PieceAction,
|
||||
PieceActionSettings,
|
||||
PieceTrigger,
|
||||
PieceTriggerSettings,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { AutoPropertiesFormComponent } from '../../piece-properties/auto-properties-form';
|
||||
import { useStepSettingsContext } from '../step-settings-context';
|
||||
|
||||
import { ConnectionSelect } from './connection-select';
|
||||
|
||||
type PieceSettingsProps = {
|
||||
step: PieceAction | PieceTrigger;
|
||||
flowId: string;
|
||||
readonly: boolean;
|
||||
};
|
||||
|
||||
const removeAuthFromProps = (
|
||||
props: Record<string, any>,
|
||||
): Record<string, any> => {
|
||||
const { auth, ...rest } = props;
|
||||
return rest;
|
||||
};
|
||||
|
||||
const PieceSettings = React.memo((props: PieceSettingsProps) => {
|
||||
const { pieceModel } = useStepSettingsContext();
|
||||
|
||||
const actionName = (props.step.settings as PieceActionSettings).actionName;
|
||||
const selectedAction = actionName
|
||||
? pieceModel?.actions[actionName]
|
||||
: undefined;
|
||||
const triggerName = (props.step.settings as PieceTriggerSettings).triggerName;
|
||||
const selectedTrigger = triggerName
|
||||
? pieceModel?.triggers[triggerName]
|
||||
: undefined;
|
||||
|
||||
const actionPropsWithoutAuth = removeAuthFromProps(
|
||||
selectedAction?.props ?? {},
|
||||
);
|
||||
const triggerPropsWithoutAuth = removeAuthFromProps(
|
||||
selectedTrigger?.props ?? {},
|
||||
);
|
||||
|
||||
const { data: webhookPrefixUrl } = flagsHooks.useFlag<string>(
|
||||
ApFlagId.WEBHOOK_URL_PREFIX,
|
||||
);
|
||||
|
||||
const { data: pausedFlowTimeoutDays } = flagsHooks.useFlag<number>(
|
||||
ApFlagId.PAUSED_FLOW_TIMEOUT_DAYS,
|
||||
);
|
||||
|
||||
const { data: webhookTimeoutSeconds } = flagsHooks.useFlag<number>(
|
||||
ApFlagId.WEBHOOK_TIMEOUT_SECONDS,
|
||||
);
|
||||
|
||||
const { data: frontendUrl } = flagsHooks.useFlag<string>(ApFlagId.PUBLIC_URL);
|
||||
const markdownVariables = {
|
||||
webhookUrl: `${webhookPrefixUrl}/${props.flowId}`,
|
||||
formUrl: `${frontendUrl}forms/${props.flowId}`,
|
||||
chatUrl: `${frontendUrl}chats/${props.flowId}`,
|
||||
pausedFlowTimeoutDays: pausedFlowTimeoutDays?.toString() ?? '',
|
||||
webhookTimeoutSeconds: webhookTimeoutSeconds?.toString() ?? '',
|
||||
};
|
||||
|
||||
const showAuthForAction =
|
||||
!isNil(selectedAction) && (selectedAction.requireAuth ?? true);
|
||||
const showAuthForTrigger =
|
||||
!isNil(selectedTrigger) && (selectedTrigger.requireAuth ?? true);
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{!pieceModel && (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Skeleton key={index} className="w-full h-8" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pieceModel && (
|
||||
<>
|
||||
{pieceModel.auth && (showAuthForAction || showAuthForTrigger) && (
|
||||
<ConnectionSelect
|
||||
isTrigger={!isNil(selectedTrigger)}
|
||||
piece={pieceModel}
|
||||
disabled={props.readonly}
|
||||
></ConnectionSelect>
|
||||
)}
|
||||
{selectedAction && (
|
||||
<AutoPropertiesFormComponent
|
||||
key={selectedAction.name}
|
||||
prefixValue={'settings.input'}
|
||||
props={actionPropsWithoutAuth}
|
||||
allowDynamicValues={true}
|
||||
disabled={props.readonly}
|
||||
useMentionTextInput={true}
|
||||
markdownVariables={markdownVariables}
|
||||
></AutoPropertiesFormComponent>
|
||||
)}
|
||||
{selectedTrigger && (
|
||||
<AutoPropertiesFormComponent
|
||||
key={selectedTrigger.name}
|
||||
prefixValue={'settings.input'}
|
||||
props={triggerPropsWithoutAuth}
|
||||
useMentionTextInput={false}
|
||||
allowDynamicValues={true}
|
||||
disabled={props.readonly}
|
||||
markdownVariables={markdownVariables}
|
||||
></AutoPropertiesFormComponent>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
PieceSettings.displayName = 'PieceSettings';
|
||||
export { PieceSettings };
|
||||
@@ -0,0 +1,248 @@
|
||||
import { DragHandleDots2Icon } from '@radix-ui/react-icons';
|
||||
import { t } from 'i18next';
|
||||
import { Trash, CopyPlus, Pencil } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import {
|
||||
Sortable,
|
||||
SortableDragHandle,
|
||||
SortableItem,
|
||||
} from '@/components/ui/sortable';
|
||||
import {
|
||||
RouterAction,
|
||||
BranchExecutionType,
|
||||
isNil,
|
||||
RouterActionSettings,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { InvalidStepIcon } from '../../../../components/custom/alert-icon';
|
||||
import { Button } from '../../../../components/ui/button';
|
||||
import EditableText from '../../../../components/ui/editable-text';
|
||||
import { Separator } from '../../../../components/ui/separator';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '../../../../components/ui/tooltip';
|
||||
import { cn } from '../../../../lib/utils';
|
||||
|
||||
type BranchListProps = {
|
||||
step: RouterAction;
|
||||
setSelectedBranchIndex: (index: number) => void;
|
||||
deleteBranch: (index: number) => void;
|
||||
duplicateBranch: (index: number) => void;
|
||||
errors: unknown[];
|
||||
readonly: boolean;
|
||||
branchNameChanged: (index: number, name: string) => void;
|
||||
moveBranch: ({
|
||||
sourceIndex,
|
||||
targetIndex,
|
||||
}: {
|
||||
sourceIndex: number;
|
||||
targetIndex: number;
|
||||
}) => void;
|
||||
};
|
||||
export const BranchesList = ({
|
||||
step,
|
||||
setSelectedBranchIndex,
|
||||
errors,
|
||||
duplicateBranch,
|
||||
deleteBranch,
|
||||
readonly,
|
||||
branchNameChanged,
|
||||
moveBranch,
|
||||
}: BranchListProps) => {
|
||||
const [branchNameEditingIndex, setBranchNameEditingIndex] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const form = useFormContext<RouterAction>();
|
||||
return (
|
||||
<Sortable
|
||||
value={step.settings.branches.map((branch, idx) => ({
|
||||
id: idx + 1,
|
||||
branch,
|
||||
}))}
|
||||
onMove={({ activeIndex, overIndex }) => {
|
||||
moveBranch({ sourceIndex: activeIndex, targetIndex: overIndex });
|
||||
}}
|
||||
>
|
||||
{step.settings.branches.map((branch, index) =>
|
||||
branch.branchType === BranchExecutionType.FALLBACK ? (
|
||||
<React.Fragment key={index}></React.Fragment>
|
||||
) : (
|
||||
<SortableItem key={index} value={index + 1} asChild>
|
||||
<div>
|
||||
<BranchListItem
|
||||
branch={branch}
|
||||
branchIndex={index}
|
||||
readonly={readonly}
|
||||
onClick={() => {
|
||||
setSelectedBranchIndex(index);
|
||||
}}
|
||||
errors={errors}
|
||||
duplicateBranch={() => {
|
||||
duplicateBranch(index);
|
||||
form.trigger();
|
||||
}}
|
||||
deleteBranch={() => {
|
||||
deleteBranch(index);
|
||||
form.trigger();
|
||||
}}
|
||||
isEditingBranchName={branchNameEditingIndex === index}
|
||||
setIsEditingBranchName={(isEditing) =>
|
||||
isEditing
|
||||
? setBranchNameEditingIndex(index)
|
||||
: setBranchNameEditingIndex(null)
|
||||
}
|
||||
branchNameChanged={(name) => {
|
||||
branchNameChanged(index, name);
|
||||
}}
|
||||
showDeleteButton={step.settings.branches.length > 2}
|
||||
></BranchListItem>
|
||||
|
||||
{index === step.settings.branches.length - 2 ? null : (
|
||||
<Separator></Separator>
|
||||
)}
|
||||
</div>
|
||||
</SortableItem>
|
||||
),
|
||||
)}
|
||||
</Sortable>
|
||||
);
|
||||
};
|
||||
|
||||
type BranchListItemProps = {
|
||||
branch: RouterActionSettings['branches'][number];
|
||||
branchIndex: number;
|
||||
readonly: boolean;
|
||||
onClick: () => void;
|
||||
errors: unknown[];
|
||||
duplicateBranch: () => void;
|
||||
deleteBranch: () => void;
|
||||
isEditingBranchName: boolean;
|
||||
setIsEditingBranchName: (isEditing: boolean) => void;
|
||||
branchNameChanged: (name: string) => void;
|
||||
showDeleteButton: boolean;
|
||||
};
|
||||
|
||||
export const BranchListItem = ({
|
||||
branch,
|
||||
branchIndex,
|
||||
readonly,
|
||||
onClick,
|
||||
errors,
|
||||
duplicateBranch,
|
||||
deleteBranch,
|
||||
isEditingBranchName,
|
||||
setIsEditingBranchName,
|
||||
branchNameChanged,
|
||||
showDeleteButton,
|
||||
}: BranchListItemProps) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'flex items-center gap-2 hover:transition-colors has-[div.button-group:hover]:bg-background text-sm hover:bg-gray-100 dark:hover:bg-accent px-2 cursor-pointer'
|
||||
}
|
||||
onClick={() => {
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
<EditableText
|
||||
key={branch.branchName + branchIndex}
|
||||
readonly={readonly}
|
||||
value={branch.branchName}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
branchNameChanged(value);
|
||||
}
|
||||
}}
|
||||
isEditing={isEditingBranchName}
|
||||
setIsEditing={setIsEditingBranchName}
|
||||
disallowEditingOnClick={true}
|
||||
></EditableText>
|
||||
|
||||
{!isNil(errors[branchIndex]) && (
|
||||
<div className="min-w-[16px]">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InvalidStepIcon className="h-4 w-4 shrink-0"></InvalidStepIcon>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{t('Incomplete settings')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<div className="grow"></div>
|
||||
<div
|
||||
className={cn('flex gap-2 py-3 items-center button-group', {
|
||||
'pointer-events-none': readonly,
|
||||
'opacity-0': readonly,
|
||||
})}
|
||||
>
|
||||
{showDeleteButton && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
size={'icon'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteBranch();
|
||||
}}
|
||||
>
|
||||
<Trash className="w-4 h-4 stroke-destructive"></Trash>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('Delete')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
size={'icon'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditingBranchName(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('Rename')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
size={'icon'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
duplicateBranch();
|
||||
}}
|
||||
>
|
||||
<CopyPlus className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('Duplicate')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SortableDragHandle
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={readonly}
|
||||
className={'shrink-0 size-7'}
|
||||
>
|
||||
<DragHandleDots2Icon className="size-4" aria-hidden="true" />
|
||||
</SortableDragHandle>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('Move')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user