Add Activepieces integration for workflow automation

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

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

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

View File

@@ -0,0 +1,179 @@
{
"extends": [
"plugin:@nx/react",
"../../.eslintrc.base.json"
],
"ignorePatterns": [
"!**/*"
],
"overrides": [
{
"files": [
"**/*.ts",
"**/*.tsx"
],
"parser": "@typescript-eslint/parser",
"settings": {
"react": {
"version": "detect"
},
"import/resolver": {
"typescript": {},
"alias": {
"map": [
[
"@",
"./src"
]
],
"extensions": [
".ts",
".tsx",
".js",
".jsx",
".json"
]
}
}
},
"env": {
"browser": true,
"node": true,
"es6": true
},
"extends": [
"eslint:recommended",
"plugin:import/errors",
"plugin:import/warnings",
"plugin:import/typescript",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
//"plugin:jsx-a11y/recommended",
"plugin:prettier/recommended",
"plugin:testing-library/react",
"plugin:jest-dom/recommended",
"plugin:vitest/legacy-recommended"
],
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"import/no-restricted-paths": [
"error",
{
"zones": [
// Previous restrictions...
// enforce unidirectional codebase:
// e.g. src/app can import from src/features but not the other way around
{
"target": "./src/features",
"from": "./src/app"
},
{
"target": [
"./src/components",
"./src/hooks",
"./src/lib",
"./src/types",
"./src/utils"
],
"from": [
"./src/features",
"./src/app"
]
}
]
}
],
"import/no-cycle": "off",
"linebreak-style": [
"error",
"unix"
],
"react/prop-types": "error",
"import/order": [
"error",
{
"groups": [
"builtin",
"external",
"internal",
"parent",
"sibling",
"index",
"object"
],
"newlines-between": "always",
"alphabetize": {
"order": "asc",
"caseInsensitive": true
}
}
],
"import/default": "off",
"import/no-named-as-default-member": "off",
"import/no-named-as-default": "off",
"react/react-in-jsx-scope": "off",
"jsx-a11y/anchor-is-valid": "off",
"@typescript-eslint/no-unused-vars": [
"error"
],
"@typescript-eslint/explicit-function-return-type": [
"off"
],
"@typescript-eslint/explicit-module-boundary-types": [
"off"
],
"@typescript-eslint/no-empty-function": [
"off"
],
"@typescript-eslint/no-explicit-any": [
"off"
],
"@typescript-eslint/indent": "off",
"prettier/prettier": [
"error",
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"jsxBracketSameLine": false
}
]
}
},
{
"files": [
"./src/components/**/*.{ts,tsx}"
],
"rules": {
"react/prop-types": "off"
}
},
{
"files": [
"*.ts",
"*.tsx",
"*.js",
"*.jsx"
],
"rules": {}
},
{
"files": [
"*.ts",
"*.tsx"
],
"rules": {}
},
{
"files": [
"*.js",
"*.jsx"
],
"rules": {}
}
]
}

View File

@@ -0,0 +1 @@
*.hbs

View File

@@ -0,0 +1,16 @@
module.exports = {
locales: ['en', 'fr', 'de', 'nl', 'ja', 'es', 'zh', 'pt' ,'zh-TW'], // Your supported languages
output: 'packages/react-ui/public/locales/$LOCALE/$NAMESPACE.json', // Where to output the JSON files
input: ['src/**/*.{js,jsx,ts,tsx}'], // Where to find your React files
defaultNamespace: 'translation', // Default namespace if not specified
createOldCatalogs: false, // Dont maintain the existing structure with old keys
lexers: {
js: ['JavascriptLexer'],
jsx: ['JavascriptLexer'],
ts: ['JavascriptLexer'],
tsx: ['JavascriptLexer'],
},
keepRemoved: false,
keySeparator: false, // Disable key separator
namespaceSeparator: false, // Disable namespace separator
};

View File

@@ -0,0 +1,21 @@
<!-- Keep title and favicon in one line, so the github actions remove them correctly -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<base href="/" />
<title><%= apTitle %></title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="<%= apFavicon %>">
<script type='text/javascript'>(function() {var gs = document.createElement('script');gs.src = 'https://join.activepieces.com/pr/js';gs.type = 'text/javascript';gs.async = 'true';gs.onload = gs.onreadystatechange = function() {var rs = this.readyState;if (rs && rs != 'complete' && rs != 'loaded') return;try {growsumo._initialize('pk_hXXmQVEnpqPOxa9D1sD085zFWUWbpwd9'); if (typeof(growsumoInit) === 'function') {growsumoInit();}} catch (e) {}};var s = document.getElementsByTagName('script')[0];s.parentNode.insertBefore(gs, s);})();</script>
<link rel="stylesheet" href="/src/styles.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,11 @@
/* eslint-disable */
export default {
displayName: 'react-ui',
preset: '../../jest.preset.js',
transform: {
'^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest',
'^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/react/babel'] }],
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
coverageDirectory: '../../coverage/packages/react-ui',
};

View File

@@ -0,0 +1,12 @@
const { join } = require('path');
// Note: If you use library-specific PostCSS/Tailwind configuration then you should remove the `postcssConfig` build
// option from your application's configuration (i.e. project.json).
//
// See: https://nx.dev/guides/using-tailwind-css-in-react#step-4:-applying-configuration-to-libraries
module.exports = {
plugins: {
"@tailwindcss/postcss": {},
},
};

View File

@@ -0,0 +1,22 @@
{
"name": "react-ui",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/react-ui/src",
"projectType": "application",
"tags": [],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "packages/react-ui/jest.config.ts"
}
},
"vite-typecheck": {
"executor": "nx:run-commands",
"options": {
"command": "tsc --noEmit -p packages/react-ui/tsconfig.app.json"
}
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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;

View File

@@ -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}
/>
);
};

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

File diff suppressed because it is too large Load Diff

View File

@@ -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>
);
}

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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>
</>
);
};

View File

@@ -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 };

View File

@@ -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>
);
};

View File

@@ -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;
};

View File

@@ -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,
};

View File

@@ -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,
},
});
}

View File

@@ -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 };

View File

@@ -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>
</>
)}
</>
</>
);
};

View File

@@ -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>
);
};

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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>
)}
</>
);
};

View File

@@ -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>
)}
</>
);
};

View File

@@ -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>
)}
</>
);
};

View File

@@ -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>
</>
);
};

View File

@@ -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>
)}
</>
);
};

View File

@@ -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 };

View File

@@ -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';

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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';

View File

@@ -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,
};

View File

@@ -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[];
};

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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 };

View File

@@ -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();
}
};
}, []);
};

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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 };

View File

@@ -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 };

View 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'
}`;
}

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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)
);
}

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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>
);
};

View File

@@ -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} />;
};

View File

@@ -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 &nbsp; 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,
};

View File

@@ -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;
}

View File

@@ -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}
/>
);
};

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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 };

View File

@@ -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,
});
};

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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>
);
};

View File

@@ -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 };

View File

@@ -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;
};

View File

@@ -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>
);
};

View File

@@ -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 };

View File

@@ -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 };

Some files were not shown because too many files have changed in this diff Show More