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:
179
activepieces-fork/packages/react-ui/.eslintrc.json
Normal file
179
activepieces-fork/packages/react-ui/.eslintrc.json
Normal 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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
1
activepieces-fork/packages/react-ui/.prettierignore
Normal file
1
activepieces-fork/packages/react-ui/.prettierignore
Normal file
@@ -0,0 +1 @@
|
||||
*.hbs
|
||||
16
activepieces-fork/packages/react-ui/i18next-parser.config.js
Normal file
16
activepieces-fork/packages/react-ui/i18next-parser.config.js
Normal 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, // Don’t 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
|
||||
};
|
||||
21
activepieces-fork/packages/react-ui/index.html
Normal file
21
activepieces-fork/packages/react-ui/index.html
Normal 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>
|
||||
11
activepieces-fork/packages/react-ui/jest.config.ts
Normal file
11
activepieces-fork/packages/react-ui/jest.config.ts
Normal 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',
|
||||
};
|
||||
12
activepieces-fork/packages/react-ui/postcss.config.js
Normal file
12
activepieces-fork/packages/react-ui/postcss.config.js
Normal 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": {},
|
||||
},
|
||||
};
|
||||
22
activepieces-fork/packages/react-ui/project.json
Normal file
22
activepieces-fork/packages/react-ui/project.json
Normal 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
75
activepieces-fork/packages/react-ui/src/app/app.tsx
Normal file
75
activepieces-fork/packages/react-ui/src/app/app.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
DefaultErrorFunction,
|
||||
SetErrorFunction,
|
||||
} from '@sinclair/typebox/errors';
|
||||
import {
|
||||
MutationCache,
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
} from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { EmbeddingProvider } from '@/components/embed-provider';
|
||||
import TelemetryProvider from '@/components/telemetry-provider';
|
||||
import { ThemeProvider } from '@/components/theme-provider';
|
||||
import { internalErrorToast, Toaster } from '@/components/ui/sonner';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { useManagePlanDialogStore } from '@/features/billing/lib/active-flows-addon-dialog-state';
|
||||
import { RefreshAnalyticsProvider } from '@/features/platform-admin/lib/refresh-analytics-context';
|
||||
import { api } from '@/lib/api';
|
||||
import { ErrorCode, isNil } from '@activepieces/shared';
|
||||
|
||||
import { EmbeddingFontLoader } from './components/embedding-font-loader';
|
||||
import { InitialDataGuard } from './components/initial-data-guard';
|
||||
import { ApRouter } from './guards';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
mutationCache: new MutationCache({
|
||||
onError: (err: Error, _, __, mutation) => {
|
||||
if (api.isApError(err, ErrorCode.QUOTA_EXCEEDED)) {
|
||||
const { openDialog } = useManagePlanDialogStore.getState();
|
||||
openDialog();
|
||||
} else if (isNil(mutation.options.onError)) {
|
||||
internalErrorToast();
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
let typesFormatsAdded = false;
|
||||
|
||||
if (!typesFormatsAdded) {
|
||||
SetErrorFunction((error) => {
|
||||
return error?.schema?.errorMessage ?? DefaultErrorFunction(error);
|
||||
});
|
||||
typesFormatsAdded = true;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const { i18n } = useTranslation();
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RefreshAnalyticsProvider>
|
||||
<EmbeddingProvider>
|
||||
<InitialDataGuard>
|
||||
<EmbeddingFontLoader>
|
||||
<TelemetryProvider>
|
||||
<TooltipProvider>
|
||||
<React.Fragment key={i18n.language}>
|
||||
<ThemeProvider storageKey="vite-ui-theme">
|
||||
<ApRouter />
|
||||
<Toaster position="bottom-right" />
|
||||
</ThemeProvider>
|
||||
</React.Fragment>
|
||||
</TooltipProvider>
|
||||
</TelemetryProvider>
|
||||
</EmbeddingFontLoader>
|
||||
</InitialDataGuard>
|
||||
</EmbeddingProvider>
|
||||
</RefreshAnalyticsProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,213 @@
|
||||
import { QuestionMarkCircledIcon } from '@radix-ui/react-icons';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { t } from 'i18next';
|
||||
import { ChevronDown, Logs } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
createSearchParams,
|
||||
useNavigate,
|
||||
useSearchParams,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import {
|
||||
LeftSideBarType,
|
||||
RightSideBarType,
|
||||
useBuilderStateContext,
|
||||
} from '@/app/builder/builder-hooks';
|
||||
import { PageHeader } from '@/components/custom/page-header';
|
||||
import { useEmbedding } from '@/components/embed-provider';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import EditableText from '@/components/ui/editable-text';
|
||||
import { HomeButton } from '@/components/ui/home-button';
|
||||
import { flowHooks } from '@/features/flows/lib/flow-hooks';
|
||||
import { foldersHooks } from '@/features/folders/lib/folders-hooks';
|
||||
import { useAuthorization } from '@/hooks/authorization-hooks';
|
||||
import { flagsHooks } from '@/hooks/flags-hooks';
|
||||
import { getProjectName, projectHooks } from '@/hooks/project-hooks';
|
||||
import { authenticationSession } from '@/lib/authentication-session';
|
||||
import { useNewWindow } from '@/lib/navigation-utils';
|
||||
import { NEW_FLOW_QUERY_PARAM } from '@/lib/utils';
|
||||
import {
|
||||
ApFlagId,
|
||||
FlowOperationType,
|
||||
FlowVersionState,
|
||||
Permission,
|
||||
supportUrl,
|
||||
UncategorizedFolderId,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import FlowActionMenu from '../../components/flow-actions-menu';
|
||||
|
||||
import { BuilderFlowStatusSection } from './flow-status';
|
||||
|
||||
export const BuilderHeader = () => {
|
||||
const [queryParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const openNewWindow = useNewWindow();
|
||||
const { data: showSupport } = flagsHooks.useFlag<boolean>(
|
||||
ApFlagId.SHOW_COMMUNITY,
|
||||
);
|
||||
|
||||
const hasPermissionToReadRuns = useAuthorization().checkAccess(
|
||||
Permission.READ_FLOW,
|
||||
);
|
||||
const [
|
||||
flow,
|
||||
flowVersion,
|
||||
setLeftSidebar,
|
||||
moveToFolderClientSide,
|
||||
applyOperation,
|
||||
setRightSidebar,
|
||||
] = useBuilderStateContext((state) => [
|
||||
state.flow,
|
||||
state.flowVersion,
|
||||
state.setLeftSidebar,
|
||||
state.moveToFolderClientSide,
|
||||
state.applyOperation,
|
||||
state.setRightSidebar,
|
||||
]);
|
||||
|
||||
const { embedState } = useEmbedding();
|
||||
const { project } = projectHooks.useCurrentProject();
|
||||
|
||||
const { data: folderData } = foldersHooks.useFolder(
|
||||
flow.folderId ?? UncategorizedFolderId,
|
||||
);
|
||||
|
||||
const isLatestVersion =
|
||||
flowVersion.state === FlowVersionState.DRAFT ||
|
||||
flowVersion.id === flow.publishedVersionId;
|
||||
const [isEditingFlowName, setIsEditingFlowName] = useState(false);
|
||||
useEffect(() => {
|
||||
setIsEditingFlowName(queryParams.get(NEW_FLOW_QUERY_PARAM) === 'true');
|
||||
}, []);
|
||||
|
||||
const goToFlowsPage = () => {
|
||||
navigate({
|
||||
pathname: authenticationSession.appendProjectRoutePrefix('/flows'),
|
||||
search: createSearchParams({
|
||||
folderId: folderData?.id ?? UncategorizedFolderId,
|
||||
}).toString(),
|
||||
});
|
||||
};
|
||||
|
||||
const titleContent = (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{!embedState.disableNavigationInBuilder && (
|
||||
<>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink
|
||||
onClick={goToFlowsPage}
|
||||
className="cursor-pointer text-base"
|
||||
>
|
||||
{getProjectName(project)}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
</>
|
||||
)}
|
||||
{!embedState.hideFlowNameInBuilder && (
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>
|
||||
<div className="flex items-center gap-1 text-base">
|
||||
<EditableText
|
||||
className="hover:cursor-text"
|
||||
value={flowVersion.displayName}
|
||||
readonly={!isLatestVersion}
|
||||
onValueChange={(value) => {
|
||||
applyOperation(
|
||||
{
|
||||
type: FlowOperationType.CHANGE_NAME,
|
||||
request: {
|
||||
displayName: value,
|
||||
},
|
||||
},
|
||||
() => {
|
||||
flowHooks.invalidateFlowsQuery(queryClient);
|
||||
},
|
||||
);
|
||||
}}
|
||||
isEditing={isEditingFlowName}
|
||||
setIsEditing={setIsEditingFlowName}
|
||||
tooltipContent=""
|
||||
/>
|
||||
<FlowActionMenu
|
||||
onVersionsListClick={() => {
|
||||
setRightSidebar(RightSideBarType.VERSIONS);
|
||||
}}
|
||||
insideBuilder={true}
|
||||
flow={flow}
|
||||
flowVersion={flowVersion}
|
||||
readonly={!isLatestVersion}
|
||||
onDelete={goToFlowsPage}
|
||||
onRename={() => {
|
||||
setIsEditingFlowName(true);
|
||||
}}
|
||||
onMoveTo={(folderId) => moveToFolderClientSide(folderId)}
|
||||
onDuplicate={() => {}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-6 flex items-center justify-center"
|
||||
>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</FlowActionMenu>
|
||||
</div>
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
)}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
|
||||
const rightContent = (
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
{showSupport && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="gap-2 px-2"
|
||||
onClick={() => openNewWindow(supportUrl)}
|
||||
>
|
||||
<QuestionMarkCircledIcon className="w-4 h-4"></QuestionMarkCircledIcon>
|
||||
{t('Support')}
|
||||
</Button>
|
||||
)}
|
||||
{hasPermissionToReadRuns && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setLeftSidebar(LeftSideBarType.RUNS)}
|
||||
className="gap-2 px-2"
|
||||
>
|
||||
<Logs className="w-4 h-4" />
|
||||
{t('Runs')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<BuilderFlowStatusSection></BuilderFlowStatusSection>
|
||||
</div>
|
||||
);
|
||||
|
||||
const leftContent = embedState.isEmbedded ? <HomeButton /> : null;
|
||||
|
||||
return (
|
||||
<PageHeader
|
||||
title={titleContent}
|
||||
rightContent={rightContent}
|
||||
leftContent={leftContent}
|
||||
showBorder={true}
|
||||
className="select-none"
|
||||
hideSidebarTrigger={embedState.isEmbedded}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useBuilderStateContext } from '@/app/builder/builder-hooks';
|
||||
import { FlowStatusToggle } from '@/features/flows/components/flow-status-toggle';
|
||||
import { FlowVersionStateDot } from '@/features/flows/components/flow-version-state-dot';
|
||||
import { FlowVersionState } from '@activepieces/shared';
|
||||
|
||||
import { PublishButton } from './publish-button';
|
||||
import { EditFlowOrViewDraftButton } from './view-draft-or-edit-flow-button';
|
||||
const BuilderFlowStatusSection = React.memo(() => {
|
||||
const [flowVersion, flow] = useBuilderStateContext((state) => [
|
||||
state.flowVersion,
|
||||
state.flow,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center space-x-2">
|
||||
<FlowVersionStateDot
|
||||
state={flowVersion.state}
|
||||
versionId={flowVersion.id}
|
||||
publishedVersionId={flow.publishedVersionId}
|
||||
></FlowVersionStateDot>
|
||||
{(flow.publishedVersionId === flowVersion.id ||
|
||||
flowVersion.state === FlowVersionState.DRAFT) && (
|
||||
<FlowStatusToggle flow={flow}></FlowStatusToggle>
|
||||
)}
|
||||
</div>
|
||||
<EditFlowOrViewDraftButton />
|
||||
<PublishButton />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
BuilderFlowStatusSection.displayName = 'BuilderFlowStatusSection';
|
||||
export { BuilderFlowStatusSection };
|
||||
@@ -0,0 +1,88 @@
|
||||
import { t } from 'i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { flowHooks } from '@/features/flows/lib/flow-hooks';
|
||||
import { useAuthorization } from '@/hooks/authorization-hooks';
|
||||
import {
|
||||
FlowStatusUpdatedResponse,
|
||||
FlowVersionState,
|
||||
Permission,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { useBuilderStateContext } from '../../builder-hooks';
|
||||
|
||||
const PublishButton = () => {
|
||||
const { checkAccess } = useAuthorization();
|
||||
const [
|
||||
flowVersion,
|
||||
flow,
|
||||
setFlow,
|
||||
setVersion,
|
||||
isSaving,
|
||||
readonly,
|
||||
isPublishing,
|
||||
setIsPublishing,
|
||||
] = useBuilderStateContext((state) => [
|
||||
state.flowVersion,
|
||||
state.flow,
|
||||
state.setFlow,
|
||||
state.setVersion,
|
||||
state.saving,
|
||||
state.readonly,
|
||||
state.isPublishing,
|
||||
state.setIsPublishing,
|
||||
]);
|
||||
const isViewingDraft =
|
||||
flowVersion.state === FlowVersionState.DRAFT ||
|
||||
flowVersion.id === flow.publishedVersionId;
|
||||
const permissionToEditFlow = checkAccess(Permission.WRITE_FLOW);
|
||||
const isPublishedVersion = flowVersion.id === flow.publishedVersionId;
|
||||
const { mutate: publish } = flowHooks.useChangeFlowStatus({
|
||||
flowId: flow.id,
|
||||
change: 'publish',
|
||||
onSuccess: (response: FlowStatusUpdatedResponse) => {
|
||||
setFlow(response.flow);
|
||||
setVersion(response.flow.version);
|
||||
toast.success(t('Your flow is now published.'), {
|
||||
duration: 3000,
|
||||
});
|
||||
},
|
||||
setIsPublishing: setIsPublishing,
|
||||
});
|
||||
if (!permissionToEditFlow || !isViewingDraft || (readonly && !isPublishing)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild className="disabled:pointer-events-auto">
|
||||
<Button
|
||||
size={'sm'}
|
||||
loading={isSaving || isPublishing}
|
||||
disabled={isPublishedVersion || !flowVersion.valid}
|
||||
onClick={() => publish()}
|
||||
>
|
||||
{t('Publish')}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{isPublishedVersion
|
||||
? t('Latest version is published')
|
||||
: !flowVersion.valid
|
||||
? t('Your flow has incomplete steps')
|
||||
: t('Publish')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
PublishButton.displayName = 'PublishButton';
|
||||
export { PublishButton };
|
||||
@@ -0,0 +1,43 @@
|
||||
import { t } from 'i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLocation } from 'react-use';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAuthorization } from '@/hooks/authorization-hooks';
|
||||
import { FlowVersionState, Permission } from '@activepieces/shared';
|
||||
|
||||
import { useBuilderStateContext, useSwitchToDraft } from '../../builder-hooks';
|
||||
|
||||
const EditFlowOrViewDraftButton = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { checkAccess } = useAuthorization();
|
||||
const { switchToDraft, isSwitchingToDraftPending } = useSwitchToDraft();
|
||||
const [flowVersion, flowId, readonly, run] = useBuilderStateContext(
|
||||
(state) => [state.flowVersion, state.flow.id, state.readonly, state.run],
|
||||
);
|
||||
const isViewingDraft = flowVersion.state === FlowVersionState.DRAFT;
|
||||
const permissionToEditFlow = checkAccess(Permission.WRITE_FLOW);
|
||||
if (!readonly || (isViewingDraft && !run)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant={'outline'}
|
||||
loading={isSwitchingToDraftPending}
|
||||
onClick={() => {
|
||||
if (location.pathname?.includes('/runs')) {
|
||||
navigate(`/flows/${flowId}`);
|
||||
} else {
|
||||
switchToDraft();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{permissionToEditFlow ? t('Edit Flow') : t('View Draft')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
EditFlowOrViewDraftButton.displayName = 'EditFlowOrViewDraftButton';
|
||||
export { EditFlowOrViewDraftButton };
|
||||
1101
activepieces-fork/packages/react-ui/src/app/builder/builder-hooks.ts
Normal file
1101
activepieces-fork/packages/react-ui/src/app/builder/builder-hooks.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
import { useRef } from 'react';
|
||||
|
||||
import {
|
||||
BuilderInitialState,
|
||||
BuilderStateContext,
|
||||
BuilderStore,
|
||||
createBuilderStore,
|
||||
} from '@/app/builder/builder-hooks';
|
||||
import { useAuthorization } from '@/hooks/authorization-hooks';
|
||||
import { projectHooks } from '@/hooks/project-hooks';
|
||||
import { Permission } from '@activepieces/shared';
|
||||
|
||||
type BuilderStateProviderProps = React.PropsWithChildren<BuilderInitialState>;
|
||||
|
||||
export function BuilderStateProvider({
|
||||
children,
|
||||
outputSampleData: sampleData,
|
||||
inputSampleData: sampleDataInput,
|
||||
...props
|
||||
}: BuilderStateProviderProps) {
|
||||
const storeRef = useRef<BuilderStore>();
|
||||
const { checkAccess } = useAuthorization();
|
||||
const readonly = !checkAccess(Permission.WRITE_FLOW) || props.readonly;
|
||||
projectHooks.useReloadPageIfProjectIdChanged(props.flow.projectId);
|
||||
if (!storeRef.current) {
|
||||
storeRef.current = createBuilderStore({
|
||||
...props,
|
||||
readonly,
|
||||
outputSampleData: sampleData,
|
||||
inputSampleData: sampleDataInput,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<BuilderStateContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</BuilderStateContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { t } from 'i18next';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
import { flowStructureUtil } from '@activepieces/shared';
|
||||
|
||||
import { useApRipple } from '../../../components/theme-provider';
|
||||
import { Button } from '../../../components/ui/button';
|
||||
import { PieceIcon } from '../../../features/pieces/components/piece-icon';
|
||||
import { stepsHooks } from '../../../features/pieces/lib/steps-hooks';
|
||||
import { useBuilderStateContext } from '../builder-hooks';
|
||||
|
||||
import { DataSelectorTreeNode } from './type';
|
||||
|
||||
const ToggleIcon = ({ expanded }: { expanded: boolean }) => {
|
||||
const toggleIconSize = 15;
|
||||
return expanded ? (
|
||||
<ChevronUp height={toggleIconSize} width={toggleIconSize}></ChevronUp>
|
||||
) : (
|
||||
<ChevronDown height={toggleIconSize} width={toggleIconSize}></ChevronDown>
|
||||
);
|
||||
};
|
||||
|
||||
type DataSelectorNodeContentProps = {
|
||||
expanded: boolean;
|
||||
setExpanded: (expanded: boolean) => void;
|
||||
depth: number;
|
||||
node: DataSelectorTreeNode;
|
||||
};
|
||||
const handleKeyPress = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
if (event.target) {
|
||||
(event.target as HTMLDivElement).click();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const DataSelectorNodeContent = ({
|
||||
node,
|
||||
expanded,
|
||||
setExpanded,
|
||||
depth,
|
||||
}: DataSelectorNodeContentProps) => {
|
||||
const flowVersion = useBuilderStateContext((state) => state.flowVersion);
|
||||
const insertMention = useBuilderStateContext((state) => state.insertMention);
|
||||
|
||||
const [ripple, rippleEvent] = useApRipple();
|
||||
const step =
|
||||
node.data.type === 'value'
|
||||
? flowStructureUtil.getStep(node.data.propertyPath, flowVersion.trigger)
|
||||
: node.data.type === 'test'
|
||||
? flowStructureUtil.getStep(node.data.stepName, flowVersion.trigger)
|
||||
: undefined;
|
||||
const stepMetadata = step
|
||||
? stepsHooks.useStepMetadata({ step }).stepMetadata
|
||||
: undefined;
|
||||
const showInsertButton =
|
||||
node.data.type === 'value' && node.data.insertable && !node.isLoopStepNode;
|
||||
const showNodeValue = !node.children && node.data.type === 'value';
|
||||
const depthMultiplier = 23 / (1 + depth * 0.05);
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyPress}
|
||||
ref={ripple}
|
||||
onClick={(e) => {
|
||||
if (node.children && node.children.length > 0) {
|
||||
rippleEvent(e);
|
||||
setExpanded(!expanded);
|
||||
} else if (
|
||||
insertMention &&
|
||||
node.data.type === 'value' &&
|
||||
node.data.insertable
|
||||
) {
|
||||
rippleEvent(e);
|
||||
insertMention(node.data.propertyPath);
|
||||
}
|
||||
}}
|
||||
className="w-full max-w-full select-none focus:outline-hidden hover:bg-accent focus:bg-accent focus:bg-opacity-75 hover:bg-opacity-75 cursor-pointer group"
|
||||
>
|
||||
<div className="grow max-w-full flex items-center gap-2 min-h-[48px] pr-3 select-none">
|
||||
<div
|
||||
style={{
|
||||
minWidth: `${
|
||||
depth * depthMultiplier + (depth === 0 ? 0 : 12) + 18
|
||||
}px`,
|
||||
}}
|
||||
></div>
|
||||
{stepMetadata && (
|
||||
<div className="shrink-0">
|
||||
<PieceIcon
|
||||
displayName={stepMetadata.displayName}
|
||||
logoUrl={stepMetadata.logoUrl}
|
||||
showTooltip={false}
|
||||
circle={false}
|
||||
border={false}
|
||||
size="sm"
|
||||
></PieceIcon>
|
||||
</div>
|
||||
)}
|
||||
{node.data.type !== 'test' && (
|
||||
<div className=" truncate">{node.data.displayName}</div>
|
||||
)}
|
||||
|
||||
{showNodeValue && (
|
||||
<>
|
||||
<div className="shrink-0">:</div>
|
||||
<div className="flex-1 text-primary truncate">
|
||||
{`${node.data.type === 'value' ? node.data.value : ''}`}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex shrink-0 gap-2 items-center">
|
||||
{showInsertButton && (
|
||||
<Button
|
||||
className="z-50 hover:opacity-100 opacity-0 p-0 group-hover:p-1 group-hover:opacity-100 focus:opacity-100"
|
||||
variant="basic"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (insertMention) {
|
||||
insertMention(
|
||||
node.data.type === 'value' ? node.data.propertyPath : '',
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('Insert')}
|
||||
</Button>
|
||||
)}
|
||||
{node.children && node.children.length > 0 && (
|
||||
<div className="shrink-0 pr-5">
|
||||
<ToggleIcon expanded={expanded}></ToggleIcon>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
DataSelectorNodeContent.displayName = 'DataSelectorNodeContent';
|
||||
export { DataSelectorNodeContent };
|
||||
@@ -0,0 +1,70 @@
|
||||
import { CollapsibleContent } from '@radix-ui/react-collapsible';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleTrigger,
|
||||
} from '../../../components/ui/collapsible';
|
||||
|
||||
import { DataSelectorNodeContent } from './data-selector-node-content';
|
||||
import { TestStepSection } from './test-step-section';
|
||||
import { DataSelectorTreeNode } from './type';
|
||||
import { dataSelectorUtils } from './utils';
|
||||
|
||||
type DataSelectorNodeProps = {
|
||||
node: DataSelectorTreeNode;
|
||||
depth: number;
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
const DataSelectorNode = ({
|
||||
node,
|
||||
depth,
|
||||
searchTerm,
|
||||
}: DataSelectorNodeProps) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchTerm && depth <= 1) {
|
||||
setExpanded(true);
|
||||
} else if (!searchTerm) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [searchTerm, depth]);
|
||||
|
||||
const isTestStepNode = dataSelectorUtils.isTestStepNode(node);
|
||||
if (isTestStepNode) {
|
||||
return <TestStepSection stepName={node.data.stepName}></TestStepSection>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible className="w-full" open={expanded} onOpenChange={setExpanded}>
|
||||
<>
|
||||
<CollapsibleTrigger asChild={true} className="w-full relative">
|
||||
<DataSelectorNodeContent
|
||||
node={node}
|
||||
expanded={expanded}
|
||||
setExpanded={setExpanded}
|
||||
depth={depth}
|
||||
></DataSelectorNodeContent>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="w-full">
|
||||
{node.children && node.children.length > 0 && (
|
||||
<div className="flex flex-col ">
|
||||
{node.children.map((node) => (
|
||||
<DataSelectorNode
|
||||
depth={depth + 1}
|
||||
node={node}
|
||||
key={node.key}
|
||||
searchTerm={searchTerm}
|
||||
></DataSelectorNode>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
DataSelectorNode.displayName = 'DataSelectorNode';
|
||||
export { DataSelectorNode };
|
||||
@@ -0,0 +1,81 @@
|
||||
import { t } from 'i18next';
|
||||
import { ExpandIcon, MinusIcon, PanelRightDashedIcon } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
import { Button } from '../../../components/ui/button';
|
||||
import { cn } from '../../../lib/utils';
|
||||
|
||||
export enum DataSelectorSizeState {
|
||||
EXPANDED,
|
||||
COLLAPSED,
|
||||
DOCKED,
|
||||
}
|
||||
|
||||
type DataSelectorSizeTogglersProps = {
|
||||
state: DataSelectorSizeState;
|
||||
setListSizeState: (state: DataSelectorSizeState) => void;
|
||||
};
|
||||
|
||||
export const DataSelectorSizeTogglers = ({
|
||||
state,
|
||||
setListSizeState: setDataSelectorSizeState,
|
||||
}: DataSelectorSizeTogglersProps) => {
|
||||
const handleClick = (newState: DataSelectorSizeState) => {
|
||||
setDataSelectorSizeState(newState);
|
||||
};
|
||||
|
||||
const buttonClassName = (btnState: DataSelectorSizeState) =>
|
||||
cn('', {
|
||||
'text-outline': state === btnState,
|
||||
'text-outline opacity-50': state !== btnState,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
className={buttonClassName(DataSelectorSizeState.EXPANDED)}
|
||||
onClick={() => handleClick(DataSelectorSizeState.EXPANDED)}
|
||||
variant="basic"
|
||||
>
|
||||
<ExpandIcon className="size-5"></ExpandIcon>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('Expand')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
className={buttonClassName(DataSelectorSizeState.DOCKED)}
|
||||
onClick={() => handleClick(DataSelectorSizeState.DOCKED)}
|
||||
variant="basic"
|
||||
>
|
||||
<PanelRightDashedIcon className="size-5"></PanelRightDashedIcon>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('Dock')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
className={buttonClassName(DataSelectorSizeState.COLLAPSED)}
|
||||
onClick={() => handleClick(DataSelectorSizeState.COLLAPSED)}
|
||||
variant="basic"
|
||||
>
|
||||
<MinusIcon className="size-5"></MinusIcon>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('Minimize')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
import { t } from 'i18next';
|
||||
import { SearchXIcon } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { textMentionUtils } from '@/app/builder/piece-properties/text-input-with-mentions/text-input-utils';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { flowStructureUtil, isNil } from '@activepieces/shared';
|
||||
|
||||
import { ScrollArea } from '../../../components/ui/scroll-area';
|
||||
import { BuilderState, useBuilderStateContext } from '../builder-hooks';
|
||||
|
||||
import { DataSelectorNode } from './data-selector-node';
|
||||
import {
|
||||
DataSelectorSizeState,
|
||||
DataSelectorSizeTogglers,
|
||||
} from './data-selector-size-togglers';
|
||||
import { DataSelectorTreeNode } from './type';
|
||||
import { dataSelectorUtils } from './utils';
|
||||
|
||||
const getDataSelectorStructure: (
|
||||
state: BuilderState,
|
||||
) => DataSelectorTreeNode[] = (state) => {
|
||||
const { selectedStep, flowVersion } = state;
|
||||
if (!selectedStep || !flowVersion || !flowVersion.trigger) {
|
||||
return [];
|
||||
}
|
||||
const pathToTargetStep = flowStructureUtil.findPathToStep(
|
||||
flowVersion.trigger,
|
||||
selectedStep,
|
||||
);
|
||||
return pathToTargetStep.map((step) => {
|
||||
try {
|
||||
return dataSelectorUtils.traverseStep(
|
||||
step,
|
||||
state.outputSampleData,
|
||||
state.isFocusInsideListMapperModeInput,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to traverse step:', error);
|
||||
return {
|
||||
key: `error-${step.name}`,
|
||||
data: {
|
||||
type: 'chunk',
|
||||
displayName: `Error loading ${step.name}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
type DataSelectorProps = {
|
||||
parentHeight: number;
|
||||
parentWidth: number;
|
||||
};
|
||||
|
||||
const doesElementHaveAnInputThatUsesMentions = (
|
||||
element: Element | null,
|
||||
): boolean => {
|
||||
if (isNil(element)) {
|
||||
return false;
|
||||
}
|
||||
if (element.classList.contains(textMentionUtils.inputWithMentionsCssClass)) {
|
||||
return true;
|
||||
}
|
||||
const parent = element.parentElement;
|
||||
if (parent) {
|
||||
return parent && doesElementHaveAnInputThatUsesMentions(parent);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const DataSelector = ({ parentHeight, parentWidth }: DataSelectorProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [DataSelectorSize, setDataSelectorSize] =
|
||||
useState<DataSelectorSizeState>(DataSelectorSizeState.DOCKED);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const dataSelectorStructure = useBuilderStateContext(
|
||||
getDataSelectorStructure,
|
||||
);
|
||||
const filteredNodes = dataSelectorUtils.filterBy(
|
||||
dataSelectorStructure,
|
||||
searchTerm,
|
||||
);
|
||||
const [showDataSelector, setShowDataSelector] = useState(false);
|
||||
|
||||
const checkFocus = useCallback(() => {
|
||||
const isTextMentionInputFocused =
|
||||
(!isNil(containerRef.current) &&
|
||||
containerRef.current.contains(document.activeElement)) ||
|
||||
doesElementHaveAnInputThatUsesMentions(document.activeElement);
|
||||
setShowDataSelector(isTextMentionInputFocused);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('focusin', checkFocus);
|
||||
document.addEventListener('focusout', checkFocus);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('focusin', checkFocus);
|
||||
document.removeEventListener('focusout', checkFocus);
|
||||
};
|
||||
}, [checkFocus]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'absolute bottom-0 mr-5 mb-5 right-0 z-50 transition-all border border-solid border-outline overflow-x-hidden bg-background shadow-lg rounded-md',
|
||||
{
|
||||
'opacity-0 pointer-events-none': !showDataSelector,
|
||||
},
|
||||
textMentionUtils.dataSelectorCssClassSelector,
|
||||
)}
|
||||
>
|
||||
<div className="text-lg items-center px-5 py-2 flex gap-2">
|
||||
{t('Data Selector')} <div className="grow"></div>{' '}
|
||||
<DataSelectorSizeTogglers
|
||||
state={DataSelectorSize}
|
||||
setListSizeState={setDataSelectorSize}
|
||||
></DataSelectorSizeTogglers>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height:
|
||||
DataSelectorSize === DataSelectorSizeState.COLLAPSED
|
||||
? '0px'
|
||||
: DataSelectorSize === DataSelectorSizeState.DOCKED
|
||||
? '450px'
|
||||
: `${parentHeight - 100}px`,
|
||||
width:
|
||||
DataSelectorSize !== DataSelectorSizeState.EXPANDED
|
||||
? '450px'
|
||||
: `${parentWidth - 40}px`,
|
||||
}}
|
||||
className="transition-all overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-5 py-2">
|
||||
<Input
|
||||
placeholder={t('Search')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
></Input>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="transition-all h-[calc(100%-56px)] w-full ">
|
||||
{filteredNodes &&
|
||||
filteredNodes.map((node) => (
|
||||
<DataSelectorNode
|
||||
depth={0}
|
||||
key={node.key}
|
||||
node={node}
|
||||
searchTerm={searchTerm}
|
||||
></DataSelectorNode>
|
||||
))}
|
||||
{filteredNodes.length === 0 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-5 flex-col">
|
||||
<SearchXIcon className="w-[35px] h-[35px]"></SearchXIcon>
|
||||
<div className="text-center font-semibold text-md">
|
||||
{t('No matching data')}
|
||||
</div>
|
||||
<div className="text-center ">
|
||||
{t('Try adjusting your search')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
DataSelector.displayName = 'DataSelector';
|
||||
export { DataSelector };
|
||||
@@ -0,0 +1,32 @@
|
||||
import { t } from 'i18next';
|
||||
|
||||
import { Button } from '../../../components/ui/button';
|
||||
import { useBuilderStateContext } from '../builder-hooks';
|
||||
|
||||
export const TestStepSection = ({ stepName }: { stepName: string }) => {
|
||||
const isTrigger = stepName === 'trigger';
|
||||
const selectStepByName = useBuilderStateContext(
|
||||
(state) => state.selectStepByName,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 select-none text-center px-12 py-10 grow items-center justify-center ">
|
||||
<div>
|
||||
{isTrigger
|
||||
? t(
|
||||
'This trigger needs to have data loaded from your account, to use as sample data.',
|
||||
)
|
||||
: t('This step needs to be tested in order to view its data.')}
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => selectStepByName(stepName)}
|
||||
variant="default"
|
||||
size="default"
|
||||
>
|
||||
{isTrigger ? t('Go to Trigger') : t('Go to Step')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
export type DataSelectorTreeChunkNodeData = {
|
||||
type: 'chunk';
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
export type DataSelectorTreeNodeData = {
|
||||
type: 'value';
|
||||
value: string | unknown;
|
||||
displayName: string;
|
||||
propertyPath: string;
|
||||
insertable: boolean;
|
||||
};
|
||||
|
||||
export type DataSelectorTestNodeData = {
|
||||
type: 'test';
|
||||
stepName: string;
|
||||
parentDisplayName: string;
|
||||
};
|
||||
|
||||
export type DataSelectorTreeNodeDataUnion =
|
||||
| DataSelectorTreeNodeData
|
||||
| DataSelectorTreeChunkNodeData
|
||||
| DataSelectorTestNodeData;
|
||||
export type DataSelectorTreeNode<
|
||||
T extends DataSelectorTreeNodeDataUnion = DataSelectorTreeNodeDataUnion,
|
||||
> = {
|
||||
key: string;
|
||||
data: T;
|
||||
children?: DataSelectorTreeNode<DataSelectorTreeNodeDataUnion>[];
|
||||
isLoopStepNode?: boolean;
|
||||
};
|
||||
@@ -0,0 +1,389 @@
|
||||
import {
|
||||
isNil,
|
||||
isObject,
|
||||
FlowAction,
|
||||
FlowActionType,
|
||||
FlowTrigger,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import {
|
||||
DataSelectorTreeNode,
|
||||
DataSelectorTestNodeData,
|
||||
DataSelectorTreeNodeDataUnion,
|
||||
DataSelectorTreeNodeData,
|
||||
} from './type';
|
||||
|
||||
type PathSegment = string | number;
|
||||
|
||||
const MAX_CHUNK_LENGTH = 10;
|
||||
const JOINED_VALUES_MAX_LENGTH = 32;
|
||||
|
||||
function buildTestStepNode(
|
||||
displayName: string,
|
||||
stepName: string,
|
||||
): DataSelectorTreeNode<DataSelectorTreeNodeData> {
|
||||
return {
|
||||
key: stepName,
|
||||
data: {
|
||||
type: 'value',
|
||||
value: '',
|
||||
displayName,
|
||||
propertyPath: stepName,
|
||||
insertable: false,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
data: {
|
||||
type: 'test',
|
||||
stepName,
|
||||
parentDisplayName: displayName,
|
||||
},
|
||||
key: `test_${stepName}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildChunkNode(
|
||||
displayName: string,
|
||||
children: DataSelectorTreeNode<DataSelectorTreeNodeDataUnion>[] | undefined,
|
||||
): DataSelectorTreeNode<DataSelectorTreeNodeDataUnion> {
|
||||
return {
|
||||
key: displayName,
|
||||
data: {
|
||||
type: 'chunk',
|
||||
displayName,
|
||||
},
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
type Node = {
|
||||
values: unknown[];
|
||||
properties: Record<string, Node>;
|
||||
};
|
||||
|
||||
function mergeUniqueKeys(
|
||||
obj: Record<string, Node>,
|
||||
obj2: Record<string, Node>,
|
||||
): Record<string, Node> {
|
||||
const result: Record<string, Node> = { ...obj };
|
||||
for (const [key, values] of Object.entries(obj2)) {
|
||||
const properties = mergeUniqueKeys(
|
||||
result[key]?.properties || {},
|
||||
values.properties,
|
||||
);
|
||||
result[key] = {
|
||||
values: [...(result[key]?.values || []), ...values.values],
|
||||
properties,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractUniqueKeys(obj: unknown): Record<string, Node> {
|
||||
let result: Record<string, Node> = {};
|
||||
if (isObject(obj)) {
|
||||
for (const [entryKey, entryValue] of Object.entries(obj)) {
|
||||
const resultValue = result[entryKey]?.values || [];
|
||||
if (Array.isArray(entryValue)) {
|
||||
const filteredValues = entryValue.filter(
|
||||
(v) => !isObject(v) && !Array.isArray(v),
|
||||
);
|
||||
resultValue.push(...filteredValues);
|
||||
} else if (!isObject(entryValue)) {
|
||||
resultValue.push(entryValue);
|
||||
}
|
||||
const properties = extractUniqueKeys(entryValue);
|
||||
result[entryKey] = {
|
||||
values: resultValue,
|
||||
properties,
|
||||
};
|
||||
}
|
||||
} else if (Array.isArray(obj)) {
|
||||
for (const value of obj) {
|
||||
const properties = extractUniqueKeys(value);
|
||||
result = mergeUniqueKeys(result, properties);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function convertArrayToZippedView(
|
||||
obj: Record<string, Node>,
|
||||
propertyPath: PathSegment[],
|
||||
): DataSelectorTreeNode<DataSelectorTreeNodeDataUnion>[] {
|
||||
const result: DataSelectorTreeNode<DataSelectorTreeNodeDataUnion>[] = [];
|
||||
for (const [key, node] of Object.entries(obj)) {
|
||||
const stepName = propertyPath[0];
|
||||
const subPath = [...propertyPath.slice(1), key];
|
||||
|
||||
const propertyPathWithFlattenArray = `flattenNestedKeys(${stepName}, ['${subPath
|
||||
.map((s) => String(s))
|
||||
.join("', '")}'])`;
|
||||
const joinedValues = node.values.join(', ');
|
||||
result.push({
|
||||
key: key,
|
||||
data: {
|
||||
type: 'value',
|
||||
value:
|
||||
joinedValues.length > JOINED_VALUES_MAX_LENGTH
|
||||
? `${joinedValues.slice(0, JOINED_VALUES_MAX_LENGTH)}...`
|
||||
: joinedValues,
|
||||
displayName: key,
|
||||
propertyPath: propertyPathWithFlattenArray,
|
||||
insertable: true,
|
||||
},
|
||||
children:
|
||||
Object.keys(node.properties).length > 0
|
||||
? convertArrayToZippedView(node.properties, [...propertyPath, key])
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildJsonPath(propertyPath: PathSegment[]): string {
|
||||
const propertyPathWithoutStepName = propertyPath.slice(1);
|
||||
//need array indexes to not be quoted so we can add 1 to them when displaying the path in mention
|
||||
return propertyPathWithoutStepName.reduce((acc, segment) => {
|
||||
return `${acc}[${
|
||||
typeof segment === 'string'
|
||||
? `'${escapeMentionKey(String(segment))}'`
|
||||
: segment
|
||||
}]`;
|
||||
}, `${propertyPath[0]}`) as string;
|
||||
}
|
||||
|
||||
function buildDataSelectorNode(
|
||||
displayName: string,
|
||||
propertyPath: PathSegment[],
|
||||
value: unknown,
|
||||
children: DataSelectorTreeNode<DataSelectorTreeNodeDataUnion>[] | undefined,
|
||||
insertable = true,
|
||||
): DataSelectorTreeNode<DataSelectorTreeNodeDataUnion> {
|
||||
const isEmptyArrayOrObject =
|
||||
(Array.isArray(value) && value.length === 0) ||
|
||||
(isObject(value) && Object.keys(value).length === 0);
|
||||
const jsonPath = buildJsonPath(propertyPath);
|
||||
|
||||
return {
|
||||
key: jsonPath,
|
||||
data: {
|
||||
type: 'value',
|
||||
value: isEmptyArrayOrObject ? 'Empty List' : value,
|
||||
displayName,
|
||||
propertyPath: jsonPath,
|
||||
insertable,
|
||||
},
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
function breakArrayIntoChunks<T>(
|
||||
array: T[],
|
||||
chunkSize: number,
|
||||
): { items: T[]; range: { start: number; end: number } }[] {
|
||||
return Array.from(
|
||||
{ length: Math.ceil(array.length / chunkSize) },
|
||||
(_, i) => ({
|
||||
items: array.slice(i * chunkSize, i * chunkSize + chunkSize),
|
||||
range: {
|
||||
start: i * chunkSize + 1,
|
||||
end: Math.min((i + 1) * chunkSize, array.length),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function traverseOutput(
|
||||
displayName: string,
|
||||
propertyPath: PathSegment[],
|
||||
node: unknown,
|
||||
zipArraysOfProperties: boolean,
|
||||
insertable = true,
|
||||
): DataSelectorTreeNode<DataSelectorTreeNodeDataUnion> {
|
||||
if (Array.isArray(node)) {
|
||||
const isArrayOfObjects = node.some((value) => isObject(value));
|
||||
if (!zipArraysOfProperties || !isArrayOfObjects) {
|
||||
const mentionNodes = node.map((value, idx) =>
|
||||
traverseOutput(
|
||||
`${displayName} [${idx + 1}]`,
|
||||
[...propertyPath, idx],
|
||||
value,
|
||||
zipArraysOfProperties,
|
||||
insertable,
|
||||
),
|
||||
);
|
||||
const chunks = breakArrayIntoChunks(mentionNodes, MAX_CHUNK_LENGTH);
|
||||
const isSingleChunk = chunks.length === 1;
|
||||
if (isSingleChunk) {
|
||||
return buildDataSelectorNode(
|
||||
displayName,
|
||||
propertyPath,
|
||||
node,
|
||||
mentionNodes,
|
||||
insertable,
|
||||
);
|
||||
}
|
||||
return buildDataSelectorNode(
|
||||
displayName,
|
||||
propertyPath,
|
||||
undefined,
|
||||
chunks.map((chunk) =>
|
||||
buildChunkNode(
|
||||
`${displayName} [${chunk.range.start}-${chunk.range.end}]`,
|
||||
chunk.items,
|
||||
),
|
||||
),
|
||||
insertable,
|
||||
);
|
||||
} else {
|
||||
return buildDataSelectorNode(
|
||||
displayName,
|
||||
propertyPath,
|
||||
node,
|
||||
convertArrayToZippedView(extractUniqueKeys(node), propertyPath),
|
||||
insertable,
|
||||
);
|
||||
}
|
||||
} else if (isObject(node)) {
|
||||
const children = Object.entries(node).map(([key, value]) => {
|
||||
if (zipArraysOfProperties) {
|
||||
return buildDataSelectorNode(
|
||||
key,
|
||||
[...propertyPath, key],
|
||||
value,
|
||||
convertArrayToZippedView(extractUniqueKeys(value), [
|
||||
...propertyPath,
|
||||
key,
|
||||
]),
|
||||
insertable,
|
||||
);
|
||||
}
|
||||
return traverseOutput(
|
||||
key,
|
||||
[...propertyPath, key],
|
||||
value,
|
||||
zipArraysOfProperties,
|
||||
insertable,
|
||||
);
|
||||
});
|
||||
return buildDataSelectorNode(
|
||||
displayName,
|
||||
propertyPath,
|
||||
node,
|
||||
children,
|
||||
insertable,
|
||||
);
|
||||
} else {
|
||||
return buildDataSelectorNode(
|
||||
displayName,
|
||||
propertyPath,
|
||||
node,
|
||||
undefined,
|
||||
insertable,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeMentionKey(key: string) {
|
||||
return key.replaceAll(/[\\"'\n\r\t’]/g, (char) => `\\${char}`);
|
||||
}
|
||||
|
||||
function getSearchableValue(
|
||||
item: DataSelectorTreeNode<DataSelectorTreeNodeDataUnion>,
|
||||
) {
|
||||
if (item.data.type === 'test') {
|
||||
return item.data.parentDisplayName;
|
||||
}
|
||||
if (item.data.type === 'chunk') {
|
||||
return item.data.displayName;
|
||||
}
|
||||
if (!isNil(item.data.value)) {
|
||||
return JSON.stringify(item.data.value).toLowerCase();
|
||||
} else if (item.data.value === null) {
|
||||
return 'null';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function traverseStep(
|
||||
step: (FlowAction | FlowTrigger) & { dfsIndex: number },
|
||||
sampleData: Record<string, unknown>,
|
||||
zipArraysOfProperties: boolean,
|
||||
): DataSelectorTreeNode<DataSelectorTreeNodeDataUnion> {
|
||||
const displayName = `${step.dfsIndex + 1}. ${step.displayName}`;
|
||||
const stepNeedsTesting = isNil(step.settings.sampleData?.lastTestDate);
|
||||
if (stepNeedsTesting) {
|
||||
return buildTestStepNode(displayName, step.name);
|
||||
}
|
||||
if (step.type === FlowActionType.LOOP_ON_ITEMS) {
|
||||
const copiedSampleData = JSON.parse(JSON.stringify(sampleData[step.name]));
|
||||
delete copiedSampleData['iterations'];
|
||||
const headNode = traverseOutput(
|
||||
displayName,
|
||||
[step.name],
|
||||
copiedSampleData,
|
||||
zipArraysOfProperties,
|
||||
true,
|
||||
);
|
||||
headNode.isLoopStepNode = true;
|
||||
return headNode;
|
||||
}
|
||||
|
||||
return traverseOutput(
|
||||
displayName,
|
||||
[step.name],
|
||||
sampleData[step.name],
|
||||
zipArraysOfProperties,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
function filterBy(
|
||||
mentions: DataSelectorTreeNode[],
|
||||
query: string | undefined,
|
||||
): DataSelectorTreeNode<DataSelectorTreeNodeDataUnion>[] {
|
||||
if (!query) {
|
||||
return mentions;
|
||||
}
|
||||
|
||||
const res = mentions
|
||||
.map((item) => {
|
||||
const filteredChildren = !isNil(item.children)
|
||||
? filterBy(item.children, query)
|
||||
: undefined;
|
||||
|
||||
if (filteredChildren && filteredChildren.length) {
|
||||
return {
|
||||
...item,
|
||||
children: filteredChildren,
|
||||
};
|
||||
}
|
||||
const searchableValue = getSearchableValue(item);
|
||||
|
||||
const displayName =
|
||||
item.data.type === 'value' ? item.data.displayName.toLowerCase() : '';
|
||||
const matchDisplayNameOrValue =
|
||||
displayName.toLowerCase().includes(query.toLowerCase()) ||
|
||||
searchableValue.toLowerCase().includes(query.toLowerCase());
|
||||
if (matchDisplayNameOrValue) {
|
||||
return item;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(
|
||||
(f) => !isNil(f),
|
||||
) as DataSelectorTreeNode<DataSelectorTreeNodeDataUnion>[];
|
||||
return res;
|
||||
}
|
||||
|
||||
export const dataSelectorUtils = {
|
||||
isTestStepNode: (
|
||||
node: DataSelectorTreeNode,
|
||||
): node is DataSelectorTreeNode<DataSelectorTestNodeData> =>
|
||||
node.data.type === 'test',
|
||||
traverseStep,
|
||||
filterBy,
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import { t } from 'i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
FlowAction,
|
||||
flowOperations,
|
||||
FlowOperationType,
|
||||
flowStructureUtil,
|
||||
FlowVersion,
|
||||
StepLocationRelativeToParent,
|
||||
PasteLocation,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { BuilderState } from '../builder-hooks';
|
||||
|
||||
type CopyActionsRequest = {
|
||||
type: 'COPY_ACTIONS';
|
||||
actions: FlowAction[];
|
||||
};
|
||||
|
||||
export function copySelectedNodes({
|
||||
selectedNodes,
|
||||
flowVersion,
|
||||
}: Pick<BuilderState, 'selectedNodes' | 'flowVersion'>) {
|
||||
const actionsToCopy = flowOperations.getActionsForCopy(
|
||||
selectedNodes,
|
||||
flowVersion,
|
||||
);
|
||||
const request: CopyActionsRequest = {
|
||||
type: 'COPY_ACTIONS',
|
||||
actions: actionsToCopy,
|
||||
};
|
||||
navigator.clipboard.writeText(JSON.stringify(request));
|
||||
}
|
||||
|
||||
export function deleteSelectedNodes({
|
||||
selectedNodes,
|
||||
applyOperation,
|
||||
selectedStep,
|
||||
exitStepSettings,
|
||||
}: Pick<
|
||||
BuilderState,
|
||||
'selectedNodes' | 'applyOperation' | 'selectedStep' | 'exitStepSettings'
|
||||
>) {
|
||||
applyOperation({
|
||||
type: FlowOperationType.DELETE_ACTION,
|
||||
request: {
|
||||
names: selectedNodes,
|
||||
},
|
||||
});
|
||||
if (selectedStep && selectedNodes.includes(selectedStep)) {
|
||||
exitStepSettings();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getActionsInClipboard(): Promise<FlowAction[]> {
|
||||
try {
|
||||
const clipboardText = await navigator.clipboard.readText();
|
||||
const request: CopyActionsRequest = JSON.parse(clipboardText);
|
||||
if (request && request.type === 'COPY_ACTIONS') {
|
||||
return request.actions;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting actions in clipboard', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function pasteNodes(
|
||||
flowVersion: BuilderState['flowVersion'],
|
||||
pastingDetails: PasteLocation,
|
||||
applyOperation: BuilderState['applyOperation'],
|
||||
) {
|
||||
const actions = await getActionsInClipboard();
|
||||
const addOperations = flowOperations.getOperationsForPaste(
|
||||
actions,
|
||||
flowVersion,
|
||||
pastingDetails,
|
||||
);
|
||||
addOperations.forEach((request) => {
|
||||
applyOperation(request);
|
||||
});
|
||||
if (addOperations.length === 0) {
|
||||
toast(t('No Steps Pasted'), {
|
||||
description: t(
|
||||
'Please make sure you have copied a step(s) and allowed permission to your clipboard',
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function getLastLocationAsPasteLocation(
|
||||
flowVersion: FlowVersion,
|
||||
): PasteLocation {
|
||||
const firstLevelParents = [
|
||||
flowVersion.trigger,
|
||||
...flowStructureUtil.getAllNextActionsWithoutChildren(flowVersion.trigger),
|
||||
];
|
||||
const lastAction = firstLevelParents[firstLevelParents.length - 1];
|
||||
return {
|
||||
parentStepName: lastAction.name,
|
||||
stepLocationRelativeToParent: StepLocationRelativeToParent.AFTER,
|
||||
};
|
||||
}
|
||||
|
||||
export function toggleSkipSelectedNodes({
|
||||
selectedNodes,
|
||||
flowVersion,
|
||||
applyOperation,
|
||||
}: Pick<BuilderState, 'selectedNodes' | 'flowVersion' | 'applyOperation'>) {
|
||||
const steps = selectedNodes.map((node) =>
|
||||
flowStructureUtil.getStepOrThrow(node, flowVersion.trigger),
|
||||
) as FlowAction[];
|
||||
const areAllStepsSkipped = steps.every((step) => !!step.skip);
|
||||
applyOperation({
|
||||
type: FlowOperationType.SET_SKIP_ACTION,
|
||||
request: {
|
||||
names: steps.map((step) => step.name),
|
||||
skip: !areAllStepsSkipped,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { Node, useKeyPress, useReactFlow } from '@xyflow/react';
|
||||
import { t } from 'i18next';
|
||||
import {
|
||||
Fullscreen,
|
||||
Hand,
|
||||
Minus,
|
||||
MousePointer,
|
||||
Plus,
|
||||
RotateCw,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { useBuilderStateContext } from '../builder-hooks';
|
||||
|
||||
import { flowUtilConsts } from './utils/consts';
|
||||
import { flowCanvasUtils } from './utils/flow-canvas-utils';
|
||||
import { ApNode } from './utils/types';
|
||||
const verticalPaddingOnFitView = 100;
|
||||
const duration = 500;
|
||||
// Calculate the node's position in relation to the canvas
|
||||
const calculateNodePositionInCanvas = (
|
||||
canvasWidth: number,
|
||||
node: Node,
|
||||
zoom: number,
|
||||
) => ({
|
||||
x:
|
||||
node.position.x +
|
||||
canvasWidth / 2 -
|
||||
(flowUtilConsts.AP_NODE_SIZE.STEP.width * zoom) / 2,
|
||||
y:
|
||||
node.position.y +
|
||||
flowUtilConsts.AP_NODE_SIZE.GRAPH_END_WIDGET.height +
|
||||
verticalPaddingOnFitView * zoom,
|
||||
});
|
||||
|
||||
// Check if the node is out of view
|
||||
const isNodeOutOfView = (
|
||||
nodePosition: { x: number; y: number },
|
||||
canvas: { width: number; height: number },
|
||||
) =>
|
||||
nodePosition.y > canvas.height ||
|
||||
nodePosition.x > canvas.width ||
|
||||
nodePosition.x < 0;
|
||||
|
||||
const calculateViewportDelta = (
|
||||
nodePosition: { x: number; y: number },
|
||||
canvas: { width: number; height: number },
|
||||
) => ({
|
||||
x:
|
||||
nodePosition.x > canvas.width
|
||||
? -1 *
|
||||
(nodePosition.x -
|
||||
canvas.width +
|
||||
flowUtilConsts.AP_NODE_SIZE.STEP.width * 2)
|
||||
: nodePosition.x < 0
|
||||
? -1 * nodePosition.x
|
||||
: 0,
|
||||
y:
|
||||
nodePosition.y > canvas.height
|
||||
? nodePosition.y - canvas.height + flowUtilConsts.AP_NODE_SIZE.STEP.height
|
||||
: 0,
|
||||
});
|
||||
|
||||
const PanningModeIndicator = ({ toggled }: { toggled: boolean }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute transition-all bg-primary/15 w-full h-full top-0 left-0',
|
||||
{
|
||||
'opacity-0': !toggled,
|
||||
},
|
||||
)}
|
||||
></div>
|
||||
);
|
||||
};
|
||||
|
||||
const CanvasControls = ({
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
hasCanvasBeenInitialised,
|
||||
selectedStep,
|
||||
}: {
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
hasCanvasBeenInitialised: boolean;
|
||||
selectedStep: string | null;
|
||||
}) => {
|
||||
const {
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
zoomTo,
|
||||
setViewport,
|
||||
getNodes,
|
||||
getNode,
|
||||
getViewport,
|
||||
} = useReactFlow();
|
||||
const handleZoomIn = useCallback(() => {
|
||||
zoomIn({
|
||||
duration,
|
||||
});
|
||||
}, [zoomIn]);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
zoomOut({
|
||||
duration,
|
||||
});
|
||||
}, [zoomOut]);
|
||||
|
||||
const handleZoomReset = useCallback(() => {
|
||||
zoomTo(1, { duration });
|
||||
}, [zoomTo]);
|
||||
|
||||
const handleFitToView = useCallback(
|
||||
(isInitialRenderCall: boolean) => {
|
||||
const nodes = getNodes();
|
||||
if (nodes.length === 0) return;
|
||||
const graphHeight = flowCanvasUtils.calculateGraphBoundingBox({
|
||||
nodes: nodes as ApNode[],
|
||||
edges: [],
|
||||
}).height;
|
||||
const zoomRatio = Math.min(
|
||||
Math.max(canvasHeight / graphHeight, 0.9),
|
||||
1.25,
|
||||
);
|
||||
|
||||
setViewport(
|
||||
{
|
||||
x:
|
||||
canvasWidth / 2 -
|
||||
(flowUtilConsts.AP_NODE_SIZE.STEP.width * zoomRatio) / 2,
|
||||
y: nodes[0].position.y + verticalPaddingOnFitView * zoomRatio,
|
||||
zoom: zoomRatio,
|
||||
},
|
||||
{
|
||||
duration: isInitialRenderCall ? 0 : duration,
|
||||
},
|
||||
);
|
||||
},
|
||||
[getNodes, canvasHeight, setViewport, canvasWidth],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasCanvasBeenInitialised) return;
|
||||
|
||||
handleFitToView(true);
|
||||
|
||||
if (selectedStep) {
|
||||
adjustViewportForSelectedStep(selectedStep);
|
||||
}
|
||||
}, [hasCanvasBeenInitialised]);
|
||||
|
||||
// Helper function to adjust the viewport for the selected step
|
||||
const adjustViewportForSelectedStep = (stepId: string) => {
|
||||
const node = getNode(stepId);
|
||||
if (!node) return;
|
||||
|
||||
const viewport = getViewport();
|
||||
|
||||
const canvas = {
|
||||
height: canvasHeight / viewport.zoom,
|
||||
width: canvasWidth / viewport.zoom,
|
||||
};
|
||||
|
||||
const nodePositionInRelationToCanvas = calculateNodePositionInCanvas(
|
||||
canvasWidth,
|
||||
node,
|
||||
viewport.zoom,
|
||||
);
|
||||
|
||||
if (isNodeOutOfView(nodePositionInRelationToCanvas, canvas)) {
|
||||
const delta = calculateViewportDelta(
|
||||
nodePositionInRelationToCanvas,
|
||||
canvas,
|
||||
);
|
||||
|
||||
setViewport({
|
||||
x: viewport.x + delta.x,
|
||||
y: viewport.y - delta.y - flowUtilConsts.AP_NODE_SIZE.STEP.height,
|
||||
zoom: viewport.zoom,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const [setPanningMode, panningMode] = useBuilderStateContext((state) => {
|
||||
return [state.setPanningMode, state.panningMode];
|
||||
});
|
||||
const spacePressed = useKeyPress('Space');
|
||||
const shiftPressed = useKeyPress('Shift');
|
||||
const isInGrabMode =
|
||||
(spacePressed || panningMode === 'grab') && !shiftPressed;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-accent absolute left-[10px] bottom-[60px] z-50 flex flex-col gap-2 shadow-md">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="accent"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!spacePressed) {
|
||||
setPanningMode('pan');
|
||||
}
|
||||
}}
|
||||
className="relative focus:outline-0"
|
||||
>
|
||||
<PanningModeIndicator toggled={!isInGrabMode} />
|
||||
<MousePointer className="size-5"></MousePointer>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{t('Select Mode')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="accent"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!spacePressed) {
|
||||
setPanningMode('grab');
|
||||
}
|
||||
}}
|
||||
className="relative focus:outline-0"
|
||||
>
|
||||
<PanningModeIndicator toggled={isInGrabMode} />
|
||||
|
||||
<Hand className="size-5"></Hand>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{t('Move Mode')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="bg-accent absolute left-[10px] bottom-[10px] z-50 flex flex-row shadow-md">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="accent" size="sm" onClick={handleZoomReset}>
|
||||
<RotateCw className="size-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{t('Reset Zoom')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="accent" size="sm" onClick={handleZoomIn}>
|
||||
<Plus className="size-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{t('Zoom In')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="accent" size="sm" onClick={handleZoomOut}>
|
||||
<Minus className="size-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{t('Zoom Out')}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="accent"
|
||||
size="sm"
|
||||
onClick={() => handleFitToView(false)}
|
||||
>
|
||||
<Fullscreen className="size-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{t('Fit to View')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { CanvasControls };
|
||||
@@ -0,0 +1,341 @@
|
||||
import { t } from 'i18next';
|
||||
import {
|
||||
ArrowLeftRight,
|
||||
ClipboardPaste,
|
||||
ClipboardPlus,
|
||||
Copy,
|
||||
CopyPlus,
|
||||
Route,
|
||||
RouteOff,
|
||||
Trash,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
} from '@/components/ui/context-menu';
|
||||
import { Shortcut, ShortcutProps } from '@/components/ui/shortcut';
|
||||
import {
|
||||
FlowAction,
|
||||
FlowActionType,
|
||||
FlowOperationType,
|
||||
flowStructureUtil,
|
||||
StepLocationRelativeToParent,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { useBuilderStateContext } from '../../builder-hooks';
|
||||
import {
|
||||
copySelectedNodes,
|
||||
deleteSelectedNodes,
|
||||
getLastLocationAsPasteLocation,
|
||||
pasteNodes,
|
||||
toggleSkipSelectedNodes,
|
||||
} from '../bulk-actions';
|
||||
|
||||
import {
|
||||
CanvasContextMenuProps,
|
||||
CanvasShortcuts,
|
||||
ContextMenuType,
|
||||
} from './canvas-context-menu';
|
||||
|
||||
const ShortcutWrapper = ({
|
||||
children,
|
||||
shortcut,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
shortcut: ShortcutProps;
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 grow">
|
||||
<div className="flex gap-2 items-center">{children}</div>
|
||||
<Shortcut {...shortcut} className="text-end" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CanvasContextMenuContent = ({
|
||||
contextMenuType,
|
||||
}: CanvasContextMenuProps) => {
|
||||
const [
|
||||
selectedNodes,
|
||||
applyOperation,
|
||||
selectedStep,
|
||||
flowVersion,
|
||||
exitStepSettings,
|
||||
readonly,
|
||||
setOpenedPieceSelectorStepNameOrAddButtonId,
|
||||
] = useBuilderStateContext((state) => [
|
||||
state.selectedNodes,
|
||||
state.applyOperation,
|
||||
state.selectedStep,
|
||||
state.flowVersion,
|
||||
state.exitStepSettings,
|
||||
state.readonly,
|
||||
state.setOpenedPieceSelectorStepNameOrAddButtonId,
|
||||
]);
|
||||
const disabled = selectedNodes.length === 0;
|
||||
const areAllStepsSkipped = selectedNodes.every(
|
||||
(node) =>
|
||||
!!(flowStructureUtil.getStep(node, flowVersion.trigger) as FlowAction)
|
||||
?.skip,
|
||||
);
|
||||
const doSelectedNodesIncludeTrigger = selectedNodes.some(
|
||||
(node) => node === flowVersion.trigger.name,
|
||||
);
|
||||
|
||||
const firstSelectedStep = flowStructureUtil.getStep(
|
||||
selectedNodes[0],
|
||||
flowVersion.trigger,
|
||||
);
|
||||
const showPasteAfterLastStep =
|
||||
!readonly && contextMenuType === ContextMenuType.CANVAS;
|
||||
const showPasteAsFirstLoopAction =
|
||||
selectedNodes.length === 1 &&
|
||||
firstSelectedStep?.type === FlowActionType.LOOP_ON_ITEMS &&
|
||||
!readonly &&
|
||||
contextMenuType === ContextMenuType.STEP;
|
||||
const showPasteAsBranchChild =
|
||||
selectedNodes.length === 1 &&
|
||||
firstSelectedStep?.type === FlowActionType.ROUTER &&
|
||||
!readonly &&
|
||||
contextMenuType === ContextMenuType.STEP;
|
||||
const showPasteAfterCurrentStep =
|
||||
selectedNodes.length === 1 &&
|
||||
!readonly &&
|
||||
contextMenuType === ContextMenuType.STEP;
|
||||
const showReplace =
|
||||
selectedNodes.length === 1 &&
|
||||
!readonly &&
|
||||
contextMenuType === ContextMenuType.STEP;
|
||||
const showCopy =
|
||||
!doSelectedNodesIncludeTrigger && contextMenuType === ContextMenuType.STEP;
|
||||
const showDuplicate =
|
||||
selectedNodes.length === 1 &&
|
||||
!doSelectedNodesIncludeTrigger &&
|
||||
contextMenuType === ContextMenuType.STEP &&
|
||||
!readonly;
|
||||
const showSkip =
|
||||
!doSelectedNodesIncludeTrigger &&
|
||||
contextMenuType === ContextMenuType.STEP &&
|
||||
!readonly;
|
||||
const isTriggerTheOnlySelectedNode =
|
||||
selectedNodes.length === 1 && doSelectedNodesIncludeTrigger;
|
||||
const showDelete =
|
||||
!readonly &&
|
||||
contextMenuType === ContextMenuType.STEP &&
|
||||
!isTriggerTheOnlySelectedNode;
|
||||
|
||||
const duplicateStep = () => {
|
||||
applyOperation({
|
||||
type: FlowOperationType.DUPLICATE_ACTION,
|
||||
request: {
|
||||
stepName: selectedNodes[0],
|
||||
},
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{showReplace && (
|
||||
<ContextMenuItem
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setOpenedPieceSelectorStepNameOrAddButtonId(selectedNodes[0]);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowLeftRight className="w-4 h-4"></ArrowLeftRight> {t('Replace')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{showCopy && (
|
||||
<ContextMenuItem
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
copySelectedNodes({ selectedNodes, flowVersion });
|
||||
}}
|
||||
>
|
||||
<ShortcutWrapper shortcut={CanvasShortcuts['Copy']}>
|
||||
<Copy className="w-4 h-4"></Copy> {t('Copy')}
|
||||
</ShortcutWrapper>
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
|
||||
<>
|
||||
{showDuplicate && (
|
||||
<ContextMenuItem
|
||||
disabled={disabled}
|
||||
onClick={duplicateStep}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<CopyPlus className="w-4 h-4"></CopyPlus> {t('Duplicate')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
|
||||
{showSkip && (
|
||||
<ContextMenuItem
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
toggleSkipSelectedNodes({
|
||||
selectedNodes,
|
||||
flowVersion,
|
||||
applyOperation,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ShortcutWrapper shortcut={CanvasShortcuts['Skip']}>
|
||||
{areAllStepsSkipped ? (
|
||||
<Route className="h-4 w-4"></Route>
|
||||
) : (
|
||||
<RouteOff className="h-4 w-4"></RouteOff>
|
||||
)}
|
||||
{areAllStepsSkipped ? t('Unskip') : t('Skip')}
|
||||
</ShortcutWrapper>
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{(showPasteAsFirstLoopAction ||
|
||||
showPasteAsBranchChild ||
|
||||
showPasteAfterCurrentStep) && (
|
||||
<ContextMenuSeparator></ContextMenuSeparator>
|
||||
)}
|
||||
|
||||
{showPasteAfterLastStep && (
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
const pasteLocation = getLastLocationAsPasteLocation(flowVersion);
|
||||
if (pasteLocation) {
|
||||
pasteNodes(flowVersion, pasteLocation, applyOperation);
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ClipboardPlus className="w-4 h-4"></ClipboardPlus>{' '}
|
||||
{t('Paste After Last Step')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
|
||||
{showPasteAsFirstLoopAction && (
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
pasteNodes(
|
||||
flowVersion,
|
||||
{
|
||||
parentStepName: selectedNodes[0],
|
||||
stepLocationRelativeToParent:
|
||||
StepLocationRelativeToParent.INSIDE_LOOP,
|
||||
},
|
||||
applyOperation,
|
||||
);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ClipboardPaste className="w-4 h-4"></ClipboardPaste>{' '}
|
||||
{t('Paste Inside Loop')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
|
||||
{showPasteAfterCurrentStep && (
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
pasteNodes(
|
||||
flowVersion,
|
||||
{
|
||||
parentStepName: selectedNodes[0],
|
||||
stepLocationRelativeToParent:
|
||||
StepLocationRelativeToParent.AFTER,
|
||||
},
|
||||
applyOperation,
|
||||
);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ClipboardPlus className="w-4 h-4"></ClipboardPlus>{' '}
|
||||
{t('Paste After')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
|
||||
{showPasteAsBranchChild && (
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger className="flex items-center gap-2">
|
||||
<ClipboardPaste className="w-4 h-4"></ClipboardPaste>{' '}
|
||||
{t('Paste Inside...')}
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
{firstSelectedStep &&
|
||||
firstSelectedStep.settings.branches.map(
|
||||
(branch, branchIndex) => (
|
||||
<ContextMenuItem
|
||||
key={branch.branchName}
|
||||
onClick={() => {
|
||||
pasteNodes(
|
||||
flowVersion,
|
||||
{
|
||||
parentStepName: selectedNodes[0],
|
||||
stepLocationRelativeToParent:
|
||||
StepLocationRelativeToParent.INSIDE_BRANCH,
|
||||
branchIndex,
|
||||
},
|
||||
applyOperation,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{branch.branchName}
|
||||
</ContextMenuItem>
|
||||
),
|
||||
)}
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
applyOperation({
|
||||
type: FlowOperationType.ADD_BRANCH,
|
||||
request: {
|
||||
stepName: firstSelectedStep.name,
|
||||
branchIndex:
|
||||
firstSelectedStep.settings.branches.length - 1,
|
||||
branchName: `Branch ${firstSelectedStep.settings.branches.length}`,
|
||||
},
|
||||
});
|
||||
pasteNodes(
|
||||
flowVersion,
|
||||
{
|
||||
parentStepName: firstSelectedStep.name,
|
||||
stepLocationRelativeToParent:
|
||||
StepLocationRelativeToParent.INSIDE_BRANCH,
|
||||
branchIndex:
|
||||
firstSelectedStep.settings.branches.length - 1,
|
||||
},
|
||||
applyOperation,
|
||||
);
|
||||
}}
|
||||
>
|
||||
+ {t('New Branch')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
)}
|
||||
|
||||
{showDelete && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
deleteSelectedNodes({
|
||||
selectedNodes,
|
||||
applyOperation,
|
||||
selectedStep,
|
||||
exitStepSettings,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ShortcutWrapper shortcut={CanvasShortcuts['Delete']}>
|
||||
<Trash className="w-4 stroke-destructive h-4"></Trash>{' '}
|
||||
<div className="text-destructive">{t('Delete')}</div>
|
||||
</ShortcutWrapper>
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuTrigger,
|
||||
} from '@/components/ui/context-menu';
|
||||
import { ShortcutProps } from '@/components/ui/shortcut';
|
||||
|
||||
import { CanvasContextMenuContent } from './canvas-context-menu-content';
|
||||
|
||||
export type CanvasShortcutsProps = Record<
|
||||
'Paste' | 'Delete' | 'Copy' | 'Skip',
|
||||
ShortcutProps
|
||||
>;
|
||||
export const CanvasShortcuts: CanvasShortcutsProps = {
|
||||
Paste: {
|
||||
withCtrl: true,
|
||||
withShift: false,
|
||||
shortcutKey: 'v',
|
||||
},
|
||||
Delete: {
|
||||
withCtrl: false,
|
||||
withShift: true,
|
||||
shortcutKey: 'Delete',
|
||||
},
|
||||
Copy: {
|
||||
withCtrl: true,
|
||||
withShift: false,
|
||||
shortcutKey: 'c',
|
||||
shouldNotPreventDefault: true,
|
||||
},
|
||||
Skip: {
|
||||
withCtrl: true,
|
||||
withShift: false,
|
||||
shortcutKey: 'e',
|
||||
},
|
||||
};
|
||||
export enum ContextMenuType {
|
||||
CANVAS = 'CANVAS',
|
||||
STEP = 'STEP',
|
||||
}
|
||||
export type CanvasContextMenuProps = {
|
||||
children?: React.ReactNode;
|
||||
contextMenuType: ContextMenuType;
|
||||
};
|
||||
export const CanvasContextMenu = ({
|
||||
contextMenuType,
|
||||
children,
|
||||
}: CanvasContextMenuProps) => {
|
||||
return (
|
||||
<ContextMenu modal={false}>
|
||||
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<CanvasContextMenuContent
|
||||
contextMenuType={contextMenuType}
|
||||
></CanvasContextMenuContent>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useDndMonitor, useDroppable, DragMoveEvent } from '@dnd-kit/core';
|
||||
import { Plus } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { PieceSelector } from '@/app/builder/pieces-selector';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isNil } from '@activepieces/shared';
|
||||
|
||||
import { useBuilderStateContext } from '../../builder-hooks';
|
||||
import { flowUtilConsts } from '../utils/consts';
|
||||
import { flowCanvasUtils } from '../utils/flow-canvas-utils';
|
||||
import { ApButtonData } from '../utils/types';
|
||||
|
||||
const ApAddButton = React.memo((props: ApButtonData) => {
|
||||
const [isStepInsideDropZone, setIsStepInsideDropzone] = useState(false);
|
||||
const [activeDraggingStep, readonly, isPieceSelectorOpen] =
|
||||
useBuilderStateContext((state) => [
|
||||
state.activeDraggingStep,
|
||||
state.readonly,
|
||||
state.openedPieceSelectorStepNameOrAddButtonId === props.edgeId,
|
||||
]);
|
||||
|
||||
const { setNodeRef } = useDroppable({
|
||||
id: props.edgeId,
|
||||
data: {
|
||||
accepts: flowUtilConsts.DRAGGED_STEP_TAG,
|
||||
...props,
|
||||
},
|
||||
});
|
||||
|
||||
const showDropIndicator = !isNil(activeDraggingStep);
|
||||
|
||||
useDndMonitor({
|
||||
onDragMove(event: DragMoveEvent) {
|
||||
setIsStepInsideDropzone(event.collisions?.[0]?.id === props.edgeId);
|
||||
},
|
||||
onDragEnd() {
|
||||
setIsStepInsideDropzone(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{showDropIndicator && !readonly && (
|
||||
<div
|
||||
style={{
|
||||
width: flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.width + 'px',
|
||||
height: flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.height + 'px',
|
||||
}}
|
||||
className={cn('transition-all bg-primary/90 rounded-md', {
|
||||
'shadow-add-button': isStepInsideDropZone,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: flowUtilConsts.AP_NODE_SIZE.STEP.width + 'px',
|
||||
height: flowUtilConsts.AP_NODE_SIZE.STEP.height + 'px',
|
||||
left: `${-flowUtilConsts.AP_NODE_SIZE.STEP.width / 2}px`,
|
||||
top: `${-flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEPS / 2}px`,
|
||||
}}
|
||||
className={cn(' absolute rounded-md box-content ')}
|
||||
ref={setNodeRef}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
{!showDropIndicator && !readonly && (
|
||||
<PieceSelector
|
||||
operation={flowCanvasUtils.createAddOperationFromAddButtonData(props)}
|
||||
id={props.edgeId}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.width + 'px',
|
||||
height: flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.height + 'px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.width + 'px',
|
||||
height: flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.height + 'px',
|
||||
}}
|
||||
className={cn('rounded-md cursor-pointer transition-all z-50', {
|
||||
'shadow-add-button': isPieceSelectorOpen,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.width + 'px',
|
||||
height: flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.height + 'px',
|
||||
}}
|
||||
className={cn(
|
||||
'bg-background border border-border border-solid relative group overflow-visible rounded-md cursor-pointer flex items-center justify-center transition-all duration-300 ease-in-out',
|
||||
{
|
||||
'bg-primary border-primary': isPieceSelectorOpen,
|
||||
},
|
||||
)}
|
||||
data-testid="add-action-button"
|
||||
>
|
||||
{!isPieceSelectorOpen && (
|
||||
<Plus className="w-3 h-3 stroke-[3px] text-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PieceSelector>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
ApAddButton.displayName = 'ApAddButton';
|
||||
export { ApAddButton };
|
||||
@@ -0,0 +1,199 @@
|
||||
import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu';
|
||||
import { useReactFlow } from '@xyflow/react';
|
||||
import { t } from 'i18next';
|
||||
import { CopyPlus, EllipsisVertical, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
FlowActionType,
|
||||
BranchExecutionType,
|
||||
FlowOperationType,
|
||||
flowStructureUtil,
|
||||
isNil,
|
||||
StepLocationRelativeToParent,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from '../../../../components/ui/dropdown-menu';
|
||||
import { cn } from '../../../../lib/utils';
|
||||
import { useBuilderStateContext } from '../../builder-hooks';
|
||||
import { flowUtilConsts } from '../utils/consts';
|
||||
import { flowCanvasUtils } from '../utils/flow-canvas-utils';
|
||||
|
||||
type BaseBranchLabel = {
|
||||
label: string;
|
||||
targetNodeName: string;
|
||||
sourceNodeName: string;
|
||||
stepLocationRelativeToParent: StepLocationRelativeToParent.INSIDE_BRANCH;
|
||||
branchIndex: number;
|
||||
};
|
||||
|
||||
const BranchLabel = (props: BaseBranchLabel) => {
|
||||
const [
|
||||
selectedStep,
|
||||
selectedBranchIndex,
|
||||
selectStepByName,
|
||||
setSelectedBranchIndex,
|
||||
step,
|
||||
applyOperation,
|
||||
readonly,
|
||||
] = useBuilderStateContext((state) => [
|
||||
state.selectedStep,
|
||||
state.selectedBranchIndex,
|
||||
state.selectStepByName,
|
||||
state.setSelectedBranchIndex,
|
||||
flowStructureUtil.getStep(props.sourceNodeName, state.flowVersion.trigger),
|
||||
state.applyOperation,
|
||||
state.readonly,
|
||||
]);
|
||||
|
||||
const isFallbackBranch =
|
||||
props.stepLocationRelativeToParent ===
|
||||
StepLocationRelativeToParent.INSIDE_BRANCH &&
|
||||
step?.type === FlowActionType.ROUTER &&
|
||||
step?.settings.branches[props.branchIndex]?.branchType ===
|
||||
BranchExecutionType.FALLBACK;
|
||||
const isNotInsideRoute =
|
||||
props.stepLocationRelativeToParent !==
|
||||
StepLocationRelativeToParent.INSIDE_BRANCH;
|
||||
const isOtherwiseBranch = isNotInsideRoute || isFallbackBranch;
|
||||
const isBranchSelected =
|
||||
selectedStep === props.sourceNodeName &&
|
||||
props.stepLocationRelativeToParent ===
|
||||
StepLocationRelativeToParent.INSIDE_BRANCH &&
|
||||
props.branchIndex === selectedBranchIndex;
|
||||
const { fitView } = useReactFlow();
|
||||
const [isDropdownMenuOpen, setIsDropdownMenuOpen] = useState(false);
|
||||
|
||||
if (isNil(step) || step.type !== FlowActionType.ROUTER) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full flex items-center justify-center "
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDropdownMenuOpen(true);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-builder-background"
|
||||
style={{
|
||||
paddingTop: flowUtilConsts.LABEL_VERTICAL_PADDING / 2 + 'px',
|
||||
paddingBottom: flowUtilConsts.LABEL_VERTICAL_PADDING / 2 + 'px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-0.5 select-none transition-all rounded-md text-sm border border-solid bg-primary-100/30 dark:bg-primary-100/15 border-primary/50 px-2 text-primary/80 dark:text-primary/90 hover:text-primary hover:border-primary',
|
||||
{
|
||||
'border-primary text-primary': isBranchSelected,
|
||||
'bg-border/60 text-foreground/70 dark:text-foreground/70 border-border hover:text-foreground/70 hover:bg-border/60 hover:border-border cursor-default':
|
||||
isOtherwiseBranch,
|
||||
},
|
||||
)}
|
||||
style={{
|
||||
height: flowUtilConsts.LABEL_HEIGHT + 'px',
|
||||
maxWidth: flowUtilConsts.AP_NODE_SIZE.STEP.width - 10 + 'px',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (
|
||||
props.stepLocationRelativeToParent ===
|
||||
StepLocationRelativeToParent.INSIDE_BRANCH &&
|
||||
!isOtherwiseBranch
|
||||
) {
|
||||
selectStepByName(props.sourceNodeName);
|
||||
setSelectedBranchIndex(props.branchIndex);
|
||||
fitView(
|
||||
flowCanvasUtils.createFocusStepInGraphParams(
|
||||
props.targetNodeName,
|
||||
),
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="truncate">
|
||||
{props.label === 'Otherwise' ? t('Otherwise') : props.label}
|
||||
</div>
|
||||
|
||||
{!isOtherwiseBranch &&
|
||||
!readonly &&
|
||||
step.type === FlowActionType.ROUTER && (
|
||||
<DropdownMenu
|
||||
modal={true}
|
||||
open={isDropdownMenuOpen}
|
||||
onOpenChange={setIsDropdownMenuOpen}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
className="h-5 shrink-0 border border-transparent hover:border-solid hover:border-primary-300/50 transition-all rounded-full w-5 flex items-center justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVertical className="h-4 w-4" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
applyOperation({
|
||||
type: FlowOperationType.DUPLICATE_BRANCH,
|
||||
request: {
|
||||
stepName: props.sourceNodeName,
|
||||
branchIndex: props.branchIndex,
|
||||
},
|
||||
});
|
||||
setSelectedBranchIndex(props.branchIndex + 1);
|
||||
}}
|
||||
>
|
||||
<div className="flex cursor-pointer flex-row gap-2 items-center">
|
||||
<CopyPlus className="h-4 w-4" />
|
||||
<span>{t('Duplicate Branch')}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={step.settings.branches.length <= 2}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSelectedBranchIndex(null);
|
||||
applyOperation({
|
||||
type: FlowOperationType.DELETE_BRANCH,
|
||||
request: {
|
||||
stepName: props.sourceNodeName,
|
||||
branchIndex: props.branchIndex,
|
||||
},
|
||||
});
|
||||
selectStepByName(props.sourceNodeName);
|
||||
}}
|
||||
>
|
||||
<div className="flex cursor-pointer flex-row gap-2 items-center">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
<span className="text-destructive">
|
||||
{t('Delete Branch')}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { BranchLabel };
|
||||
@@ -0,0 +1,101 @@
|
||||
import { BaseEdge, EdgeProps } from '@xyflow/react';
|
||||
|
||||
import { StepLocationRelativeToParent } from '@activepieces/shared';
|
||||
|
||||
import { flowUtilConsts } from '../utils/consts';
|
||||
import { ApLoopReturnEdge } from '../utils/types';
|
||||
|
||||
import { ApAddButton } from './add-button';
|
||||
|
||||
export const ApLoopReturnLineCanvasEdge = ({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
data,
|
||||
id,
|
||||
}: EdgeProps & ApLoopReturnEdge) => {
|
||||
const horizontalLineLength =
|
||||
Math.abs(sourceX - targetX) - 2 * flowUtilConsts.ARC_LENGTH;
|
||||
|
||||
const verticalLineLength = data.verticalSpaceBetweenReturnNodeStartAndEnd;
|
||||
const ARROW_RIGHT = ` m-5 -6 l6 6 m-6 0 m6 0 l-6 6 m3 -6`;
|
||||
const endLineLength =
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEPS -
|
||||
2 * flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEP_AND_LINE +
|
||||
8;
|
||||
const path = `
|
||||
M ${sourceX - 0.5} ${
|
||||
sourceY - flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEP_AND_LINE
|
||||
}
|
||||
v 1
|
||||
${flowUtilConsts.ARC_LEFT_DOWN} h -${horizontalLineLength}
|
||||
${flowUtilConsts.ARC_RIGHT_UP} v -${verticalLineLength}
|
||||
a15,15 0 0,1 15,-15
|
||||
|
||||
h ${horizontalLineLength / 2 - 2 * flowUtilConsts.ARC_LENGTH}
|
||||
${ARROW_RIGHT}
|
||||
|
||||
M ${sourceX - flowUtilConsts.ARC_LENGTH - horizontalLineLength / 2} ${
|
||||
sourceY +
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEP_AND_LINE +
|
||||
flowUtilConsts.ARC_LENGTH / 2
|
||||
}
|
||||
v${endLineLength} ${
|
||||
data.drawArrowHeadAfterEnd ? flowUtilConsts.ARROW_DOWN : ''
|
||||
}
|
||||
`;
|
||||
const buttonPosition = {
|
||||
x:
|
||||
sourceX -
|
||||
horizontalLineLength / 2 -
|
||||
flowUtilConsts.ARC_LENGTH -
|
||||
flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.width / 2,
|
||||
y: sourceY + endLineLength / 2,
|
||||
};
|
||||
const showDebugForLineEndPoint = false;
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
path={path}
|
||||
style={{ strokeWidth: `${flowUtilConsts.LINE_WIDTH}px` }}
|
||||
className="relative"
|
||||
></BaseEdge>
|
||||
{showDebugForLineEndPoint && (
|
||||
<foreignObject
|
||||
x={targetX}
|
||||
y={targetY}
|
||||
className="w-[20px] h-[20px] rounded-full bg-[red] flex items-center justify-center absolute"
|
||||
>
|
||||
<div className=" w-[20px] h-[20px] rounded-full bg-[red] flex items-center justify-center"></div>
|
||||
</foreignObject>
|
||||
)}
|
||||
|
||||
{
|
||||
<foreignObject
|
||||
x={buttonPosition.x}
|
||||
y={buttonPosition.y}
|
||||
width={flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.width}
|
||||
height={flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.height}
|
||||
className="overflow-visible"
|
||||
>
|
||||
<ApAddButton
|
||||
edgeId={id}
|
||||
stepLocationRelativeToParent={StepLocationRelativeToParent.AFTER}
|
||||
parentStepName={data.parentStepName}
|
||||
></ApAddButton>
|
||||
</foreignObject>
|
||||
}
|
||||
|
||||
{showDebugForLineEndPoint && (
|
||||
<foreignObject
|
||||
x={sourceX}
|
||||
y={sourceY}
|
||||
className="w-[20px] h-[20px] rounded-full bg-[red] flex items-center justify-center absolute"
|
||||
>
|
||||
<div className=" w-[20px] h-[20px] rounded-full bg-[red] flex items-center justify-center"></div>
|
||||
</foreignObject>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { BaseEdge, EdgeProps } from '@xyflow/react';
|
||||
|
||||
import { StepLocationRelativeToParent } from '@activepieces/shared';
|
||||
|
||||
import { flowUtilConsts } from '../utils/consts';
|
||||
import { ApLoopStartEdge } from '../utils/types';
|
||||
|
||||
import { ApAddButton } from './add-button';
|
||||
|
||||
export const ApLoopStartLineCanvasEdge = ({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
data,
|
||||
source,
|
||||
id,
|
||||
}: EdgeProps & ApLoopStartEdge) => {
|
||||
const startY = sourceY + flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEP_AND_LINE;
|
||||
const verticalLineLength =
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEPS -
|
||||
2 * flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEP_AND_LINE;
|
||||
|
||||
const horizontalLineLength =
|
||||
Math.abs(targetX - sourceX) - 2 * flowUtilConsts.ARC_LENGTH;
|
||||
const path = `M ${sourceX} ${startY} v${verticalLineLength / 2}
|
||||
${flowUtilConsts.ARC_RIGHT_DOWN} h${horizontalLineLength}
|
||||
${flowUtilConsts.ARC_RIGHT} v${verticalLineLength}
|
||||
${!data.isLoopEmpty ? flowUtilConsts.ARROW_DOWN : ''}`;
|
||||
|
||||
const showDebugForLineEndPoint = false;
|
||||
const buttonPosition = {
|
||||
x:
|
||||
sourceX -
|
||||
flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.width / 2 +
|
||||
horizontalLineLength +
|
||||
flowUtilConsts.ARC_LENGTH * 2,
|
||||
y: startY + verticalLineLength + flowUtilConsts.ARC_LENGTH,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
path={path}
|
||||
style={{ strokeWidth: `${flowUtilConsts.LINE_WIDTH}px` }}
|
||||
className="relative"
|
||||
></BaseEdge>
|
||||
{!data.isLoopEmpty && (
|
||||
<foreignObject
|
||||
x={buttonPosition.x}
|
||||
y={buttonPosition.y}
|
||||
width={flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.width}
|
||||
height={flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.height}
|
||||
className="overflow-visible cursor-default"
|
||||
>
|
||||
<ApAddButton
|
||||
edgeId={id}
|
||||
stepLocationRelativeToParent={
|
||||
StepLocationRelativeToParent.INSIDE_LOOP
|
||||
}
|
||||
parentStepName={source}
|
||||
></ApAddButton>
|
||||
</foreignObject>
|
||||
)}
|
||||
|
||||
{showDebugForLineEndPoint && (
|
||||
<foreignObject
|
||||
x={sourceX}
|
||||
y={startY}
|
||||
className="w-[20px] h-[20px] rounded-full bg-[red] flex items-center justify-center absolute"
|
||||
>
|
||||
<div className=" w-[20px] h-[20px] rounded-full bg-[red] flex items-center justify-center"></div>
|
||||
</foreignObject>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { BaseEdge, EdgeProps } from '@xyflow/react';
|
||||
|
||||
import { StepLocationRelativeToParent } from '@activepieces/shared';
|
||||
|
||||
import { flowUtilConsts } from '../utils/consts';
|
||||
import { ApRouterEndEdge } from '../utils/types';
|
||||
|
||||
import { ApAddButton } from './add-button';
|
||||
|
||||
export const ApRouterEndCanvasEdge = ({
|
||||
sourceX,
|
||||
targetX,
|
||||
targetY,
|
||||
sourceY,
|
||||
data,
|
||||
id,
|
||||
}: EdgeProps & Omit<ApRouterEndEdge, 'position'>) => {
|
||||
const verticalLineLength =
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEPS -
|
||||
2 * flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEP_AND_LINE;
|
||||
|
||||
const horizontalLineLength =
|
||||
(Math.abs(targetX - sourceX) - 2 * flowUtilConsts.ARC_LENGTH) *
|
||||
(targetX > sourceX ? 1 : -1);
|
||||
|
||||
const distanceBetweenTargetAndSource = Math.abs(targetX - sourceX);
|
||||
|
||||
const generatePath = () => {
|
||||
// Start point
|
||||
let path = `M ${sourceX - 0.5} ${
|
||||
sourceY - flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEP_AND_LINE
|
||||
}`;
|
||||
|
||||
// Vertical line from start
|
||||
path += `v ${data.verticalSpaceBetweenLastNodeInBranchAndEndLine}`;
|
||||
|
||||
// Arc or vertical line based on distance
|
||||
if (distanceBetweenTargetAndSource >= flowUtilConsts.ARC_LENGTH) {
|
||||
path +=
|
||||
targetX > sourceX
|
||||
? flowUtilConsts.ARC_RIGHT_DOWN
|
||||
: flowUtilConsts.ARC_LEFT_DOWN;
|
||||
} else {
|
||||
path += `v ${
|
||||
flowUtilConsts.ARC_LENGTH +
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEP_AND_LINE +
|
||||
2
|
||||
}`;
|
||||
}
|
||||
|
||||
// Optional horizontal line
|
||||
if (data.drawHorizontalLine) {
|
||||
path += `h ${horizontalLineLength} ${
|
||||
targetX > sourceX ? flowUtilConsts.ARC_RIGHT : flowUtilConsts.ARC_LEFT
|
||||
}`;
|
||||
}
|
||||
|
||||
// Optional ending vertical line with arrow
|
||||
if (data.drawEndingVerticalLine) {
|
||||
path += `v${verticalLineLength}`;
|
||||
if (!data.isNextStepEmpty) {
|
||||
path += flowUtilConsts.ARROW_DOWN;
|
||||
}
|
||||
}
|
||||
|
||||
return path;
|
||||
};
|
||||
|
||||
const path = generatePath();
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
path={path}
|
||||
style={{ strokeWidth: `${flowUtilConsts.LINE_WIDTH}px` }}
|
||||
/>
|
||||
|
||||
{data.drawEndingVerticalLine && (
|
||||
<foreignObject
|
||||
x={
|
||||
targetX -
|
||||
flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.width / 2 -
|
||||
flowUtilConsts.LINE_WIDTH / 2
|
||||
}
|
||||
y={targetY - verticalLineLength}
|
||||
width={flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.width}
|
||||
height={flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.height}
|
||||
className="overflow-visible"
|
||||
>
|
||||
<ApAddButton
|
||||
edgeId={id}
|
||||
stepLocationRelativeToParent={StepLocationRelativeToParent.AFTER}
|
||||
parentStepName={data.routerOrBranchStepName}
|
||||
/>
|
||||
</foreignObject>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
import { BaseEdge, EdgeProps } from '@xyflow/react';
|
||||
|
||||
import { StepLocationRelativeToParent } from '@activepieces/shared';
|
||||
|
||||
import { flowUtilConsts } from '../utils/consts';
|
||||
import { ApRouterStartEdge } from '../utils/types';
|
||||
|
||||
import { ApAddButton } from './add-button';
|
||||
import { BranchLabel } from './branch-label';
|
||||
|
||||
export const ApRouterStartCanvasEdge = ({
|
||||
sourceX,
|
||||
targetX,
|
||||
targetY,
|
||||
data,
|
||||
source,
|
||||
target,
|
||||
id,
|
||||
}: EdgeProps & Omit<ApRouterStartEdge, 'position'>) => {
|
||||
const verticalLineLength =
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEPS -
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEP_AND_LINE +
|
||||
flowUtilConsts.LABEL_HEIGHT;
|
||||
|
||||
const distanceBetweenSourceAndTarget = Math.abs(targetX - sourceX);
|
||||
const generatePath = () => {
|
||||
// Start point and initial vertical line
|
||||
let path = `M ${targetX} ${
|
||||
targetY - flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEP_AND_LINE
|
||||
}`;
|
||||
|
||||
// Add arrow if branch is not empty
|
||||
if (!data.isBranchEmpty) {
|
||||
path += flowUtilConsts.ARROW_DOWN;
|
||||
}
|
||||
|
||||
// Vertical line up
|
||||
path += `v -${verticalLineLength}`;
|
||||
|
||||
// Arc or vertical line based on distance
|
||||
if (distanceBetweenSourceAndTarget >= flowUtilConsts.ARC_LENGTH) {
|
||||
// Add appropriate arc based on source position
|
||||
path +=
|
||||
sourceX > targetX ? ' a12,12 0 0,1 12,-12' : ' a-12,-12 0 0,0 -12,-12';
|
||||
|
||||
if (data.drawHorizontalLine) {
|
||||
// Calculate horizontal line length
|
||||
const horizontalLength =
|
||||
(Math.abs(targetX - sourceX) + 3 - 2 * flowUtilConsts.ARC_LENGTH) *
|
||||
(sourceX > targetX ? 1 : -1);
|
||||
|
||||
// Add horizontal line and arc
|
||||
path += `h ${horizontalLength}`;
|
||||
path +=
|
||||
sourceX > targetX
|
||||
? flowUtilConsts.ARC_LEFT_UP
|
||||
: flowUtilConsts.ARC_RIGHT_UP;
|
||||
}
|
||||
|
||||
if (data.drawStartingVerticalLine) {
|
||||
// Add final vertical line
|
||||
const finalVerticalLength =
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEPS / 2 -
|
||||
2 * flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEP_AND_LINE;
|
||||
path += `v -${finalVerticalLength}`;
|
||||
}
|
||||
} else {
|
||||
// If distance is small, just draw vertical line
|
||||
path += `v -${
|
||||
flowUtilConsts.ARC_LENGTH +
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEP_AND_LINE
|
||||
}`;
|
||||
}
|
||||
|
||||
return path;
|
||||
};
|
||||
|
||||
const path = generatePath();
|
||||
|
||||
const branchLabelProps =
|
||||
data.stepLocationRelativeToParent ===
|
||||
StepLocationRelativeToParent.INSIDE_BRANCH
|
||||
? {
|
||||
label: data.label,
|
||||
sourceNodeName: source,
|
||||
targetNodeName: target,
|
||||
stepLocationRelativeToParent: data.stepLocationRelativeToParent,
|
||||
branchIndex: data.branchIndex,
|
||||
}
|
||||
: {
|
||||
label: data.label,
|
||||
sourceNodeName: source,
|
||||
targetNodeName: target,
|
||||
stepLocationRelativeToParent: data.stepLocationRelativeToParent,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
path={path}
|
||||
style={{ strokeWidth: `${flowUtilConsts.LINE_WIDTH}px` }}
|
||||
></BaseEdge>
|
||||
{!data.isBranchEmpty && (
|
||||
<foreignObject
|
||||
x={targetX - flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.width / 2}
|
||||
y={targetY - verticalLineLength / 2}
|
||||
width={flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.width}
|
||||
height={flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.height}
|
||||
className="overflow-visible"
|
||||
>
|
||||
{data.stepLocationRelativeToParent !==
|
||||
StepLocationRelativeToParent.INSIDE_BRANCH && (
|
||||
<ApAddButton
|
||||
edgeId={id}
|
||||
stepLocationRelativeToParent={data.stepLocationRelativeToParent}
|
||||
parentStepName={source}
|
||||
></ApAddButton>
|
||||
)}
|
||||
|
||||
{data.stepLocationRelativeToParent ===
|
||||
StepLocationRelativeToParent.INSIDE_BRANCH && (
|
||||
<ApAddButton
|
||||
edgeId={id}
|
||||
stepLocationRelativeToParent={data.stepLocationRelativeToParent}
|
||||
parentStepName={source}
|
||||
branchIndex={data.branchIndex}
|
||||
></ApAddButton>
|
||||
)}
|
||||
</foreignObject>
|
||||
)}
|
||||
|
||||
<foreignObject
|
||||
width={flowUtilConsts.AP_NODE_SIZE.STEP.width - 10 + 'px'}
|
||||
height={
|
||||
flowUtilConsts.LABEL_HEIGHT +
|
||||
flowUtilConsts.LABEL_VERTICAL_PADDING +
|
||||
'px'
|
||||
}
|
||||
x={targetX - (flowUtilConsts.AP_NODE_SIZE.STEP.width - 10) / 2}
|
||||
y={
|
||||
targetY -
|
||||
verticalLineLength / 2 -
|
||||
flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.height -
|
||||
30
|
||||
}
|
||||
className="flex items-center "
|
||||
>
|
||||
<BranchLabel
|
||||
key={branchLabelProps.label + branchLabelProps.targetNodeName}
|
||||
sourceNodeName={source}
|
||||
targetNodeName={target}
|
||||
stepLocationRelativeToParent={data.stepLocationRelativeToParent}
|
||||
branchIndex={data.branchIndex}
|
||||
label={data.label}
|
||||
/>
|
||||
</foreignObject>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { BaseEdge, EdgeProps } from '@xyflow/react';
|
||||
|
||||
import { StepLocationRelativeToParent } from '@activepieces/shared';
|
||||
|
||||
import { flowUtilConsts } from '../utils/consts';
|
||||
import { ApStraightLineEdge } from '../utils/types';
|
||||
|
||||
import { ApAddButton } from './add-button';
|
||||
|
||||
export const ApStraightLineCanvasEdge = ({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetY,
|
||||
data,
|
||||
id,
|
||||
source,
|
||||
}: EdgeProps & ApStraightLineEdge) => {
|
||||
const lineStartX = sourceX;
|
||||
const lineStartY = sourceY;
|
||||
const lineLength = targetY - sourceY;
|
||||
const path = `M ${lineStartX} ${lineStartY} v${lineLength}
|
||||
${data.drawArrowHead ? flowUtilConsts.ARROW_DOWN : ''}`;
|
||||
const showDebugForLineEndPoint = false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
path={path}
|
||||
style={{ strokeWidth: `${flowUtilConsts.LINE_WIDTH}px` }}
|
||||
/>
|
||||
{!data.hideAddButton && (
|
||||
<foreignObject
|
||||
x={lineStartX - flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.width / 2}
|
||||
y={
|
||||
lineStartY +
|
||||
(targetY - sourceY) / 2 -
|
||||
flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.height / 2
|
||||
}
|
||||
width={flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.width}
|
||||
height={flowUtilConsts.AP_NODE_SIZE.ADD_BUTTON.height}
|
||||
className="overflow-visible cursor-default"
|
||||
>
|
||||
<ApAddButton
|
||||
edgeId={id}
|
||||
parentStepName={source}
|
||||
stepLocationRelativeToParent={StepLocationRelativeToParent.AFTER}
|
||||
></ApAddButton>
|
||||
</foreignObject>
|
||||
)}
|
||||
|
||||
{showDebugForLineEndPoint && (
|
||||
<foreignObject
|
||||
x={lineStartX}
|
||||
y={lineStartY + targetY - sourceY}
|
||||
className="w-[20px] h-[20px] rounded-full bg-[red] flex items-center justify-center absolute"
|
||||
>
|
||||
<div className=" w-[20px] h-[20px] rounded-full bg-[red] flex items-center justify-center"></div>
|
||||
</foreignObject>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
rectIntersection,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { useViewport } from '@xyflow/react';
|
||||
import { t } from 'i18next';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
FlowOperationType,
|
||||
StepLocationRelativeToParent,
|
||||
flowStructureUtil,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { useBuilderStateContext } from '../builder-hooks';
|
||||
|
||||
import StepDragOverlay from './step-drag-overlay';
|
||||
import { ApButtonData } from './utils/types';
|
||||
|
||||
const FlowDragLayer = ({
|
||||
children,
|
||||
cursorPosition,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
cursorPosition: { x: number; y: number };
|
||||
}) => {
|
||||
const viewport = useViewport();
|
||||
const [previousViewPort, setPreviousViewPort] = useState(viewport);
|
||||
const [
|
||||
setActiveDraggingStep,
|
||||
applyOperation,
|
||||
flowVersion,
|
||||
activeDraggingStep,
|
||||
] = useBuilderStateContext((state) => [
|
||||
state.setActiveDraggingStep,
|
||||
state.applyOperation,
|
||||
state.flowVersion,
|
||||
state.activeDraggingStep,
|
||||
]);
|
||||
|
||||
const fixCursorSnapOffset = useCallback(
|
||||
(args: Parameters<typeof rectIntersection>[0]) => {
|
||||
// Bail out if keyboard activated
|
||||
if (!args.pointerCoordinates) {
|
||||
return rectIntersection(args);
|
||||
}
|
||||
const { x, y } = args.pointerCoordinates;
|
||||
const { width, height } = args.collisionRect;
|
||||
const deltaViewport = {
|
||||
x: previousViewPort.x - viewport.x,
|
||||
y: previousViewPort.y - viewport.y,
|
||||
};
|
||||
const updated = {
|
||||
...args,
|
||||
// The collision rectangle is broken when using snapCenterToCursor. Reset
|
||||
// the collision rectangle based on pointer location and overlay size.
|
||||
collisionRect: {
|
||||
width,
|
||||
height,
|
||||
bottom: y + height / 2 + deltaViewport.y,
|
||||
left: x - width / 2 + deltaViewport.x,
|
||||
right: x + width / 2 + deltaViewport.x,
|
||||
top: y - height / 2 + deltaViewport.y,
|
||||
},
|
||||
};
|
||||
return rectIntersection(updated);
|
||||
},
|
||||
[viewport.x, viewport.y, previousViewPort.x, previousViewPort.y],
|
||||
);
|
||||
const draggedStep = activeDraggingStep
|
||||
? flowStructureUtil.getStep(activeDraggingStep, flowVersion.trigger)
|
||||
: undefined;
|
||||
|
||||
const handleDragStart = (e: DragStartEvent) => {
|
||||
setActiveDraggingStep(e.active.id.toString());
|
||||
setPreviousViewPort(viewport);
|
||||
};
|
||||
|
||||
const handleDragCancel = () => {
|
||||
setActiveDraggingStep(null);
|
||||
};
|
||||
|
||||
const handleDragEnd = (e: DragEndEvent) => {
|
||||
setActiveDraggingStep(null);
|
||||
if (
|
||||
e.over &&
|
||||
e.over.data.current &&
|
||||
e.over.data.current.accepts === e.active.data?.current?.type
|
||||
) {
|
||||
const droppedAtNodeData: ApButtonData = e.over.data
|
||||
.current as ApButtonData;
|
||||
if (
|
||||
droppedAtNodeData &&
|
||||
droppedAtNodeData.parentStepName &&
|
||||
draggedStep &&
|
||||
draggedStep.name !== droppedAtNodeData.parentStepName
|
||||
) {
|
||||
const isPartOfInnerFlow = flowStructureUtil.isChildOf(
|
||||
draggedStep,
|
||||
droppedAtNodeData.parentStepName,
|
||||
);
|
||||
if (isPartOfInnerFlow) {
|
||||
toast(t('Invalid Move'), {
|
||||
description: t(
|
||||
'The destination location is a child of the dragged step',
|
||||
),
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
applyOperation({
|
||||
type: FlowOperationType.MOVE_ACTION,
|
||||
request: {
|
||||
name: draggedStep.name,
|
||||
newParentStep: droppedAtNodeData.parentStepName,
|
||||
stepLocationRelativeToNewParent:
|
||||
droppedAtNodeData.stepLocationRelativeToParent,
|
||||
branchIndex:
|
||||
droppedAtNodeData.stepLocationRelativeToParent ===
|
||||
StepLocationRelativeToParent.INSIDE_BRANCH
|
||||
? droppedAtNodeData.branchIndex
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 10,
|
||||
},
|
||||
}),
|
||||
useSensor(TouchSensor),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<DndContext
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
sensors={sensors}
|
||||
collisionDetection={fixCursorSnapOffset}
|
||||
>
|
||||
{children}
|
||||
<DragOverlay dropAnimation={{ duration: 0 }}></DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
{draggedStep && (
|
||||
<StepDragOverlay
|
||||
cursorPosition={cursorPosition}
|
||||
step={draggedStep}
|
||||
></StepDragOverlay>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { FlowDragLayer };
|
||||
@@ -0,0 +1,257 @@
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
SelectionMode,
|
||||
OnSelectionChangeParams,
|
||||
useStoreApi,
|
||||
PanOnScrollMode,
|
||||
useKeyPress,
|
||||
BackgroundVariant,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
FlowActionType,
|
||||
flowStructureUtil,
|
||||
FlowVersion,
|
||||
isNil,
|
||||
Step,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import {
|
||||
doesSelectionRectangleExist,
|
||||
NODE_SELECTION_RECT_CLASS_NAME,
|
||||
useBuilderStateContext,
|
||||
useFocusOnStep,
|
||||
useHandleKeyPressOnCanvas,
|
||||
useResizeCanvas,
|
||||
} from '../builder-hooks';
|
||||
|
||||
import {
|
||||
CanvasContextMenu,
|
||||
ContextMenuType,
|
||||
} from './context-menu/canvas-context-menu';
|
||||
import { FlowDragLayer } from './flow-drag-layer';
|
||||
import {
|
||||
flowUtilConsts,
|
||||
SELECTION_RECT_CHEVRON_ATTRIBUTE,
|
||||
STEP_CONTEXT_MENU_ATTRIBUTE,
|
||||
} from './utils/consts';
|
||||
import { flowCanvasUtils } from './utils/flow-canvas-utils';
|
||||
import { AboveFlowWidgets } from './widgets';
|
||||
import { useShowChevronNextToSelection } from './widgets/selection-chevron-button';
|
||||
const getChildrenKey = (step: Step) => {
|
||||
switch (step.type) {
|
||||
case FlowActionType.LOOP_ON_ITEMS:
|
||||
return step.firstLoopAction ? step.firstLoopAction.name : '';
|
||||
case FlowActionType.ROUTER:
|
||||
return step.children.reduce((routerKey, child) => {
|
||||
const childrenKey = child
|
||||
? flowStructureUtil
|
||||
.getAllSteps(child)
|
||||
.reduce(
|
||||
(childKey, grandChild) => `${childKey}-${grandChild.name}`,
|
||||
'',
|
||||
)
|
||||
: 'null';
|
||||
return `${routerKey}-${childrenKey}`;
|
||||
}, '');
|
||||
case FlowActionType.CODE:
|
||||
case FlowActionType.PIECE:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
const createGraphKey = (flowVersion: FlowVersion) => {
|
||||
return flowStructureUtil
|
||||
.getAllSteps(flowVersion.trigger)
|
||||
.reduce((acc, step) => {
|
||||
const branchesNames =
|
||||
step.type === FlowActionType.ROUTER
|
||||
? step.settings.branches.map((branch) => branch.branchName).join('-')
|
||||
: '0';
|
||||
const childrenKey = getChildrenKey(step);
|
||||
return `${acc}-${step.displayName}-${step.type}-${
|
||||
step.nextAction ? step.nextAction.name : ''
|
||||
}-${
|
||||
step.type === FlowActionType.PIECE ? step.settings.pieceName : ''
|
||||
}-${branchesNames}-${childrenKey}}`;
|
||||
}, '');
|
||||
};
|
||||
|
||||
export const FlowCanvas = React.memo(
|
||||
({
|
||||
setHasCanvasBeenInitialised,
|
||||
}: {
|
||||
setHasCanvasBeenInitialised: (value: boolean) => void;
|
||||
}) => {
|
||||
const [
|
||||
flowVersion,
|
||||
setSelectedNodes,
|
||||
selectedNodes,
|
||||
selectedStep,
|
||||
panningMode,
|
||||
selectStepByName,
|
||||
] = useBuilderStateContext((state) => {
|
||||
return [
|
||||
state.flowVersion,
|
||||
state.setSelectedNodes,
|
||||
state.selectedNodes,
|
||||
state.selectedStep,
|
||||
state.panningMode,
|
||||
state.selectStepByName,
|
||||
];
|
||||
});
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useShowChevronNextToSelection();
|
||||
useFocusOnStep();
|
||||
useHandleKeyPressOnCanvas();
|
||||
useResizeCanvas(containerRef, setHasCanvasBeenInitialised);
|
||||
const storeApi = useStoreApi();
|
||||
const isShiftKeyPressed = useKeyPress('Shift');
|
||||
const inGrabPanningMode = !isShiftKeyPressed && panningMode === 'grab';
|
||||
const onSelectionChange = useCallback(
|
||||
(ev: OnSelectionChangeParams) => {
|
||||
const selectedNodes = ev.nodes.map((n) => n.id);
|
||||
if (selectedNodes.length === 0 && selectedStep) {
|
||||
selectedNodes.push(selectedStep);
|
||||
}
|
||||
setSelectedNodes(selectedNodes);
|
||||
},
|
||||
[setSelectedNodes, selectedStep],
|
||||
);
|
||||
const graphKey = createGraphKey(flowVersion);
|
||||
const graph = useMemo(() => {
|
||||
return flowCanvasUtils.convertFlowVersionToGraph(flowVersion);
|
||||
}, [graphKey]);
|
||||
const [contextMenuType, setContextMenuType] = useState<ContextMenuType>(
|
||||
ContextMenuType.CANVAS,
|
||||
);
|
||||
const onContextMenu = useCallback(
|
||||
(ev: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
ev.target instanceof HTMLElement ||
|
||||
ev.target instanceof SVGElement
|
||||
) {
|
||||
const stepElement = ev.target.closest(
|
||||
`[data-${STEP_CONTEXT_MENU_ATTRIBUTE}]`,
|
||||
);
|
||||
const stepName = stepElement?.getAttribute(
|
||||
`data-${STEP_CONTEXT_MENU_ATTRIBUTE}`,
|
||||
);
|
||||
|
||||
if (stepElement && stepName) {
|
||||
selectStepByName(stepName);
|
||||
storeApi.getState().addSelectedNodes([stepName]);
|
||||
}
|
||||
const targetIsSelectionChevron = ev.target.closest(
|
||||
`[data-${SELECTION_RECT_CHEVRON_ATTRIBUTE}]`,
|
||||
);
|
||||
const targetIsSelectionRect = ev.target.classList.contains(
|
||||
NODE_SELECTION_RECT_CLASS_NAME,
|
||||
);
|
||||
const showStepContextMenu =
|
||||
stepElement || targetIsSelectionRect || targetIsSelectionChevron;
|
||||
if (showStepContextMenu) {
|
||||
setContextMenuType(ContextMenuType.STEP);
|
||||
} else {
|
||||
setContextMenuType(ContextMenuType.CANVAS);
|
||||
}
|
||||
const shouldRemoveSelectionRect =
|
||||
!targetIsSelectionRect && !targetIsSelectionChevron;
|
||||
if (shouldRemoveSelectionRect) {
|
||||
document
|
||||
.querySelector(`.${NODE_SELECTION_RECT_CLASS_NAME}`)
|
||||
?.remove();
|
||||
}
|
||||
}
|
||||
},
|
||||
[setSelectedNodes, selectedNodes, doesSelectionRectangleExist],
|
||||
);
|
||||
|
||||
const onSelectionEnd = useCallback(() => {
|
||||
const selectedSteps = selectedNodes.map((node) =>
|
||||
flowStructureUtil.getStepOrThrow(node, flowVersion.trigger),
|
||||
);
|
||||
selectedSteps.forEach((step) => {
|
||||
if (
|
||||
step.type === FlowActionType.LOOP_ON_ITEMS ||
|
||||
step.type === FlowActionType.ROUTER
|
||||
) {
|
||||
const childrenNotSelected = flowStructureUtil
|
||||
.getAllChildSteps(step)
|
||||
.filter((c) => isNil(selectedNodes.find((n) => n === c.name)));
|
||||
selectedSteps.push(...childrenNotSelected);
|
||||
}
|
||||
});
|
||||
const step = selectedStep
|
||||
? flowStructureUtil.getStep(selectedStep, flowVersion.trigger)
|
||||
: null;
|
||||
if (selectedNodes.length === 0 && step) {
|
||||
selectedSteps.push(step);
|
||||
}
|
||||
storeApi
|
||||
.getState()
|
||||
.addSelectedNodes(selectedSteps.map((step) => step.name));
|
||||
}, [selectedNodes, storeApi, selectedStep]);
|
||||
const [cursorPosition, setCursorPosition] = useState({ x: 0, y: 0 });
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="size-full relative overflow-hidden z-30 bg-builder-background"
|
||||
>
|
||||
<FlowDragLayer cursorPosition={cursorPosition}>
|
||||
<CanvasContextMenu contextMenuType={contextMenuType}>
|
||||
<ReactFlow
|
||||
className="bg-builder-background"
|
||||
onContextMenu={onContextMenu}
|
||||
onPaneClick={() => {
|
||||
storeApi.getState().unselectNodesAndEdges();
|
||||
}}
|
||||
nodeTypes={flowUtilConsts.nodeTypes}
|
||||
nodes={graph.nodes}
|
||||
edgeTypes={flowUtilConsts.edgeTypes}
|
||||
edges={graph.edges}
|
||||
draggable={false}
|
||||
edgesFocusable={false}
|
||||
elevateEdgesOnSelect={false}
|
||||
maxZoom={1.5}
|
||||
minZoom={0.5}
|
||||
panOnDrag={inGrabPanningMode ? [0, 1] : [1]}
|
||||
zoomOnDoubleClick={false}
|
||||
panOnScroll={true}
|
||||
panOnScrollMode={PanOnScrollMode.Free}
|
||||
fitView={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={true}
|
||||
nodesDraggable={false}
|
||||
nodesFocusable={false}
|
||||
onNodeDrag={(event) => {
|
||||
setCursorPosition({ x: event.clientX, y: event.clientY });
|
||||
}}
|
||||
selectionKeyCode={inGrabPanningMode ? 'Shift' : null}
|
||||
multiSelectionKeyCode={inGrabPanningMode ? 'Shift' : null}
|
||||
selectionOnDrag={inGrabPanningMode ? false : true}
|
||||
selectNodesOnDrag={true}
|
||||
selectionMode={SelectionMode.Partial}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onSelectionEnd={onSelectionEnd}
|
||||
>
|
||||
<AboveFlowWidgets></AboveFlowWidgets>
|
||||
<Background
|
||||
gap={10}
|
||||
size={1}
|
||||
variant={BackgroundVariant.Dots}
|
||||
bgColor={`var(--builder-background)`}
|
||||
color={`var(--builder-background-pattern)`}
|
||||
/>
|
||||
</ReactFlow>
|
||||
</CanvasContextMenu>
|
||||
</FlowDragLayer>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
FlowCanvas.displayName = 'FlowCanvas';
|
||||
@@ -0,0 +1,171 @@
|
||||
import { DragMoveEvent, useDndMonitor, useDroppable } from '@dnd-kit/core';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import React, { useId, useState } from 'react';
|
||||
|
||||
import { PieceSelector } from '@/app/builder/pieces-selector';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isNil } from '@activepieces/shared';
|
||||
|
||||
import { useBuilderStateContext } from '../../builder-hooks';
|
||||
import { flowUtilConsts } from '../utils/consts';
|
||||
import { flowCanvasUtils } from '../utils/flow-canvas-utils';
|
||||
import { ApBigAddButtonNode } from '../utils/types';
|
||||
|
||||
const ApBigAddButtonCanvasNode = React.memo(
|
||||
({ data, id }: Omit<ApBigAddButtonNode, 'position'>) => {
|
||||
const [isIsStepInsideDropzone, setIsStepInsideDropzone] = useState(false);
|
||||
const [readonly, activeDraggingStep, isPieceSelectorOpened] =
|
||||
useBuilderStateContext((state) => [
|
||||
state.readonly,
|
||||
state.activeDraggingStep,
|
||||
state.openedPieceSelectorStepNameOrAddButtonId === id,
|
||||
]);
|
||||
const draggableId = useId();
|
||||
const { setNodeRef } = useDroppable({
|
||||
id: draggableId,
|
||||
data: {
|
||||
accepts: flowUtilConsts.DRAGGED_STEP_TAG,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
const isShowingDropIndicator = !isNil(activeDraggingStep);
|
||||
useDndMonitor({
|
||||
onDragMove(event: DragMoveEvent) {
|
||||
setIsStepInsideDropzone(event.over?.id === draggableId);
|
||||
},
|
||||
onDragEnd() {
|
||||
setIsStepInsideDropzone(false);
|
||||
},
|
||||
});
|
||||
return (
|
||||
<>
|
||||
{
|
||||
<div
|
||||
style={{
|
||||
height: `${flowUtilConsts.AP_NODE_SIZE.STEP.height}px`,
|
||||
width: `${flowUtilConsts.AP_NODE_SIZE.STEP.width}px`,
|
||||
}}
|
||||
className="flex justify-center items-center "
|
||||
>
|
||||
{!readonly && (
|
||||
//we use transparent colors when opening the piece selector, so to not show the pattern of the background inside the button, we wrap the big add button in a div with the background color
|
||||
<div className="bg-builder-background">
|
||||
<div
|
||||
style={{
|
||||
height: `${flowUtilConsts.AP_NODE_SIZE.BIG_ADD_BUTTON.height}px`,
|
||||
width: `${flowUtilConsts.AP_NODE_SIZE.BIG_ADD_BUTTON.width}px`,
|
||||
}}
|
||||
className=" cursor-auto border-none flex items-center justify-center relative "
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${flowUtilConsts.AP_NODE_SIZE.BIG_ADD_BUTTON.height}px`,
|
||||
width: `${flowUtilConsts.AP_NODE_SIZE.BIG_ADD_BUTTON.width}px`,
|
||||
}}
|
||||
id={id}
|
||||
className={cn('rounded-lg bg-background relative', {
|
||||
'bg-primary/80':
|
||||
isShowingDropIndicator || isPieceSelectorOpened,
|
||||
'shadow-add-button':
|
||||
isIsStepInsideDropzone || isPieceSelectorOpened,
|
||||
'transition-all':
|
||||
isIsStepInsideDropzone ||
|
||||
isPieceSelectorOpened ||
|
||||
isShowingDropIndicator,
|
||||
})}
|
||||
>
|
||||
{!isShowingDropIndicator && (
|
||||
<PieceSelector
|
||||
operation={flowCanvasUtils.createAddOperationFromAddButtonData(
|
||||
data,
|
||||
)}
|
||||
id={id}
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
variant="transparent"
|
||||
className="w-full h-full flex items-center hover:bg-accent-foreground rounded-lg border-border border-solid border"
|
||||
>
|
||||
<Plus
|
||||
className={cn('w-6 h-6 text-foreground ', {
|
||||
'opacity-0':
|
||||
isShowingDropIndicator ||
|
||||
isPieceSelectorOpened,
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
</span>
|
||||
</PieceSelector>
|
||||
)}
|
||||
</div>
|
||||
{isShowingDropIndicator && (
|
||||
//this is an invisible div that is used to show the drop indicator when the step is being dragged over the big add button, it is a rectangle so there is more leanancy to drop the step on the big add button
|
||||
<div
|
||||
style={{
|
||||
height: `${flowUtilConsts.AP_NODE_SIZE.STEP.height}px`,
|
||||
width: `${flowUtilConsts.AP_NODE_SIZE.STEP.width}px`,
|
||||
top: `-${
|
||||
flowUtilConsts.AP_NODE_SIZE.STEP.height / 2 -
|
||||
flowUtilConsts.AP_NODE_SIZE.BIG_ADD_BUTTON.width / 2
|
||||
}px`,
|
||||
}}
|
||||
className=" absolute "
|
||||
ref={setNodeRef}
|
||||
>
|
||||
{' '}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{readonly && (
|
||||
<div
|
||||
style={{
|
||||
height: `${flowUtilConsts.AP_NODE_SIZE.STEP.height}px`,
|
||||
width: `${flowUtilConsts.AP_NODE_SIZE.STEP.width}px`,
|
||||
}}
|
||||
className=" cursor-auto flex items-center justify-center relative "
|
||||
>
|
||||
<svg
|
||||
height={flowUtilConsts.AP_NODE_SIZE.STEP.height}
|
||||
width={flowUtilConsts.AP_NODE_SIZE.STEP.width}
|
||||
className="overflow-visible border-transparent "
|
||||
style={{
|
||||
stroke: 'var(--xy-edge-stroke, var(--xy-edge-stroke))',
|
||||
}}
|
||||
shapeRendering="auto"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
d={`M ${
|
||||
flowUtilConsts.AP_NODE_SIZE.STEP.width / 2
|
||||
} -10 v ${flowUtilConsts.AP_NODE_SIZE.STEP.height + 14}`}
|
||||
fill="transparent"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
style={flowUtilConsts.HANDLE_STYLING}
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
style={flowUtilConsts.HANDLE_STYLING}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ApBigAddButtonCanvasNode.displayName = 'ApBigAddButtonCanvasNode';
|
||||
export { ApBigAddButtonCanvasNode };
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
|
||||
import { flowUtilConsts } from '../utils/consts';
|
||||
import { ApGraphEndNode } from '../utils/types';
|
||||
import FlowEndWidget from '../widgets/flow-end-widget';
|
||||
|
||||
const ApGraphEndWidgetNode = ({ data }: Omit<ApGraphEndNode, 'position'>) => {
|
||||
return (
|
||||
<>
|
||||
<div className="h-px w-px relative ">
|
||||
{data.showWidget && <FlowEndWidget></FlowEndWidget>}
|
||||
</div>
|
||||
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
style={flowUtilConsts.HANDLE_STYLING}
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
style={flowUtilConsts.HANDLE_STYLING}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ApGraphEndWidgetNode.displayName = 'ApGraphEndWidgetNode';
|
||||
export default ApGraphEndWidgetNode;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
|
||||
import { flowUtilConsts } from '../utils/consts';
|
||||
|
||||
//used purely to help calculate the loop graph width
|
||||
const ApLoopReturnCanvasNode = () => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="h-px bg-transparent pointer-events-none "
|
||||
style={{
|
||||
width: flowUtilConsts.AP_NODE_SIZE.LOOP_RETURN_NODE.width,
|
||||
}}
|
||||
></div>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Top}
|
||||
style={flowUtilConsts.HANDLE_STYLING}
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Bottom}
|
||||
style={flowUtilConsts.HANDLE_STYLING}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ApLoopReturnCanvasNode.displayName = 'EmptyLoopReturnCanvasNode';
|
||||
export default ApLoopReturnCanvasNode;
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { Handle, NodeProps, Position } from '@xyflow/react';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { useBuilderStateContext } from '@/app/builder/builder-hooks';
|
||||
import { PieceSelector } from '@/app/builder/pieces-selector';
|
||||
import { stepsHooks } from '@/features/pieces/lib/steps-hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
FlowOperationType,
|
||||
Step,
|
||||
FlowTriggerType,
|
||||
flowStructureUtil,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import {
|
||||
flowUtilConsts,
|
||||
STEP_CONTEXT_MENU_ATTRIBUTE,
|
||||
} from '../../utils/consts';
|
||||
import { flowCanvasUtils } from '../../utils/flow-canvas-utils';
|
||||
import { ApStepNode } from '../../utils/types';
|
||||
|
||||
import { StepInvalidOrSkippedIcon } from './step-invalid-or-skipped-icon';
|
||||
import { StepNodeChevron } from './step-node-chevron';
|
||||
import { StepNodeDisplayName } from './step-node-display-name';
|
||||
import { StepNodeLogo } from './step-node-logo';
|
||||
import { StepNodeName } from './step-node-name';
|
||||
import { ApStepNodeStatus } from './step-node-status';
|
||||
import { TriggerWidget } from './trigger-widget';
|
||||
|
||||
const ApStepCanvasNode = React.memo(
|
||||
({ data: { step } }: NodeProps & Omit<ApStepNode, 'position'>) => {
|
||||
const [
|
||||
selectStepByName,
|
||||
isSelected,
|
||||
isDragging,
|
||||
readonly,
|
||||
flowVersion,
|
||||
setSelectedBranchIndex,
|
||||
isPieceSelectorOpened,
|
||||
setOpenedPieceSelectorStepNameOrAddButtonId,
|
||||
isStepValid,
|
||||
] = useBuilderStateContext((state) => [
|
||||
state.selectStepByName,
|
||||
state.selectedStep === step.name,
|
||||
state.activeDraggingStep === step.name,
|
||||
state.readonly,
|
||||
state.flowVersion,
|
||||
state.setSelectedBranchIndex,
|
||||
state.openedPieceSelectorStepNameOrAddButtonId === step.name,
|
||||
state.setOpenedPieceSelectorStepNameOrAddButtonId,
|
||||
flowStructureUtil.getStep(step.name, state.flowVersion.trigger)?.valid,
|
||||
]);
|
||||
const { stepMetadata } = stepsHooks.useStepMetadata({
|
||||
step,
|
||||
});
|
||||
const stepIndex = useMemo(() => {
|
||||
const steps = flowStructureUtil.getAllSteps(flowVersion.trigger);
|
||||
return steps.findIndex((s) => s.name === step.name) + 1;
|
||||
}, [step, flowVersion]);
|
||||
const isTrigger = flowStructureUtil.isTrigger(step.type);
|
||||
const isSkipped = flowCanvasUtils.isSkipped(step.name, flowVersion.trigger);
|
||||
|
||||
const { attributes, listeners, setNodeRef } = useDraggable({
|
||||
id: step.name,
|
||||
disabled: isTrigger || readonly,
|
||||
data: {
|
||||
type: flowUtilConsts.DRAGGED_STEP_TAG,
|
||||
},
|
||||
});
|
||||
|
||||
const handleStepClick = (
|
||||
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||
) => {
|
||||
selectStepByName(step.name);
|
||||
setSelectedBranchIndex(null);
|
||||
if (step.type === FlowTriggerType.EMPTY) {
|
||||
setOpenedPieceSelectorStepNameOrAddButtonId(step.name);
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
const stepNodeDivAttributes = isPieceSelectorOpened ? {} : attributes;
|
||||
const stepNodeDivListeners = isPieceSelectorOpened ? {} : listeners;
|
||||
return (
|
||||
<div
|
||||
{...{ [`data-${STEP_CONTEXT_MENU_ATTRIBUTE}`]: step.name }}
|
||||
style={{
|
||||
height: `${flowUtilConsts.AP_NODE_SIZE.STEP.height}px`,
|
||||
width: `${flowUtilConsts.AP_NODE_SIZE.STEP.width}px`,
|
||||
maxWidth: `${flowUtilConsts.AP_NODE_SIZE.STEP.width}px`,
|
||||
}}
|
||||
className={cn(
|
||||
'transition-all border-box rounded-md border border-solid border-border relative overflow-show group',
|
||||
{
|
||||
'border-primary': isSelected,
|
||||
'bg-background': !isDragging,
|
||||
'border-none': isDragging,
|
||||
'shadow-none': isDragging,
|
||||
'bg-accent': isSkipped,
|
||||
'rounded-tl-none': isTrigger,
|
||||
'hover:border-ring': !isSelected,
|
||||
},
|
||||
)}
|
||||
onClick={(e) => handleStepClick(e)}
|
||||
key={step.name}
|
||||
ref={isPieceSelectorOpened ? null : setNodeRef}
|
||||
{...stepNodeDivAttributes}
|
||||
{...stepNodeDivListeners}
|
||||
>
|
||||
{isTrigger && <TriggerWidget isSelected={isSelected} />}
|
||||
<StepInvalidOrSkippedIcon
|
||||
isValid={!!isStepValid}
|
||||
isSkipped={isSkipped}
|
||||
/>
|
||||
<ApStepNodeStatus stepName={step.name} />
|
||||
<StepNodeName stepName={step.name} />
|
||||
<div className="px-3 h-full w-full overflow-hidden">
|
||||
{!isDragging && (
|
||||
<PieceSelector
|
||||
operation={{
|
||||
type: getPieceSelectorOperationType(step),
|
||||
stepName: step.name,
|
||||
}}
|
||||
id={step.name}
|
||||
openSelectorOnClick={false}
|
||||
stepToReplacePieceDisplayName={stepMetadata?.displayName}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-center h-full w-full gap-[10px]"
|
||||
onClick={(e) => {
|
||||
if (!isPieceSelectorOpened) {
|
||||
handleStepClick(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StepNodeLogo
|
||||
isSkipped={isSkipped}
|
||||
logoUrl={stepMetadata?.logoUrl ?? ''}
|
||||
displayName={stepMetadata?.displayName ?? ''}
|
||||
/>
|
||||
<StepNodeDisplayName
|
||||
stepDisplayName={step.displayName}
|
||||
stepIndex={stepIndex}
|
||||
isSkipped={isSkipped}
|
||||
pieceDisplayName={stepMetadata?.displayName ?? ''}
|
||||
/>
|
||||
{!readonly && <StepNodeChevron />}
|
||||
</div>
|
||||
</PieceSelector>
|
||||
)}
|
||||
|
||||
<Handle
|
||||
type="source"
|
||||
style={flowUtilConsts.HANDLE_STYLING}
|
||||
position={Position.Bottom}
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
style={flowUtilConsts.HANDLE_STYLING}
|
||||
position={Position.Top}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ApStepCanvasNode.displayName = 'ApStepCanvasNode';
|
||||
export { ApStepCanvasNode };
|
||||
|
||||
function getPieceSelectorOperationType(step: Step) {
|
||||
if (flowStructureUtil.isTrigger(step.type)) {
|
||||
return FlowOperationType.UPDATE_TRIGGER;
|
||||
}
|
||||
return FlowOperationType.UPDATE_ACTION;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { t } from 'i18next';
|
||||
import { RouteOff } from 'lucide-react';
|
||||
|
||||
import { InvalidStepIcon } from '@/components/custom/alert-icon';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
import { flowUtilConsts } from '../../utils/consts';
|
||||
|
||||
const StepInvalidOrSkippedIcon = ({
|
||||
isValid,
|
||||
isSkipped,
|
||||
}: {
|
||||
isValid: boolean;
|
||||
isSkipped: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="absolute flex items-center -left-[22px] bg-builder-background "
|
||||
style={{ height: `${flowUtilConsts.AP_NODE_SIZE.STEP.height}px` }}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
{!isValid && !isSkipped && (
|
||||
<InvalidStepIcon className="h-4 w-4"></InvalidStepIcon>
|
||||
)}
|
||||
{isSkipped && (
|
||||
<RouteOff className="w-3.5 h-3.5 animate-fade text-muted-foreground"></RouteOff>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{(!isValid || isSkipped) && (
|
||||
<TooltipContent>
|
||||
<div>
|
||||
{!isValid && !isSkipped && t('Incomplete step')}
|
||||
{isSkipped && t('Skipped')}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { StepInvalidOrSkippedIcon };
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const StepNodeChevron = () => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-1 size-7 "
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (e.target) {
|
||||
const rightClickEvent = new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
button: 2,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
});
|
||||
e.target.dispatchEvent(rightClickEvent);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChevronDown className="w-4 h-4 stroke-muted-foreground" />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export { StepNodeChevron };
|
||||
@@ -0,0 +1,37 @@
|
||||
import { TextWithTooltip } from '@/components/custom/text-with-tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const StepNodeDisplayName = ({
|
||||
stepDisplayName,
|
||||
stepIndex,
|
||||
isSkipped,
|
||||
pieceDisplayName,
|
||||
}: {
|
||||
stepDisplayName: string;
|
||||
stepIndex: number;
|
||||
isSkipped: boolean;
|
||||
pieceDisplayName: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className="grow flex flex-col items-start justify-center min-w-0 w-full">
|
||||
<div className=" flex items-center justify-between min-w-0 w-full">
|
||||
<TextWithTooltip tooltipMessage={stepDisplayName}>
|
||||
<div
|
||||
className={cn('text-sm truncate grow shrink ', {
|
||||
'text-accent-foreground/70': isSkipped,
|
||||
})}
|
||||
>
|
||||
{stepIndex}. {stepDisplayName}
|
||||
</div>
|
||||
</TextWithTooltip>
|
||||
</div>
|
||||
<div className="flex justify-between mt-0.5 w-full items-center">
|
||||
<div className="text-xs text-muted-foreground text-ellipsis overflow-hidden whitespace-nowrap w-full">
|
||||
{pieceDisplayName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { StepNodeDisplayName };
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ImageWithColorBackground } from '@/components/ui/image-with-color-background';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const StepNodeLogo = ({
|
||||
isSkipped,
|
||||
logoUrl,
|
||||
displayName,
|
||||
}: {
|
||||
isSkipped: boolean;
|
||||
logoUrl: string;
|
||||
displayName: string;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center justify-center rounded-sm', {
|
||||
'opacity-80': isSkipped,
|
||||
})}
|
||||
>
|
||||
<ImageWithColorBackground
|
||||
src={logoUrl}
|
||||
alt={displayName}
|
||||
key={logoUrl + displayName}
|
||||
border={true}
|
||||
className="w-9 h-9 p-2"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { StepNodeLogo };
|
||||
@@ -0,0 +1,16 @@
|
||||
import { flowUtilConsts } from '../../utils/consts';
|
||||
|
||||
const StepNodeName = ({ stepName }: { stepName: string }) => {
|
||||
return (
|
||||
<div
|
||||
className="absolute left-full bg-builder-background ml-3 text-accent-foreground text-xs opacity-0 transition-all duration-300 group-hover:opacity-100 "
|
||||
style={{
|
||||
top: `${flowUtilConsts.AP_NODE_SIZE.STEP.height / 2 - 12}px`,
|
||||
}}
|
||||
>
|
||||
{stepName}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { StepNodeName };
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { StepStatusIcon } from '@/features/flow-runs/components/step-status-icon';
|
||||
import { flowRunUtils } from '@/features/flow-runs/lib/flow-run-utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { useBuilderStateContext } from '../../../builder-hooks';
|
||||
import { flowCanvasUtils } from '../../utils/flow-canvas-utils';
|
||||
|
||||
const ApStepNodeStatus = ({ stepName }: { stepName: string }) => {
|
||||
const [run, loopIndexes, flowVersion] = useBuilderStateContext((state) => [
|
||||
state.run,
|
||||
state.loopsIndexes,
|
||||
state.flowVersion,
|
||||
]);
|
||||
const stepStatusInRun = useMemo(() => {
|
||||
return flowCanvasUtils.getStepStatus(
|
||||
stepName,
|
||||
run,
|
||||
loopIndexes,
|
||||
flowVersion,
|
||||
);
|
||||
}, [stepName, run, loopIndexes, flowVersion]);
|
||||
if (!stepStatusInRun) {
|
||||
return null;
|
||||
}
|
||||
const { variant, text } = flowRunUtils.getStatusIconForStep(stepStatusInRun);
|
||||
return (
|
||||
<div className="absolute right-[1px] text-sm h-[20px] -top-[28px]">
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-1 animate-in fade-in slide-in-from-bottom-2 duration-500 items-center justify-center px-2 rounded-md ',
|
||||
{
|
||||
hidden: !stepStatusInRun,
|
||||
'text-green-800 bg-green-50 border border-green-200':
|
||||
variant === 'success',
|
||||
'text-red-800 bg-red-50 border border-red-200': variant === 'error',
|
||||
'bg-background border border-border text-foreground':
|
||||
variant === 'default',
|
||||
},
|
||||
)}
|
||||
>
|
||||
<StepStatusIcon
|
||||
status={stepStatusInRun}
|
||||
size="3"
|
||||
hideTooltip={true}
|
||||
></StepStatusIcon>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
ApStepNodeStatus.displayName = 'ApStepNodeStatus';
|
||||
|
||||
export { ApStepNodeStatus };
|
||||
@@ -0,0 +1,22 @@
|
||||
import { t } from 'i18next';
|
||||
import { Goal } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const TriggerWidget = ({ isSelected }: { isSelected: boolean }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center absolute transition-all -translate-y-[26px] -translate-x-[1px] border-border border border-1 border-b-transparent justify-center gap-1 rounded-t-md bg-background text-muted-foreground text-xs py-1 px-2 ',
|
||||
{
|
||||
'border-primary text-primary ': isSelected,
|
||||
'group-hover:border-ring ': !isSelected,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<Goal className="w-[10px] h-[10px]"></Goal> {t('Trigger')}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { TriggerWidget };
|
||||
@@ -0,0 +1,66 @@
|
||||
import { t } from 'i18next';
|
||||
|
||||
import { useSidebar } from '@/components/ui/sidebar-shadcn';
|
||||
import { stepsHooks } from '@/features/pieces/lib/steps-hooks';
|
||||
import { FlowAction, FlowTrigger } from '@activepieces/shared';
|
||||
|
||||
import {
|
||||
BUILDER_NAVIGATION_SIDEBAR_ID,
|
||||
flowUtilConsts,
|
||||
LEFT_SIDEBAR_ID,
|
||||
} from './utils/consts';
|
||||
|
||||
const StepDragOverlay = ({
|
||||
step,
|
||||
cursorPosition,
|
||||
}: {
|
||||
step: FlowAction | FlowTrigger;
|
||||
cursorPosition: { x: number; y: number };
|
||||
}) => {
|
||||
//the overlay position is relatiive to the whole screen so when items that squeeze the canvas from the left are rendered, we need to adjust the position
|
||||
//so we need to get the width of the left sidebar and the navigation bar and subtract them from the cursor position
|
||||
const { open } = useSidebar();
|
||||
const builderLeftSidebar = document.getElementById(LEFT_SIDEBAR_ID);
|
||||
const builderLeftSidebarWidth = builderLeftSidebar?.clientWidth ?? 0;
|
||||
const builderNavigationBar = document.getElementById(
|
||||
BUILDER_NAVIGATION_SIDEBAR_ID,
|
||||
);
|
||||
const builderNavigationBarWidth = open
|
||||
? builderNavigationBar?.clientWidth ?? 0
|
||||
: 0;
|
||||
const left = `${
|
||||
cursorPosition.x -
|
||||
flowUtilConsts.STEP_DRAG_OVERLAY_WIDTH / 2 -
|
||||
builderLeftSidebarWidth -
|
||||
builderNavigationBarWidth
|
||||
}px`;
|
||||
const top = `${
|
||||
cursorPosition.y - flowUtilConsts.STEP_DRAG_OVERLAY_HEIGHT - 20
|
||||
}px`;
|
||||
const { stepMetadata } = stepsHooks.useStepMetadata({
|
||||
step,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'p-4 absolute left-0 top-0 opacity-75 flex items-center justify-center rounded-2xl border border-solid border bg-background'
|
||||
}
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
height: `${flowUtilConsts.STEP_DRAG_OVERLAY_HEIGHT}px`,
|
||||
width: `${flowUtilConsts.STEP_DRAG_OVERLAY_WIDTH}px`,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
id={t('logo')}
|
||||
className={'object-contain left-0 right-0 static'}
|
||||
src={step?.settings?.customLogoUrl ?? stepMetadata?.logoUrl}
|
||||
alt={t('Step Icon')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StepDragOverlay;
|
||||
@@ -0,0 +1,111 @@
|
||||
import { ApLoopReturnLineCanvasEdge as ApLoopReturnCanvasEdge } from '../edges/loop-return-edge';
|
||||
import { ApLoopStartLineCanvasEdge as ApLoopStartCanvasEdge } from '../edges/loop-start-edge';
|
||||
import { ApRouterEndCanvasEdge } from '../edges/router-end-edge';
|
||||
import { ApRouterStartCanvasEdge } from '../edges/router-start-edge';
|
||||
import { ApStraightLineCanvasEdge } from '../edges/straight-line-edge';
|
||||
import { ApBigAddButtonCanvasNode } from '../nodes/big-add-button-node';
|
||||
import ApGraphEndWidgetNode from '../nodes/flow-end-widget-node';
|
||||
import ApLoopReturnCanvasNode from '../nodes/loop-return-node';
|
||||
import { ApStepCanvasNode } from '../nodes/step-node';
|
||||
|
||||
import { ApEdgeType, ApNodeType } from './types';
|
||||
|
||||
const ARC_LENGTH = 15;
|
||||
const ARC_LEFT = `a${ARC_LENGTH},${ARC_LENGTH} 0 0,0 -${ARC_LENGTH},${ARC_LENGTH}`;
|
||||
const ARC_RIGHT = `a${ARC_LENGTH},${ARC_LENGTH} 0 0,1 ${ARC_LENGTH},${ARC_LENGTH}`;
|
||||
const ARC_LEFT_DOWN = `a${ARC_LENGTH},${ARC_LENGTH} 0 0,1 -${ARC_LENGTH},${ARC_LENGTH}`;
|
||||
const ARC_RIGHT_DOWN = `a${ARC_LENGTH},${ARC_LENGTH} 0 0,0 ${ARC_LENGTH},${ARC_LENGTH}`;
|
||||
const ARC_RIGHT_UP = `a${ARC_LENGTH},${ARC_LENGTH} 0 0,1 -${ARC_LENGTH},-${ARC_LENGTH}`;
|
||||
const ARC_LEFT_UP = `a-${ARC_LENGTH},-${ARC_LENGTH} 0 0,0 ${ARC_LENGTH},-${ARC_LENGTH}`;
|
||||
const ARROW_DOWN = 'm6 -6 l-6 6 m-6 -6 l6 6';
|
||||
const VERTICAL_SPACE_BETWEEN_STEP_AND_LINE = 7;
|
||||
const VERTICAL_SPACE_BETWEEN_STEPS = 60;
|
||||
const VERTICAL_OFFSET_BETWEEN_LOOP_AND_CHILD =
|
||||
VERTICAL_SPACE_BETWEEN_STEPS * 1.5 + 2 * ARC_LENGTH;
|
||||
const LABEL_HEIGHT = 30;
|
||||
const LABEL_VERTICAL_PADDING = 12;
|
||||
const STEP_DRAG_OVERLAY_WIDTH = 75;
|
||||
const STEP_DRAG_OVERLAY_HEIGHT = 75;
|
||||
const VERTICAL_OFFSET_BETWEEN_ROUTER_AND_CHILD =
|
||||
VERTICAL_OFFSET_BETWEEN_LOOP_AND_CHILD + LABEL_HEIGHT;
|
||||
const LINE_WIDTH = 1.5;
|
||||
const DRAGGED_STEP_TAG = 'dragged-step';
|
||||
const HORIZONTAL_SPACE_BETWEEN_NODES = 80;
|
||||
const AP_NODE_SIZE: Record<
|
||||
Exclude<ApNodeType, ApNodeType.GRAPH_START_WIDGET>,
|
||||
{ height: number; width: number }
|
||||
> = {
|
||||
[ApNodeType.BIG_ADD_BUTTON]: {
|
||||
height: 50,
|
||||
width: 50,
|
||||
},
|
||||
[ApNodeType.ADD_BUTTON]: {
|
||||
height: 20,
|
||||
width: 20,
|
||||
},
|
||||
[ApNodeType.STEP]: {
|
||||
height: 60,
|
||||
width: 232,
|
||||
},
|
||||
[ApNodeType.LOOP_RETURN_NODE]: {
|
||||
height: 60,
|
||||
width: 232,
|
||||
},
|
||||
[ApNodeType.GRAPH_END_WIDGET]: {
|
||||
height: 0,
|
||||
width: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const doesNodeAffectBoundingBoxWidth: (
|
||||
type: ApNodeType,
|
||||
) => type is
|
||||
| ApNodeType.BIG_ADD_BUTTON
|
||||
| ApNodeType.STEP
|
||||
| ApNodeType.LOOP_RETURN_NODE = (type) =>
|
||||
type === ApNodeType.BIG_ADD_BUTTON ||
|
||||
type === ApNodeType.STEP ||
|
||||
type === ApNodeType.LOOP_RETURN_NODE;
|
||||
export const flowUtilConsts = {
|
||||
ARC_LENGTH,
|
||||
ARC_LEFT,
|
||||
ARC_RIGHT,
|
||||
ARC_LEFT_DOWN,
|
||||
ARC_RIGHT_DOWN,
|
||||
VERTICAL_OFFSET_BETWEEN_LOOP_AND_CHILD,
|
||||
AP_NODE_SIZE,
|
||||
VERTICAL_SPACE_BETWEEN_STEP_AND_LINE,
|
||||
ARROW_DOWN,
|
||||
VERTICAL_SPACE_BETWEEN_STEPS,
|
||||
ARC_RIGHT_UP,
|
||||
LINE_WIDTH,
|
||||
LABEL_HEIGHT,
|
||||
ARC_LEFT_UP,
|
||||
VERTICAL_OFFSET_BETWEEN_ROUTER_AND_CHILD,
|
||||
doesNodeAffectBoundingBox: doesNodeAffectBoundingBoxWidth,
|
||||
edgeTypes: {
|
||||
[ApEdgeType.STRAIGHT_LINE]: ApStraightLineCanvasEdge,
|
||||
[ApEdgeType.LOOP_START_EDGE]: ApLoopStartCanvasEdge,
|
||||
[ApEdgeType.LOOP_RETURN_EDGE]: ApLoopReturnCanvasEdge,
|
||||
[ApEdgeType.ROUTER_START_EDGE]: ApRouterStartCanvasEdge,
|
||||
[ApEdgeType.ROUTER_END_EDGE]: ApRouterEndCanvasEdge,
|
||||
},
|
||||
nodeTypes: {
|
||||
[ApNodeType.STEP]: ApStepCanvasNode,
|
||||
[ApNodeType.LOOP_RETURN_NODE]: ApLoopReturnCanvasNode,
|
||||
[ApNodeType.BIG_ADD_BUTTON]: ApBigAddButtonCanvasNode,
|
||||
[ApNodeType.GRAPH_END_WIDGET]: ApGraphEndWidgetNode,
|
||||
},
|
||||
DRAGGED_STEP_TAG,
|
||||
HORIZONTAL_SPACE_BETWEEN_NODES,
|
||||
HANDLE_STYLING: { opacity: 0, cursor: 'default' },
|
||||
LABEL_VERTICAL_PADDING,
|
||||
STEP_DRAG_OVERLAY_WIDTH,
|
||||
STEP_DRAG_OVERLAY_HEIGHT,
|
||||
};
|
||||
|
||||
export const STEP_CONTEXT_MENU_ATTRIBUTE = 'step-context-menu';
|
||||
export const SELECTION_RECT_CHEVRON_ATTRIBUTE = 'selection-rect-chevron';
|
||||
export const EMPTY_STEP_PARENT_NAME = 'empty-step-parent';
|
||||
export const LEFT_SIDEBAR_ID = 'builder-left-sidebar';
|
||||
export const BUILDER_NAVIGATION_SIDEBAR_ID = 'builder-navigation-sidebar';
|
||||
@@ -0,0 +1,510 @@
|
||||
import { t } from 'i18next';
|
||||
|
||||
import { flowRunUtils } from '@/features/flow-runs/lib/flow-run-utils';
|
||||
import {
|
||||
FlowAction,
|
||||
FlowActionType,
|
||||
FlowOperationType,
|
||||
FlowRun,
|
||||
flowStructureUtil,
|
||||
FlowVersion,
|
||||
isNil,
|
||||
LoopOnItemsAction,
|
||||
RouterAction,
|
||||
StepLocationRelativeToParent,
|
||||
FlowTrigger,
|
||||
FlowTriggerType,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { flowUtilConsts } from './consts';
|
||||
import {
|
||||
ApBigAddButtonNode,
|
||||
ApButtonData,
|
||||
ApEdge,
|
||||
ApEdgeType,
|
||||
ApGraph,
|
||||
ApGraphEndNode,
|
||||
ApLoopReturnNode,
|
||||
ApNodeType,
|
||||
ApStepNode,
|
||||
ApStraightLineEdge,
|
||||
} from './types';
|
||||
|
||||
const createBigAddButtonGraph: (
|
||||
parentStep: LoopOnItemsAction | RouterAction,
|
||||
nodeData: ApBigAddButtonNode['data'],
|
||||
) => ApGraph = (parentStep, nodeData) => {
|
||||
const bigAddButtonNode: ApBigAddButtonNode = {
|
||||
id: `${parentStep.name}-big-add-button-${nodeData.edgeId}`,
|
||||
type: ApNodeType.BIG_ADD_BUTTON,
|
||||
position: { x: 0, y: 0 },
|
||||
data: nodeData,
|
||||
selectable: false,
|
||||
style: {
|
||||
pointerEvents: 'all',
|
||||
},
|
||||
};
|
||||
const graphEndNode: ApGraphEndNode = {
|
||||
id: `${parentStep.name}-subgraph-end-${nodeData.edgeId}`,
|
||||
type: ApNodeType.GRAPH_END_WIDGET as const,
|
||||
position: {
|
||||
x: flowUtilConsts.AP_NODE_SIZE.STEP.width / 2,
|
||||
y:
|
||||
flowUtilConsts.AP_NODE_SIZE.STEP.height +
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEPS,
|
||||
},
|
||||
data: {},
|
||||
selectable: false,
|
||||
};
|
||||
|
||||
const straightLineEdge: ApStraightLineEdge = {
|
||||
id: `big-button-straight-line-for${nodeData.edgeId}`,
|
||||
source: `${parentStep.name}-big-add-button-${nodeData.edgeId}`,
|
||||
target: `${parentStep.name}-subgraph-end-${nodeData.edgeId}`,
|
||||
type: ApEdgeType.STRAIGHT_LINE as const,
|
||||
data: {
|
||||
drawArrowHead: false,
|
||||
hideAddButton: true,
|
||||
parentStepName: parentStep.name,
|
||||
},
|
||||
};
|
||||
return {
|
||||
nodes: [bigAddButtonNode, graphEndNode],
|
||||
edges: [straightLineEdge],
|
||||
};
|
||||
};
|
||||
|
||||
const createStepGraph: (
|
||||
step: FlowAction | FlowTrigger,
|
||||
graphHeight: number,
|
||||
) => ApGraph = (step, graphHeight) => {
|
||||
const stepNode: ApStepNode = {
|
||||
id: step.name,
|
||||
type: ApNodeType.STEP as const,
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
step,
|
||||
},
|
||||
selectable: step.name !== 'trigger',
|
||||
draggable: true,
|
||||
style: {
|
||||
pointerEvents: 'all',
|
||||
},
|
||||
};
|
||||
|
||||
const graphEndNode: ApGraphEndNode = {
|
||||
id: `${step.name}-subgraph-end`,
|
||||
type: ApNodeType.GRAPH_END_WIDGET as const,
|
||||
position: {
|
||||
x: flowUtilConsts.AP_NODE_SIZE.STEP.width / 2,
|
||||
y: graphHeight,
|
||||
},
|
||||
data: {},
|
||||
selectable: false,
|
||||
};
|
||||
|
||||
const straightLineEdge: ApStraightLineEdge = {
|
||||
id: `${step.name}-${step.nextAction?.name ?? 'graph-end'}-edge`,
|
||||
source: step.name,
|
||||
target: `${step.name}-subgraph-end`,
|
||||
type: ApEdgeType.STRAIGHT_LINE as const,
|
||||
data: {
|
||||
drawArrowHead: !isNil(step.nextAction),
|
||||
parentStepName: step.name,
|
||||
},
|
||||
};
|
||||
return {
|
||||
nodes: [stepNode, graphEndNode],
|
||||
edges:
|
||||
step.type !== FlowActionType.LOOP_ON_ITEMS &&
|
||||
step.type !== FlowActionType.ROUTER
|
||||
? [straightLineEdge]
|
||||
: [],
|
||||
};
|
||||
};
|
||||
|
||||
const buildGraph: (step: FlowAction | FlowTrigger | undefined) => ApGraph = (
|
||||
step,
|
||||
) => {
|
||||
if (isNil(step)) {
|
||||
return {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
};
|
||||
}
|
||||
|
||||
const graph: ApGraph = createStepGraph(
|
||||
step,
|
||||
flowUtilConsts.AP_NODE_SIZE.STEP.height +
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEPS,
|
||||
);
|
||||
const childGraph =
|
||||
step.type === FlowActionType.LOOP_ON_ITEMS
|
||||
? buildLoopChildGraph(step)
|
||||
: step.type === FlowActionType.ROUTER
|
||||
? buildRouterChildGraph(step)
|
||||
: null;
|
||||
|
||||
const graphWithChild = childGraph ? mergeGraph(graph, childGraph) : graph;
|
||||
const nextStepGraph = buildGraph(step.nextAction);
|
||||
return mergeGraph(
|
||||
graphWithChild,
|
||||
offsetGraph(nextStepGraph, {
|
||||
x: 0,
|
||||
y: calculateGraphBoundingBox(graphWithChild).height,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
function offsetGraph(
|
||||
graph: ApGraph,
|
||||
offset: { x: number; y: number },
|
||||
): ApGraph {
|
||||
return {
|
||||
nodes: graph.nodes.map((node) => ({
|
||||
...node,
|
||||
position: {
|
||||
x: node.position.x + offset.x,
|
||||
y: node.position.y + offset.y,
|
||||
},
|
||||
})),
|
||||
edges: graph.edges,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeGraph(graph1: ApGraph, graph2: ApGraph): ApGraph {
|
||||
return {
|
||||
nodes: [...graph1.nodes, ...graph2.nodes],
|
||||
edges: [...graph1.edges, ...graph2.edges],
|
||||
};
|
||||
}
|
||||
|
||||
function createFocusStepInGraphParams(stepName: string) {
|
||||
return {
|
||||
nodes: [{ id: stepName }],
|
||||
duration: 1000,
|
||||
maxZoom: 1.25,
|
||||
minZoom: 1.25,
|
||||
};
|
||||
}
|
||||
|
||||
const calculateGraphBoundingBox = (graph: ApGraph) => {
|
||||
const minX = Math.min(
|
||||
...graph.nodes
|
||||
.filter((node) => flowUtilConsts.doesNodeAffectBoundingBox(node.type))
|
||||
.map((node) => node.position.x),
|
||||
);
|
||||
const minY = Math.min(...graph.nodes.map((node) => node.position.y));
|
||||
const maxX = Math.max(
|
||||
...graph.nodes
|
||||
.filter((node) => flowUtilConsts.doesNodeAffectBoundingBox(node.type))
|
||||
.map((node) => node.position.x + flowUtilConsts.AP_NODE_SIZE.STEP.width),
|
||||
);
|
||||
const maxY = Math.max(...graph.nodes.map((node) => node.position.y));
|
||||
const width = maxX - minX;
|
||||
const height = maxY - minY;
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
left: -minX + flowUtilConsts.AP_NODE_SIZE.STEP.width / 2,
|
||||
right: maxX - flowUtilConsts.AP_NODE_SIZE.STEP.width / 2,
|
||||
top: minY,
|
||||
bottom: maxY,
|
||||
};
|
||||
};
|
||||
|
||||
const buildLoopChildGraph: (step: LoopOnItemsAction) => ApGraph = (step) => {
|
||||
const childGraph = step.firstLoopAction
|
||||
? buildGraph(step.firstLoopAction)
|
||||
: createBigAddButtonGraph(step, {
|
||||
parentStepName: step.name,
|
||||
stepLocationRelativeToParent: StepLocationRelativeToParent.INSIDE_LOOP,
|
||||
edgeId: `${step.name}-loop-start-edge`,
|
||||
});
|
||||
|
||||
const childGraphBoundingBox = calculateGraphBoundingBox(childGraph);
|
||||
const deltaLeftX =
|
||||
-(
|
||||
childGraphBoundingBox.width +
|
||||
flowUtilConsts.AP_NODE_SIZE.STEP.width +
|
||||
flowUtilConsts.HORIZONTAL_SPACE_BETWEEN_NODES -
|
||||
flowUtilConsts.AP_NODE_SIZE.STEP.width / 2 -
|
||||
childGraphBoundingBox.right
|
||||
) /
|
||||
2 -
|
||||
flowUtilConsts.AP_NODE_SIZE.STEP.width / 2;
|
||||
|
||||
const loopReturnNode: ApLoopReturnNode = {
|
||||
id: `${step.name}-loop-return-node`,
|
||||
type: ApNodeType.LOOP_RETURN_NODE,
|
||||
position: {
|
||||
x: deltaLeftX + flowUtilConsts.AP_NODE_SIZE.STEP.width / 2,
|
||||
y:
|
||||
flowUtilConsts.AP_NODE_SIZE.STEP.height +
|
||||
flowUtilConsts.VERTICAL_OFFSET_BETWEEN_LOOP_AND_CHILD +
|
||||
childGraphBoundingBox.height / 2,
|
||||
},
|
||||
data: {},
|
||||
selectable: false,
|
||||
};
|
||||
const childGraphAfterOffset = offsetGraph(childGraph, {
|
||||
x:
|
||||
deltaLeftX +
|
||||
flowUtilConsts.AP_NODE_SIZE.STEP.width +
|
||||
flowUtilConsts.HORIZONTAL_SPACE_BETWEEN_NODES +
|
||||
childGraphBoundingBox.left,
|
||||
y:
|
||||
flowUtilConsts.VERTICAL_OFFSET_BETWEEN_LOOP_AND_CHILD +
|
||||
flowUtilConsts.AP_NODE_SIZE.STEP.height,
|
||||
});
|
||||
const edges: ApEdge[] = [
|
||||
{
|
||||
id: `${step.name}-loop-start-edge`,
|
||||
source: step.name,
|
||||
target: `${childGraph.nodes[0].id}`,
|
||||
type: ApEdgeType.LOOP_START_EDGE as const,
|
||||
data: {
|
||||
isLoopEmpty: isNil(step.firstLoopAction),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: `${step.name}-loop-return-node`,
|
||||
source: `${childGraph.nodes[childGraph.nodes.length - 1].id}`,
|
||||
target: `${step.name}-loop-return-node`,
|
||||
type: ApEdgeType.LOOP_RETURN_EDGE as const,
|
||||
data: {
|
||||
parentStepName: step.name,
|
||||
isLoopEmpty: isNil(step.firstLoopAction),
|
||||
drawArrowHeadAfterEnd: !isNil(step.nextAction),
|
||||
verticalSpaceBetweenReturnNodeStartAndEnd:
|
||||
childGraphBoundingBox.height +
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEPS,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const subgraphEndSubNode: ApGraphEndNode = {
|
||||
id: `${step.name}-loop-subgraph-end`,
|
||||
type: ApNodeType.GRAPH_END_WIDGET,
|
||||
position: {
|
||||
x: flowUtilConsts.AP_NODE_SIZE.STEP.width / 2,
|
||||
y:
|
||||
flowUtilConsts.AP_NODE_SIZE.STEP.height +
|
||||
flowUtilConsts.VERTICAL_OFFSET_BETWEEN_LOOP_AND_CHILD +
|
||||
childGraphBoundingBox.height +
|
||||
flowUtilConsts.ARC_LENGTH +
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEPS,
|
||||
},
|
||||
data: {},
|
||||
selectable: false,
|
||||
};
|
||||
|
||||
return {
|
||||
nodes: [loopReturnNode, ...childGraphAfterOffset.nodes, subgraphEndSubNode],
|
||||
edges: [...edges, ...childGraphAfterOffset.edges],
|
||||
};
|
||||
};
|
||||
|
||||
const buildRouterChildGraph = (step: RouterAction) => {
|
||||
const childGraphs = step.children.map((branch, index) => {
|
||||
return branch
|
||||
? buildGraph(branch)
|
||||
: createBigAddButtonGraph(step, {
|
||||
parentStepName: step.name,
|
||||
stepLocationRelativeToParent:
|
||||
StepLocationRelativeToParent.INSIDE_BRANCH,
|
||||
branchIndex: index,
|
||||
edgeId: `${step.name}-branch-${index}-start-edge`,
|
||||
});
|
||||
});
|
||||
|
||||
const childGraphsAfterOffset = offsetRouterChildSteps(childGraphs);
|
||||
|
||||
const maxHeight = Math.max(
|
||||
...childGraphsAfterOffset.map((cg) => calculateGraphBoundingBox(cg).height),
|
||||
);
|
||||
|
||||
const subgraphEndSubNode: ApGraphEndNode = {
|
||||
id: `${step.name}-branch-subgraph-end`,
|
||||
type: ApNodeType.GRAPH_END_WIDGET,
|
||||
position: {
|
||||
x: flowUtilConsts.AP_NODE_SIZE.STEP.width / 2,
|
||||
y:
|
||||
flowUtilConsts.AP_NODE_SIZE.STEP.height +
|
||||
flowUtilConsts.VERTICAL_OFFSET_BETWEEN_ROUTER_AND_CHILD +
|
||||
maxHeight +
|
||||
flowUtilConsts.ARC_LENGTH +
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEPS,
|
||||
},
|
||||
data: {},
|
||||
selectable: false,
|
||||
};
|
||||
const edges: ApEdge[] = childGraphsAfterOffset
|
||||
.map((childGraph, branchIndex) => {
|
||||
return [
|
||||
{
|
||||
id: `${step.name}-branch-${branchIndex}-start-edge`,
|
||||
source: step.name,
|
||||
target: `${childGraph.nodes[0].id}`,
|
||||
type: ApEdgeType.ROUTER_START_EDGE as const,
|
||||
data: {
|
||||
isBranchEmpty: isNil(step.children[branchIndex]),
|
||||
label:
|
||||
step.settings.branches[branchIndex]?.branchName ??
|
||||
`${t('Branch')} ${branchIndex + 1} (missing branch)`,
|
||||
branchIndex,
|
||||
stepLocationRelativeToParent:
|
||||
StepLocationRelativeToParent.INSIDE_BRANCH as const,
|
||||
drawHorizontalLine:
|
||||
branchIndex === 0 ||
|
||||
branchIndex === childGraphsAfterOffset.length - 1,
|
||||
drawStartingVerticalLine: branchIndex === 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: `${step.name}-branch-${branchIndex}-end-edge`,
|
||||
source: `${childGraph.nodes.at(-1)!.id}`,
|
||||
target: subgraphEndSubNode.id,
|
||||
type: ApEdgeType.ROUTER_END_EDGE as const,
|
||||
data: {
|
||||
drawEndingVerticalLine: branchIndex === 0,
|
||||
verticalSpaceBetweenLastNodeInBranchAndEndLine:
|
||||
subgraphEndSubNode.position.y -
|
||||
childGraph.nodes.at(-1)!.position.y -
|
||||
flowUtilConsts.VERTICAL_SPACE_BETWEEN_STEPS -
|
||||
flowUtilConsts.ARC_LENGTH,
|
||||
drawHorizontalLine:
|
||||
branchIndex === 0 ||
|
||||
branchIndex === childGraphsAfterOffset.length - 1,
|
||||
routerOrBranchStepName: step.name,
|
||||
isNextStepEmpty: isNil(step.nextAction),
|
||||
},
|
||||
},
|
||||
];
|
||||
})
|
||||
.flat();
|
||||
|
||||
return {
|
||||
nodes: [
|
||||
...childGraphsAfterOffset.map((cg) => cg.nodes).flat(),
|
||||
subgraphEndSubNode,
|
||||
],
|
||||
edges: [...childGraphsAfterOffset.map((cg) => cg.edges).flat(), ...edges],
|
||||
};
|
||||
};
|
||||
|
||||
const offsetRouterChildSteps = (childGraphs: ApGraph[]) => {
|
||||
const childGraphsBoundingBoxes = childGraphs.map((childGraph) =>
|
||||
calculateGraphBoundingBox(childGraph),
|
||||
);
|
||||
const totalWidth =
|
||||
childGraphsBoundingBoxes.reduce((acc, current) => acc + current.width, 0) +
|
||||
flowUtilConsts.HORIZONTAL_SPACE_BETWEEN_NODES * (childGraphs.length - 1);
|
||||
let deltaLeftX =
|
||||
-(
|
||||
totalWidth -
|
||||
childGraphsBoundingBoxes[0].left -
|
||||
childGraphsBoundingBoxes[childGraphs.length - 1].right
|
||||
) /
|
||||
2 -
|
||||
childGraphsBoundingBoxes[0].left;
|
||||
|
||||
return childGraphsBoundingBoxes.map((childGraphBoundingBox, index) => {
|
||||
const x = deltaLeftX + childGraphBoundingBox.left;
|
||||
deltaLeftX +=
|
||||
childGraphBoundingBox.width +
|
||||
flowUtilConsts.HORIZONTAL_SPACE_BETWEEN_NODES;
|
||||
return offsetGraph(childGraphs[index], {
|
||||
x,
|
||||
y:
|
||||
flowUtilConsts.AP_NODE_SIZE.STEP.height +
|
||||
flowUtilConsts.VERTICAL_OFFSET_BETWEEN_ROUTER_AND_CHILD,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const createAddOperationFromAddButtonData = (data: ApButtonData) => {
|
||||
if (
|
||||
data.stepLocationRelativeToParent ===
|
||||
StepLocationRelativeToParent.INSIDE_BRANCH
|
||||
) {
|
||||
return {
|
||||
type: FlowOperationType.ADD_ACTION,
|
||||
actionLocation: {
|
||||
parentStep: data.parentStepName,
|
||||
stepLocationRelativeToParent: data.stepLocationRelativeToParent,
|
||||
branchIndex: data.branchIndex,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
return {
|
||||
type: FlowOperationType.ADD_ACTION,
|
||||
actionLocation: {
|
||||
parentStep: data.parentStepName,
|
||||
stepLocationRelativeToParent: data.stepLocationRelativeToParent,
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
|
||||
const isSkipped = (stepName: string, trigger: FlowTrigger) => {
|
||||
const step = flowStructureUtil.getStep(stepName, trigger);
|
||||
if (
|
||||
isNil(step) ||
|
||||
step.type === FlowTriggerType.EMPTY ||
|
||||
step.type === FlowTriggerType.PIECE
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const skippedParents = flowStructureUtil
|
||||
.findPathToStep(trigger, stepName)
|
||||
.filter(
|
||||
(stepInPath) =>
|
||||
stepInPath.type === FlowActionType.LOOP_ON_ITEMS ||
|
||||
stepInPath.type === FlowActionType.ROUTER,
|
||||
)
|
||||
.filter((routerOrLoop) =>
|
||||
flowStructureUtil.isChildOf(routerOrLoop, stepName),
|
||||
)
|
||||
.filter((parent) => parent.skip);
|
||||
|
||||
return skippedParents.length > 0 || !!step.skip;
|
||||
};
|
||||
|
||||
const getStepStatus = (
|
||||
stepName: string | undefined,
|
||||
run: FlowRun | null,
|
||||
loopIndexes: Record<string, number>,
|
||||
flowVersion: FlowVersion,
|
||||
) => {
|
||||
if (isNil(run) || isNil(stepName) || isNil(run.steps)) {
|
||||
return undefined;
|
||||
}
|
||||
const stepOutput = flowRunUtils.extractStepOutput(
|
||||
stepName,
|
||||
loopIndexes,
|
||||
run.steps,
|
||||
flowVersion.trigger,
|
||||
);
|
||||
return stepOutput?.status;
|
||||
};
|
||||
|
||||
export const flowCanvasUtils = {
|
||||
convertFlowVersionToGraph(version: FlowVersion): ApGraph {
|
||||
const graph = buildGraph(version.trigger);
|
||||
const graphEndWidget = graph.nodes.findLast(
|
||||
(node) => node.type === ApNodeType.GRAPH_END_WIDGET,
|
||||
) as ApGraphEndNode;
|
||||
if (graphEndWidget) {
|
||||
graphEndWidget.data.showWidget = true;
|
||||
} else {
|
||||
console.warn('Flow end widget not found');
|
||||
}
|
||||
return graph;
|
||||
},
|
||||
createFocusStepInGraphParams,
|
||||
calculateGraphBoundingBox,
|
||||
createAddOperationFromAddButtonData,
|
||||
isSkipped,
|
||||
getStepStatus,
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
import { Edge } from '@xyflow/react';
|
||||
|
||||
import {
|
||||
FlowAction,
|
||||
StepLocationRelativeToParent,
|
||||
FlowTrigger,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
export enum ApNodeType {
|
||||
STEP = 'STEP',
|
||||
ADD_BUTTON = 'ADD_BUTTON',
|
||||
BIG_ADD_BUTTON = 'BIG_ADD_BUTTON',
|
||||
GRAPH_END_WIDGET = 'GRAPH_END_WIDGET',
|
||||
GRAPH_START_WIDGET = 'GRAPH_START_WIDGET',
|
||||
/**Used for calculating the loop graph width */
|
||||
LOOP_RETURN_NODE = 'LOOP_RETURN_NODE',
|
||||
}
|
||||
export type ApBoundingBox = {
|
||||
width: number;
|
||||
height: number;
|
||||
left: number;
|
||||
right: number;
|
||||
};
|
||||
|
||||
export type ApStepNode = {
|
||||
id: string;
|
||||
type: ApNodeType.STEP;
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
data: {
|
||||
step: FlowAction | FlowTrigger;
|
||||
};
|
||||
selectable?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
draggable?: boolean;
|
||||
};
|
||||
|
||||
export type ApLoopReturnNode = {
|
||||
id: string;
|
||||
type: ApNodeType.LOOP_RETURN_NODE;
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
data: Record<string, never>;
|
||||
selectable?: boolean;
|
||||
};
|
||||
|
||||
export type ApButtonData = {
|
||||
edgeId: string;
|
||||
} & (
|
||||
| {
|
||||
parentStepName: string;
|
||||
stepLocationRelativeToParent:
|
||||
| StepLocationRelativeToParent.AFTER
|
||||
| StepLocationRelativeToParent.INSIDE_LOOP;
|
||||
}
|
||||
| {
|
||||
parentStepName: string;
|
||||
stepLocationRelativeToParent: StepLocationRelativeToParent.INSIDE_BRANCH;
|
||||
branchIndex: number;
|
||||
}
|
||||
);
|
||||
|
||||
export type ApBigAddButtonNode = {
|
||||
id: string;
|
||||
type: ApNodeType.BIG_ADD_BUTTON;
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
data: ApButtonData;
|
||||
selectable?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
export type ApGraphEndNode = {
|
||||
id: string;
|
||||
type: ApNodeType.GRAPH_END_WIDGET;
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
data: {
|
||||
showWidget?: boolean;
|
||||
};
|
||||
selectable?: boolean;
|
||||
};
|
||||
|
||||
export type ApNode =
|
||||
| ApStepNode
|
||||
| ApGraphEndNode
|
||||
| ApBigAddButtonNode
|
||||
| ApLoopReturnNode;
|
||||
|
||||
export enum ApEdgeType {
|
||||
STRAIGHT_LINE = 'ApStraightLineEdge',
|
||||
LOOP_START_EDGE = 'ApLoopStartEdge',
|
||||
LOOP_CLOSE_EDGE = 'ApLoopCloseEdge',
|
||||
LOOP_RETURN_EDGE = 'ApLoopReturnEdge',
|
||||
ROUTER_START_EDGE = 'ApRouterStartEdge',
|
||||
ROUTER_END_EDGE = 'ApRouterEndEdge',
|
||||
}
|
||||
|
||||
export type ApStraightLineEdge = Edge & {
|
||||
type: ApEdgeType.STRAIGHT_LINE;
|
||||
data: {
|
||||
drawArrowHead: boolean;
|
||||
hideAddButton?: boolean;
|
||||
parentStepName: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ApLoopStartEdge = Edge & {
|
||||
type: ApEdgeType.LOOP_START_EDGE;
|
||||
data: {
|
||||
isLoopEmpty: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type ApLoopCloseEdge = Edge & {
|
||||
type: ApEdgeType.LOOP_CLOSE_EDGE;
|
||||
};
|
||||
|
||||
export type ApLoopReturnEdge = Edge & {
|
||||
type: ApEdgeType.LOOP_RETURN_EDGE;
|
||||
data: {
|
||||
parentStepName: string;
|
||||
isLoopEmpty: boolean;
|
||||
drawArrowHeadAfterEnd: boolean;
|
||||
verticalSpaceBetweenReturnNodeStartAndEnd: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type ApRouterStartEdge = Edge & {
|
||||
type: ApEdgeType.ROUTER_START_EDGE;
|
||||
data: {
|
||||
isBranchEmpty: boolean;
|
||||
label: string;
|
||||
drawHorizontalLine: boolean;
|
||||
drawStartingVerticalLine: boolean;
|
||||
} & {
|
||||
stepLocationRelativeToParent: StepLocationRelativeToParent.INSIDE_BRANCH;
|
||||
branchIndex: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type ApRouterEndEdge = Edge & {
|
||||
type: ApEdgeType.ROUTER_END_EDGE;
|
||||
data: {
|
||||
drawHorizontalLine: boolean;
|
||||
verticalSpaceBetweenLastNodeInBranchAndEndLine: number;
|
||||
} & (
|
||||
| {
|
||||
routerOrBranchStepName: string;
|
||||
drawEndingVerticalLine: true;
|
||||
isNextStepEmpty: boolean;
|
||||
}
|
||||
| {
|
||||
drawEndingVerticalLine: false;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export type ApEdge =
|
||||
| ApLoopStartEdge
|
||||
| ApLoopReturnEdge
|
||||
| ApStraightLineEdge
|
||||
| ApRouterStartEdge
|
||||
| ApRouterEndEdge;
|
||||
export type ApGraph = {
|
||||
nodes: ApNode[];
|
||||
edges: ApEdge[];
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { t } from 'i18next';
|
||||
|
||||
const FlowEndWidget = () => {
|
||||
return (
|
||||
<div
|
||||
className=" text-center w-[50px] bg-builder-background text-foreground/70 rounded-lg animate-fade -ml-[25px]"
|
||||
key={'flow-end-button'}
|
||||
id="flow-end-button"
|
||||
>
|
||||
<div className="w-full px-2 py-1 text-center h-full bg-border/80 rounded-lg ">
|
||||
{t('End')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FlowEndWidget.displayName = 'FlowEndWidget';
|
||||
export default FlowEndWidget;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useReactFlow } from '@xyflow/react';
|
||||
import { t } from 'i18next';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { BuilderState } from '@/app/builder/builder-hooks';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
FlowAction,
|
||||
FlowVersion,
|
||||
Step,
|
||||
flowStructureUtil,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { flowCanvasUtils } from '../utils/flow-canvas-utils';
|
||||
|
||||
type IncompleteSettingsButtonProps = {
|
||||
flowVersion: FlowVersion;
|
||||
selectStepByName: BuilderState['selectStepByName'];
|
||||
};
|
||||
|
||||
const IncompleteSettingsButton: React.FC<IncompleteSettingsButtonProps> = ({
|
||||
flowVersion,
|
||||
selectStepByName,
|
||||
}) => {
|
||||
const invalidSteps = useMemo(
|
||||
() =>
|
||||
flowStructureUtil
|
||||
.getAllSteps(flowVersion.trigger)
|
||||
.filter(filterValidOrSkippedSteps).length,
|
||||
[flowVersion],
|
||||
);
|
||||
const { fitView } = useReactFlow();
|
||||
function onClick() {
|
||||
const invalidSteps = flowStructureUtil
|
||||
.getAllSteps(flowVersion.trigger)
|
||||
.filter(filterValidOrSkippedSteps);
|
||||
if (invalidSteps.length > 0) {
|
||||
selectStepByName(invalidSteps[0].name);
|
||||
fitView(
|
||||
flowCanvasUtils.createFocusStepInGraphParams(invalidSteps[0].name),
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
!flowVersion.valid && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-[28px] hover:bg-amber-50 p-2 dark:hover:bg-amber-950 dark:bg-amber-950 bg-amber-50 border border-solid border-amber-500 hover:border-amber-700 dark:hover:border-amber-600 dark:border-amber-900 dark:text-amber-600 text-amber-700 hover:text-amber-700 dark:hover:text-amber-600 animate-fade"
|
||||
key={'complete-flow-button'}
|
||||
onClick={(e) => {
|
||||
onClick();
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{t('incompleteSteps', { invalidSteps: invalidSteps })}
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
IncompleteSettingsButton.displayName = 'IncompleteSettingsButton';
|
||||
export default IncompleteSettingsButton;
|
||||
function filterValidOrSkippedSteps(step: Step) {
|
||||
if ((step as FlowAction).skip) return false;
|
||||
return !step.valid;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ViewportPortal } from '@xyflow/react';
|
||||
import React from 'react';
|
||||
|
||||
import FlowEndWidget from '@/app/builder/flow-canvas/widgets/flow-end-widget';
|
||||
import IncompleteSettingsButton from '@/app/builder/flow-canvas/widgets/incomplete-settings-widget';
|
||||
import { TestFlowWidget } from '@/app/builder/flow-canvas/widgets/test-flow-widget';
|
||||
|
||||
import { useBuilderStateContext } from '../../builder-hooks';
|
||||
import { flowUtilConsts } from '../utils/consts';
|
||||
|
||||
const AboveFlowWidgets = React.memo(() => {
|
||||
const [flowVersion, selectStepByName, readonly] = useBuilderStateContext(
|
||||
(state) => [state.flowVersion, state.selectStepByName, state.readonly],
|
||||
);
|
||||
return (
|
||||
<ViewportPortal>
|
||||
<WidgetWrapper>
|
||||
<div
|
||||
style={{
|
||||
transform: `translate(0px,-${flowUtilConsts.AP_NODE_SIZE.STEP.height}px )`,
|
||||
position: 'absolute',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<div className="justify-center items-center flex w-[260px]">
|
||||
<TestFlowWidget></TestFlowWidget>
|
||||
{!readonly && (
|
||||
<IncompleteSettingsButton
|
||||
flowVersion={flowVersion}
|
||||
selectStepByName={selectStepByName}
|
||||
></IncompleteSettingsButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</WidgetWrapper>
|
||||
</ViewportPortal>
|
||||
);
|
||||
});
|
||||
AboveFlowWidgets.displayName = 'AboveFlowWidgets';
|
||||
const BelowFlowWidget = React.memo(() => {
|
||||
return (
|
||||
<ViewportPortal>
|
||||
<WidgetWrapper>
|
||||
<div
|
||||
style={{
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-center gap-2"
|
||||
style={{ width: flowUtilConsts.AP_NODE_SIZE.STEP.width + 'px' }}
|
||||
>
|
||||
<FlowEndWidget></FlowEndWidget>
|
||||
</div>
|
||||
</div>
|
||||
</WidgetWrapper>
|
||||
</ViewportPortal>
|
||||
);
|
||||
});
|
||||
|
||||
const WidgetWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<div
|
||||
style={{ width: flowUtilConsts.AP_NODE_SIZE.STEP.width + 'px' }}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BelowFlowWidget.displayName = 'BelowFlowWidget';
|
||||
export { AboveFlowWidgets, BelowFlowWidget };
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import { NODE_SELECTION_RECT_CLASS_NAME } from '../../builder-hooks';
|
||||
import { SELECTION_RECT_CHEVRON_ATTRIBUTE } from '../utils/consts';
|
||||
|
||||
const showChevronNextToSelection = (targetDiv: HTMLElement) => {
|
||||
const container = document.createElement('div');
|
||||
targetDiv.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="absolute top-0 -left-10 z-50"
|
||||
{...{ [`data-${SELECTION_RECT_CHEVRON_ATTRIBUTE}`]: true }}
|
||||
onClick={(e) => {
|
||||
const rightClickEvent = new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
button: 2,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
});
|
||||
e.target.dispatchEvent(rightClickEvent);
|
||||
}}
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</Button>,
|
||||
);
|
||||
return root;
|
||||
};
|
||||
|
||||
export const useShowChevronNextToSelection = () => {
|
||||
useEffect(() => {
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (
|
||||
node instanceof HTMLElement &&
|
||||
node.children.length > 0 &&
|
||||
node.children[0].classList.contains(NODE_SELECTION_RECT_CLASS_NAME)
|
||||
) {
|
||||
root = showChevronNextToSelection(node.children[0] as HTMLElement);
|
||||
}
|
||||
});
|
||||
// Handle removed nodes
|
||||
mutation.removedNodes.forEach((node) => {
|
||||
if (
|
||||
node instanceof HTMLElement &&
|
||||
node.children.length > 0 &&
|
||||
node.children[0].classList.contains(NODE_SELECTION_RECT_CLASS_NAME)
|
||||
) {
|
||||
if (root) {
|
||||
root.unmount();
|
||||
root = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
// Unmount all roots on cleanup
|
||||
if (root) {
|
||||
root.unmount();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { t } from 'i18next';
|
||||
|
||||
import {
|
||||
ChatDrawerSource,
|
||||
useBuilderStateContext,
|
||||
} from '@/app/builder/builder-hooks';
|
||||
import { flowHooks } from '@/features/flows/lib/flow-hooks';
|
||||
import { pieceSelectorUtils } from '@/features/pieces/lib/piece-selector-utils';
|
||||
import { isNil, FlowTriggerType } from '@activepieces/shared';
|
||||
|
||||
import ViewOnlyWidget from '../view-only-widget';
|
||||
|
||||
import { TestButton } from './test-button';
|
||||
|
||||
const TestFlowWidget = () => {
|
||||
const [setChatDrawerOpenSource, flowVersion, readonly, setRun] =
|
||||
useBuilderStateContext((state) => [
|
||||
state.setChatDrawerOpenSource,
|
||||
state.flowVersion,
|
||||
state.readonly,
|
||||
state.setRun,
|
||||
]);
|
||||
|
||||
const triggerHasSampleData =
|
||||
flowVersion.trigger.type === FlowTriggerType.PIECE &&
|
||||
!isNil(flowVersion.trigger.settings.sampleData?.lastTestDate);
|
||||
|
||||
const isChatTrigger = pieceSelectorUtils.isChatTrigger(
|
||||
flowVersion.trigger.settings.pieceName,
|
||||
flowVersion.trigger.settings.triggerName,
|
||||
);
|
||||
|
||||
const { mutate: runFlow, isPending } = flowHooks.useTestFlow({
|
||||
flowVersionId: flowVersion.id,
|
||||
onUpdateRun: (run) => {
|
||||
setRun(run, flowVersion);
|
||||
},
|
||||
});
|
||||
|
||||
if (!flowVersion.valid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isChatTrigger) {
|
||||
return (
|
||||
<TestButton
|
||||
onClick={() => {
|
||||
setChatDrawerOpenSource(ChatDrawerSource.TEST_FLOW);
|
||||
}}
|
||||
text={t('Open Chat')}
|
||||
loading={isPending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (readonly) {
|
||||
return <ViewOnlyWidget />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TestButton
|
||||
onClick={() => {
|
||||
runFlow();
|
||||
}}
|
||||
text={t('Test Flow')}
|
||||
triggerHasNoSampleData={!triggerHasSampleData}
|
||||
loading={isPending}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
TestFlowWidget.displayName = 'TestFlowWidget';
|
||||
|
||||
export { TestFlowWidget };
|
||||
@@ -0,0 +1,83 @@
|
||||
import { t } from 'i18next';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
type TestButtonProps = {
|
||||
onClick: () => void;
|
||||
text: string;
|
||||
triggerHasNoSampleData?: boolean;
|
||||
loading?: boolean;
|
||||
showKeyboardShortcut?: boolean;
|
||||
};
|
||||
|
||||
const TestButton = ({
|
||||
onClick,
|
||||
text,
|
||||
triggerHasNoSampleData = false,
|
||||
loading = false,
|
||||
showKeyboardShortcut = true,
|
||||
}: TestButtonProps) => {
|
||||
const isMac = /(Mac)/i.test(navigator.userAgent);
|
||||
|
||||
useEffect(() => {
|
||||
const keydownHandler = (event: KeyboardEvent) => {
|
||||
if (
|
||||
(isMac && event.metaKey && event.key.toLocaleLowerCase() === 'd') ||
|
||||
(!isMac && event.ctrlKey && event.key.toLocaleLowerCase() === 'd')
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (!loading && !triggerHasNoSampleData) {
|
||||
onClick();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', keydownHandler, { capture: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', keydownHandler, { capture: true });
|
||||
};
|
||||
}, [isMac, loading, onClick]);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="bg-builder-background">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 bg-primary-100/50! dark:text-primary-foreground text-primary hover:text-primary disabled:pointer-events-auto hover:border-primary! border-primary/50 border border-solid rounded-lg animate-fade"
|
||||
loading={loading}
|
||||
disabled={triggerHasNoSampleData}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex justify-center items-center gap-2">
|
||||
{text}
|
||||
{showKeyboardShortcut && (
|
||||
<span className="text-[10px] bg-primary/13 h-[20px] flex items-center justify-center px-1 rounded-sm tracking-widest whitespace-nowrap">
|
||||
{isMac ? '⌘ + D' : 'Ctrl + D'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{triggerHasNoSampleData && (
|
||||
<TooltipContent side="bottom">
|
||||
{t('Please test the trigger first')}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
TestButton.displayName = 'TestButton';
|
||||
|
||||
export { TestButton };
|
||||
@@ -0,0 +1,15 @@
|
||||
import { t } from 'i18next';
|
||||
|
||||
const ViewOnlyWidget = () => {
|
||||
return (
|
||||
<div
|
||||
className="p-2 bg-border text-foreground/70 rounded-lg animate-fade"
|
||||
key={'view-only-widget'}
|
||||
>
|
||||
{t('View Only')}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ViewOnlyWidget.displayName = 'ViewOnlyWidget';
|
||||
export default ViewOnlyWidget;
|
||||
@@ -0,0 +1,226 @@
|
||||
import { DotsVerticalIcon } from '@radix-ui/react-icons';
|
||||
import { t } from 'i18next';
|
||||
import { Eye, EyeIcon, Pencil } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import {
|
||||
LeftSideBarType,
|
||||
useBuilderStateContext,
|
||||
} from '@/app/builder/builder-hooks';
|
||||
import { CardListItem } from '@/components/custom/card-list';
|
||||
import { PermissionNeededTooltip } from '@/components/custom/permission-needed-tooltip';
|
||||
import { useEmbedding } from '@/components/embed-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { LoadingSpinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { UserAvatar } from '@/components/ui/user-avatar';
|
||||
import { FlowVersionStateDot } from '@/features/flows/components/flow-version-state-dot';
|
||||
import { flowHooks } from '@/features/flows/lib/flow-hooks';
|
||||
import { useAuthorization } from '@/hooks/authorization-hooks';
|
||||
import { formatUtils } from '@/lib/utils';
|
||||
import {
|
||||
FlowVersionMetadata,
|
||||
FlowVersionState,
|
||||
Permission,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
type UseAsDraftOptionProps = {
|
||||
versionNumber: number;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
const UseAsDraftDropdownMenuOption = ({
|
||||
versionNumber,
|
||||
onConfirm,
|
||||
}: UseAsDraftOptionProps) => {
|
||||
const { checkAccess } = useAuthorization();
|
||||
const userHasPermissionToWriteFlow = checkAccess(Permission.WRITE_FLOW);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
disabled={!userHasPermissionToWriteFlow}
|
||||
className="w-full"
|
||||
>
|
||||
<PermissionNeededTooltip hasPermission={userHasPermissionToWriteFlow}>
|
||||
<DropdownMenuItem
|
||||
className="w-full"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
disabled={!userHasPermissionToWriteFlow}
|
||||
>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
<span>{t('Use as Draft')}</span>
|
||||
</DropdownMenuItem>
|
||||
</PermissionNeededTooltip>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Are you sure?')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('Your current draft version will be overwritten with')}{' '}
|
||||
<span className="font-semibold">
|
||||
{t('version #')}
|
||||
{versionNumber}
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="justify-end">
|
||||
<DialogClose asChild>
|
||||
<Button variant={'outline'}>{t('Cancel')}</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button onClick={() => onConfirm()}>{t('Confirm')}</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
UseAsDraftDropdownMenuOption.displayName = 'UseAsDraftDropdownMenuOption';
|
||||
|
||||
type FlowVersionDetailsCardProps = {
|
||||
flowVersion: FlowVersionMetadata;
|
||||
selected: boolean;
|
||||
publishedVersionId: string | undefined | null;
|
||||
flowVersionNumber: number;
|
||||
};
|
||||
const FlowVersionDetailsCard = React.memo(
|
||||
({
|
||||
flowVersion,
|
||||
flowVersionNumber,
|
||||
selected,
|
||||
publishedVersionId,
|
||||
}: FlowVersionDetailsCardProps) => {
|
||||
const { checkAccess } = useAuthorization();
|
||||
const userHasPermissionToWriteFlow = checkAccess(Permission.WRITE_FLOW);
|
||||
const [setBuilderVersion, setLeftSidebar, setReadonly] =
|
||||
useBuilderStateContext((state) => [
|
||||
state.setVersion,
|
||||
state.setLeftSidebar,
|
||||
state.setReadOnly,
|
||||
]);
|
||||
const [dropdownMenuOpen, setDropdownMenuOpen] = useState(false);
|
||||
const { mutate: viewVersion, isPending } = flowHooks.useFetchFlowVersion({
|
||||
onSuccess: (populatedFlowVersion) => {
|
||||
setBuilderVersion(populatedFlowVersion);
|
||||
setReadonly(
|
||||
populatedFlowVersion.state === FlowVersionState.LOCKED ||
|
||||
!userHasPermissionToWriteFlow,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: overWriteDraftWithVersion, isPending: isDraftPending } =
|
||||
flowHooks.useOverWriteDraftWithVersion({
|
||||
onSuccess: (populatedFlowVersion) => {
|
||||
setBuilderVersion(populatedFlowVersion.version);
|
||||
setLeftSidebar(LeftSideBarType.NONE);
|
||||
},
|
||||
});
|
||||
|
||||
const handleOverwriteDraftWtihVersion = () => {
|
||||
overWriteDraftWithVersion(flowVersion);
|
||||
setDropdownMenuOpen(false);
|
||||
};
|
||||
|
||||
const showAvatar = !useEmbedding().embedState.isEmbedded;
|
||||
|
||||
return (
|
||||
<CardListItem interactive={false}>
|
||||
{showAvatar && flowVersion.updatedByUser && (
|
||||
<UserAvatar
|
||||
size={28}
|
||||
name={
|
||||
flowVersion.updatedByUser.firstName +
|
||||
' ' +
|
||||
flowVersion.updatedByUser.lastName
|
||||
}
|
||||
email={flowVersion.updatedByUser.email}
|
||||
/>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<p className="text-sm font-medium leading-none select-none pointer-events-none">
|
||||
{formatUtils.formatDate(new Date(flowVersion.created))}
|
||||
</p>
|
||||
<p className="flex gap-1 text-xs text-muted-foreground">
|
||||
{t('Version')} {flowVersionNumber}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grow"></div>
|
||||
<div className="flex font-medium gap-2 justify-center items-center">
|
||||
{selected && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="size-10 flex justify-center items-center">
|
||||
<EyeIcon className="w-5 h-5 "></EyeIcon>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('Viewing')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<FlowVersionStateDot
|
||||
state={flowVersion.state}
|
||||
versionId={flowVersion.id}
|
||||
publishedVersionId={publishedVersionId}
|
||||
></FlowVersionStateDot>
|
||||
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => setDropdownMenuOpen(open)}
|
||||
open={dropdownMenuOpen}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={isPending || isDraftPending}
|
||||
size={'icon'}
|
||||
>
|
||||
{(isPending || isDraftPending) && <LoadingSpinner />}
|
||||
{!isPending && !isDraftPending && <DotsVerticalIcon />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-40">
|
||||
<DropdownMenuItem
|
||||
onClick={() => viewVersion(flowVersion)}
|
||||
className="w-full"
|
||||
>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
<span>{t('View')}</span>
|
||||
</DropdownMenuItem>
|
||||
{flowVersion.state !== FlowVersionState.DRAFT && (
|
||||
<UseAsDraftDropdownMenuOption
|
||||
versionNumber={flowVersionNumber}
|
||||
onConfirm={handleOverwriteDraftWtihVersion}
|
||||
></UseAsDraftDropdownMenuOption>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardListItem>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
FlowVersionDetailsCard.displayName = 'FlowVersionDetailsCard';
|
||||
export { FlowVersionDetailsCard };
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { t } from 'i18next';
|
||||
|
||||
import {
|
||||
RightSideBarType,
|
||||
useBuilderStateContext,
|
||||
} from '@/app/builder/builder-hooks';
|
||||
import { CardList, CardListItemSkeleton } from '@/components/custom/card-list';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { flowsApi } from '@/features/flows/lib/flows-api';
|
||||
import { FlowVersionMetadata, SeekPage } from '@activepieces/shared';
|
||||
|
||||
import { SidebarHeader } from '../sidebar-header';
|
||||
|
||||
import { FlowVersionDetailsCard } from './flow-versions-card';
|
||||
|
||||
const FlowVersionsList = () => {
|
||||
const [flow, setRightSidebar, selectedFlowVersion] = useBuilderStateContext(
|
||||
(state) => [state.flow, state.setRightSidebar, state.flowVersion],
|
||||
);
|
||||
|
||||
const {
|
||||
data: flowVersionPage,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery<SeekPage<FlowVersionMetadata>, Error>({
|
||||
queryKey: ['flow-versions', flow.id],
|
||||
queryFn: () =>
|
||||
flowsApi.listVersions(flow.id, {
|
||||
limit: 1000,
|
||||
cursor: undefined,
|
||||
}),
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarHeader onClose={() => setRightSidebar(RightSideBarType.NONE)}>
|
||||
{t('Version History')}
|
||||
</SidebarHeader>
|
||||
<CardList>
|
||||
{isLoading && <CardListItemSkeleton numberOfCards={10} />}
|
||||
{isError && <div>{t('Error, please try again.')}</div>}
|
||||
{flowVersionPage && flowVersionPage.data && (
|
||||
<ScrollArea className="w-full h-full">
|
||||
{flowVersionPage.data.map((flowVersion, index) => (
|
||||
<FlowVersionDetailsCard
|
||||
selected={flowVersion.id === selectedFlowVersion?.id}
|
||||
publishedVersionId={flow.publishedVersionId}
|
||||
flowVersion={flowVersion}
|
||||
flowVersionNumber={flowVersionPage.data.length - index}
|
||||
key={flowVersion.id}
|
||||
/>
|
||||
))}
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardList>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
FlowVersionsList.displayName = 'FlowVersionsList';
|
||||
|
||||
export { FlowVersionsList };
|
||||
287
activepieces-fork/packages/react-ui/src/app/builder/index.tsx
Normal file
287
activepieces-fork/packages/react-ui/src/app/builder/index.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ImperativePanelHandle } from 'react-resizable-panels';
|
||||
|
||||
import {
|
||||
LeftSideBarType,
|
||||
RightSideBarType,
|
||||
useBuilderStateContext,
|
||||
useShowBuilderIsSavingWarningBeforeLeaving,
|
||||
useSwitchToDraft,
|
||||
} from '@/app/builder/builder-hooks';
|
||||
import { DataSelector } from '@/app/builder/data-selector';
|
||||
import { CanvasControls } from '@/app/builder/flow-canvas/canvas-controls';
|
||||
import { StepSettingsProvider } from '@/app/builder/step-settings/step-settings-context';
|
||||
import { ChatDrawer } from '@/app/routes/chat/chat-drawer';
|
||||
import { ShowPoweredBy } from '@/components/show-powered-by';
|
||||
import { useSocket } from '@/components/socket-provider';
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from '@/components/ui/resizable-panel';
|
||||
import { RunDetailsBar } from '@/features/flow-runs/components/run-details-bar';
|
||||
import { flowRunsApi } from '@/features/flow-runs/lib/flow-runs-api';
|
||||
import { piecesHooks } from '@/features/pieces/lib/pieces-hooks';
|
||||
import { platformHooks } from '@/hooks/platform-hooks';
|
||||
import {
|
||||
FlowAction,
|
||||
FlowActionType,
|
||||
FlowTrigger,
|
||||
FlowTriggerType,
|
||||
FlowVersionState,
|
||||
WebsocketClientEvent,
|
||||
flowStructureUtil,
|
||||
isNil,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { cn, useElementSize } from '../../lib/utils';
|
||||
|
||||
import { BuilderHeader } from './builder-header/builder-header';
|
||||
import { FlowCanvas } from './flow-canvas';
|
||||
import { LEFT_SIDEBAR_ID } from './flow-canvas/utils/consts';
|
||||
import { FlowVersionsList } from './flow-versions';
|
||||
import { FlowRunDetails } from './run-details';
|
||||
import { RunsList } from './run-list';
|
||||
import { StepSettingsContainer } from './step-settings';
|
||||
|
||||
const minWidthOfSidebar = 'min-w-[max(20vw,400px)]';
|
||||
const animateResizeClassName = `transition-all duration-200`;
|
||||
|
||||
const useAnimateSidebar = (
|
||||
sidebarValue: LeftSideBarType | RightSideBarType,
|
||||
) => {
|
||||
const handleRef = useRef<ImperativePanelHandle>(null);
|
||||
const sidebarClosed = [LeftSideBarType.NONE, RightSideBarType.NONE].includes(
|
||||
sidebarValue,
|
||||
);
|
||||
useEffect(() => {
|
||||
const sidebarSize = handleRef.current?.getSize() ?? 0;
|
||||
if (sidebarClosed) {
|
||||
handleRef.current?.resize(0);
|
||||
} else if (sidebarSize === 0) {
|
||||
handleRef.current?.resize(25);
|
||||
}
|
||||
}, [handleRef, sidebarValue, sidebarClosed]);
|
||||
return handleRef;
|
||||
};
|
||||
|
||||
const BuilderPage = () => {
|
||||
const { platform } = platformHooks.useCurrentPlatform();
|
||||
const [setRun, flowVersion, leftSidebar, rightSidebar, run, selectedStep] =
|
||||
useBuilderStateContext((state) => [
|
||||
state.setRun,
|
||||
state.flowVersion,
|
||||
state.leftSidebar,
|
||||
state.rightSidebar,
|
||||
state.run,
|
||||
state.selectedStep,
|
||||
]);
|
||||
|
||||
useShowBuilderIsSavingWarningBeforeLeaving();
|
||||
|
||||
const { memorizedSelectedStep } = useBuilderStateContext((state) => {
|
||||
const flowVersion = state.flowVersion;
|
||||
if (isNil(state.selectedStep) || isNil(flowVersion)) {
|
||||
return {
|
||||
memorizedSelectedStep: undefined,
|
||||
};
|
||||
}
|
||||
const step = flowStructureUtil.getStep(
|
||||
state.selectedStep,
|
||||
flowVersion.trigger,
|
||||
);
|
||||
|
||||
return {
|
||||
memorizedSelectedStep: step,
|
||||
};
|
||||
});
|
||||
const middlePanelRef = useRef<HTMLDivElement>(null);
|
||||
const middlePanelSize = useElementSize(middlePanelRef);
|
||||
const [isDraggingHandle, setIsDraggingHandle] = useState(false);
|
||||
const rightHandleRef = useAnimateSidebar(rightSidebar);
|
||||
const leftHandleRef = useAnimateSidebar(leftSidebar);
|
||||
const rightSidePanelRef = useRef<HTMLDivElement>(null);
|
||||
const { pieceModel, refetch: refetchPiece } =
|
||||
piecesHooks.usePieceModelForStepSettings({
|
||||
name: memorizedSelectedStep?.settings.pieceName,
|
||||
version: memorizedSelectedStep?.settings.pieceVersion,
|
||||
enabled:
|
||||
memorizedSelectedStep?.type === FlowActionType.PIECE ||
|
||||
memorizedSelectedStep?.type === FlowTriggerType.PIECE,
|
||||
getExactVersion: flowVersion.state === FlowVersionState.LOCKED,
|
||||
});
|
||||
const socket = useSocket();
|
||||
const { mutate: fetchAndUpdateRun } = useMutation({
|
||||
mutationFn: flowRunsApi.getPopulated,
|
||||
});
|
||||
useEffect(() => {
|
||||
socket.on(WebsocketClientEvent.REFRESH_PIECE, () => {
|
||||
refetchPiece();
|
||||
});
|
||||
socket.on(WebsocketClientEvent.FLOW_RUN_PROGRESS, (data) => {
|
||||
const runId = data?.runId;
|
||||
if (run && run?.id === runId) {
|
||||
fetchAndUpdateRun(runId, {
|
||||
onSuccess: (run) => {
|
||||
setRun(run, flowVersion);
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
socket.removeAllListeners(WebsocketClientEvent.REFRESH_PIECE);
|
||||
socket.removeAllListeners(WebsocketClientEvent.FLOW_RUN_PROGRESS);
|
||||
};
|
||||
}, [socket.id, run?.id]);
|
||||
|
||||
const { switchToDraft, isSwitchingToDraftPending } = useSwitchToDraft();
|
||||
const [hasCanvasBeenInitialised, setHasCanvasBeenInitialised] =
|
||||
useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col relative">
|
||||
<div className="z-50">
|
||||
<BuilderHeader />
|
||||
</div>
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
<ResizablePanel
|
||||
id="left-sidebar"
|
||||
defaultSize={0}
|
||||
minSize={0}
|
||||
maxSize={39}
|
||||
order={1}
|
||||
ref={leftHandleRef}
|
||||
className={cn('min-w-0 z-20 ', {
|
||||
[minWidthOfSidebar]: leftSidebar !== LeftSideBarType.NONE,
|
||||
[animateResizeClassName]: !isDraggingHandle,
|
||||
})}
|
||||
>
|
||||
<div id={LEFT_SIDEBAR_ID} className="w-full h-full">
|
||||
{leftSidebar === LeftSideBarType.RUNS && <RunsList />}
|
||||
{leftSidebar === LeftSideBarType.RUN_DETAILS && <FlowRunDetails />}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle
|
||||
onDragging={setIsDraggingHandle}
|
||||
withHandle={leftSidebar !== LeftSideBarType.NONE}
|
||||
className={
|
||||
leftSidebar === LeftSideBarType.NONE ? 'bg-transparent' : ''
|
||||
}
|
||||
/>
|
||||
|
||||
<ResizablePanel defaultSize={100} order={2} id="flow-canvas">
|
||||
<div ref={middlePanelRef} className="relative h-full w-full">
|
||||
<FlowCanvas
|
||||
setHasCanvasBeenInitialised={setHasCanvasBeenInitialised}
|
||||
></FlowCanvas>
|
||||
|
||||
<RunDetailsBar
|
||||
run={run}
|
||||
isLoading={isSwitchingToDraftPending}
|
||||
exitRun={() => {
|
||||
socket.removeAllListeners(
|
||||
WebsocketClientEvent.FLOW_RUN_PROGRESS,
|
||||
);
|
||||
switchToDraft();
|
||||
}}
|
||||
/>
|
||||
{middlePanelRef.current &&
|
||||
middlePanelRef.current.clientWidth > 0 && (
|
||||
<CanvasControls
|
||||
canvasHeight={middlePanelRef.current?.clientHeight ?? 0}
|
||||
canvasWidth={middlePanelRef.current?.clientWidth ?? 0}
|
||||
hasCanvasBeenInitialised={hasCanvasBeenInitialised}
|
||||
selectedStep={selectedStep}
|
||||
></CanvasControls>
|
||||
)}
|
||||
|
||||
<ShowPoweredBy
|
||||
position="absolute"
|
||||
show={platform?.plan.showPoweredBy}
|
||||
/>
|
||||
<DataSelector
|
||||
parentHeight={middlePanelSize.height}
|
||||
parentWidth={middlePanelSize.width}
|
||||
></DataSelector>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle
|
||||
disabled={rightSidebar === RightSideBarType.NONE}
|
||||
withHandle={rightSidebar !== RightSideBarType.NONE}
|
||||
onDragging={setIsDraggingHandle}
|
||||
className={
|
||||
rightSidebar === RightSideBarType.NONE ? 'bg-transparent' : ''
|
||||
}
|
||||
/>
|
||||
|
||||
<ResizablePanel
|
||||
ref={rightHandleRef}
|
||||
id="right-sidebar"
|
||||
defaultSize={0}
|
||||
minSize={0}
|
||||
maxSize={60}
|
||||
order={3}
|
||||
className={cn('min-w-0 bg-background z-30', {
|
||||
[minWidthOfSidebar]: rightSidebar !== RightSideBarType.NONE,
|
||||
[animateResizeClassName]: !isDraggingHandle,
|
||||
})}
|
||||
>
|
||||
<div ref={rightSidePanelRef} className="h-full w-full">
|
||||
{rightSidebar === RightSideBarType.PIECE_SETTINGS &&
|
||||
memorizedSelectedStep && (
|
||||
<StepSettingsProvider
|
||||
pieceModel={pieceModel}
|
||||
selectedStep={memorizedSelectedStep}
|
||||
key={constructContainerKey({
|
||||
flowVersionId: flowVersion.id,
|
||||
step: memorizedSelectedStep,
|
||||
hasPieceModelLoaded: !!pieceModel,
|
||||
})}
|
||||
>
|
||||
<StepSettingsContainer />
|
||||
</StepSettingsProvider>
|
||||
)}
|
||||
{rightSidebar === RightSideBarType.VERSIONS && <FlowVersionsList />}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
<ChatDrawer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
BuilderPage.displayName = 'BuilderPage';
|
||||
export { BuilderPage };
|
||||
|
||||
function constructContainerKey({
|
||||
flowVersionId,
|
||||
step,
|
||||
hasPieceModelLoaded,
|
||||
}: {
|
||||
flowVersionId: string;
|
||||
step?: FlowAction | FlowTrigger;
|
||||
hasPieceModelLoaded: boolean;
|
||||
}) {
|
||||
const stepName = step?.name;
|
||||
const triggerOrActionName =
|
||||
step?.type === FlowTriggerType.PIECE
|
||||
? step?.settings.triggerName
|
||||
: step?.settings.actionName;
|
||||
const pieceName =
|
||||
step?.type === FlowTriggerType.PIECE || step?.type === FlowActionType.PIECE
|
||||
? step?.settings.pieceName
|
||||
: undefined;
|
||||
//we need to re-render the step settings form when the step is skipped, so when the user edits the settings after setting it to skipped the changes are reflected in the update request
|
||||
const isSkipped =
|
||||
step?.type != FlowTriggerType.EMPTY &&
|
||||
step?.type != FlowTriggerType.PIECE &&
|
||||
step?.skip;
|
||||
return `${flowVersionId}-${stepName ?? ''}-${triggerOrActionName ?? ''}-${
|
||||
pieceName ?? ''
|
||||
}-${'skipped-' + !!isSkipped}-${
|
||||
hasPieceModelLoaded ? 'loaded' : 'not-loaded'
|
||||
}`;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { t } from 'i18next';
|
||||
import React from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
} from '@/components/ui/form';
|
||||
import { ReadMoreDescription } from '@/components/ui/read-more-description';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { FlowAction, FlowTrigger } from '@activepieces/shared';
|
||||
|
||||
type ActionErrorHandlingFormProps = {
|
||||
hideContinueOnFailure?: boolean;
|
||||
hideRetryOnFailure?: boolean;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
const ActionErrorHandlingForm = React.memo(
|
||||
({
|
||||
hideContinueOnFailure,
|
||||
hideRetryOnFailure,
|
||||
disabled,
|
||||
}: ActionErrorHandlingFormProps) => {
|
||||
const form = useFormContext<FlowAction | FlowTrigger>();
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{hideContinueOnFailure !== true && (
|
||||
<FormField
|
||||
name="settings.errorHandlingOptions.continueOnFailure.value"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start justify-between">
|
||||
<FormLabel
|
||||
htmlFor="continueOnFailure"
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<FormControl>
|
||||
<Switch
|
||||
disabled={disabled}
|
||||
id="continueOnFailure"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<span className="ml-3 grow">{t('Continue on Failure')}</span>
|
||||
</FormLabel>
|
||||
<ReadMoreDescription
|
||||
text={t(
|
||||
'Enable this option to skip this step and continue the flow normally if it fails.',
|
||||
)}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{hideRetryOnFailure !== true && (
|
||||
<FormField
|
||||
name="settings.errorHandlingOptions.retryOnFailure.value"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start justify-between">
|
||||
<FormLabel
|
||||
htmlFor="retryOnFailure"
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<FormControl>
|
||||
<Switch
|
||||
disabled={disabled}
|
||||
id="retryOnFailure"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<span className="ml-3 grow">{t('Retry on Failure')}</span>
|
||||
</FormLabel>
|
||||
<ReadMoreDescription
|
||||
text={t(
|
||||
'Automatically retry up to four attempts when failed.',
|
||||
)}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ActionErrorHandlingForm.displayName = 'ActionErrorHandlingForm';
|
||||
export { ActionErrorHandlingForm };
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, { useRef } from 'react';
|
||||
|
||||
import { ArraySubProps } from '@activepieces/pieces-framework';
|
||||
|
||||
import {
|
||||
useBuilderStateContext,
|
||||
useIsFocusInsideListMapperModeInput,
|
||||
} from '../builder-hooks';
|
||||
|
||||
import { AutoPropertiesFormComponent } from './auto-properties-form';
|
||||
import { TextInputWithMentions } from './text-input-with-mentions';
|
||||
|
||||
type BaseArrayPropertyProps = {
|
||||
inputName: string;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
type ArrayPiecePropertyInInlineItemModeProps = BaseArrayPropertyProps &
|
||||
(
|
||||
| { arrayProperties: ArraySubProps<boolean> }
|
||||
| {
|
||||
arrayProperties: undefined;
|
||||
onChange: (value: string) => void;
|
||||
value: string;
|
||||
}
|
||||
);
|
||||
|
||||
const ArrayPiecePropertyInInlineItemMode = React.memo(
|
||||
(props: ArrayPiecePropertyInInlineItemModeProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [
|
||||
isFocusInsideListMapperModeInput,
|
||||
setIsFocusInsideListMapperModeInput,
|
||||
] = useBuilderStateContext((state) => [
|
||||
state.isFocusInsideListMapperModeInput,
|
||||
state.setIsFocusInsideListMapperModeInput,
|
||||
]);
|
||||
const { inputName, disabled } = props;
|
||||
useIsFocusInsideListMapperModeInput({
|
||||
containerRef,
|
||||
setIsFocusInsideListMapperModeInput,
|
||||
isFocusInsideListMapperModeInput,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full" ref={containerRef}>
|
||||
{props.arrayProperties ? (
|
||||
<div className="p-4 border rounded-md flex flex-col gap-4">
|
||||
<AutoPropertiesFormComponent
|
||||
prefixValue={inputName}
|
||||
props={props.arrayProperties}
|
||||
useMentionTextInput={true}
|
||||
allowDynamicValues={false}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<TextInputWithMentions
|
||||
disabled={disabled}
|
||||
onChange={props.onChange}
|
||||
initialValue={props.value ?? null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ArrayPiecePropertyInInlineItemMode.displayName =
|
||||
'ArrayPiecePropertyInInlineItemMode';
|
||||
export { ArrayPiecePropertyInInlineItemMode };
|
||||
@@ -0,0 +1,212 @@
|
||||
import { t } from 'i18next';
|
||||
import { Plus, TrashIcon } from 'lucide-react';
|
||||
import { nanoid } from 'nanoid';
|
||||
import React, { useState } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { ArrayInput } from '@/components/custom/array-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { TextWithIcon } from '@/components/ui/text-with-icon';
|
||||
import {
|
||||
ArrayProperty,
|
||||
ArraySubProps,
|
||||
PropertyType,
|
||||
} from '@activepieces/pieces-framework';
|
||||
|
||||
import { AutoPropertiesFormComponent } from './auto-properties-form';
|
||||
import { TextInputWithMentions } from './text-input-with-mentions';
|
||||
|
||||
type ArrayPropertyProps = {
|
||||
inputName: string;
|
||||
useMentionTextInput: boolean;
|
||||
arrayProperty: ArrayProperty<boolean>;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
type ArrayField = {
|
||||
id: string;
|
||||
value: string | Record<string, unknown>;
|
||||
};
|
||||
|
||||
const getDefaultValuesForInputs = (arrayProperties: ArraySubProps<boolean>) => {
|
||||
return Object.entries(arrayProperties).reduce((acc, [key, value]) => {
|
||||
switch (value.type) {
|
||||
case PropertyType.LONG_TEXT:
|
||||
case PropertyType.SHORT_TEXT:
|
||||
case PropertyType.NUMBER:
|
||||
case PropertyType.JSON:
|
||||
case PropertyType.COLOR:
|
||||
return {
|
||||
...acc,
|
||||
[key]: '',
|
||||
};
|
||||
case PropertyType.CHECKBOX:
|
||||
return {
|
||||
...acc,
|
||||
[key]: false,
|
||||
};
|
||||
case PropertyType.STATIC_DROPDOWN:
|
||||
case PropertyType.STATIC_MULTI_SELECT_DROPDOWN:
|
||||
case PropertyType.MULTI_SELECT_DROPDOWN:
|
||||
case PropertyType.DATE_TIME:
|
||||
return {
|
||||
...acc,
|
||||
[key]: null,
|
||||
};
|
||||
case PropertyType.FILE:
|
||||
return {
|
||||
...acc,
|
||||
[key]: null,
|
||||
};
|
||||
}
|
||||
}, {} as Record<string, unknown>);
|
||||
};
|
||||
const ArrayPieceProperty = React.memo(
|
||||
({
|
||||
inputName,
|
||||
useMentionTextInput,
|
||||
disabled,
|
||||
arrayProperty,
|
||||
}: ArrayPropertyProps) => {
|
||||
const form = useFormContext();
|
||||
|
||||
const [fields, setFields] = useState<ArrayField[]>(() => {
|
||||
const formValues = form.getValues(inputName);
|
||||
if (formValues) {
|
||||
return formValues.map((value: string | Record<string, unknown>) => ({
|
||||
id: nanoid(),
|
||||
value,
|
||||
}));
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const updateFormValue = (newFields: ArrayField[]) => {
|
||||
form.setValue(
|
||||
inputName,
|
||||
newFields.map((f) => f.value),
|
||||
{ shouldValidate: true },
|
||||
);
|
||||
};
|
||||
|
||||
const append = () => {
|
||||
//passing empty object will result in react form putting in the initial values when the user first started editing
|
||||
const value = arrayProperty.properties
|
||||
? getDefaultValuesForInputs(arrayProperty.properties)
|
||||
: '';
|
||||
const formValues = form.getValues(inputName) || [];
|
||||
const newFields = [
|
||||
...formValues.map((value: string | Record<string, unknown>) => ({
|
||||
id: nanoid(),
|
||||
value,
|
||||
})),
|
||||
{ id: nanoid(), value },
|
||||
];
|
||||
|
||||
setFields(newFields);
|
||||
updateFormValue(newFields);
|
||||
};
|
||||
|
||||
const remove = (index: number) => {
|
||||
const currentFields: ArrayField[] = form
|
||||
.getValues(inputName)
|
||||
.map((value: string | Record<string, unknown>) => ({
|
||||
id: nanoid(),
|
||||
value,
|
||||
}));
|
||||
const newFields = currentFields.filter((_, i) => i !== index);
|
||||
setFields(newFields);
|
||||
updateFormValue(newFields);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{arrayProperty.properties && (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
className="p-4 border rounded-md flex flex-col gap-4"
|
||||
key={'array-item-' + field.id}
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<div className="font-semibold"> #{index + 1}</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-8 shrink-0"
|
||||
onClick={() => {
|
||||
remove(index);
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<TrashIcon
|
||||
className="size-4 text-destructive"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">{t('Remove')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<AutoPropertiesFormComponent
|
||||
prefixValue={`${inputName}.[${index}]`}
|
||||
props={arrayProperty.properties!}
|
||||
useMentionTextInput={useMentionTextInput}
|
||||
allowDynamicValues={false}
|
||||
disabled={disabled}
|
||||
onValueChange={() => {
|
||||
form.trigger(inputName);
|
||||
}}
|
||||
></AutoPropertiesFormComponent>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!disabled && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => {
|
||||
append();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<TextWithIcon icon={<Plus size={18} />} text={t('Add Item')} />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!arrayProperty.properties && (
|
||||
<ArrayInput
|
||||
inputName={inputName}
|
||||
disabled={disabled}
|
||||
required={arrayProperty.required}
|
||||
customInputNode={(onChange, value, disabled) => {
|
||||
if (!useMentionTextInput) {
|
||||
return (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TextInputWithMentions
|
||||
initialValue={value}
|
||||
onChange={(newValue) => onChange(newValue)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ArrayPieceProperty.displayName = 'ArrayPieceProperty';
|
||||
export { ArrayPieceProperty };
|
||||
@@ -0,0 +1,341 @@
|
||||
import { t } from 'i18next';
|
||||
import { Calendar, SquareFunction, File } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { ControllerRenderProps, useFormContext } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FormItem, FormLabel } from '@/components/ui/form';
|
||||
import { ReadMoreDescription } from '@/components/ui/read-more-description';
|
||||
import { Toggle } from '@/components/ui/toggle';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { formUtils } from '@/features/pieces/lib/form-utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
PieceAuthProperty,
|
||||
PieceProperty,
|
||||
PropertyType,
|
||||
} from '@activepieces/pieces-framework';
|
||||
import {
|
||||
FlowAction,
|
||||
FlowTrigger,
|
||||
PropertyExecutionType,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { ArrayPiecePropertyInInlineItemMode } from './array-property-in-inline-item-mode';
|
||||
import { TextInputWithMentions } from './text-input-with-mentions';
|
||||
|
||||
function AutoFormFieldWrapper({
|
||||
placeBeforeLabelText = false,
|
||||
children,
|
||||
allowDynamicValues,
|
||||
propertyName,
|
||||
inputName,
|
||||
property,
|
||||
disabled,
|
||||
field,
|
||||
dynamicInputModeToggled,
|
||||
//we have to pass this prop, because props inside custom auth can be secret text, which means their labels will become (Connection)
|
||||
isForConnectionSelect = false,
|
||||
}: AutoFormFieldWrapperProps) {
|
||||
const isArrayProperty =
|
||||
!isPieceAuthProperty(property) && property.type === PropertyType.ARRAY;
|
||||
const isAuthProperty = isForConnectionSelect || Array.isArray(property);
|
||||
return (
|
||||
<AutoFormFielWrapperErrorBoundary
|
||||
field={field}
|
||||
property={property ?? null}
|
||||
dynamicInputModeToggled={dynamicInputModeToggled}
|
||||
>
|
||||
<FormItem className="flex flex-col gap-1">
|
||||
<FormLabel className="flex items-center gap-1 ">
|
||||
{placeBeforeLabelText && !dynamicInputModeToggled && children}
|
||||
<div className="pt-1">
|
||||
<span>
|
||||
{isAuthProperty ? t('Connection') : property.displayName}
|
||||
</span>{' '}
|
||||
{(isAuthProperty || property.required) && (
|
||||
<span className="text-destructive">*</span>
|
||||
)}
|
||||
</div>
|
||||
{property && !isAuthProperty && (
|
||||
<PropertyTypeTooltip property={property} />
|
||||
)}
|
||||
<span className="grow"></span>
|
||||
{allowDynamicValues && (
|
||||
<DynamicValueToggle
|
||||
propertyName={propertyName}
|
||||
inputName={inputName}
|
||||
property={property}
|
||||
disabled={disabled}
|
||||
isToggled={dynamicInputModeToggled ?? false}
|
||||
/>
|
||||
)}
|
||||
</FormLabel>
|
||||
|
||||
{dynamicInputModeToggled && !isArrayProperty && (
|
||||
<TextInputWithMentions
|
||||
disabled={disabled}
|
||||
onChange={field.onChange}
|
||||
initialValue={field.value ?? null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isArrayProperty && dynamicInputModeToggled && (
|
||||
<ArrayPiecePropertyInInlineItemMode
|
||||
disabled={disabled}
|
||||
arrayProperties={property.properties}
|
||||
inputName={inputName}
|
||||
onChange={field.onChange}
|
||||
value={field.value ?? null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!placeBeforeLabelText && !dynamicInputModeToggled && (
|
||||
<div>{children}</div>
|
||||
)}
|
||||
|
||||
{!isForConnectionSelect &&
|
||||
!Array.isArray(property) &&
|
||||
property.description && (
|
||||
<ReadMoreDescription text={t(property.description)} />
|
||||
)}
|
||||
</FormItem>
|
||||
</AutoFormFielWrapperErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function AutoFormFielWrapperErrorBoundary({
|
||||
children,
|
||||
field,
|
||||
property,
|
||||
dynamicInputModeToggled,
|
||||
}: AutoFormFielWrapperErrorBoundaryProps) {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallbackRender={() => (
|
||||
<div className="text-sm flex items-center justify-between">
|
||||
<div className="text-red-500">
|
||||
{t('input value is invalid, please contact support')}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
JSON.stringify({
|
||||
stringifiedValue: stringifyValue(field.value),
|
||||
property,
|
||||
dynamicInputModeToggled,
|
||||
disabled: field.disabled,
|
||||
}),
|
||||
);
|
||||
toast(t('Info copied to clipboard, please send it to support'), {
|
||||
duration: 3000,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('Info')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function getValueForInputOnDynamicToggleChange(
|
||||
property: PieceProperty | PieceAuthProperty[],
|
||||
newMode: PropertyExecutionType,
|
||||
currentValue: unknown,
|
||||
) {
|
||||
const isAuthProperty = isPieceAuthProperty(property);
|
||||
switch (newMode) {
|
||||
case PropertyExecutionType.DYNAMIC: {
|
||||
if (!isAuthProperty && property.type === PropertyType.ARRAY) {
|
||||
return formUtils.getDefaultPropertyValue({
|
||||
property,
|
||||
dynamicInputModeToggled: true,
|
||||
});
|
||||
}
|
||||
//to show what the selected value is for dropdowns
|
||||
if (
|
||||
typeof currentValue === 'string' ||
|
||||
typeof currentValue === 'number'
|
||||
) {
|
||||
return currentValue;
|
||||
}
|
||||
return JSON.stringify(currentValue);
|
||||
}
|
||||
case PropertyExecutionType.MANUAL:
|
||||
if (isAuthProperty) {
|
||||
return '';
|
||||
}
|
||||
return formUtils.getDefaultPropertyValue({
|
||||
property,
|
||||
dynamicInputModeToggled: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function DynamicValueToggle({
|
||||
propertyName,
|
||||
inputName,
|
||||
property,
|
||||
disabled,
|
||||
isToggled,
|
||||
}: DynamicValueToggleProps) {
|
||||
const form = useFormContext<FlowAction | FlowTrigger>();
|
||||
function updatePropertySettings(mode: PropertyExecutionType) {
|
||||
const propertySettingsForSingleProperty = {
|
||||
...form.getValues().settings?.propertySettings?.[propertyName],
|
||||
type: mode,
|
||||
};
|
||||
form.setValue(
|
||||
`settings.propertySettings.${propertyName}`,
|
||||
propertySettingsForSingleProperty,
|
||||
);
|
||||
}
|
||||
function handleDynamicValueToggleChange(mode: PropertyExecutionType) {
|
||||
updatePropertySettings(mode);
|
||||
if (isInputNameLiteral(inputName)) {
|
||||
const currentValue = form.getValues(inputName);
|
||||
const newValue = getValueForInputOnDynamicToggleChange(
|
||||
property,
|
||||
mode,
|
||||
currentValue,
|
||||
);
|
||||
form.setValue(inputName, newValue, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
} else {
|
||||
throw new Error(
|
||||
'inputName is not a member of step settings input, you might be using dynamic properties where you should not',
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="flex gap-2 items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Toggle
|
||||
pressed={isToggled}
|
||||
onPressedChange={(newIsToggled) =>
|
||||
handleDynamicValueToggleChange(
|
||||
newIsToggled
|
||||
? PropertyExecutionType.DYNAMIC
|
||||
: PropertyExecutionType.MANUAL,
|
||||
)
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SquareFunction
|
||||
className={cn('size-5', {
|
||||
'text-foreground': isToggled,
|
||||
'text-muted-foreground': !isToggled,
|
||||
})}
|
||||
/>
|
||||
</Toggle>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{t('Dynamic value')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function PropertyTypeTooltip({ property }: { property: PieceProperty }) {
|
||||
if (
|
||||
property.type !== PropertyType.FILE &&
|
||||
property.type !== PropertyType.DATE_TIME
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{property.type === PropertyType.FILE ? (
|
||||
<File className="w-4 h-4 stroke-foreground/55"></File>
|
||||
) : (
|
||||
property.type === PropertyType.DATE_TIME && (
|
||||
<Calendar className="w-4 h-4 stroke-foreground/55"></Calendar>
|
||||
)
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<>
|
||||
{property.type === PropertyType.FILE &&
|
||||
t('File Input i.e a url or file passed from a previous step')}
|
||||
{property.type === PropertyType.DATE_TIME &&
|
||||
t('Date Input must comply with ISO 8601 format')}
|
||||
</>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
function stringifyValue(value: unknown) {
|
||||
try {
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
} catch (e) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
AutoFormFieldWrapper.displayName = 'AutoFormFieldWrapper';
|
||||
|
||||
export { AutoFormFieldWrapper };
|
||||
|
||||
type DynamicValueToggleProps = {
|
||||
propertyName: string;
|
||||
inputName: string;
|
||||
property: PieceProperty | PieceAuthProperty[];
|
||||
disabled: boolean;
|
||||
isToggled: boolean;
|
||||
};
|
||||
|
||||
type AutoFormFieldWrapperProps = {
|
||||
children: React.ReactNode;
|
||||
allowDynamicValues: boolean;
|
||||
propertyName: string;
|
||||
hideDescription?: boolean;
|
||||
placeBeforeLabelText?: boolean;
|
||||
disabled: boolean;
|
||||
field: ControllerRenderProps<any, string>;
|
||||
inputName: string;
|
||||
dynamicInputModeToggled?: boolean;
|
||||
property: PieceProperty | PieceAuthProperty[];
|
||||
isForConnectionSelect?: boolean;
|
||||
};
|
||||
type AutoFormFielWrapperErrorBoundaryProps = {
|
||||
children: React.ReactNode;
|
||||
field: ControllerRenderProps;
|
||||
property: PieceProperty | PieceAuthProperty[] | null;
|
||||
dynamicInputModeToggled?: boolean;
|
||||
};
|
||||
function isInputNameLiteral(
|
||||
inputName: string,
|
||||
): inputName is `settings.input.${string}` {
|
||||
return inputName.match(/settings\.input\./) !== null;
|
||||
}
|
||||
function isPieceAuthProperty(
|
||||
property: PieceProperty | PieceAuthProperty[],
|
||||
): property is PieceAuthProperty[] {
|
||||
const authPropertyTypes = [
|
||||
PropertyType.SECRET_TEXT,
|
||||
PropertyType.BASIC_AUTH,
|
||||
PropertyType.OAUTH2,
|
||||
PropertyType.CUSTOM_AUTH,
|
||||
];
|
||||
return (
|
||||
Array.isArray(property) ||
|
||||
authPropertyTypes.some((authType) => property.type === authType)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import { t } from 'i18next';
|
||||
import React from 'react';
|
||||
import { ControllerRenderProps, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { JsonEditor } from '@/components/custom/json-editor';
|
||||
import { ApMarkdown } from '@/components/custom/markdown';
|
||||
import { SearchableSelect } from '@/components/custom/searchable-select';
|
||||
import { ColorPicker } from '@/components/ui/color-picker';
|
||||
import { FormControl, FormField } from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { AgentTools } from '@/features/agents/agent-tools';
|
||||
import { AgentStructuredOutput } from '@/features/agents/structured-output';
|
||||
import {
|
||||
OAuth2Props,
|
||||
PieceProperty,
|
||||
PiecePropertyMap,
|
||||
PropertyType,
|
||||
ArraySubProps,
|
||||
} from '@activepieces/pieces-framework';
|
||||
import {
|
||||
AgentPieceProps,
|
||||
FlowActionType,
|
||||
FlowTriggerType,
|
||||
isNil,
|
||||
PropertyExecutionType,
|
||||
Step,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { MultiSelectPieceProperty } from '../../../components/custom/multi-select-piece-property';
|
||||
|
||||
import { ArrayPieceProperty } from './array-property';
|
||||
import { AutoFormFieldWrapper } from './auto-form-field-wrapper';
|
||||
import { BuilderJsonEditorWrapper } from './builder-json-wrapper';
|
||||
import CustomProperty from './custom-property';
|
||||
import { DictionaryProperty } from './dictionary-property';
|
||||
import { DynamicDropdownPieceProperty } from './dynamic-dropdown-piece-property';
|
||||
import { DynamicProperties } from './dynamic-piece-property';
|
||||
import { TextInputWithMentions } from './text-input-with-mentions';
|
||||
|
||||
type AutoFormProps = {
|
||||
props: PiecePropertyMap | OAuth2Props | ArraySubProps<boolean>;
|
||||
allowDynamicValues: boolean;
|
||||
prefixValue: string;
|
||||
markdownVariables?: Record<string, string>;
|
||||
useMentionTextInput: boolean;
|
||||
disabled?: boolean;
|
||||
onValueChange?: (val: { value: unknown; propertyName: string }) => void;
|
||||
};
|
||||
|
||||
const AutoPropertiesFormComponent = React.memo(
|
||||
({
|
||||
markdownVariables,
|
||||
props,
|
||||
allowDynamicValues,
|
||||
prefixValue,
|
||||
disabled,
|
||||
useMentionTextInput,
|
||||
onValueChange,
|
||||
}: AutoFormProps) => {
|
||||
const form = useFormContext();
|
||||
const step = form.getValues() as Step;
|
||||
|
||||
return (
|
||||
Object.keys(props).length > 0 && (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{Object.entries(props).map(([propertyName]) => {
|
||||
const isPieceStep =
|
||||
step.type === FlowActionType.PIECE ||
|
||||
step.type === FlowTriggerType.PIECE;
|
||||
const dynamicInputModeToggled = isPieceStep
|
||||
? step.settings.propertySettings[propertyName]?.type ===
|
||||
PropertyExecutionType.DYNAMIC
|
||||
: false;
|
||||
return (
|
||||
<FormField
|
||||
key={propertyName}
|
||||
name={`${prefixValue}.${propertyName}`}
|
||||
control={form.control}
|
||||
render={({ field }) =>
|
||||
selectFormComponentForProperty({
|
||||
field: {
|
||||
...field,
|
||||
onChange: (value) => {
|
||||
field.onChange(value);
|
||||
//must come after because the form value won't be updated yet otherwise
|
||||
onValueChange?.({
|
||||
value,
|
||||
propertyName,
|
||||
});
|
||||
},
|
||||
},
|
||||
propertyName,
|
||||
inputName: `${prefixValue}.${propertyName}`,
|
||||
property: props[propertyName],
|
||||
allowDynamicValues,
|
||||
markdownVariables: markdownVariables ?? {},
|
||||
useMentionTextInput: useMentionTextInput,
|
||||
disabled: disabled ?? false,
|
||||
dynamicInputModeToggled,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
type selectFormComponentForPropertyParams = {
|
||||
field: ControllerRenderProps<Record<string, any>, string>;
|
||||
propertyName: string;
|
||||
inputName: string;
|
||||
property: PieceProperty;
|
||||
allowDynamicValues: boolean;
|
||||
markdownVariables: Record<string, string>;
|
||||
useMentionTextInput: boolean;
|
||||
disabled: boolean;
|
||||
dynamicInputModeToggled: boolean;
|
||||
};
|
||||
|
||||
export const selectFormComponentForProperty = ({
|
||||
field,
|
||||
propertyName,
|
||||
inputName,
|
||||
property,
|
||||
allowDynamicValues,
|
||||
markdownVariables,
|
||||
useMentionTextInput,
|
||||
disabled,
|
||||
dynamicInputModeToggled,
|
||||
}: selectFormComponentForPropertyParams) => {
|
||||
if (propertyName === AgentPieceProps.AGENT_TOOLS) {
|
||||
return <AgentTools disabled={disabled} agentToolsField={field} />;
|
||||
} else if (propertyName === AgentPieceProps.STRUCTURED_OUTPUT) {
|
||||
return (
|
||||
<AgentStructuredOutput
|
||||
disabled={disabled}
|
||||
structuredOutputField={field}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
switch (property.type) {
|
||||
case PropertyType.ARRAY:
|
||||
return (
|
||||
<AutoFormFieldWrapper
|
||||
property={property}
|
||||
propertyName={propertyName}
|
||||
field={field}
|
||||
disabled={disabled}
|
||||
inputName={inputName}
|
||||
allowDynamicValues={allowDynamicValues}
|
||||
dynamicInputModeToggled={dynamicInputModeToggled}
|
||||
>
|
||||
<ArrayPieceProperty
|
||||
disabled={disabled}
|
||||
arrayProperty={property}
|
||||
inputName={inputName}
|
||||
useMentionTextInput={useMentionTextInput}
|
||||
></ArrayPieceProperty>
|
||||
</AutoFormFieldWrapper>
|
||||
);
|
||||
case PropertyType.OBJECT:
|
||||
return (
|
||||
<AutoFormFieldWrapper
|
||||
property={property}
|
||||
propertyName={propertyName}
|
||||
field={field}
|
||||
inputName={inputName}
|
||||
disabled={disabled}
|
||||
allowDynamicValues={allowDynamicValues}
|
||||
dynamicInputModeToggled={dynamicInputModeToggled}
|
||||
>
|
||||
<DictionaryProperty
|
||||
disabled={disabled}
|
||||
values={field.value}
|
||||
onChange={field.onChange}
|
||||
useMentionTextInput={useMentionTextInput}
|
||||
></DictionaryProperty>
|
||||
</AutoFormFieldWrapper>
|
||||
);
|
||||
case PropertyType.CHECKBOX:
|
||||
return (
|
||||
<AutoFormFieldWrapper
|
||||
property={property}
|
||||
propertyName={propertyName}
|
||||
disabled={disabled}
|
||||
field={field}
|
||||
inputName={inputName}
|
||||
allowDynamicValues={allowDynamicValues}
|
||||
placeBeforeLabelText={true}
|
||||
dynamicInputModeToggled={dynamicInputModeToggled}
|
||||
>
|
||||
<FormControl>
|
||||
<Switch
|
||||
id={propertyName}
|
||||
checked={field.value}
|
||||
disabled={disabled}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</AutoFormFieldWrapper>
|
||||
);
|
||||
case PropertyType.MARKDOWN:
|
||||
return (
|
||||
<ApMarkdown
|
||||
markdown={property.description}
|
||||
variables={markdownVariables}
|
||||
variant={property.variant}
|
||||
/>
|
||||
);
|
||||
case PropertyType.STATIC_DROPDOWN:
|
||||
return (
|
||||
<AutoFormFieldWrapper
|
||||
property={property}
|
||||
propertyName={propertyName}
|
||||
inputName={inputName}
|
||||
field={field}
|
||||
disabled={disabled}
|
||||
allowDynamicValues={allowDynamicValues}
|
||||
dynamicInputModeToggled={dynamicInputModeToggled}
|
||||
>
|
||||
<SearchableSelect
|
||||
options={property.options.options}
|
||||
onChange={field.onChange}
|
||||
value={field.value}
|
||||
disabled={disabled}
|
||||
placeholder={property.options.placeholder ?? t('Select an option')}
|
||||
showDeselect={!property.required}
|
||||
></SearchableSelect>
|
||||
</AutoFormFieldWrapper>
|
||||
);
|
||||
case PropertyType.JSON:
|
||||
return (
|
||||
<AutoFormFieldWrapper
|
||||
propertyName={propertyName}
|
||||
inputName={inputName}
|
||||
property={property}
|
||||
field={field}
|
||||
disabled={disabled}
|
||||
allowDynamicValues={allowDynamicValues}
|
||||
dynamicInputModeToggled={dynamicInputModeToggled}
|
||||
>
|
||||
{useMentionTextInput ? (
|
||||
<BuilderJsonEditorWrapper
|
||||
field={field}
|
||||
disabled={disabled}
|
||||
></BuilderJsonEditorWrapper>
|
||||
) : (
|
||||
<JsonEditor field={field} readonly={disabled}></JsonEditor>
|
||||
)}
|
||||
</AutoFormFieldWrapper>
|
||||
);
|
||||
case PropertyType.STATIC_MULTI_SELECT_DROPDOWN:
|
||||
return (
|
||||
<AutoFormFieldWrapper
|
||||
property={property}
|
||||
inputName={inputName}
|
||||
propertyName={propertyName}
|
||||
field={field}
|
||||
disabled={disabled}
|
||||
allowDynamicValues={allowDynamicValues}
|
||||
dynamicInputModeToggled={dynamicInputModeToggled}
|
||||
>
|
||||
<MultiSelectPieceProperty
|
||||
placeholder={property.options.placeholder ?? t('Select an option')}
|
||||
options={property.options.options}
|
||||
onChange={field.onChange}
|
||||
initialValues={field.value}
|
||||
disabled={disabled}
|
||||
showDeselect={
|
||||
!isNil(field.value) &&
|
||||
field.value.length > 0 &&
|
||||
!property.required
|
||||
}
|
||||
></MultiSelectPieceProperty>
|
||||
</AutoFormFieldWrapper>
|
||||
);
|
||||
case PropertyType.MULTI_SELECT_DROPDOWN:
|
||||
case PropertyType.DROPDOWN:
|
||||
return (
|
||||
<AutoFormFieldWrapper
|
||||
inputName={inputName}
|
||||
property={property}
|
||||
propertyName={propertyName}
|
||||
field={field}
|
||||
disabled={disabled}
|
||||
allowDynamicValues={allowDynamicValues}
|
||||
dynamicInputModeToggled={dynamicInputModeToggled}
|
||||
>
|
||||
<DynamicDropdownPieceProperty
|
||||
refreshers={property.refreshers}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
disabled={disabled}
|
||||
propertyName={propertyName}
|
||||
multiple={property.type === PropertyType.MULTI_SELECT_DROPDOWN}
|
||||
showDeselect={!property.required}
|
||||
shouldRefreshOnSearch={property.refreshOnSearch ?? false}
|
||||
></DynamicDropdownPieceProperty>
|
||||
</AutoFormFieldWrapper>
|
||||
);
|
||||
case PropertyType.DATE_TIME:
|
||||
case PropertyType.SHORT_TEXT:
|
||||
case PropertyType.LONG_TEXT:
|
||||
case PropertyType.FILE:
|
||||
case PropertyType.NUMBER:
|
||||
case PropertyType.SECRET_TEXT:
|
||||
return (
|
||||
<AutoFormFieldWrapper
|
||||
property={property}
|
||||
inputName={inputName}
|
||||
field={field}
|
||||
propertyName={propertyName}
|
||||
disabled={disabled}
|
||||
allowDynamicValues={false}
|
||||
dynamicInputModeToggled={dynamicInputModeToggled}
|
||||
>
|
||||
{useMentionTextInput ? (
|
||||
<TextInputWithMentions
|
||||
disabled={disabled}
|
||||
initialValue={field.value}
|
||||
onChange={field.onChange}
|
||||
></TextInputWithMentions>
|
||||
) : (
|
||||
<Input
|
||||
ref={field.ref}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
disabled={disabled}
|
||||
type={
|
||||
property.type === PropertyType.SECRET_TEXT ? 'password' : 'text'
|
||||
}
|
||||
></Input>
|
||||
)}
|
||||
</AutoFormFieldWrapper>
|
||||
);
|
||||
case PropertyType.DYNAMIC:
|
||||
return (
|
||||
<DynamicProperties
|
||||
refreshers={property.refreshers}
|
||||
propertyName={propertyName}
|
||||
disabled={disabled}
|
||||
></DynamicProperties>
|
||||
);
|
||||
case PropertyType.CUSTOM_AUTH:
|
||||
case PropertyType.BASIC_AUTH:
|
||||
case PropertyType.OAUTH2:
|
||||
return <></>;
|
||||
case PropertyType.CUSTOM:
|
||||
return (
|
||||
<CustomProperty
|
||||
code={property.code}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
disabled={disabled}
|
||||
property={property}
|
||||
></CustomProperty>
|
||||
);
|
||||
case PropertyType.COLOR:
|
||||
return (
|
||||
<AutoFormFieldWrapper
|
||||
property={property}
|
||||
inputName={inputName}
|
||||
propertyName={propertyName}
|
||||
field={field}
|
||||
disabled={disabled}
|
||||
allowDynamicValues={allowDynamicValues}
|
||||
dynamicInputModeToggled={dynamicInputModeToggled}
|
||||
>
|
||||
<ColorPicker value={field.value} onChange={field.onChange} />
|
||||
</AutoFormFieldWrapper>
|
||||
);
|
||||
}
|
||||
};
|
||||
AutoPropertiesFormComponent.displayName = 'AutoFormComponent';
|
||||
export { AutoPropertiesFormComponent };
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ControllerRenderProps } from 'react-hook-form';
|
||||
|
||||
import { useBuilderStateContext } from '@/app/builder/builder-hooks';
|
||||
import { textMentionUtils } from '@/app/builder/piece-properties/text-input-with-mentions/text-input-utils';
|
||||
import { JsonEditor } from '@/components/custom/json-editor';
|
||||
|
||||
interface BuilderJsonEditorWrapperProps {
|
||||
field: ControllerRenderProps<Record<string, any>, string>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const BuilderJsonEditorWrapper = ({
|
||||
field,
|
||||
disabled,
|
||||
}: BuilderJsonEditorWrapperProps) => {
|
||||
const [setInsertStateHandler] = useBuilderStateContext((state) => [
|
||||
state.setInsertMentionHandler,
|
||||
]);
|
||||
|
||||
return (
|
||||
<JsonEditor
|
||||
field={field}
|
||||
readonly={disabled ?? false}
|
||||
onFocus={(ref) => {
|
||||
setInsertStateHandler((propertyPath) => {
|
||||
ref.current?.view?.dispatch({
|
||||
changes: {
|
||||
from: ref.current.view.state.selection.main.head,
|
||||
insert: `{{${propertyPath}}}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
}}
|
||||
className={textMentionUtils.inputWithMentionsCssClass}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
BuilderJsonEditorWrapper.displayName = 'BuilderJsonEditorWrapper';
|
||||
export { BuilderJsonEditorWrapper };
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useId } from 'react';
|
||||
|
||||
import { useEmbedding } from '@/components/embed-provider';
|
||||
import { projectHooks } from '@/hooks/project-hooks';
|
||||
import { CustomProperty as CustomPropertyType } from '@activepieces/pieces-framework';
|
||||
const CUSTOM_PROPERTY_CONTAINER_ID = 'custom-property-container';
|
||||
|
||||
type CustomPropertyParams = {
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
code: string;
|
||||
disabled: boolean;
|
||||
property: CustomPropertyType<boolean>;
|
||||
};
|
||||
|
||||
const parseFunctionString = (code: string) => {
|
||||
return new Function(
|
||||
'params',
|
||||
`
|
||||
return (${code})(params);
|
||||
`,
|
||||
);
|
||||
};
|
||||
const CustomProperty = ({
|
||||
value,
|
||||
onChange,
|
||||
code,
|
||||
disabled,
|
||||
property,
|
||||
}: CustomPropertyParams) => {
|
||||
const { project } = projectHooks.useCurrentProject();
|
||||
const { embedState } = useEmbedding();
|
||||
const id = useId();
|
||||
const containerId = CUSTOM_PROPERTY_CONTAINER_ID + '-' + id;
|
||||
useEffect(() => {
|
||||
try {
|
||||
const params = {
|
||||
containerId,
|
||||
value,
|
||||
onChange,
|
||||
isEmbedded: embedState.isEmbedded,
|
||||
projectId: project.id,
|
||||
disabled,
|
||||
property,
|
||||
};
|
||||
// Create function that takes a params object
|
||||
const fn = parseFunctionString(code);
|
||||
// Execute the function with args as the params object
|
||||
const cleanUpFunction = fn(params);
|
||||
if (cleanUpFunction && typeof cleanUpFunction === 'function') {
|
||||
return cleanUpFunction;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error executing custom code:', error);
|
||||
}
|
||||
}, []);
|
||||
return <div id={containerId}></div>;
|
||||
};
|
||||
|
||||
CustomProperty.displayName = 'CustomProperty';
|
||||
export default CustomProperty;
|
||||
@@ -0,0 +1,155 @@
|
||||
import { t } from 'i18next';
|
||||
import { Plus, TrashIcon } from 'lucide-react';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { TextWithIcon } from '@/components/ui/text-with-icon';
|
||||
|
||||
import { TextInputWithMentions } from './text-input-with-mentions';
|
||||
|
||||
type DictionaryInputItem = {
|
||||
key: string;
|
||||
value: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
type DictionaryInputProps = {
|
||||
values: Record<string, string> | undefined;
|
||||
onChange: (values: Record<string, string>) => void;
|
||||
disabled?: boolean;
|
||||
useMentionTextInput?: boolean;
|
||||
};
|
||||
|
||||
export const DictionaryProperty = ({
|
||||
values,
|
||||
onChange,
|
||||
disabled,
|
||||
useMentionTextInput,
|
||||
}: DictionaryInputProps) => {
|
||||
const id = useRef(1);
|
||||
const valuesArray = Object.entries(values ?? {}).map((el) => {
|
||||
id.current++;
|
||||
return {
|
||||
key: el[0],
|
||||
value: el[1],
|
||||
id: `${id.current}`,
|
||||
};
|
||||
});
|
||||
const valuesArrayRef = useRef(valuesArray);
|
||||
// To allow keys that have the same prefix to be added in any order
|
||||
const valuesArrayRefUnique = valuesArrayRef.current
|
||||
.toReversed()
|
||||
.filter(
|
||||
(el, index, self) => self.findIndex((t) => t.key === el.key) === index,
|
||||
)
|
||||
.toReversed();
|
||||
const haveValuesChangedFromOutside =
|
||||
valuesArrayRefUnique.length !== valuesArray.length ||
|
||||
valuesArray.reduce((acc, _, index) => {
|
||||
return (
|
||||
acc ||
|
||||
valuesArrayRefUnique[index].key !== valuesArray[index].key ||
|
||||
valuesArrayRefUnique[index].value !== valuesArray[index].value
|
||||
);
|
||||
}, false);
|
||||
|
||||
if (haveValuesChangedFromOutside) {
|
||||
valuesArrayRef.current = valuesArray;
|
||||
}
|
||||
|
||||
const remove = (index: number) => {
|
||||
const newValues = valuesArrayRef.current.filter((_, i) => i !== index);
|
||||
valuesArrayRef.current = newValues;
|
||||
updateValue(newValues);
|
||||
};
|
||||
const add = () => {
|
||||
id.current++;
|
||||
const newValues = [
|
||||
...valuesArrayRef.current,
|
||||
{ key: '', value: '', id: `${id.current}` },
|
||||
];
|
||||
valuesArrayRef.current = newValues;
|
||||
updateValue(newValues);
|
||||
};
|
||||
|
||||
const onChangeValue = (
|
||||
index: number,
|
||||
value: string | undefined,
|
||||
key: string | undefined,
|
||||
) => {
|
||||
const newValues = [...valuesArrayRef.current];
|
||||
if (value !== undefined) {
|
||||
newValues[index].value = value;
|
||||
}
|
||||
if (key !== undefined) {
|
||||
newValues[index].key = key;
|
||||
}
|
||||
valuesArrayRef.current = newValues;
|
||||
updateValue(newValues);
|
||||
};
|
||||
|
||||
const updateValue = (items: DictionaryInputItem[]) => {
|
||||
onChange(
|
||||
items.reduce((acc, current) => {
|
||||
return { ...acc, [current.key]: current.value };
|
||||
}, {}),
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{valuesArrayRef.current.map(({ key, value, id }, index) => (
|
||||
<div
|
||||
key={'dictionary-input-' + id}
|
||||
className="flex items-center gap-3 items-center"
|
||||
>
|
||||
<Input
|
||||
value={key}
|
||||
disabled={disabled}
|
||||
className="basis-[50%] h-full max-w-[50%] h-[38px]"
|
||||
onChange={(e) => onChangeValue(index, undefined, e.target.value)}
|
||||
/>
|
||||
<div className="basis-[50%] max-w-[50%]">
|
||||
{useMentionTextInput ? (
|
||||
<TextInputWithMentions
|
||||
initialValue={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChangeValue(index, e, undefined)}
|
||||
></TextInputWithMentions>
|
||||
) : (
|
||||
<Input
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
className="h-full"
|
||||
onChange={(e) =>
|
||||
onChangeValue(index, e.target.value, undefined)
|
||||
}
|
||||
></Input>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-8 shrink-0"
|
||||
disabled={disabled}
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<TrashIcon className="size-4 text-destructive" aria-hidden="true" />
|
||||
<span className="sr-only">{t('Remove')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={add}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
>
|
||||
<TextWithIcon icon={<Plus size={18} />} text={t('Add Item')} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
import deepEqual from 'deep-equal';
|
||||
import { t } from 'i18next';
|
||||
import React, { useContext, useEffect, useRef, useState } from 'react';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { useBuilderStateContext } from '@/app/builder/builder-hooks';
|
||||
import { SearchableSelect } from '@/components/custom/searchable-select';
|
||||
import { piecesHooks } from '@/features/pieces/lib/pieces-hooks';
|
||||
import { DropdownState, PropertyType } from '@activepieces/pieces-framework';
|
||||
import { FlowAction, isNil, FlowTrigger } from '@activepieces/shared';
|
||||
|
||||
import { MultiSelectPieceProperty } from '../../../components/custom/multi-select-piece-property';
|
||||
|
||||
import { DynamicPropertiesErrorBoundary } from './dynamic-piece-properties-error-boundary';
|
||||
import { DynamicPropertiesContext } from './dynamic-properties-context';
|
||||
|
||||
type SelectPiecePropertyProps = {
|
||||
refreshers: string[];
|
||||
propertyName: string;
|
||||
value?: unknown;
|
||||
multiple?: boolean;
|
||||
disabled: boolean;
|
||||
onChange: (value: unknown | undefined) => void;
|
||||
showDeselect?: boolean;
|
||||
shouldRefreshOnSearch?: boolean;
|
||||
};
|
||||
const DynamicDropdownPiecePropertyImplementation = React.memo(
|
||||
(props: SelectPiecePropertyProps) => {
|
||||
const [flowVersion, readonly] = useBuilderStateContext((state) => [
|
||||
state.flowVersion,
|
||||
state.readonly,
|
||||
]);
|
||||
const form = useFormContext<FlowAction | FlowTrigger>();
|
||||
const isFirstRender = useRef(true);
|
||||
const previousValues = useRef<undefined | unknown[]>(undefined);
|
||||
const firstDropdownState = useRef<DropdownState<unknown> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const newRefreshers = [...props.refreshers, 'auth'];
|
||||
const [dropdownState, setDropdownState] = useState<DropdownState<unknown>>({
|
||||
disabled: false,
|
||||
placeholder: t('Select an option'),
|
||||
options: [],
|
||||
});
|
||||
const { propertyLoadingFinished, propertyLoadingStarted } = useContext(
|
||||
DynamicPropertiesContext,
|
||||
);
|
||||
const { mutate, isPending, error } = piecesHooks.usePieceOptions<
|
||||
PropertyType.DROPDOWN | PropertyType.MULTI_SELECT_DROPDOWN
|
||||
>({
|
||||
onMutate: () => {
|
||||
propertyLoadingStarted(props.propertyName);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
propertyLoadingFinished(props.propertyName);
|
||||
},
|
||||
onSuccess: () => {
|
||||
propertyLoadingFinished(props.propertyName);
|
||||
},
|
||||
});
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
const refresherValues = newRefreshers.map((refresher) =>
|
||||
useWatch({
|
||||
name: `settings.input.${refresher}` as const,
|
||||
control: form.control,
|
||||
}),
|
||||
);
|
||||
/* eslint-enable react-hooks/rules-of-hooks */
|
||||
const refresh = (term?: string) => {
|
||||
const input: Record<string, unknown> = {};
|
||||
newRefreshers.forEach((refresher, index) => {
|
||||
input[refresher] = refresherValues[index];
|
||||
});
|
||||
const { settings } = form.getValues();
|
||||
const actionOrTriggerName = settings.actionName ?? settings.triggerName;
|
||||
const { pieceName, pieceVersion } = settings;
|
||||
mutate(
|
||||
{
|
||||
request: {
|
||||
pieceName,
|
||||
pieceVersion,
|
||||
propertyName: props.propertyName,
|
||||
actionOrTriggerName: actionOrTriggerName,
|
||||
input,
|
||||
flowVersionId: flowVersion.id,
|
||||
flowId: flowVersion.flowId,
|
||||
searchValue: term,
|
||||
},
|
||||
propertyType: PropertyType.DROPDOWN,
|
||||
},
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
if (!firstDropdownState.current) {
|
||||
firstDropdownState.current = response.options;
|
||||
}
|
||||
setDropdownState(response.options);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isFirstRender.current &&
|
||||
!deepEqual(previousValues.current, refresherValues)
|
||||
) {
|
||||
props.onChange(null);
|
||||
}
|
||||
|
||||
previousValues.current = refresherValues;
|
||||
isFirstRender.current = false;
|
||||
refresh();
|
||||
}, refresherValues);
|
||||
|
||||
const selectOptions = dropdownState.options.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
}));
|
||||
const isDisabled = dropdownState.disabled || props.disabled;
|
||||
return props.multiple ? (
|
||||
<MultiSelectPieceProperty
|
||||
placeholder={dropdownState.placeholder ?? t('Select an option')}
|
||||
options={selectOptions}
|
||||
loading={isPending}
|
||||
onChange={(value) => props.onChange(value)}
|
||||
disabled={isDisabled}
|
||||
initialValues={props.value as unknown[]}
|
||||
showDeselect={
|
||||
props.showDeselect &&
|
||||
!isNil(props.value) &&
|
||||
Array.isArray(props.value) &&
|
||||
props.value.length > 0 &&
|
||||
!isDisabled
|
||||
}
|
||||
showRefresh={!isPending && !readonly}
|
||||
onRefresh={refresh}
|
||||
refreshOnSearch={props.shouldRefreshOnSearch ? refresh : undefined}
|
||||
cachedOptions={firstDropdownState.current?.options ?? []}
|
||||
/>
|
||||
) : (
|
||||
<SearchableSelect
|
||||
options={selectOptions}
|
||||
disabled={dropdownState.disabled || props.disabled}
|
||||
loading={isPending}
|
||||
placeholder={dropdownState.placeholder ?? t('Select an option')}
|
||||
value={props.value}
|
||||
onChange={(value) => props.onChange(value)}
|
||||
showDeselect={
|
||||
props.showDeselect && !isNil(props.value) && !props.disabled
|
||||
}
|
||||
onRefresh={refresh}
|
||||
showRefresh={!isPending && !readonly}
|
||||
refreshOnSearch={props.shouldRefreshOnSearch ? refresh : undefined}
|
||||
cachedOptions={firstDropdownState.current?.options ?? []}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const DynamicDropdownPieceProperty = React.memo(
|
||||
(props: SelectPiecePropertyProps) => {
|
||||
return (
|
||||
<DynamicPropertiesErrorBoundary>
|
||||
<DynamicDropdownPiecePropertyImplementation {...props} />
|
||||
</DynamicPropertiesErrorBoundary>
|
||||
);
|
||||
},
|
||||
);
|
||||
DynamicDropdownPieceProperty.displayName = 'DynamicDropdownPieceProperty';
|
||||
DynamicDropdownPiecePropertyImplementation.displayName =
|
||||
'DynamicDropdownPiecePropertyImplementation';
|
||||
export { DynamicDropdownPieceProperty };
|
||||
@@ -0,0 +1,54 @@
|
||||
import { t } from 'i18next';
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const DynamicPropertiesErrorBoundary = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const [key, setKey] = useState(Date.now());
|
||||
const triedRerenderingRef = useRef(false);
|
||||
return (
|
||||
<ErrorBoundary
|
||||
key={key}
|
||||
fallback={
|
||||
!triedRerenderingRef.current ? (
|
||||
<div className="text-sm text-red-500 italic flex justify-between items-center">
|
||||
{t('Unexpected error, please retry')}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setKey(Date.now());
|
||||
triedRerenderingRef.current = true;
|
||||
}}
|
||||
>
|
||||
{<RefreshCcw className="w-4 h-4 text-foreground!"></RefreshCcw>}{' '}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-red-500 italic flex justify-between items-center">
|
||||
{t('Unexpected error, please refresh the page or contact support')}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
{<RefreshCcw className="w-4 h-4 text-foreground!"></RefreshCcw>}{' '}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
DynamicPropertiesErrorBoundary.displayName = 'DynamicPropertiesErrorBoundary';
|
||||
export { DynamicPropertiesErrorBoundary };
|
||||
@@ -0,0 +1,204 @@
|
||||
import deepEqual from 'deep-equal';
|
||||
import React, { useState, useRef, useContext } from 'react';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { useDeepCompareEffectNoCheck } from 'use-deep-compare-effect';
|
||||
|
||||
import { useBuilderStateContext } from '@/app/builder/builder-hooks';
|
||||
import { SkeletonList } from '@/components/ui/skeleton';
|
||||
import { formUtils } from '@/features/pieces/lib/form-utils';
|
||||
import { piecesHooks } from '@/features/pieces/lib/pieces-hooks';
|
||||
import { PiecePropertyMap, PropertyType } from '@activepieces/pieces-framework';
|
||||
import {
|
||||
FlowAction,
|
||||
FlowTrigger,
|
||||
PropertyExecutionType,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { useStepSettingsContext } from '../step-settings/step-settings-context';
|
||||
|
||||
import { AutoPropertiesFormComponent } from './auto-properties-form';
|
||||
import { DynamicPropertiesErrorBoundary } from './dynamic-piece-properties-error-boundary';
|
||||
import { DynamicPropertiesContext } from './dynamic-properties-context';
|
||||
type DynamicPropertiesProps = {
|
||||
refreshers: string[];
|
||||
propertyName: string;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
const removeOptionsFromDropdownPropertiesSchema = (
|
||||
schema: PiecePropertyMap,
|
||||
) => {
|
||||
return Object.fromEntries(
|
||||
Object.entries(schema).map(([key, value]) => {
|
||||
if (
|
||||
value.type === PropertyType.STATIC_DROPDOWN ||
|
||||
value.type === PropertyType.STATIC_MULTI_SELECT_DROPDOWN
|
||||
) {
|
||||
return [key, { ...value, options: { disabled: false, options: [] } }];
|
||||
}
|
||||
return [key, value];
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const DynamicPropertiesImplementation = React.memo(
|
||||
(props: DynamicPropertiesProps) => {
|
||||
const [flowVersion, readonly] = useBuilderStateContext((state) => [
|
||||
state.flowVersion,
|
||||
state.readonly,
|
||||
]);
|
||||
const form = useFormContext<FlowAction | FlowTrigger>();
|
||||
const { updateFormSchema } = useStepSettingsContext();
|
||||
const allInputValues = useWatch({
|
||||
name: `settings.input`,
|
||||
control: form.control,
|
||||
});
|
||||
const refreshersPropertiesNames = [...props.refreshers, 'auth'];
|
||||
const refresherValues = refreshersPropertiesNames.reduce<
|
||||
Record<string, unknown>
|
||||
>((acc, refresher) => {
|
||||
acc[refresher] = allInputValues[refresher];
|
||||
return acc;
|
||||
}, {});
|
||||
const previousValues = useRef<Record<string, unknown>>(refresherValues);
|
||||
const { propertyLoadingFinished, propertyLoadingStarted } = useContext(
|
||||
DynamicPropertiesContext,
|
||||
);
|
||||
const [propertyMap, setPropertyMap] = useState<
|
||||
PiecePropertyMap | undefined
|
||||
>(undefined);
|
||||
|
||||
const { mutate, isPending } =
|
||||
piecesHooks.usePieceOptions<PropertyType.DYNAMIC>({
|
||||
onMutate: () => {
|
||||
propertyLoadingStarted(props.propertyName);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
propertyLoadingFinished(props.propertyName);
|
||||
},
|
||||
onSuccess: () => {
|
||||
propertyLoadingFinished(props.propertyName);
|
||||
},
|
||||
});
|
||||
|
||||
useDeepCompareEffectNoCheck(() => {
|
||||
if (!deepEqual(previousValues.current, refresherValues)) {
|
||||
// the field state won't be cleared if you only unset the parent prop value
|
||||
if (propertyMap) {
|
||||
Object.keys(propertyMap).forEach((childPropName) => {
|
||||
form.setValue(
|
||||
`settings.input.${props.propertyName}.${childPropName}` as const,
|
||||
null,
|
||||
{
|
||||
//never validate for each prop, it can be a long list of props and cause the browser to freeze
|
||||
shouldValidate: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
form.setValue(`settings.input.${props.propertyName}` as const, null, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
|
||||
previousValues.current = refresherValues;
|
||||
const { settings } = form.getValues();
|
||||
const actionOrTriggerName = settings.actionName ?? settings.triggerName;
|
||||
const { pieceName, pieceVersion } = settings;
|
||||
mutate(
|
||||
{
|
||||
request: {
|
||||
pieceName,
|
||||
pieceVersion,
|
||||
propertyName: props.propertyName,
|
||||
actionOrTriggerName: actionOrTriggerName,
|
||||
input: refresherValues,
|
||||
flowVersionId: flowVersion.id,
|
||||
flowId: flowVersion.flowId,
|
||||
},
|
||||
propertyType: PropertyType.DYNAMIC,
|
||||
},
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
const currentValue = form.getValues(
|
||||
`settings.input.${props.propertyName}`,
|
||||
);
|
||||
const defaultValue = formUtils.getDefaultValueForProperties({
|
||||
props: response.options,
|
||||
existingInput: currentValue ?? {},
|
||||
propertySettings:
|
||||
form.getValues().settings?.propertySettings?.[
|
||||
props.propertyName
|
||||
],
|
||||
});
|
||||
setPropertyMap(response.options);
|
||||
const schemaWithoutDropdownOptions =
|
||||
removeOptionsFromDropdownPropertiesSchema(response.options);
|
||||
updateFormSchema(
|
||||
`settings.input.${props.propertyName}`,
|
||||
schemaWithoutDropdownOptions,
|
||||
);
|
||||
|
||||
if (!readonly) {
|
||||
// previously the schema didn't have this property, so we need to set it
|
||||
// we can't always set it to MANUAL, because some sub properties might be dynamic and have the same name as the dynamic property
|
||||
// which will override the sub property exectuion type
|
||||
if (
|
||||
!form.getValues().settings?.propertySettings?.[
|
||||
props.propertyName
|
||||
]
|
||||
) {
|
||||
form.setValue(
|
||||
`settings.propertySettings.${props.propertyName}.type`,
|
||||
PropertyExecutionType.MANUAL as unknown,
|
||||
);
|
||||
}
|
||||
form.setValue(
|
||||
`settings.propertySettings.${props.propertyName}.schema`,
|
||||
schemaWithoutDropdownOptions,
|
||||
);
|
||||
}
|
||||
|
||||
form.setValue(
|
||||
`settings.input.${props.propertyName}`,
|
||||
defaultValue,
|
||||
{
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [refresherValues]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isPending && (
|
||||
<SkeletonList numberOfItems={3} className="h-7"></SkeletonList>
|
||||
)}
|
||||
{!isPending && propertyMap && (
|
||||
<AutoPropertiesFormComponent
|
||||
prefixValue={`settings.input.${props.propertyName}`}
|
||||
props={propertyMap}
|
||||
useMentionTextInput={true}
|
||||
disabled={props.disabled}
|
||||
allowDynamicValues={true}
|
||||
></AutoPropertiesFormComponent>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const DynamicProperties = React.memo((props: DynamicPropertiesProps) => {
|
||||
return (
|
||||
<DynamicPropertiesErrorBoundary>
|
||||
<DynamicPropertiesImplementation {...props} />
|
||||
</DynamicPropertiesErrorBoundary>
|
||||
);
|
||||
});
|
||||
DynamicPropertiesImplementation.displayName = 'DynamicPropertiesImplementation';
|
||||
DynamicProperties.displayName = 'DynamicProperties';
|
||||
export { DynamicProperties };
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createContext, useState, useCallback, useMemo } from 'react';
|
||||
|
||||
export const DynamicPropertiesContext = createContext<{
|
||||
propertiesNamesStillLoading: string[];
|
||||
propertyLoadingFinished: (propertyName: string) => void;
|
||||
propertyLoadingStarted: (propertyName: string) => void;
|
||||
isLoadingDynamicProperties: boolean;
|
||||
}>({
|
||||
propertiesNamesStillLoading: [],
|
||||
propertyLoadingFinished: (propertyName: string) => {},
|
||||
propertyLoadingStarted: (propertyName: string) => {},
|
||||
isLoadingDynamicProperties: false,
|
||||
});
|
||||
|
||||
export const DynamicPropertiesProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const [propertiesNamesStillLoading, setPropertiesNamesStillLoading] =
|
||||
useState<string[]>([]);
|
||||
|
||||
const propertyLoadingFinished = useCallback((propertyName: string) => {
|
||||
setPropertiesNamesStillLoading((prev) =>
|
||||
prev.filter((name) => name !== propertyName),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const propertyLoadingStarted = useCallback((propertyName: string) => {
|
||||
setPropertiesNamesStillLoading((prev) => [...prev, propertyName]);
|
||||
}, []);
|
||||
|
||||
const isLoadingDynamicProperties = useMemo(
|
||||
() => propertiesNamesStillLoading.length > 0,
|
||||
[propertiesNamesStillLoading],
|
||||
);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
propertiesNamesStillLoading,
|
||||
propertyLoadingFinished,
|
||||
propertyLoadingStarted,
|
||||
isLoadingDynamicProperties,
|
||||
}),
|
||||
[
|
||||
propertiesNamesStillLoading,
|
||||
propertyLoadingFinished,
|
||||
propertyLoadingStarted,
|
||||
isLoadingDynamicProperties,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<DynamicPropertiesContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</DynamicPropertiesContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import Document from '@tiptap/extension-document';
|
||||
import HardBreak from '@tiptap/extension-hard-break';
|
||||
import History from '@tiptap/extension-history';
|
||||
import Mention, { MentionNodeAttrs } from '@tiptap/extension-mention';
|
||||
import Paragraph from '@tiptap/extension-paragraph';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import Text from '@tiptap/extension-text';
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
|
||||
import './tip-tap.css';
|
||||
import { stepsHooks } from '@/features/pieces/lib/steps-hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { flowStructureUtil, isNil } from '@activepieces/shared';
|
||||
|
||||
import { useBuilderStateContext } from '../../builder-hooks';
|
||||
|
||||
import { textMentionUtils } from './text-input-utils';
|
||||
|
||||
type TextInputWithMentionsProps = {
|
||||
className?: string;
|
||||
initialValue?: unknown;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
const extensions = (placeholder?: string) => {
|
||||
return [
|
||||
Document,
|
||||
History,
|
||||
HardBreak,
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
Paragraph.configure({
|
||||
HTMLAttributes: {},
|
||||
}),
|
||||
Text,
|
||||
Mention.configure({
|
||||
suggestion: {
|
||||
char: '',
|
||||
},
|
||||
deleteTriggerWithBackspace: true,
|
||||
renderHTML({ node }) {
|
||||
const mentionAttrs: MentionNodeAttrs =
|
||||
node.attrs as unknown as MentionNodeAttrs;
|
||||
return textMentionUtils.generateMentionHtmlElement(mentionAttrs);
|
||||
},
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
function convertToText(value: unknown): string {
|
||||
if (isNil(value)) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value.toString();
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export const TextInputWithMentions = ({
|
||||
className,
|
||||
initialValue,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder,
|
||||
}: TextInputWithMentionsProps) => {
|
||||
const steps = useBuilderStateContext((state) =>
|
||||
flowStructureUtil.getAllSteps(state.flowVersion.trigger),
|
||||
);
|
||||
const stepsMetadata = stepsHooks
|
||||
.useStepsMetadata(steps)
|
||||
.map(({ data: metadata }, index) => {
|
||||
if (metadata) {
|
||||
return {
|
||||
...metadata,
|
||||
stepDisplayName: steps[index].displayName,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const setInsertMentionHandler = useBuilderStateContext(
|
||||
(state) => state.setInsertMentionHandler,
|
||||
);
|
||||
|
||||
const insertMention = (propertyPath: string) => {
|
||||
const mentionNode = textMentionUtils.createMentionNodeFromText(
|
||||
`{{${propertyPath}}}`,
|
||||
steps,
|
||||
stepsMetadata,
|
||||
);
|
||||
editor?.chain().focus().insertContent(mentionNode).run();
|
||||
};
|
||||
const editor = useEditor({
|
||||
editable: !disabled,
|
||||
extensions: extensions(placeholder),
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: textMentionUtils.convertTextToTipTapJsonContent(
|
||||
convertToText(initialValue),
|
||||
steps,
|
||||
stepsMetadata,
|
||||
),
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: cn(
|
||||
className ??
|
||||
' w-full rounded-sm border shadow-xs border-input bg-background px-3 min-h-9 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
textMentionUtils.inputWithMentionsCssClass,
|
||||
{
|
||||
'cursor-not-allowed opacity-50': disabled,
|
||||
},
|
||||
),
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
const editorContent = editor.getJSON();
|
||||
const textResult =
|
||||
textMentionUtils.convertTiptapJsonToText(editorContent);
|
||||
if (onChange) {
|
||||
onChange(textResult);
|
||||
}
|
||||
},
|
||||
onFocus: () => {
|
||||
setInsertMentionHandler(insertMention);
|
||||
},
|
||||
});
|
||||
|
||||
return <EditorContent editor={editor} />;
|
||||
};
|
||||
@@ -0,0 +1,292 @@
|
||||
import { MentionNodeAttrs } from '@tiptap/extension-mention';
|
||||
import { JSONContent } from '@tiptap/react';
|
||||
|
||||
import { StepMetadata } from '@/lib/types';
|
||||
import {
|
||||
FlowAction,
|
||||
FlowTrigger,
|
||||
assertNotNullOrUndefined,
|
||||
isNil,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
const removeQuotes = (text: string) => {
|
||||
if (
|
||||
(text.startsWith('"') && text.endsWith('"')) ||
|
||||
(text.startsWith("'") && text.endsWith("'"))
|
||||
) {
|
||||
return text.slice(1, -1);
|
||||
}
|
||||
return text;
|
||||
};
|
||||
const incrementArrayIndexes = (text: string) => {
|
||||
const numberText = Number(text);
|
||||
if (Number.isNaN(numberText)) {
|
||||
return text;
|
||||
}
|
||||
return `${numberText + 1}`;
|
||||
};
|
||||
|
||||
const keysWithinPath = (path: string) => {
|
||||
return path
|
||||
.split(/\.|\[|\]/)
|
||||
.filter((key) => key && key.trim().length > 0)
|
||||
.map(incrementArrayIndexes)
|
||||
.map(removeQuotes);
|
||||
};
|
||||
|
||||
type ApMentionNodeAttrs = {
|
||||
logoUrl?: string;
|
||||
displayText: string;
|
||||
serverValue: string;
|
||||
};
|
||||
const flattenNestedKeysRegex = /^flattenNestedKeys\((\w+),\s*\[(.*?)\]\)$/;
|
||||
enum TipTapNodeTypes {
|
||||
paragraph = 'paragraph',
|
||||
text = 'text',
|
||||
hardBreak = 'hardBreak',
|
||||
mention = 'mention',
|
||||
}
|
||||
|
||||
const isMentionNodeText = (item: string) => {
|
||||
const itemIsToken = item.match(/^\{\{(.*)\}\}$/);
|
||||
if (itemIsToken) {
|
||||
const content = itemIsToken[1].trim();
|
||||
const itemIsFlattenedArray = content.match(flattenNestedKeysRegex);
|
||||
if (itemIsFlattenedArray) {
|
||||
return true;
|
||||
}
|
||||
return /^(step_\d+|trigger)/.test(content);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
type StepMetadataWithDisplayName = StepMetadata & { stepDisplayName: string };
|
||||
|
||||
function convertTextToTipTapJsonContent(
|
||||
userInputText: string,
|
||||
steps: (FlowAction | FlowTrigger)[],
|
||||
stepsMetadata: (StepMetadataWithDisplayName | undefined)[],
|
||||
): {
|
||||
type: TipTapNodeTypes.paragraph;
|
||||
content: JSONContent[];
|
||||
}[] {
|
||||
const inputSplitToNodesContent = userInputText
|
||||
.split(/(\{\{.*?\}\})/)
|
||||
.map((el) => el.split(new RegExp(`(\n)`)))
|
||||
.flat(1)
|
||||
.filter((el) => el);
|
||||
return inputSplitToNodesContent.reduce(
|
||||
(result, node) => {
|
||||
if (node === '\n') {
|
||||
result.push({
|
||||
type: TipTapNodeTypes.paragraph,
|
||||
content: [],
|
||||
});
|
||||
} else if (isMentionNodeText(node)) {
|
||||
result[result.length - 1].content.push(
|
||||
createMentionNodeFromText(node, steps, stepsMetadata),
|
||||
);
|
||||
} else {
|
||||
result[result.length - 1].content.push({
|
||||
type: TipTapNodeTypes.text,
|
||||
text: node,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
[
|
||||
{
|
||||
content: [],
|
||||
type: TipTapNodeTypes.paragraph,
|
||||
},
|
||||
] as {
|
||||
type: TipTapNodeTypes.paragraph;
|
||||
content: JSONContent[];
|
||||
}[],
|
||||
);
|
||||
}
|
||||
|
||||
function parseFlattenArrayPath(input: string): {
|
||||
isValid: boolean;
|
||||
stepName?: string;
|
||||
arrayPath?: string[];
|
||||
} {
|
||||
const match = input.match(flattenNestedKeysRegex);
|
||||
|
||||
if (!match) {
|
||||
return { isValid: false };
|
||||
}
|
||||
|
||||
const stepName = match[1];
|
||||
const arrayPath = match[2]
|
||||
.split(',')
|
||||
.map((item) => item.trim().replace(/['"]/g, ''));
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
stepName,
|
||||
arrayPath,
|
||||
};
|
||||
}
|
||||
|
||||
const removeIntroplationBrackets = (text: string) => {
|
||||
if (text.startsWith('{{') && text.endsWith('}}')) {
|
||||
return text.slice(2, text.length - 2).trim();
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
function parseStepAndNameFromMention(mention: string) {
|
||||
const mentionWithoutInterpolationBrackets =
|
||||
removeIntroplationBrackets(mention);
|
||||
const { isValid, stepName, arrayPath } = parseFlattenArrayPath(
|
||||
mentionWithoutInterpolationBrackets,
|
||||
);
|
||||
if (isValid) {
|
||||
return {
|
||||
stepName,
|
||||
path: arrayPath ?? [],
|
||||
};
|
||||
}
|
||||
const keys = keysWithinPath(mentionWithoutInterpolationBrackets);
|
||||
if (keys.length === 0) {
|
||||
return {
|
||||
stepName: null,
|
||||
path: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
stepName: keys[0],
|
||||
path: keys.slice(1),
|
||||
};
|
||||
}
|
||||
|
||||
function parseLabelFromMention(
|
||||
mention: string,
|
||||
steps: (FlowAction | FlowTrigger)[],
|
||||
stepsMetadata: (StepMetadataWithDisplayName | undefined)[],
|
||||
) {
|
||||
const { stepName, path } = parseStepAndNameFromMention(mention);
|
||||
const stepIdx = steps.findIndex((step) => step.name === stepName);
|
||||
if (stepIdx < 0) {
|
||||
return {
|
||||
displayText: `(Missing) ${stepName}`,
|
||||
serverValue: mention,
|
||||
logoUrl: '/src/assets/img/custom/incomplete.png',
|
||||
};
|
||||
}
|
||||
const stepMetadata = stepsMetadata[stepIdx];
|
||||
return {
|
||||
displayText: `${stepIdx + 1}. ${
|
||||
stepMetadata?.stepDisplayName ?? ''
|
||||
} ${path.join(' ')}`,
|
||||
serverValue: mention,
|
||||
logoUrl: stepMetadata?.logoUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function createMentionNodeFromText(
|
||||
mention: string,
|
||||
steps: (FlowAction | FlowTrigger)[],
|
||||
stepsMetadata: (StepMetadataWithDisplayName | undefined)[],
|
||||
) {
|
||||
return {
|
||||
type: TipTapNodeTypes.mention,
|
||||
attrs: {
|
||||
id: mention,
|
||||
label: JSON.stringify(
|
||||
parseLabelFromMention(mention, steps, stepsMetadata),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function convertTiptapJsonToText(nodes: JSONContent[]): string {
|
||||
const res = nodes.map((node, index) => {
|
||||
switch (node.type) {
|
||||
case TipTapNodeTypes.hardBreak:
|
||||
return '\n';
|
||||
case TipTapNodeTypes.text: {
|
||||
//replace with a normal space
|
||||
return node.text ? node.text.replaceAll('\u00A0', ' ') : '';
|
||||
}
|
||||
case TipTapNodeTypes.mention: {
|
||||
return node.attrs?.label
|
||||
? JSON.parse(node.attrs.label).serverValue
|
||||
: '';
|
||||
}
|
||||
case TipTapNodeTypes.paragraph: {
|
||||
return `${
|
||||
isNil(node.content) ? '' : convertTiptapJsonToText(node.content)
|
||||
}${index < nodes.length - 1 ? '\n' : ''}`;
|
||||
}
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
return res.join('');
|
||||
}
|
||||
|
||||
const generateMentionHtmlElement = (mentionAttrs: MentionNodeAttrs) => {
|
||||
const mentionElement = document.createElement('span');
|
||||
const apMentionNodeAttrs: ApMentionNodeAttrs = JSON.parse(
|
||||
mentionAttrs.label || '{}',
|
||||
);
|
||||
mentionElement.className =
|
||||
'inline-flex bg-muted/10 break-all my-1 mx-px border border-[#9e9e9e] border-solid items-center gap-2 py-1 px-2 rounded-[3px] text-muted-foreground ';
|
||||
assertNotNullOrUndefined(mentionAttrs.label, 'mentionAttrs.label');
|
||||
assertNotNullOrUndefined(mentionAttrs.id, 'mentionAttrs.id');
|
||||
assertNotNullOrUndefined(
|
||||
apMentionNodeAttrs.displayText,
|
||||
'apMentionNodeAttrs.displayText',
|
||||
);
|
||||
mentionElement.dataset.id = mentionAttrs.id;
|
||||
mentionElement.dataset.label = mentionAttrs.label;
|
||||
mentionElement.dataset.displayText = apMentionNodeAttrs.displayText;
|
||||
mentionElement.dataset.type = TipTapNodeTypes.mention;
|
||||
mentionElement.contentEditable = 'false';
|
||||
|
||||
if (apMentionNodeAttrs.logoUrl) {
|
||||
const imgElement = document.createElement('img');
|
||||
imgElement.src = apMentionNodeAttrs.logoUrl;
|
||||
imgElement.className = 'object-contain w-4 h-4';
|
||||
mentionElement.appendChild(imgElement);
|
||||
} else {
|
||||
const emptyImagePlaceHolder = document.createElement('span');
|
||||
emptyImagePlaceHolder.className = 'h-4 -mr-2';
|
||||
mentionElement.appendChild(emptyImagePlaceHolder);
|
||||
}
|
||||
|
||||
const mentiontextDiv = document.createTextNode(
|
||||
apMentionNodeAttrs.displayText,
|
||||
);
|
||||
mentionElement.setAttribute('serverValue', apMentionNodeAttrs.serverValue);
|
||||
|
||||
mentionElement.appendChild(mentiontextDiv);
|
||||
return mentionElement;
|
||||
};
|
||||
|
||||
const inputWithMentionsCssClass = 'ap-text-with-mentions';
|
||||
const dataSelectorCssClassSelector = 'ap-data-selector';
|
||||
const isDataSelectorOrChildOfDataSelector = (element: HTMLElement) => {
|
||||
return (
|
||||
element.classList.contains(dataSelectorCssClassSelector) ||
|
||||
!isNil(element.closest(`.${dataSelectorCssClassSelector}`))
|
||||
);
|
||||
};
|
||||
export const textMentionUtils = {
|
||||
convertTextToTipTapJsonContent,
|
||||
convertTiptapJsonToText: ({ content }: JSONContent) => {
|
||||
const nodes = content ?? [];
|
||||
const res =
|
||||
nodes.length === 1 && isNil(nodes[0].content)
|
||||
? ''
|
||||
: convertTiptapJsonToText(nodes);
|
||||
return res;
|
||||
},
|
||||
generateMentionHtmlElement,
|
||||
createMentionNodeFromText,
|
||||
inputWithMentionsCssClass,
|
||||
dataSelectorCssClassSelector,
|
||||
isDataSelectorOrChildOfDataSelector,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
.tiptap p.is-editor-empty:first-child::before {
|
||||
color: #adb5bd;
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { t } from 'i18next';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import ActivepiecesCreateTodoGuide from '@/assets/img/custom/ActivepiecesCreateTodoGuide.png';
|
||||
import ActivepiecesTodo from '@/assets/img/custom/ActivepiecesTodo.png';
|
||||
import ExternalChannelTodo from '@/assets/img/custom/External_Channel_Todo.png';
|
||||
import { RadioGroupList } from '@/components/custom/radio-group-list';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useNewWindow } from '@/lib/navigation-utils';
|
||||
import { PieceSelectorOperation, PieceSelectorPieceItem } from '@/lib/types';
|
||||
import { isNil, TodoType } from '@activepieces/shared';
|
||||
|
||||
import { useBuilderStateContext } from '../builder-hooks';
|
||||
|
||||
import {
|
||||
createRouterStep,
|
||||
createTodoStep,
|
||||
createWaitForApprovalStep,
|
||||
} from './custom-piece-selector-items-utils';
|
||||
import GenericActionOrTriggerItem from './generic-piece-selector-item';
|
||||
|
||||
type AddTodoStepDialogProps = {
|
||||
pieceSelectorItem: PieceSelectorPieceItem;
|
||||
operation: PieceSelectorOperation;
|
||||
hidePieceIconAndDescription: boolean;
|
||||
};
|
||||
|
||||
const AddTodoStepDialog = ({
|
||||
operation,
|
||||
pieceSelectorItem,
|
||||
hidePieceIconAndDescription,
|
||||
}: AddTodoStepDialogProps) => {
|
||||
const [todoType, setTodoType] = useState<TodoType>(TodoType.INTERNAL);
|
||||
const [hoveredTodoType, setHoveredTodoType] = useState<TodoType | null>(null);
|
||||
const [handleAddingOrUpdatingStep] = useBuilderStateContext((state) => [
|
||||
state.handleAddingOrUpdatingStep,
|
||||
]);
|
||||
|
||||
const handleAddCreateTodoAction = () => {
|
||||
const todoStepName = createTodoStep({
|
||||
pieceMetadata: pieceSelectorItem.pieceMetadata,
|
||||
operation,
|
||||
todoType,
|
||||
handleAddingOrUpdatingStep,
|
||||
});
|
||||
if (isNil(todoStepName)) {
|
||||
return;
|
||||
}
|
||||
switch (todoType) {
|
||||
case TodoType.INTERNAL: {
|
||||
createRouterStep({
|
||||
parentStepName: todoStepName,
|
||||
logoUrl: pieceSelectorItem.pieceMetadata.logoUrl,
|
||||
handleAddingOrUpdatingStep,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case TodoType.EXTERNAL: {
|
||||
const waitForApprovalStepName = createWaitForApprovalStep({
|
||||
pieceMetadata: pieceSelectorItem.pieceMetadata,
|
||||
parentStepName: todoStepName,
|
||||
handleAddingOrUpdatingStep,
|
||||
});
|
||||
if (!waitForApprovalStepName) {
|
||||
return;
|
||||
}
|
||||
createRouterStep({
|
||||
parentStepName: waitForApprovalStepName,
|
||||
logoUrl: pieceSelectorItem.pieceMetadata.logoUrl,
|
||||
handleAddingOrUpdatingStep,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<GenericActionOrTriggerItem
|
||||
item={pieceSelectorItem}
|
||||
hidePieceIconAndDescription={hidePieceIconAndDescription}
|
||||
stepMetadataWithSuggestions={pieceSelectorItem.pieceMetadata}
|
||||
onClick={() => setOpen(true)}
|
||||
></GenericActionOrTriggerItem>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-6xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle className="text-xl">{t('Create Todo')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-1">
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div className="md:w-1/2 space-y-6">
|
||||
<h3 className="text-lg font-medium">
|
||||
{t('Where would you like the todo to be reviewed?')}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<TodoRadioGroup
|
||||
setTodoType={setTodoType}
|
||||
setHoveredOption={setHoveredTodoType}
|
||||
selectedTodoType={todoType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:w-1/2 flex flex-col items-center justify-center">
|
||||
<PreviewImage todoType={hoveredTodoType || todoType} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0 mt-3 pt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
className="mr-2"
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleAddCreateTodoAction}>
|
||||
{t('Add Steps')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
AddTodoStepDialog.displayName = 'CreateTodoDialog';
|
||||
export { AddTodoStepDialog as CreateTodoDialog };
|
||||
const PreviewImage = ({ todoType }: { todoType: TodoType }) => {
|
||||
const image =
|
||||
todoType === TodoType.INTERNAL
|
||||
? ActivepiecesCreateTodoGuide
|
||||
: ExternalChannelTodo;
|
||||
const alt =
|
||||
todoType === TodoType.INTERNAL ? 'Todos flow' : 'External channel flow';
|
||||
const title =
|
||||
todoType === TodoType.INTERNAL
|
||||
? t('Preview (Activepieces Todos)')
|
||||
: t('Preview (External channel)');
|
||||
const description =
|
||||
todoType === TodoType.INTERNAL
|
||||
? t('Users will manage tasks directly in our interface')
|
||||
: t(
|
||||
'Send notifications with approval links via external channels like Slack, Teams or Email. Best for collaborating with external stakeholders.',
|
||||
);
|
||||
return (
|
||||
<div className="overflow-hidden p-3 w-full h-full">
|
||||
<div className="flex flex-col items-center h-[480px]">
|
||||
<h3 className="text-md font-medium mb-3 text-center">{title}</h3>
|
||||
|
||||
<div className="w-full h-[350px] rounded mb-2 flex items-center justify-center bg-muted/50 relative">
|
||||
<img src={image} alt={alt} className="w-full h-full object-contain" />
|
||||
<div className="absolute -bottom-1 left-0 right-0 h-28 bg-linear-to-t from-white dark:from-background to-transparent"></div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground italic text-center mb-2">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TodoRadioGroup = ({
|
||||
setTodoType,
|
||||
setHoveredOption,
|
||||
selectedTodoType,
|
||||
}: {
|
||||
setTodoType: (todoType: TodoType) => void;
|
||||
setHoveredOption: (todoType: TodoType | null) => void;
|
||||
selectedTodoType: TodoType;
|
||||
}) => {
|
||||
const openNewWindow = useNewWindow();
|
||||
|
||||
return (
|
||||
<RadioGroupList
|
||||
value={selectedTodoType}
|
||||
items={[
|
||||
{
|
||||
label: t('Internal Todos'),
|
||||
value: TodoType.INTERNAL,
|
||||
description: t('Users will manage tasks directly in our interface'),
|
||||
labelExtra: (
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon className="w-4 h-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="w-[550px]">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<span className="text-sm select-none">
|
||||
{t('Users will manage tasks directly in our interface')}
|
||||
</span>{' '}
|
||||
<span
|
||||
className="text-sm text-primary underline cursor-pointer"
|
||||
onClick={() => openNewWindow('/todos')}
|
||||
>
|
||||
{t('here')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted rounded p-1">
|
||||
<img
|
||||
src={ActivepiecesTodo}
|
||||
alt="Todo UI"
|
||||
className="w-full h-auto rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t('External Channel (Slack, Teams, Email, ...)'),
|
||||
value: TodoType.EXTERNAL,
|
||||
description: t(
|
||||
'Send notifications with approval links via external channels like Slack, Teams or Email. Best for collaborating with external stakeholders.',
|
||||
),
|
||||
},
|
||||
]}
|
||||
onChange={setTodoType}
|
||||
onHover={setHoveredOption}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { CardListItem } from '@/components/custom/card-list';
|
||||
import { PieceIcon } from '@/features/pieces/components/piece-icon';
|
||||
import { PieceSelectorItem, StepMetadataWithSuggestions } from '@/lib/types';
|
||||
import { FlowActionType, FlowTriggerType } from '@activepieces/shared';
|
||||
|
||||
type AIActionItemProps = {
|
||||
item: PieceSelectorItem;
|
||||
hidePieceIconAndDescription: boolean;
|
||||
stepMetadataWithSuggestions: StepMetadataWithSuggestions;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const getPieceSelectorItemInfo = (item: PieceSelectorItem) => {
|
||||
if (
|
||||
item.type === FlowActionType.PIECE ||
|
||||
item.type === FlowTriggerType.PIECE
|
||||
) {
|
||||
return {
|
||||
displayName: item.actionOrTrigger.displayName,
|
||||
description: item.actionOrTrigger.description,
|
||||
};
|
||||
}
|
||||
return {
|
||||
displayName: item.displayName,
|
||||
description: item.description,
|
||||
};
|
||||
};
|
||||
|
||||
const AIActionItem = ({
|
||||
item,
|
||||
stepMetadataWithSuggestions,
|
||||
onClick,
|
||||
}: AIActionItemProps) => {
|
||||
const pieceSelectorItemInfo = getPieceSelectorItemInfo(item);
|
||||
|
||||
return (
|
||||
<CardListItem
|
||||
className="p-4 w-full h-full rounded-md flex flex-col justify-between h-[125px]"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-center">
|
||||
<PieceIcon
|
||||
logoUrl={stepMetadataWithSuggestions.logoUrl}
|
||||
displayName={stepMetadataWithSuggestions.displayName}
|
||||
showTooltip={false}
|
||||
size={'lg'}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<div className="text-sm font-medium leading-tight">
|
||||
{pieceSelectorItemInfo.displayName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardListItem>
|
||||
);
|
||||
};
|
||||
|
||||
AIActionItem.displayName = 'AIActionItem';
|
||||
export default AIActionItem;
|
||||
@@ -0,0 +1,114 @@
|
||||
import { t } from 'i18next';
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useTelemetry } from '@/components/telemetry-provider';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { flagsHooks } from '@/hooks/flags-hooks';
|
||||
import {
|
||||
PieceSelectorOperation,
|
||||
StepMetadataWithSuggestions,
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
ApFlagId,
|
||||
FlowActionType,
|
||||
TelemetryEventName,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { usePieceSearchContext } from '../../../../features/pieces/lib/piece-search-context';
|
||||
import { useBuilderStateContext } from '../../builder-hooks';
|
||||
import { convertStepMetadataToPieceSelectorItems } from '../piece-actions-or-triggers-list';
|
||||
|
||||
import AIActionItem from './ai-action';
|
||||
|
||||
type AIPieceActionsListProps = {
|
||||
hidePieceIconAndDescription: boolean;
|
||||
stepMetadataWithSuggestions: StepMetadataWithSuggestions;
|
||||
operation: PieceSelectorOperation;
|
||||
};
|
||||
|
||||
const ACTION_ICON_MAP: Record<string, string> = {
|
||||
run_agent: 'https://cdn.activepieces.com/pieces/agent.png',
|
||||
generateImage: 'https://cdn.activepieces.com/pieces/image-ai.svg',
|
||||
askAi: 'https://cdn.activepieces.com/pieces/text-ai.svg',
|
||||
summarizeText: 'https://cdn.activepieces.com/pieces/text-ai.svg',
|
||||
classifyText: 'https://cdn.activepieces.com/pieces/text-ai.svg',
|
||||
extractStructuredData: 'https://cdn.activepieces.com/pieces/ai-utility.svg',
|
||||
};
|
||||
|
||||
export const AIPieceActionsList: React.FC<AIPieceActionsListProps> = ({
|
||||
stepMetadataWithSuggestions,
|
||||
hidePieceIconAndDescription,
|
||||
operation,
|
||||
}) => {
|
||||
const { capture } = useTelemetry();
|
||||
const { searchQuery } = usePieceSearchContext();
|
||||
const [handleAddingOrUpdatingStep] = useBuilderStateContext((state) => [
|
||||
state.handleAddingOrUpdatingStep,
|
||||
]);
|
||||
const { data: isAgentsConfigured } = flagsHooks.useFlag<boolean>(
|
||||
ApFlagId.AGENTS_CONFIGURED,
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const aiActions = convertStepMetadataToPieceSelectorItems(
|
||||
stepMetadataWithSuggestions,
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full" viewPortClassName="h-full">
|
||||
<div className="grid grid-cols-3 p-2 gap-3 min-w-[350px]">
|
||||
{aiActions.map((item, index) => {
|
||||
const actionIcon =
|
||||
item.type === FlowActionType.PIECE
|
||||
? ACTION_ICON_MAP[item.actionOrTrigger.name]
|
||||
: 'https://cdn.activepieces.com/pieces/image-ai.svg';
|
||||
return (
|
||||
<AIActionItem
|
||||
key={index}
|
||||
item={item}
|
||||
hidePieceIconAndDescription={hidePieceIconAndDescription}
|
||||
stepMetadataWithSuggestions={{
|
||||
...stepMetadataWithSuggestions,
|
||||
logoUrl: actionIcon,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!isAgentsConfigured) {
|
||||
toast('Connect to OpenAI', {
|
||||
description: t(
|
||||
"To create an agent, you'll first need to connect to OpenAI in platform settings.",
|
||||
),
|
||||
action: {
|
||||
label: 'Set Up',
|
||||
onClick: () => {
|
||||
navigate('/platform/setup/ai');
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.type === FlowActionType.PIECE) {
|
||||
capture({
|
||||
name: TelemetryEventName.PIECE_SELECTOR_SEARCH,
|
||||
payload: {
|
||||
search: searchQuery,
|
||||
isTrigger: false,
|
||||
selectedActionOrTriggerName: item.actionOrTrigger.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
handleAddingOrUpdatingStep({
|
||||
pieceSelectorItem: item,
|
||||
operation,
|
||||
selectStepAfter: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { CardListItemSkeleton } from '@/components/custom/card-list';
|
||||
import {
|
||||
PieceSelectorTabType,
|
||||
usePieceSelectorTabs,
|
||||
} from '@/features/pieces/lib/piece-selector-tabs-provider';
|
||||
import { piecesHooks } from '@/features/pieces/lib/pieces-hooks';
|
||||
import { stepUtils } from '@/features/pieces/lib/step-utils';
|
||||
import { PieceSelectorOperation } from '@/lib/types';
|
||||
import { FlowOperationType, isNil } from '@activepieces/shared';
|
||||
|
||||
import { AIPieceActionsList } from './ai-actions-list';
|
||||
|
||||
const AITabContent = ({ operation }: { operation: PieceSelectorOperation }) => {
|
||||
const { selectedTab } = usePieceSelectorTabs();
|
||||
const { pieceModel, isLoading } = piecesHooks.usePiece({
|
||||
name: '@activepieces/piece-ai',
|
||||
});
|
||||
|
||||
if (
|
||||
selectedTab !== PieceSelectorTabType.AI_AND_AGENTS ||
|
||||
operation.type !== FlowOperationType.ADD_ACTION
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isLoading || isNil(pieceModel)) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<CardListItemSkeleton numberOfCards={2} withCircle={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const metadata = stepUtils.mapPieceToMetadata({
|
||||
piece: pieceModel,
|
||||
type: 'action',
|
||||
});
|
||||
|
||||
const pieceMetadataWithSuggestion = {
|
||||
...metadata,
|
||||
suggestedActions: Object.values(pieceModel?.actions),
|
||||
suggestedTriggers: Object.values(pieceModel.triggers),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<AIPieceActionsList
|
||||
stepMetadataWithSuggestions={pieceMetadataWithSuggestion}
|
||||
hidePieceIconAndDescription={false}
|
||||
operation={operation}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { AITabContent };
|
||||
@@ -0,0 +1,210 @@
|
||||
import { t } from 'i18next';
|
||||
|
||||
import { internalErrorToast } from '@/components/ui/sonner';
|
||||
import { pieceSelectorUtils } from '@/features/pieces/lib/piece-selector-utils';
|
||||
import {
|
||||
CORE_STEP_METADATA,
|
||||
TODO_ACTIONS,
|
||||
} from '@/features/pieces/lib/step-utils';
|
||||
import {
|
||||
PieceSelectorItem,
|
||||
PieceSelectorOperation,
|
||||
PieceSelectorPieceItem,
|
||||
PieceStepMetadataWithSuggestions,
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
FlowActionType,
|
||||
BranchExecutionType,
|
||||
BranchOperator,
|
||||
FlowOperationType,
|
||||
isNil,
|
||||
RouterActionSettings,
|
||||
RouterExecutionType,
|
||||
StepLocationRelativeToParent,
|
||||
TodoType,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { BuilderState } from '../builder-hooks';
|
||||
|
||||
const getTodoActionName = (todoType: TodoType) => {
|
||||
switch (todoType) {
|
||||
case TodoType.INTERNAL:
|
||||
return TODO_ACTIONS.createTodoAndWait;
|
||||
case TodoType.EXTERNAL:
|
||||
return TODO_ACTIONS.createTodo;
|
||||
}
|
||||
};
|
||||
|
||||
const getActionFromPieceMetadata = (
|
||||
pieceMetadata: PieceStepMetadataWithSuggestions,
|
||||
actionName: string,
|
||||
) => {
|
||||
const result = pieceMetadata.suggestedActions?.find(
|
||||
(action) => action.name === actionName,
|
||||
);
|
||||
if (isNil(result)) {
|
||||
internalErrorToast();
|
||||
console.error(`Action ${actionName} not found in piece metadata`);
|
||||
return null;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const createRouterStep = ({
|
||||
parentStepName,
|
||||
logoUrl,
|
||||
handleAddingOrUpdatingStep,
|
||||
}: {
|
||||
parentStepName: string;
|
||||
logoUrl: string;
|
||||
handleAddingOrUpdatingStep: BuilderState['handleAddingOrUpdatingStep'];
|
||||
}) => {
|
||||
const routerOnApprovalSettings: RouterActionSettings = {
|
||||
branches: [
|
||||
{
|
||||
conditions: [
|
||||
[
|
||||
{
|
||||
operator: BranchOperator.TEXT_EXACTLY_MATCHES,
|
||||
firstValue: `{{ ${parentStepName}['status'] }}`,
|
||||
secondValue: 'Accepted',
|
||||
caseSensitive: false,
|
||||
},
|
||||
],
|
||||
],
|
||||
branchType: BranchExecutionType.CONDITION,
|
||||
branchName: 'Accepted',
|
||||
},
|
||||
{
|
||||
branchType: BranchExecutionType.FALLBACK,
|
||||
branchName: 'Rejected',
|
||||
},
|
||||
],
|
||||
executionType: RouterExecutionType.EXECUTE_FIRST_MATCH,
|
||||
};
|
||||
return handleAddingOrUpdatingStep({
|
||||
pieceSelectorItem: {
|
||||
...CORE_STEP_METADATA[FlowActionType.ROUTER],
|
||||
displayName: t('Check Todo Status'),
|
||||
},
|
||||
operation: {
|
||||
type: FlowOperationType.ADD_ACTION,
|
||||
actionLocation: {
|
||||
parentStep: parentStepName,
|
||||
stepLocationRelativeToParent: StepLocationRelativeToParent.AFTER,
|
||||
},
|
||||
},
|
||||
selectStepAfter: false,
|
||||
overrideSettings: routerOnApprovalSettings,
|
||||
customLogoUrl: logoUrl,
|
||||
});
|
||||
};
|
||||
|
||||
export const createTodoStep = ({
|
||||
pieceMetadata,
|
||||
operation,
|
||||
todoType,
|
||||
handleAddingOrUpdatingStep,
|
||||
}: {
|
||||
pieceMetadata: PieceStepMetadataWithSuggestions;
|
||||
operation: PieceSelectorOperation;
|
||||
todoType: TodoType;
|
||||
handleAddingOrUpdatingStep: BuilderState['handleAddingOrUpdatingStep'];
|
||||
}) => {
|
||||
const actionName = getTodoActionName(todoType);
|
||||
const createTodoAction = getActionFromPieceMetadata(
|
||||
pieceMetadata,
|
||||
actionName,
|
||||
);
|
||||
if (isNil(createTodoAction)) {
|
||||
return null;
|
||||
}
|
||||
return handleAddingOrUpdatingStep({
|
||||
pieceSelectorItem: {
|
||||
actionOrTrigger: createTodoAction,
|
||||
type: FlowActionType.PIECE,
|
||||
pieceMetadata: pieceMetadata,
|
||||
},
|
||||
operation,
|
||||
selectStepAfter: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const createWaitForApprovalStep = ({
|
||||
pieceMetadata,
|
||||
parentStepName,
|
||||
handleAddingOrUpdatingStep,
|
||||
}: {
|
||||
pieceMetadata: PieceStepMetadataWithSuggestions;
|
||||
parentStepName: string;
|
||||
handleAddingOrUpdatingStep: BuilderState['handleAddingOrUpdatingStep'];
|
||||
}) => {
|
||||
const waitForApprovalAction = getActionFromPieceMetadata(
|
||||
pieceMetadata,
|
||||
TODO_ACTIONS.waitForApproval,
|
||||
);
|
||||
if (isNil(waitForApprovalAction)) {
|
||||
return null;
|
||||
}
|
||||
const pieceSelectorItem: PieceSelectorItem = {
|
||||
actionOrTrigger: waitForApprovalAction,
|
||||
type: FlowActionType.PIECE,
|
||||
pieceMetadata: pieceMetadata,
|
||||
};
|
||||
const waitForApprovalStep = {
|
||||
pieceSelectorItem,
|
||||
operation: {
|
||||
type: FlowOperationType.ADD_ACTION,
|
||||
actionLocation: {
|
||||
parentStep: parentStepName,
|
||||
stepLocationRelativeToParent: StepLocationRelativeToParent.AFTER,
|
||||
},
|
||||
},
|
||||
selectStepAfter: false,
|
||||
} as const;
|
||||
const waitForApprovalStepName =
|
||||
handleAddingOrUpdatingStep(waitForApprovalStep);
|
||||
const defaultValues = pieceSelectorUtils.getDefaultStepValues({
|
||||
stepName: waitForApprovalStepName,
|
||||
pieceSelectorItem: {
|
||||
actionOrTrigger: waitForApprovalAction,
|
||||
type: FlowActionType.PIECE,
|
||||
pieceMetadata: pieceMetadata,
|
||||
},
|
||||
});
|
||||
defaultValues.settings.input.taskId = `{{ ${parentStepName}['id'] }}`;
|
||||
return handleAddingOrUpdatingStep({
|
||||
pieceSelectorItem,
|
||||
operation: {
|
||||
type: FlowOperationType.UPDATE_ACTION,
|
||||
stepName: waitForApprovalStepName,
|
||||
},
|
||||
selectStepAfter: false,
|
||||
overrideSettings: defaultValues.settings,
|
||||
});
|
||||
};
|
||||
|
||||
export const handleAddingOrUpdatingCustomAgentPieceSelectorItem = (
|
||||
agentPieceSelectorItem: PieceSelectorPieceItem,
|
||||
operation: PieceSelectorOperation,
|
||||
handleAddingOrUpdatingStep: BuilderState['handleAddingOrUpdatingStep'],
|
||||
) => {
|
||||
const stepName = handleAddingOrUpdatingStep({
|
||||
pieceSelectorItem: agentPieceSelectorItem,
|
||||
operation,
|
||||
selectStepAfter: true,
|
||||
});
|
||||
const defaultValues = pieceSelectorUtils.getDefaultStepValues({
|
||||
stepName,
|
||||
pieceSelectorItem: agentPieceSelectorItem,
|
||||
});
|
||||
return handleAddingOrUpdatingStep({
|
||||
pieceSelectorItem: agentPieceSelectorItem,
|
||||
operation: {
|
||||
type: FlowOperationType.UPDATE_ACTION,
|
||||
stepName,
|
||||
},
|
||||
selectStepAfter: false,
|
||||
overrideSettings: defaultValues.settings,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
CardListItem,
|
||||
CardListItemSkeleton,
|
||||
} from '@/components/custom/card-list';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { PieceIcon } from '@/features/pieces/components/piece-icon';
|
||||
import {
|
||||
PieceSelectorTabType,
|
||||
usePieceSelectorTabs,
|
||||
} from '@/features/pieces/lib/piece-selector-tabs-provider';
|
||||
import { piecesHooks } from '@/features/pieces/lib/pieces-hooks';
|
||||
import { PieceSelectorOperation } from '@/lib/types';
|
||||
import { FlowOperationType } from '@activepieces/shared';
|
||||
|
||||
import { PieceActionsOrTriggersList } from './piece-actions-or-triggers-list';
|
||||
|
||||
const ExploreTabContent = ({
|
||||
operation,
|
||||
}: {
|
||||
operation: PieceSelectorOperation;
|
||||
}) => {
|
||||
const { selectedTab, selectedPieceInExplore, setSelectedPieceInExplore } =
|
||||
usePieceSelectorTabs();
|
||||
const { data: categories, isLoading: isLoadingPieces } =
|
||||
piecesHooks.usePiecesSearch({
|
||||
shouldCaptureEvent: false,
|
||||
searchQuery: '',
|
||||
type:
|
||||
operation.type === FlowOperationType.UPDATE_TRIGGER
|
||||
? 'trigger'
|
||||
: 'action',
|
||||
});
|
||||
if (selectedTab !== PieceSelectorTabType.EXPLORE) {
|
||||
return null;
|
||||
}
|
||||
if (isLoadingPieces) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<CardListItemSkeleton numberOfCards={2} withCircle={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedPieceInExplore) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<PieceActionsOrTriggersList
|
||||
stepMetadataWithSuggestions={selectedPieceInExplore}
|
||||
hidePieceIconAndDescription={false}
|
||||
operation={operation}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full w-full">
|
||||
<div className="flex p-2 ">
|
||||
{categories.map((category) => (
|
||||
<div key={category.title} className="flex w-[50%] flex-col gap-0.5 ">
|
||||
<div className="text-sm text-muted-foreground mb-1.5">
|
||||
{category.title}
|
||||
</div>
|
||||
|
||||
{category.metadata.map((pieceMetadata) => (
|
||||
<CardListItem
|
||||
className="rounded-sm py-3"
|
||||
key={pieceMetadata.displayName}
|
||||
onClick={() => setSelectedPieceInExplore(pieceMetadata)}
|
||||
>
|
||||
<div className="flex gap-2 items-center h-full">
|
||||
<PieceIcon
|
||||
logoUrl={pieceMetadata.logoUrl}
|
||||
displayName={pieceMetadata.displayName}
|
||||
showTooltip={false}
|
||||
size={'sm'}
|
||||
/>
|
||||
<div className="grow h-full flex items-center justify-left text-sm">
|
||||
{pieceMetadata.displayName}
|
||||
</div>
|
||||
</div>{' '}
|
||||
</CardListItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
export { ExploreTabContent };
|
||||
@@ -0,0 +1,83 @@
|
||||
import { CardListItem } from '@/components/custom/card-list';
|
||||
import { PieceIcon } from '@/features/pieces/components/piece-icon';
|
||||
import { PIECE_SELECTOR_ELEMENTS_HEIGHTS } from '@/features/pieces/lib/piece-selector-utils';
|
||||
import { PieceSelectorItem, StepMetadataWithSuggestions } from '@/lib/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FlowActionType, FlowTriggerType } from '@activepieces/shared';
|
||||
type GenericActionOrTriggerItemProps = {
|
||||
item: PieceSelectorItem;
|
||||
hidePieceIconAndDescription: boolean;
|
||||
stepMetadataWithSuggestions: StepMetadataWithSuggestions;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const getPieceSelectorItemInfo = (item: PieceSelectorItem) => {
|
||||
if (
|
||||
item.type === FlowActionType.PIECE ||
|
||||
item.type === FlowTriggerType.PIECE
|
||||
) {
|
||||
return {
|
||||
displayName: item.actionOrTrigger.displayName,
|
||||
description: item.actionOrTrigger.description,
|
||||
};
|
||||
}
|
||||
return {
|
||||
displayName: item.displayName,
|
||||
description: item.description,
|
||||
};
|
||||
};
|
||||
|
||||
const GenericActionOrTriggerItem = ({
|
||||
item,
|
||||
hidePieceIconAndDescription,
|
||||
stepMetadataWithSuggestions,
|
||||
onClick,
|
||||
}: GenericActionOrTriggerItemProps) => {
|
||||
// we add this style because we hide the piece icon and description when they are in a virtualized list
|
||||
const style = hidePieceIconAndDescription
|
||||
? {
|
||||
height: `${PIECE_SELECTOR_ELEMENTS_HEIGHTS.ACTION_OR_TRIGGER_ITEM_HEIGHT}px`,
|
||||
maxHeight: `${PIECE_SELECTOR_ELEMENTS_HEIGHTS.ACTION_OR_TRIGGER_ITEM_HEIGHT}px`,
|
||||
}
|
||||
: {
|
||||
minHeight: '54px',
|
||||
};
|
||||
const pieceSelectorItemInfo = getPieceSelectorItemInfo(item);
|
||||
return (
|
||||
<CardListItem
|
||||
className={cn('p-2 w-full ', {
|
||||
truncate: hidePieceIconAndDescription,
|
||||
})}
|
||||
onClick={onClick}
|
||||
style={style}
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div
|
||||
className={cn({
|
||||
'opacity-0': hidePieceIconAndDescription,
|
||||
})}
|
||||
>
|
||||
<PieceIcon
|
||||
logoUrl={stepMetadataWithSuggestions.logoUrl}
|
||||
displayName={stepMetadataWithSuggestions.displayName}
|
||||
showTooltip={false}
|
||||
size={'sm'}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="text-sm">{pieceSelectorItemInfo.displayName}</div>
|
||||
{!hidePieceIconAndDescription && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{pieceSelectorItemInfo.description.endsWith('.')
|
||||
? pieceSelectorItemInfo.description.slice(0, -1)
|
||||
: pieceSelectorItemInfo.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardListItem>
|
||||
);
|
||||
};
|
||||
|
||||
GenericActionOrTriggerItem.displayName = 'GenericActionOrTriggerItem';
|
||||
export default GenericActionOrTriggerItem;
|
||||
@@ -0,0 +1,214 @@
|
||||
import { t } from 'i18next';
|
||||
import {
|
||||
LayoutGridIcon,
|
||||
PuzzleIcon,
|
||||
SparklesIcon,
|
||||
WrenchIcon,
|
||||
} from 'lucide-react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
|
||||
import { useBuilderStateContext } from '@/app/builder/builder-hooks';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { PiecesSearchInput } from '@/features/pieces/components/piece-selector-search';
|
||||
import { PieceSelectorTabs } from '@/features/pieces/components/piece-selector-tabs';
|
||||
import {
|
||||
PieceSelectorTabsProvider,
|
||||
PieceSelectorTabType,
|
||||
} from '@/features/pieces/lib/piece-selector-tabs-provider';
|
||||
import { pieceSelectorUtils } from '@/features/pieces/lib/piece-selector-utils';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { PieceSelectorOperation } from '@/lib/types';
|
||||
import { FlowOperationType, FlowTriggerType } from '@activepieces/shared';
|
||||
|
||||
import {
|
||||
PieceSearchProvider,
|
||||
usePieceSearchContext,
|
||||
} from '../../../features/pieces/lib/piece-search-context';
|
||||
|
||||
import { AITabContent } from './ai-tab-content';
|
||||
import { ExploreTabContent } from './explore-tab-content';
|
||||
import { PiecesCardList } from './pieces-card-list';
|
||||
|
||||
const getTabsList = (operationType: FlowOperationType) => {
|
||||
const baseTabs = [
|
||||
{
|
||||
value: PieceSelectorTabType.EXPLORE,
|
||||
name: t('Explore'),
|
||||
icon: <LayoutGridIcon className="size-5" />,
|
||||
},
|
||||
{
|
||||
value: PieceSelectorTabType.APPS,
|
||||
name: t('Apps'),
|
||||
icon: <PuzzleIcon className="size-5" />,
|
||||
},
|
||||
{
|
||||
value: PieceSelectorTabType.UTILITY,
|
||||
name: t('Utility'),
|
||||
icon: <WrenchIcon className="size-5" />,
|
||||
},
|
||||
];
|
||||
|
||||
if (operationType === FlowOperationType.ADD_ACTION) {
|
||||
baseTabs.splice(1, 0, {
|
||||
value: PieceSelectorTabType.AI_AND_AGENTS,
|
||||
name: t('AI & Agents'),
|
||||
icon: <SparklesIcon className="size-5" />,
|
||||
});
|
||||
}
|
||||
|
||||
return baseTabs;
|
||||
};
|
||||
|
||||
type PieceSelectorProps = {
|
||||
children: React.ReactNode;
|
||||
id: string;
|
||||
operation: PieceSelectorOperation;
|
||||
openSelectorOnClick?: boolean;
|
||||
stepToReplacePieceDisplayName?: string;
|
||||
};
|
||||
|
||||
const PieceSelectorWrapper = (props: PieceSelectorProps) => {
|
||||
return (
|
||||
<PieceSearchProvider>
|
||||
<PieceSelectorContent {...props} />
|
||||
</PieceSearchProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const PieceSelectorContent = ({
|
||||
children,
|
||||
operation,
|
||||
id,
|
||||
openSelectorOnClick = true,
|
||||
stepToReplacePieceDisplayName,
|
||||
}: PieceSelectorProps) => {
|
||||
const [
|
||||
openedPieceSelectorStepNameOrAddButtonId,
|
||||
setOpenedPieceSelectorStepNameOrAddButtonId,
|
||||
setSelectedPieceMetadataInPieceSelector,
|
||||
isForEmptyTrigger,
|
||||
deselectStep,
|
||||
] = useBuilderStateContext((state) => [
|
||||
state.openedPieceSelectorStepNameOrAddButtonId,
|
||||
state.setOpenedPieceSelectorStepNameOrAddButtonId,
|
||||
state.setSelectedPieceMetadataInPieceSelector,
|
||||
state.flowVersion.trigger.type === FlowTriggerType.EMPTY &&
|
||||
id === 'trigger',
|
||||
state.deselectStep,
|
||||
]);
|
||||
const { searchQuery, setSearchQuery } = usePieceSearchContext();
|
||||
const isForReplace =
|
||||
operation.type === FlowOperationType.UPDATE_ACTION ||
|
||||
(operation.type === FlowOperationType.UPDATE_TRIGGER && !isForEmptyTrigger);
|
||||
const [debouncedQuery] = useDebounce(searchQuery, 300);
|
||||
const isOpen = openedPieceSelectorStepNameOrAddButtonId === id;
|
||||
const isMobile = useIsMobile();
|
||||
const { listHeightRef, popoverTriggerRef } =
|
||||
pieceSelectorUtils.useAdjustPieceListHeightToAvailableSpace();
|
||||
const listHeight = Math.min(listHeightRef.current, 300);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTimeout(() => {
|
||||
searchInputRef.current?.focus();
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchQuery('');
|
||||
setSelectedPieceMetadataInPieceSelector(null);
|
||||
};
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
modal={true}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
clearSearch();
|
||||
setOpenedPieceSelectorStepNameOrAddButtonId(null);
|
||||
if (isForEmptyTrigger) {
|
||||
deselectStep();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger
|
||||
ref={popoverTriggerRef}
|
||||
asChild={true}
|
||||
onClick={() => {
|
||||
if (openSelectorOnClick) {
|
||||
setOpenedPieceSelectorStepNameOrAddButtonId(id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PopoverTrigger>
|
||||
|
||||
<PieceSelectorTabsProvider
|
||||
initiallySelectedTab={
|
||||
isForReplace || isMobile
|
||||
? PieceSelectorTabType.NONE
|
||||
: PieceSelectorTabType.EXPLORE
|
||||
}
|
||||
onTabChange={clearSearch}
|
||||
key={isOpen ? 'open' : 'closed'}
|
||||
>
|
||||
<PopoverContent
|
||||
onContextMenu={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="w-[340px] md:w-[600px] p-0 shadow-lg"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<div>
|
||||
<PiecesSearchInput
|
||||
searchInputRef={searchInputRef}
|
||||
onSearchChange={(e) => {
|
||||
setSelectedPieceMetadataInPieceSelector(null);
|
||||
if (e === '') {
|
||||
clearSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{!isMobile && (
|
||||
<PieceSelectorTabs tabs={getTabsList(operation.type)} />
|
||||
)}
|
||||
<Separator orientation="horizontal" className="mt-1" />
|
||||
</div>
|
||||
<div
|
||||
className=" flex flex-row max-h-[300px]"
|
||||
style={{
|
||||
height: listHeight + 'px',
|
||||
}}
|
||||
>
|
||||
<ExploreTabContent operation={operation} />
|
||||
<AITabContent operation={operation} />
|
||||
|
||||
<PiecesCardList
|
||||
//this is done to avoid debounced results when user clears search
|
||||
searchQuery={searchQuery === '' ? '' : debouncedQuery}
|
||||
operation={operation}
|
||||
stepToReplacePieceDisplayName={
|
||||
isMobile ? undefined : stepToReplacePieceDisplayName
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</PopoverContent>
|
||||
</PieceSelectorTabsProvider>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export { PieceSelectorWrapper as PieceSelector };
|
||||
@@ -0,0 +1,36 @@
|
||||
import { t } from 'i18next';
|
||||
import { SearchX } from 'lucide-react';
|
||||
|
||||
import { useEmbedding } from '@/components/embed-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { flagsHooks } from '@/hooks/flags-hooks';
|
||||
import { ApFlagId, feedbackUrl } from '@activepieces/shared';
|
||||
|
||||
const NoResultsFound = () => {
|
||||
const { data: showCommunityLinks } = flagsHooks.useFlag<boolean>(
|
||||
ApFlagId.SHOW_COMMUNITY,
|
||||
);
|
||||
const isEmbedding = useEmbedding().embedState.isEmbedded;
|
||||
const showRequestPieceButton = showCommunityLinks && !isEmbedding;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 items-center justify-center h-full ">
|
||||
<SearchX className="w-14 h-14" />
|
||||
<div className="text-sm ">{t('No pieces found')}</div>
|
||||
<div className="text-sm ">{t('Try adjusting your search')}</div>
|
||||
{showRequestPieceButton && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
window.open(`${feedbackUrl}`, '_blank', 'noopener noreferrer');
|
||||
}}
|
||||
>
|
||||
{t('Request Piece')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { NoResultsFound };
|
||||
@@ -0,0 +1,145 @@
|
||||
import { t } from 'i18next';
|
||||
import { MoveLeft } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
import { CardList } from '@/components/custom/card-list';
|
||||
import { useTelemetry } from '@/components/telemetry-provider';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { pieceSelectorUtils } from '@/features/pieces/lib/piece-selector-utils';
|
||||
import { CORE_ACTIONS_METADATA } from '@/features/pieces/lib/step-utils';
|
||||
import {
|
||||
PieceSelectorItem,
|
||||
PieceSelectorOperation,
|
||||
StepMetadataWithSuggestions,
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
FlowActionType,
|
||||
isNil,
|
||||
FlowTriggerType,
|
||||
TelemetryEventName,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { usePieceSearchContext } from '../../../features/pieces/lib/piece-search-context';
|
||||
import { useBuilderStateContext } from '../builder-hooks';
|
||||
|
||||
import { CreateTodoDialog } from './add-todo-step-dialog';
|
||||
import GenericActionOrTriggerItem from './generic-piece-selector-item';
|
||||
type PieceActionsOrTriggersListProps = {
|
||||
hidePieceIconAndDescription: boolean;
|
||||
stepMetadataWithSuggestions: StepMetadataWithSuggestions | null;
|
||||
operation: PieceSelectorOperation;
|
||||
};
|
||||
export const convertStepMetadataToPieceSelectorItems = (
|
||||
stepMetadataWithSuggestions: StepMetadataWithSuggestions,
|
||||
): PieceSelectorItem[] => {
|
||||
switch (stepMetadataWithSuggestions.type) {
|
||||
case FlowActionType.PIECE: {
|
||||
const actions = pieceSelectorUtils.removeHiddenActions(
|
||||
stepMetadataWithSuggestions,
|
||||
);
|
||||
return actions.map((action) => ({
|
||||
actionOrTrigger: action,
|
||||
type: FlowActionType.PIECE,
|
||||
pieceMetadata: stepMetadataWithSuggestions,
|
||||
}));
|
||||
}
|
||||
case FlowTriggerType.PIECE: {
|
||||
const triggers = Object.values(
|
||||
stepMetadataWithSuggestions.suggestedTriggers ?? {},
|
||||
);
|
||||
return triggers.map((trigger) => ({
|
||||
actionOrTrigger: trigger,
|
||||
type: FlowTriggerType.PIECE,
|
||||
pieceMetadata: stepMetadataWithSuggestions,
|
||||
}));
|
||||
}
|
||||
case FlowActionType.CODE:
|
||||
case FlowActionType.LOOP_ON_ITEMS:
|
||||
case FlowActionType.ROUTER: {
|
||||
return CORE_ACTIONS_METADATA.filter(
|
||||
(step) => step.type === stepMetadataWithSuggestions.type,
|
||||
);
|
||||
}
|
||||
default: {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const PieceActionsOrTriggersList: React.FC<
|
||||
PieceActionsOrTriggersListProps
|
||||
> = ({
|
||||
stepMetadataWithSuggestions,
|
||||
hidePieceIconAndDescription,
|
||||
operation,
|
||||
}) => {
|
||||
const { capture } = useTelemetry();
|
||||
const { searchQuery } = usePieceSearchContext();
|
||||
const [handleAddingOrUpdatingStep] = useBuilderStateContext((state) => [
|
||||
state.handleAddingOrUpdatingStep,
|
||||
]);
|
||||
if (isNil(stepMetadataWithSuggestions)) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 items-center justify-center h-full w-full">
|
||||
<MoveLeft className="w-10 h-10 rtl:rotate-180" />
|
||||
<div className="text-sm">{t('Please select a piece first')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const actionsOrTriggers = convertStepMetadataToPieceSelectorItems(
|
||||
stepMetadataWithSuggestions,
|
||||
);
|
||||
return (
|
||||
<ScrollArea className="h-full" viewPortClassName="h-full">
|
||||
<CardList className="min-w-[350px] h-full gap-0" listClassName="gap-0">
|
||||
{actionsOrTriggers &&
|
||||
actionsOrTriggers.map((item, index) => {
|
||||
const isCreateTodoAction =
|
||||
item.type === FlowActionType.PIECE &&
|
||||
item.actionOrTrigger.name === 'createTodo';
|
||||
|
||||
if (isCreateTodoAction) {
|
||||
return (
|
||||
<CreateTodoDialog
|
||||
key={index}
|
||||
pieceSelectorItem={item}
|
||||
operation={operation}
|
||||
hidePieceIconAndDescription={hidePieceIconAndDescription}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<GenericActionOrTriggerItem
|
||||
key={index}
|
||||
item={item}
|
||||
hidePieceIconAndDescription={hidePieceIconAndDescription}
|
||||
stepMetadataWithSuggestions={stepMetadataWithSuggestions}
|
||||
onClick={() => {
|
||||
if (
|
||||
item.type === FlowActionType.PIECE ||
|
||||
item.type === FlowTriggerType.PIECE
|
||||
) {
|
||||
capture({
|
||||
name: TelemetryEventName.PIECE_SELECTOR_SEARCH,
|
||||
payload: {
|
||||
search: searchQuery,
|
||||
isTrigger: item.type === FlowTriggerType.PIECE,
|
||||
selectedActionOrTriggerName: item.actionOrTrigger.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
handleAddingOrUpdatingStep({
|
||||
pieceSelectorItem: item,
|
||||
operation,
|
||||
selectStepAfter: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</CardList>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { CardListItem } from '@/components/custom/card-list';
|
||||
import { PieceIcon } from '@/features/pieces/components/piece-icon';
|
||||
import { PIECE_SELECTOR_ELEMENTS_HEIGHTS } from '@/features/pieces/lib/piece-selector-utils';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import {
|
||||
PieceSelectorOperation,
|
||||
StepMetadataWithSuggestions,
|
||||
} from '@/lib/types';
|
||||
import { cn, wait } from '@/lib/utils';
|
||||
|
||||
import { useBuilderStateContext } from '../builder-hooks';
|
||||
|
||||
import { PieceActionsOrTriggersList } from './piece-actions-or-triggers-list';
|
||||
|
||||
type PieceCardListItemProps = {
|
||||
pieceMetadata: StepMetadataWithSuggestions;
|
||||
searchQuery: string;
|
||||
operation: PieceSelectorOperation;
|
||||
isTemporaryDisabledUntilNextCursorMove: boolean;
|
||||
};
|
||||
|
||||
const PieceCardListItem = ({
|
||||
pieceMetadata,
|
||||
searchQuery,
|
||||
operation,
|
||||
isTemporaryDisabledUntilNextCursorMove,
|
||||
}: PieceCardListItemProps) => {
|
||||
const isMobile = useIsMobile();
|
||||
const showSuggestions = searchQuery.length > 0 || isMobile;
|
||||
const isMouseOver = useRef(false);
|
||||
const selectPieceMetatdata = async () => {
|
||||
if (isTemporaryDisabledUntilNextCursorMove || showSuggestions) {
|
||||
return;
|
||||
}
|
||||
isMouseOver.current = true;
|
||||
await wait(250);
|
||||
if (isMouseOver.current) {
|
||||
setSelectedPieceMetadataInPieceSelector(pieceMetadata);
|
||||
}
|
||||
};
|
||||
const [
|
||||
selectedPieceMetadataInPieceSelector,
|
||||
setSelectedPieceMetadataInPieceSelector,
|
||||
] = useBuilderStateContext((state) => [
|
||||
state.selectedPieceMetadataInPieceSelector,
|
||||
state.setSelectedPieceMetadataInPieceSelector,
|
||||
]);
|
||||
const itemHeight = PIECE_SELECTOR_ELEMENTS_HEIGHTS.PIECE_ITEM_HEIGHT;
|
||||
return (
|
||||
<>
|
||||
<CardListItem
|
||||
className={cn('flex-col p-3 gap-1 items-start truncate', {
|
||||
'hover:bg-transparent!': isTemporaryDisabledUntilNextCursorMove,
|
||||
})}
|
||||
style={{ height: `${itemHeight}px`, maxHeight: `${itemHeight}px` }}
|
||||
selected={
|
||||
selectedPieceMetadataInPieceSelector?.displayName ===
|
||||
pieceMetadata.displayName && searchQuery.length === 0
|
||||
}
|
||||
interactive={!showSuggestions}
|
||||
onMouseEnter={selectPieceMetatdata}
|
||||
onMouseMove={selectPieceMetatdata}
|
||||
onClick={() => {
|
||||
if (!showSuggestions) {
|
||||
setSelectedPieceMetadataInPieceSelector(pieceMetadata);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
isMouseOver.current = false;
|
||||
}}
|
||||
id={pieceMetadata.displayName}
|
||||
data-testid={pieceMetadata.displayName}
|
||||
>
|
||||
<div className="flex gap-2 items-center h-full">
|
||||
<PieceIcon
|
||||
logoUrl={pieceMetadata.logoUrl}
|
||||
displayName={pieceMetadata.displayName}
|
||||
showTooltip={false}
|
||||
size={'sm'}
|
||||
/>
|
||||
<div className="grow h-full flex items-center justify-left text-sm">
|
||||
{pieceMetadata.displayName}
|
||||
</div>
|
||||
</div>
|
||||
</CardListItem>
|
||||
|
||||
{showSuggestions && (
|
||||
<div>
|
||||
<PieceActionsOrTriggersList
|
||||
stepMetadataWithSuggestions={pieceMetadata}
|
||||
hidePieceIconAndDescription={true}
|
||||
operation={operation}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
PieceCardListItem.displayName = 'PieceCardListItem';
|
||||
export { PieceCardListItem };
|
||||
@@ -0,0 +1,228 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { CardListItemSkeleton } from '@/components/custom/card-list';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { VirtualizedScrollArea } from '@/components/ui/virtualized-scroll-area';
|
||||
import {
|
||||
PieceSelectorTabType,
|
||||
usePieceSelectorTabs,
|
||||
} from '@/features/pieces/lib/piece-selector-tabs-provider';
|
||||
import {
|
||||
PIECE_SELECTOR_ELEMENTS_HEIGHTS,
|
||||
pieceSelectorUtils,
|
||||
} from '@/features/pieces/lib/piece-selector-utils';
|
||||
import { piecesHooks } from '@/features/pieces/lib/pieces-hooks';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import {
|
||||
PieceSelectorOperation,
|
||||
StepMetadataWithSuggestions,
|
||||
CategorizedStepMetadataWithSuggestions,
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
FlowActionType,
|
||||
FlowOperationType,
|
||||
FlowTriggerType,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { useBuilderStateContext } from '../builder-hooks';
|
||||
|
||||
import { NoResultsFound } from './no-results-found';
|
||||
import { PieceActionsOrTriggersList } from './piece-actions-or-triggers-list';
|
||||
import { PieceCardListItem } from './piece-card-item';
|
||||
|
||||
type PiecesCardListProps = {
|
||||
searchQuery: string;
|
||||
operation: PieceSelectorOperation;
|
||||
stepToReplacePieceDisplayName?: string;
|
||||
};
|
||||
|
||||
export const PiecesCardList: React.FC<PiecesCardListProps> = ({
|
||||
searchQuery,
|
||||
operation,
|
||||
stepToReplacePieceDisplayName,
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [selectedPieceMetadataInPieceSelector] = useBuilderStateContext(
|
||||
(state) => [state.selectedPieceMetadataInPieceSelector],
|
||||
);
|
||||
const { isLoading: isLoadingPieces, data: categories } =
|
||||
piecesHooks.usePiecesSearch({
|
||||
shouldCaptureEvent: true,
|
||||
searchQuery,
|
||||
type:
|
||||
operation.type === FlowOperationType.UPDATE_TRIGGER
|
||||
? 'trigger'
|
||||
: 'action',
|
||||
});
|
||||
|
||||
const noResultsFound = !isLoadingPieces && categories.length === 0;
|
||||
const [mouseMoved, setMouseMoved] = useState(false);
|
||||
const showActionsOrTriggersInsidePiecesList =
|
||||
searchQuery.length > 0 || isMobile;
|
||||
const virtualizedItems = transformPiecesMetadataToVirtualizedItems(
|
||||
categories,
|
||||
showActionsOrTriggersInsidePiecesList,
|
||||
);
|
||||
|
||||
const initialIndexToScrollToInPiecesList = virtualizedItems.findIndex(
|
||||
(item) => item.displayName === stepToReplacePieceDisplayName,
|
||||
);
|
||||
const { selectedTab } = usePieceSelectorTabs();
|
||||
|
||||
const isLoading = isLoadingPieces;
|
||||
const showActionsOrTriggersList =
|
||||
searchQuery.length === 0 && !isMobile && !noResultsFound && !isLoading;
|
||||
const showPiecesList = !noResultsFound && !isLoading;
|
||||
if (
|
||||
[PieceSelectorTabType.EXPLORE, PieceSelectorTabType.AI_AND_AGENTS].includes(
|
||||
selectedTab,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onMouseMove={() => {
|
||||
setMouseMoved(!isLoadingPieces);
|
||||
}}
|
||||
className={cn('w-full md:w-[250px] md:min-w-[250px] transition-all ', {
|
||||
'w-full md:w-full': searchQuery.length > 0 || noResultsFound,
|
||||
})}
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<CardListItemSkeleton numberOfCards={2} withCircle={false} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPiecesList && (
|
||||
<VirtualizedScrollArea
|
||||
key={`${selectedTab}-${searchQuery}`}
|
||||
initialScroll={{
|
||||
index: initialIndexToScrollToInPiecesList,
|
||||
clickAfterScroll: true,
|
||||
}}
|
||||
items={virtualizedItems}
|
||||
estimateSize={(index) => virtualizedItems[index].height}
|
||||
getItemKey={(index) => virtualizedItems[index].id}
|
||||
renderItem={(item) => {
|
||||
if (item.isCategory) {
|
||||
return (
|
||||
<div
|
||||
className={cn('p-2 pb-0 text-sm text-muted-foreground')}
|
||||
id={item.displayName}
|
||||
>
|
||||
{item.displayName}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PieceCardListItem
|
||||
pieceMetadata={item.pieceMetadata}
|
||||
searchQuery={searchQuery}
|
||||
operation={operation}
|
||||
isTemporaryDisabledUntilNextCursorMove={!mouseMoved}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{noResultsFound && <NoResultsFound />}
|
||||
</div>
|
||||
|
||||
{showActionsOrTriggersList && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="h-full" />
|
||||
<PieceActionsOrTriggersList
|
||||
stepMetadataWithSuggestions={selectedPieceMetadataInPieceSelector}
|
||||
hidePieceIconAndDescription={false}
|
||||
operation={operation}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type VirtualizedItem = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
height: number;
|
||||
} & (
|
||||
| {
|
||||
isCategory: true;
|
||||
}
|
||||
| {
|
||||
isCategory: false;
|
||||
pieceMetadata: StepMetadataWithSuggestions;
|
||||
}
|
||||
);
|
||||
const transformPiecesMetadataToVirtualizedItems = (
|
||||
searchResult: CategorizedStepMetadataWithSuggestions[],
|
||||
showActionsOrTriggersInsidePiecesList: boolean,
|
||||
) => {
|
||||
return searchResult.reduce<VirtualizedItem[]>((result, category) => {
|
||||
if (!showActionsOrTriggersInsidePiecesList) {
|
||||
result.push({
|
||||
id: category.title,
|
||||
displayName: category.title,
|
||||
height: PIECE_SELECTOR_ELEMENTS_HEIGHTS.CATEGORY_ITEM_HEIGHT,
|
||||
isCategory: true,
|
||||
});
|
||||
}
|
||||
category.metadata.forEach((pieceMetadata, index) => {
|
||||
result.push({
|
||||
id: `${pieceMetadata.displayName}-${index}`,
|
||||
height: getItemHeight(
|
||||
pieceMetadata,
|
||||
showActionsOrTriggersInsidePiecesList,
|
||||
),
|
||||
isCategory: false,
|
||||
pieceMetadata,
|
||||
displayName: pieceMetadata.displayName,
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const getItemHeight = (
|
||||
pieceMetadata: StepMetadataWithSuggestions,
|
||||
showActionsOrTriggersInsidePiecesList: boolean,
|
||||
) => {
|
||||
const { ACTION_OR_TRIGGER_ITEM_HEIGHT, PIECE_ITEM_HEIGHT } =
|
||||
PIECE_SELECTOR_ELEMENTS_HEIGHTS;
|
||||
if (
|
||||
pieceMetadata.type === FlowActionType.PIECE &&
|
||||
showActionsOrTriggersInsidePiecesList
|
||||
) {
|
||||
const actionsListWithoutHiddenActions =
|
||||
pieceSelectorUtils.removeHiddenActions(pieceMetadata);
|
||||
return (
|
||||
ACTION_OR_TRIGGER_ITEM_HEIGHT *
|
||||
Object.values(actionsListWithoutHiddenActions).length +
|
||||
PIECE_ITEM_HEIGHT
|
||||
);
|
||||
}
|
||||
if (
|
||||
pieceMetadata.type === FlowTriggerType.PIECE &&
|
||||
showActionsOrTriggersInsidePiecesList
|
||||
) {
|
||||
return (
|
||||
ACTION_OR_TRIGGER_ITEM_HEIGHT *
|
||||
Object.values(pieceMetadata.suggestedTriggers ?? {}).length +
|
||||
PIECE_ITEM_HEIGHT
|
||||
);
|
||||
}
|
||||
const isCoreAction =
|
||||
pieceMetadata.type === FlowActionType.CODE ||
|
||||
pieceMetadata.type === FlowActionType.LOOP_ON_ITEMS ||
|
||||
pieceMetadata.type === FlowActionType.ROUTER;
|
||||
if (isCoreAction && showActionsOrTriggersInsidePiecesList) {
|
||||
return ACTION_OR_TRIGGER_ITEM_HEIGHT + PIECE_ITEM_HEIGHT;
|
||||
}
|
||||
return PIECE_ITEM_HEIGHT;
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import { t } from 'i18next';
|
||||
import { Timer } from 'lucide-react';
|
||||
|
||||
import { JsonViewer } from '@/components/json-viewer';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { AgentTimeline } from '@/features/agents/agent-timeline';
|
||||
import { StepStatusIcon } from '@/features/flow-runs/components/step-status-icon';
|
||||
import { formatUtils } from '@/lib/utils';
|
||||
import {
|
||||
FlowAction,
|
||||
StepOutput,
|
||||
StepOutputStatus,
|
||||
flowStructureUtil,
|
||||
AgentResult,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
type Props = {
|
||||
stepDetails: StepOutput;
|
||||
selectedStep: FlowAction;
|
||||
};
|
||||
|
||||
export const FlowStepInputOutput = ({ stepDetails, selectedStep }: Props) => {
|
||||
const isAgent = flowStructureUtil.isAgentPiece(selectedStep);
|
||||
const isRunning =
|
||||
stepDetails.status === StepOutputStatus.RUNNING ||
|
||||
stepDetails.status === StepOutputStatus.PAUSED;
|
||||
|
||||
const parsedOutput =
|
||||
stepDetails.errorMessage ?? stepDetails.output ?? 'No output';
|
||||
|
||||
const tabCount = isAgent ? 3 : 2;
|
||||
const gridCols = tabCount === 3 ? 'grid-cols-3' : 'grid-cols-2';
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2 text-base font-medium">
|
||||
<StepStatusIcon status={stepDetails.status} size="5" />
|
||||
<span>{selectedStep.displayName}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Timer className="w-4 h-4" />
|
||||
<span>
|
||||
{t('Duration')}:{' '}
|
||||
{formatUtils.formatDuration(stepDetails.duration ?? 0, false)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue={isAgent ? 'timeline' : 'input'} className="w-full">
|
||||
<TabsList className={`w-full grid ${gridCols}`}>
|
||||
<TabsTrigger value="input">{t('Input')}</TabsTrigger>
|
||||
{isAgent && (
|
||||
<TabsTrigger value="timeline">{t('Timeline')}</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="output">{t('Output')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="input">
|
||||
<JsonViewer json={stepDetails.input} title={t('Input')} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="timeline">
|
||||
<AgentTimeline agentResult={stepDetails.output as AgentResult} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="output">
|
||||
{isRunning ? (
|
||||
<div className="mt-4 space-y-4">
|
||||
<Skeleton className="h-8 w-1/3" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-4 w-1/4" />
|
||||
</div>
|
||||
) : (
|
||||
<JsonViewer json={parsedOutput} title={t('Output')} />
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useBuilderStateContext } from '@/app/builder/builder-hooks';
|
||||
import {
|
||||
flowStructureUtil,
|
||||
StepOutput,
|
||||
FlowAction,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { FlowStepInputOutput } from './flow-step-input-output';
|
||||
|
||||
type FlowStepIOProps = {
|
||||
stepDetails: StepOutput;
|
||||
};
|
||||
|
||||
const FlowStepIO = React.memo(({ stepDetails }: FlowStepIOProps) => {
|
||||
const [flowVersion, selectedStepName] = useBuilderStateContext((state) => [
|
||||
state.flowVersion,
|
||||
state.selectedStep,
|
||||
]);
|
||||
|
||||
const selectedStep = selectedStepName
|
||||
? (flowStructureUtil.getStep(
|
||||
selectedStepName,
|
||||
flowVersion.trigger,
|
||||
) as FlowAction)
|
||||
: undefined;
|
||||
|
||||
if (!selectedStep) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FlowStepInputOutput
|
||||
stepDetails={stepDetails}
|
||||
selectedStep={selectedStep}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
FlowStepIO.displayName = 'FlowStepIO';
|
||||
|
||||
export { FlowStepIO };
|
||||
@@ -0,0 +1,143 @@
|
||||
import { t } from 'i18next';
|
||||
import { ChevronLeft, Info } from 'lucide-react';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
LeftSideBarType,
|
||||
useBuilderStateContext,
|
||||
} from '@/app/builder/builder-hooks';
|
||||
import { CardList } from '@/components/custom/card-list';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from '@/components/ui/resizable-panel';
|
||||
import { LoadingSpinner } from '@/components/ui/spinner';
|
||||
import { flagsHooks } from '@/hooks/flags-hooks';
|
||||
import {
|
||||
ApFlagId,
|
||||
FlowRun,
|
||||
FlowRunStatus,
|
||||
isNil,
|
||||
RunEnvironment,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { flowRunUtils } from '../../../features/flow-runs/lib/flow-run-utils';
|
||||
import { SidebarHeader } from '../sidebar-header';
|
||||
|
||||
import { FlowStepIO } from './flow-step-io';
|
||||
import { FlowStepDetailsCardItem } from './run-step-card-item';
|
||||
|
||||
function getMessage(run: FlowRun | null, retentionDays: number | null) {
|
||||
if (
|
||||
!run ||
|
||||
[
|
||||
FlowRunStatus.RUNNING,
|
||||
FlowRunStatus.QUEUED,
|
||||
FlowRunStatus.SUCCEEDED,
|
||||
].includes(run.status)
|
||||
)
|
||||
return null;
|
||||
if ([FlowRunStatus.INTERNAL_ERROR].includes(run.status)) {
|
||||
return t('There are no logs captured for this run.');
|
||||
}
|
||||
if (isNil(run.logsFileId)) {
|
||||
return t(
|
||||
'Logs are kept for {days} days after execution and then deleted.',
|
||||
{ days: retentionDays },
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const FlowRunDetails = React.memo(() => {
|
||||
const { data: rententionDays } = flagsHooks.useFlag<number>(
|
||||
ApFlagId.EXECUTION_DATA_RETENTION_DAYS,
|
||||
);
|
||||
|
||||
const [setLeftSidebar, run, steps, loopsIndexes, flowVersion, selectedStep] =
|
||||
useBuilderStateContext((state) => {
|
||||
const steps =
|
||||
state.run && state.run.steps ? Object.keys(state.run.steps) : [];
|
||||
return [
|
||||
state.setLeftSidebar,
|
||||
state.run,
|
||||
steps,
|
||||
state.loopsIndexes,
|
||||
state.flowVersion,
|
||||
state.selectedStep,
|
||||
];
|
||||
});
|
||||
|
||||
const selectedStepOutput = useMemo(() => {
|
||||
return run && selectedStep && run.steps
|
||||
? flowRunUtils.extractStepOutput(
|
||||
selectedStep,
|
||||
loopsIndexes,
|
||||
run.steps,
|
||||
flowVersion.trigger,
|
||||
)
|
||||
: null;
|
||||
}, [run, selectedStep, loopsIndexes, flowVersion.trigger]);
|
||||
|
||||
const message = getMessage(run, rententionDays);
|
||||
|
||||
if (!isNil(message))
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center gap-4 w-full h-full">
|
||||
<Info size={36} className="text-muted-foreground" />
|
||||
<h4 className="px-6 text-sm text-center text-muted-foreground ">
|
||||
{message}
|
||||
</h4>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup direction="vertical">
|
||||
<SidebarHeader onClose={() => setLeftSidebar(LeftSideBarType.NONE)}>
|
||||
<div className="flex gap-2 items-center">
|
||||
{run && run.environment !== RunEnvironment.TESTING && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={'sm'}
|
||||
onClick={() => setLeftSidebar(LeftSideBarType.RUNS)}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</Button>
|
||||
)}
|
||||
<span>{t('Run Details')}</span>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<ResizablePanel className="h-full min-h-[80px]">
|
||||
<CardList className="pr-2 h-full" listClassName="gap-0.5">
|
||||
{steps.length > 0 &&
|
||||
steps
|
||||
.filter((path) => !isNil(path))
|
||||
.map((path) => (
|
||||
<FlowStepDetailsCardItem
|
||||
stepName={path}
|
||||
depth={0}
|
||||
key={path}
|
||||
></FlowStepDetailsCardItem>
|
||||
))}
|
||||
{steps.length === 0 && (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<LoadingSpinner></LoadingSpinner>
|
||||
</div>
|
||||
)}
|
||||
</CardList>
|
||||
</ResizablePanel>
|
||||
{selectedStepOutput && (
|
||||
<>
|
||||
<ResizableHandle withHandle={true} />
|
||||
<ResizablePanel defaultValue={25} className="min-h-[100px]">
|
||||
<FlowStepIO stepDetails={selectedStepOutput}></FlowStepIO>
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
});
|
||||
|
||||
FlowRunDetails.displayName = 'FlowRunDetails';
|
||||
export { FlowRunDetails };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user