diff --git a/bun.lock b/bun.lock index ff18082ffcf..1d7922526a3 100644 --- a/bun.lock +++ b/bun.lock @@ -97,10 +97,13 @@ "@tiptap/extension-document": "3.15.3", "@tiptap/extension-hard-break": "3.15.3", "@tiptap/extension-history": "3.15.3", + "@tiptap/extension-image": "3.15.3", "@tiptap/extension-mention": "3.15.3", "@tiptap/extension-paragraph": "3.15.3", "@tiptap/extension-placeholder": "3.15.3", + "@tiptap/extension-table": "3.15.3", "@tiptap/extension-text": "3.15.3", + "@tiptap/markdown": "3.15.3", "@tiptap/pm": "3.15.3", "@tiptap/react": "3.15.3", "@tiptap/starter-kit": "3.15.3", @@ -2087,6 +2090,8 @@ "@tiptap/extension-horizontal-rule": ["@tiptap/extension-horizontal-rule@3.15.3", "", { "peerDependencies": { "@tiptap/core": "^3.15.3", "@tiptap/pm": "^3.15.3" } }, "sha512-FYkN7L6JsfwwNEntmLklCVKvgL0B0N47OXMacRk6kYKQmVQ4Nvc7q/VJLpD9sk4wh4KT1aiCBfhKEBTu5pv1fg=="], + "@tiptap/extension-image": ["@tiptap/extension-image@3.15.3", "", { "peerDependencies": { "@tiptap/core": "^3.15.3" } }, "sha512-Tjq9BHlC/0bGR9/uySA0tv6I1Ua1Q5t5P/mdbWyZi4JdUpKHRfgenzfXF5DYnklJ01QJ7uOPSp9sAGgPzBixtQ=="], + "@tiptap/extension-italic": ["@tiptap/extension-italic@3.15.3", "", { "peerDependencies": { "@tiptap/core": "^3.15.3" } }, "sha512-6XeuPjcWy7OBxpkgOV7bD6PATO5jhIxc8SEK4m8xn8nelGTBIbHGqK37evRv+QkC7E0MUryLtzwnmmiaxcKL0Q=="], "@tiptap/extension-link": ["@tiptap/extension-link@3.15.3", "", { "dependencies": { "linkifyjs": "^4.3.2" }, "peerDependencies": { "@tiptap/core": "^3.15.3", "@tiptap/pm": "^3.15.3" } }, "sha512-PdDXyBF9Wco9U1x6e+b7tKBWG+kqBDXDmaYXHkFm/gYuQCQafVJ5mdrDdKgkHDWVnJzMWZXBcZjT9r57qtlLWg=="], @@ -2107,12 +2112,16 @@ "@tiptap/extension-strike": ["@tiptap/extension-strike@3.15.3", "", { "peerDependencies": { "@tiptap/core": "^3.15.3" } }, "sha512-Y1P3eGNY7RxQs2BcR6NfLo9VfEOplXXHAqkOM88oowWWOE7dMNeFFZM9H8HNxoQgXJ7H0aWW9B7ZTWM9hWli2Q=="], + "@tiptap/extension-table": ["@tiptap/extension-table@3.15.3", "", { "peerDependencies": { "@tiptap/core": "^3.15.3", "@tiptap/pm": "^3.15.3" } }, "sha512-dJk0u2JX1J/3x/ps641qdxQPOiie5txQhs2M1srgDeeFu//ORCePAxryJCw1bgf0TEVwFWwFTCtcOFR5SSgMZQ=="], + "@tiptap/extension-text": ["@tiptap/extension-text@3.15.3", "", { "peerDependencies": { "@tiptap/core": "^3.15.3" } }, "sha512-MhkBz8ZvrqOKtKNp+ZWISKkLUlTrDR7tbKZc2OnNcUTttL9dz0HwT+cg91GGz19fuo7ttDcfsPV6eVmflvGToA=="], "@tiptap/extension-underline": ["@tiptap/extension-underline@3.15.3", "", { "peerDependencies": { "@tiptap/core": "^3.15.3" } }, "sha512-r/IwcNN0W366jGu4Y0n2MiFq9jGa4aopOwtfWO4d+J0DyeS2m7Go3+KwoUqi0wQTiVU74yfi4DF6eRsMQ9/iHQ=="], "@tiptap/extensions": ["@tiptap/extensions@3.15.3", "", { "peerDependencies": { "@tiptap/core": "^3.15.3", "@tiptap/pm": "^3.15.3" } }, "sha512-ycx/BgxR4rc9tf3ZyTdI98Z19yKLFfqM3UN+v42ChuIwkzyr9zyp7kG8dB9xN2lNqrD+5y/HyJobz/VJ7T90gA=="], + "@tiptap/markdown": ["@tiptap/markdown@3.15.3", "", { "dependencies": { "marked": "^15.0.12" }, "peerDependencies": { "@tiptap/core": "^3.15.3", "@tiptap/pm": "^3.15.3" } }, "sha512-JjjZ/X7H2+/Jeapk8GurbncJVyG9ai5YD/eJLBKDyWqsoQwsrHbDlYBx26q9J5VwWXzxe9G6fmHNlAfw0Pokow=="], + "@tiptap/pm": ["@tiptap/pm@3.15.3", "", { "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", "prosemirror-commands": "^1.6.2", "prosemirror-dropcursor": "^1.8.1", "prosemirror-gapcursor": "^1.3.2", "prosemirror-history": "^1.4.1", "prosemirror-inputrules": "^1.4.0", "prosemirror-keymap": "^1.2.2", "prosemirror-markdown": "^1.13.1", "prosemirror-menu": "^1.2.4", "prosemirror-model": "^1.24.1", "prosemirror-schema-basic": "^1.2.3", "prosemirror-schema-list": "^1.5.0", "prosemirror-state": "^1.4.3", "prosemirror-tables": "^1.6.4", "prosemirror-trailing-node": "^3.0.0", "prosemirror-transform": "^1.10.2", "prosemirror-view": "^1.38.1" } }, "sha512-Zm1BaU1TwFi3CQiisxjgnzzIus+q40bBKWLqXf6WEaus8Z6+vo1MT2pU52dBCMIRaW9XNDq3E5cmGtMc1AlveA=="], "@tiptap/react": ["@tiptap/react@3.15.3", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "fast-equals": "^5.3.3", "use-sync-external-store": "^1.4.0" }, "optionalDependencies": { "@tiptap/extension-bubble-menu": "^3.15.3", "@tiptap/extension-floating-menu": "^3.15.3" }, "peerDependencies": { "@tiptap/core": "^3.15.3", "@tiptap/pm": "^3.15.3", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XvouB+Hrqw8yFmZLPEh+HWlMeRSjZfHSfWfWuw5d8LSwnxnPeu3Bg/rjHrRrdwb+7FumtzOnNWMorpb/PSOttQ=="], @@ -4485,6 +4494,8 @@ "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "matcher-collection": ["matcher-collection@2.0.1", "", { "dependencies": { "@types/minimatch": "^3.0.3", "minimatch": "^3.0.2" } }, "sha512-daE62nS2ZQsDg9raM0IlZzLmI2u+7ZapXBwdoeBUKAYERPDDIc0qNqA8E0Rp2D+gspKR7BgIFP52GeujaGXWeQ=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], diff --git a/docs/flows/known-limits.mdx b/docs/flows/known-limits.mdx index 319ec35f8b1..c306a875411 100644 --- a/docs/flows/known-limits.mdx +++ b/docs/flows/known-limits.mdx @@ -39,8 +39,11 @@ Files from actions or triggers are stored in the database/S3 to support retries - **Maximum File Size**: **10 MB** - (Configurable via `AP_MAX_FILE_SIZE_MB`, default: **4 MB**) + (Configurable via `AP_MAX_FILE_SIZE_MB`, default: **25 MB**) +- **Maximum Flow Run Log Size**: **25 MB** + (Configurable via `AP_MAX_FLOW_RUN_LOG_SIZE_MB`, default: **25 MB**) + > This is the total combined size of all inputs and outputs for each step in a single flow run. --- ### Key / Value Storage Limits diff --git a/docs/install/configuration/breaking-changes.mdx b/docs/install/configuration/breaking-changes.mdx index fabf53729e9..8009ea41201 100644 --- a/docs/install/configuration/breaking-changes.mdx +++ b/docs/install/configuration/breaking-changes.mdx @@ -6,12 +6,16 @@ icon: "hammer" ## 0.77.0 ### What has changed? -- If you are on the embed plan, the "Use a Template" dialog is no longer shown when clicking the "New Flow" button. -- We removed /flow-templates endpoints and replaced them with /templates + +- For Embed Plan users: the "Use a Template" dialog no longer appears when clicking the "New Flow" button. +- The `/flow-templates` API endpoints have been removed and replaced by `/templates`. +- Log size configuration has changed: `AP_MAX_FILE_SIZE_MB` no longer controls flow run logs. Use `AP_MAX_FLOW_RUN_LOG_SIZE_MB` instead. ### Do you need to take action? -- If you are on the embed plan, update your implementation to redirect your users to the `/templates` page instead. -- Check out new endpoints https://www.activepieces.com/docs/endpoints/templates/schema + +- If you are on the embed plan, update your implementation to redirect users to the `/templates` page. +- Review the new endpoints documentation: [Templates API Schema](https://www.activepieces.com/docs/endpoints/templates/schema). +- If you use a custom value for `AP_MAX_FILE_SIZE_MB`, be sure to also set `AP_MAX_FLOW_RUN_LOG_SIZE_MB` accordingly. ## 0.75.0 diff --git a/docs/install/configuration/environment-variables.mdx b/docs/install/configuration/environment-variables.mdx index a8ce72469cd..24d431cd623 100644 --- a/docs/install/configuration/environment-variables.mdx +++ b/docs/install/configuration/environment-variables.mdx @@ -77,7 +77,8 @@ it will produce these values. | `AP_SMTP_PASSWORD` | The password for the SMTP server that activepieces uses to send emails | `None` | secret1234 | | `AP_SMTP_SENDER_EMAIL` | The email address from which activepieces sends emails. | `None` | test@mail.example.com | | `AP_SMTP_SENDER_NAME` | The sender name activepieces uses to send emails. -| `AP_MAX_FILE_SIZE_MB` | The maximum allowed file size in megabytes for uploads including logs of flow runs. If logs exceed this size, they will be truncated which may cause flow execution issues. | `10` | `10` | +| `AP_MAX_FILE_SIZE_MB` | The maximum allowed file size (in megabytes) for **uploaded files** in steps or triggers. Files larger than this value will be rejected. This does **not** control flow run log size—see `AP_MAX_FLOW_RUN_LOG_SIZE_MB`. | `25` | `10` | +| `AP_MAX_FLOW_RUN_LOG_SIZE_MB` | The maximum allowed size (in megabytes) of the **flow run logs**—this is the total combined size of all inputs and outputs for each step in a single flow run. If logs exceed this size, they will be truncated, which may cause flow execution issues. | `25` | `25` | | `AP_FILE_STORAGE_LOCATION` | The location to store files. Possible values are `DB` for storing files in the database or `S3` for storing files in an S3-compatible storage service. | `DB` | | | `AP_PAUSED_FLOW_TIMEOUT_DAYS` | The maximum allowed pause duration in days for a paused flow, please note it can not exceed `AP_EXECUTION_DATA_RETENTION_DAYS` | `30` | | `AP_MAX_RECORDS_PER_TABLE` | The maximum allowed number of records per table | `10000` | `10000` diff --git a/docs/openapi.json b/docs/openapi.json index 61da7d5e2ef..5ec69e4fc84 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -496,6 +496,110 @@ "schemaVersion": { "type": "string", "nullable": true + }, + "notes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "content": { + "type": "string" + }, + "ownerId": { + "type": "string", + "nullable": true + }, + "color": { + "anyOf": [ + { + "type": "string", + "enum": [ + "orange" + ] + }, + { + "type": "string", + "enum": [ + "red" + ] + }, + { + "type": "string", + "enum": [ + "green" + ] + }, + { + "type": "string", + "enum": [ + "blue" + ] + }, + { + "type": "string", + "enum": [ + "purple" + ] + }, + { + "type": "string", + "enum": [ + "yellow" + ] + } + ] + }, + "position": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ] + }, + "size": { + "type": "object", + "properties": { + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "required": [ + "width", + "height" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "content", + "color", + "position", + "size", + "createdAt", + "updatedAt" + ] + }, + "nullable": true } }, "required": [ @@ -2561,101 +2665,367 @@ "type", "request" ] - } - ] - }, - "project": { - "type": "object", - "properties": { - "displayName": { - "type": "string" - } - }, - "required": [ - "displayName" - ] - } - }, - "required": [ - "flowVersion", - "request" - ] - } - }, - "required": [ - "id", - "created", - "updated", - "platformId", - "action", - "data" - ] - }, - "flow.deleted": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "created": { - "type": "string" - }, - "updated": { - "type": "string" - }, - "platformId": { - "type": "string" - }, - "projectId": { - "type": "string" - }, - "projectDisplayName": { - "type": "string" - }, - "userId": { - "type": "string" - }, - "userEmail": { - "type": "string" - }, - "ip": { - "type": "string" - }, - "action": { - "type": "string", - "enum": [ - "flow.deleted" - ] - }, - "data": { - "type": "object", - "properties": { - "flow": { - "type": "object", - "properties": { - "id": { - "type": "string" }, - "created": { - "type": "string" + { + "title": "Update Owner", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "UPDATE_OWNER" + ] + }, + "request": { + "type": "object", + "properties": { + "ownerId": { + "type": "string" + } + }, + "required": [ + "ownerId" + ] + } + }, + "required": [ + "type", + "request" + ] }, - "updated": { - "type": "string" - } - }, - "required": [ - "id", - "created", - "updated" - ] - }, - "flowVersion": { - "type": "object", - "properties": { - "id": { - "type": "string" + { + "title": "Update Note", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "UPDATE_NOTE" + ] + }, + "request": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "content": { + "type": "string" + }, + "ownerId": { + "type": "string", + "nullable": true + }, + "color": { + "anyOf": [ + { + "type": "string", + "enum": [ + "orange" + ] + }, + { + "type": "string", + "enum": [ + "red" + ] + }, + { + "type": "string", + "enum": [ + "green" + ] + }, + { + "type": "string", + "enum": [ + "blue" + ] + }, + { + "type": "string", + "enum": [ + "purple" + ] + }, + { + "type": "string", + "enum": [ + "yellow" + ] + } + ] + }, + "position": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ] + }, + "size": { + "type": "object", + "properties": { + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "required": [ + "width", + "height" + ] + } + }, + "required": [ + "id", + "content", + "color", + "position", + "size" + ] + } + }, + "required": [ + "type", + "request" + ] }, - "displayName": { + { + "title": "Delete Note", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "DELETE_NOTE" + ] + }, + "request": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + } + }, + "required": [ + "type", + "request" + ] + }, + { + "title": "Add Note", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "ADD_NOTE" + ] + }, + "request": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "content": { + "type": "string" + }, + "color": { + "anyOf": [ + { + "type": "string", + "enum": [ + "orange" + ] + }, + { + "type": "string", + "enum": [ + "red" + ] + }, + { + "type": "string", + "enum": [ + "green" + ] + }, + { + "type": "string", + "enum": [ + "blue" + ] + }, + { + "type": "string", + "enum": [ + "purple" + ] + }, + { + "type": "string", + "enum": [ + "yellow" + ] + } + ] + }, + "position": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ] + }, + "size": { + "type": "object", + "properties": { + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "required": [ + "width", + "height" + ] + } + }, + "required": [ + "id", + "content", + "color", + "position", + "size" + ] + } + }, + "required": [ + "type", + "request" + ] + } + ] + }, + "project": { + "type": "object", + "properties": { + "displayName": { + "type": "string" + } + }, + "required": [ + "displayName" + ] + } + }, + "required": [ + "flowVersion", + "request" + ] + } + }, + "required": [ + "id", + "created", + "updated", + "platformId", + "action", + "data" + ] + }, + "flow.deleted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "created": { + "type": "string" + }, + "updated": { + "type": "string" + }, + "platformId": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "projectDisplayName": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "userEmail": { + "type": "string" + }, + "ip": { + "type": "string" + }, + "action": { + "type": "string", + "enum": [ + "flow.deleted" + ] + }, + "data": { + "type": "object", + "properties": { + "flow": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "created": { + "type": "string" + }, + "updated": { + "type": "string" + } + }, + "required": [ + "id", + "created", + "updated" + ] + }, + "flowVersion": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "displayName": { "type": "string" }, "flowId": { @@ -4520,99 +4890,26 @@ "categories": { "type": "array", "items": { - "anyOf": [ - { - "type": "string", - "enum": [ - "ANALYTICS" - ] - }, - { - "type": "string", - "enum": [ - "COMMUNICATION" - ] - }, - { - "type": "string", - "enum": [ - "CONTENT" - ] - }, - { - "type": "string", - "enum": [ - "CUSTOMER_SUPPORT" - ] - }, - { - "type": "string", - "enum": [ - "DEVELOPMENT" - ] - }, - { - "type": "string", - "enum": [ - "E_COMMERCE" - ] - }, - { - "type": "string", - "enum": [ - "FINANCE" - ] - }, - { - "type": "string", - "enum": [ - "HR" - ] - }, - { - "type": "string", - "enum": [ - "IT_OPERATIONS" - ] - }, - { - "type": "string", - "enum": [ - "MARKETING" - ] - }, - { - "type": "string", - "enum": [ - "PRODUCTIVITY" - ] - }, - { - "type": "string", - "enum": [ - "SALES" - ] - } - ] - } - }, - "pieces": { - "type": "array", - "items": { - "type": "string" - } - }, - "platformId": { - "type": "string", - "nullable": true - }, - "flows": { - "type": "array", - "items": { - "type": "object", - "properties": { - "displayName": { - "type": "string" + "type": "string" + } + }, + "pieces": { + "type": "array", + "items": { + "type": "string" + } + }, + "platformId": { + "type": "string", + "nullable": true + }, + "flows": { + "type": "array", + "items": { + "type": "object", + "properties": { + "displayName": { + "type": "string" }, "trigger": { "anyOf": [ @@ -4753,6 +5050,109 @@ "type": "string", "nullable": true }, + "notes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "content": { + "type": "string" + }, + "ownerId": { + "type": "string", + "nullable": true + }, + "color": { + "anyOf": [ + { + "type": "string", + "enum": [ + "orange" + ] + }, + { + "type": "string", + "enum": [ + "red" + ] + }, + { + "type": "string", + "enum": [ + "green" + ] + }, + { + "type": "string", + "enum": [ + "blue" + ] + }, + { + "type": "string", + "enum": [ + "purple" + ] + }, + { + "type": "string", + "enum": [ + "yellow" + ] + } + ] + }, + "position": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ] + }, + "size": { + "type": "object", + "properties": { + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "required": [ + "width", + "height" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "content", + "color", + "position", + "size", + "createdAt", + "updatedAt" + ] + } + }, "description": { "type": "string" } @@ -4760,7 +5160,8 @@ "required": [ "displayName", "trigger", - "valid" + "valid", + "notes" ] } }, @@ -11100,6 +11501,109 @@ "additionalProperties": { "type": "string" } + }, + "notes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "content": { + "type": "string" + }, + "ownerId": { + "type": "string", + "nullable": true + }, + "color": { + "anyOf": [ + { + "type": "string", + "enum": [ + "orange" + ] + }, + { + "type": "string", + "enum": [ + "red" + ] + }, + { + "type": "string", + "enum": [ + "green" + ] + }, + { + "type": "string", + "enum": [ + "blue" + ] + }, + { + "type": "string", + "enum": [ + "purple" + ] + }, + { + "type": "string", + "enum": [ + "yellow" + ] + } + ] + }, + "position": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ] + }, + "size": { + "type": "object", + "properties": { + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "required": [ + "width", + "height" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "content", + "color", + "position", + "size", + "createdAt", + "updatedAt" + ] + } } }, "required": [ @@ -11112,7 +11616,8 @@ "valid", "agentIds", "state", - "connectionIds" + "connectionIds", + "notes" ] }, "triggerSource": { @@ -11578,67 +12083,171 @@ "additionalProperties": { "type": "string" } - } - }, - "required": [ - "id", - "created", - "updated", - "flowId", - "displayName", - "trigger", - "valid", - "agentIds", - "state", - "connectionIds" - ] - }, - "triggerSource": { - "type": "object", - "properties": { - "schedule": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "CRON_EXPRESSION" - ] - }, - "cronExpression": { - "type": "string" - }, - "timezone": { - "type": "string" - } - }, - "required": [ - "type", - "cronExpression", - "timezone" - ], - "nullable": true - } - } - } - }, - "required": [ - "id", - "created", - "updated", - "projectId", - "externalId", - "status", - "operationStatus", - "version" - ] - } - }, - "next": { - "description": "Cursor to the next page", - "type": "string", - "nullable": true - }, + }, + "notes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "content": { + "type": "string" + }, + "ownerId": { + "type": "string", + "nullable": true + }, + "color": { + "anyOf": [ + { + "type": "string", + "enum": [ + "orange" + ] + }, + { + "type": "string", + "enum": [ + "red" + ] + }, + { + "type": "string", + "enum": [ + "green" + ] + }, + { + "type": "string", + "enum": [ + "blue" + ] + }, + { + "type": "string", + "enum": [ + "purple" + ] + }, + { + "type": "string", + "enum": [ + "yellow" + ] + } + ] + }, + "position": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ] + }, + "size": { + "type": "object", + "properties": { + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "required": [ + "width", + "height" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "content", + "color", + "position", + "size", + "createdAt", + "updatedAt" + ] + } + } + }, + "required": [ + "id", + "created", + "updated", + "flowId", + "displayName", + "trigger", + "valid", + "agentIds", + "state", + "connectionIds", + "notes" + ] + }, + "triggerSource": { + "type": "object", + "properties": { + "schedule": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "CRON_EXPRESSION" + ] + }, + "cronExpression": { + "type": "string" + }, + "timezone": { + "type": "string" + } + }, + "required": [ + "type", + "cronExpression", + "timezone" + ], + "nullable": true + } + } + } + }, + "required": [ + "id", + "created", + "updated", + "projectId", + "externalId", + "status", + "operationStatus", + "version" + ] + } + }, + "next": { + "description": "Cursor to the next page", + "type": "string", + "nullable": true + }, "previous": { "description": "Cursor to the previous page", "type": "string", @@ -11997,6 +12606,110 @@ "schemaVersion": { "type": "string", "nullable": true + }, + "notes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "content": { + "type": "string" + }, + "ownerId": { + "type": "string", + "nullable": true + }, + "color": { + "anyOf": [ + { + "type": "string", + "enum": [ + "orange" + ] + }, + { + "type": "string", + "enum": [ + "red" + ] + }, + { + "type": "string", + "enum": [ + "green" + ] + }, + { + "type": "string", + "enum": [ + "blue" + ] + }, + { + "type": "string", + "enum": [ + "purple" + ] + }, + { + "type": "string", + "enum": [ + "yellow" + ] + } + ] + }, + "position": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ] + }, + "size": { + "type": "object", + "properties": { + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "required": [ + "width", + "height" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "content", + "color", + "position", + "size", + "createdAt", + "updatedAt" + ] + }, + "nullable": true } }, "required": [ @@ -14062,78 +14775,344 @@ "type", "request" ] - } - ] - } - } - } - }, - "parameters": [ - { - "schema": { - "pattern": "^[0-9a-zA-Z]{21}$", - "type": "string" - }, - "in": "path", - "name": "id", - "required": true - } - ], - "security": [ - { - "apiKey": [] - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - }, - "get": { - "tags": [ - "flows" - ], - "description": "Get a flow by id", - "parameters": [ - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "versionId", - "required": false - }, - { - "schema": { - "pattern": "^[0-9a-zA-Z]{21}$", - "type": "string" - }, - "in": "path", - "name": "id", - "required": true - } - ], - "security": [ - { - "apiKey": [] - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "created": { - "type": "string" - }, - "updated": { + }, + { + "title": "Update Owner", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "UPDATE_OWNER" + ] + }, + "request": { + "type": "object", + "properties": { + "ownerId": { + "type": "string" + } + }, + "required": [ + "ownerId" + ] + } + }, + "required": [ + "type", + "request" + ] + }, + { + "title": "Update Note", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "UPDATE_NOTE" + ] + }, + "request": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "content": { + "type": "string" + }, + "ownerId": { + "type": "string", + "nullable": true + }, + "color": { + "anyOf": [ + { + "type": "string", + "enum": [ + "orange" + ] + }, + { + "type": "string", + "enum": [ + "red" + ] + }, + { + "type": "string", + "enum": [ + "green" + ] + }, + { + "type": "string", + "enum": [ + "blue" + ] + }, + { + "type": "string", + "enum": [ + "purple" + ] + }, + { + "type": "string", + "enum": [ + "yellow" + ] + } + ] + }, + "position": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ] + }, + "size": { + "type": "object", + "properties": { + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "required": [ + "width", + "height" + ] + } + }, + "required": [ + "id", + "content", + "color", + "position", + "size" + ] + } + }, + "required": [ + "type", + "request" + ] + }, + { + "title": "Delete Note", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "DELETE_NOTE" + ] + }, + "request": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + } + }, + "required": [ + "type", + "request" + ] + }, + { + "title": "Add Note", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "ADD_NOTE" + ] + }, + "request": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "content": { + "type": "string" + }, + "color": { + "anyOf": [ + { + "type": "string", + "enum": [ + "orange" + ] + }, + { + "type": "string", + "enum": [ + "red" + ] + }, + { + "type": "string", + "enum": [ + "green" + ] + }, + { + "type": "string", + "enum": [ + "blue" + ] + }, + { + "type": "string", + "enum": [ + "purple" + ] + }, + { + "type": "string", + "enum": [ + "yellow" + ] + } + ] + }, + "position": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ] + }, + "size": { + "type": "object", + "properties": { + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "required": [ + "width", + "height" + ] + } + }, + "required": [ + "id", + "content", + "color", + "position", + "size" + ] + } + }, + "required": [ + "type", + "request" + ] + } + ] + } + } + } + }, + "parameters": [ + { + "schema": { + "pattern": "^[0-9a-zA-Z]{21}$", + "type": "string" + }, + "in": "path", + "name": "id", + "required": true + } + ], + "security": [ + { + "apiKey": [] + } + ], + "responses": { + "200": { + "description": "Default Response" + } + } + }, + "get": { + "tags": [ + "flows" + ], + "description": "Get a flow by id", + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "versionId", + "required": false + }, + { + "schema": { + "pattern": "^[0-9a-zA-Z]{21}$", + "type": "string" + }, + "in": "path", + "name": "id", + "required": true + } + ], + "security": [ + { + "apiKey": [] + } + ], + "responses": { + "200": { + "description": "Default Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "created": { + "type": "string" + }, + "updated": { "type": "string" }, "projectId": { @@ -14402,6 +15381,109 @@ "additionalProperties": { "type": "string" } + }, + "notes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "content": { + "type": "string" + }, + "ownerId": { + "type": "string", + "nullable": true + }, + "color": { + "anyOf": [ + { + "type": "string", + "enum": [ + "orange" + ] + }, + { + "type": "string", + "enum": [ + "red" + ] + }, + { + "type": "string", + "enum": [ + "green" + ] + }, + { + "type": "string", + "enum": [ + "blue" + ] + }, + { + "type": "string", + "enum": [ + "purple" + ] + }, + { + "type": "string", + "enum": [ + "yellow" + ] + } + ] + }, + "position": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ] + }, + "size": { + "type": "object", + "properties": { + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "required": [ + "width", + "height" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "content", + "color", + "position", + "size", + "createdAt", + "updatedAt" + ] + } } }, "required": [ @@ -14414,7 +15496,8 @@ "valid", "agentIds", "state", - "connectionIds" + "connectionIds", + "notes" ] }, "triggerSource": { @@ -14603,80 +15686,7 @@ "categories": { "type": "array", "items": { - "anyOf": [ - { - "type": "string", - "enum": [ - "ANALYTICS" - ] - }, - { - "type": "string", - "enum": [ - "COMMUNICATION" - ] - }, - { - "type": "string", - "enum": [ - "CONTENT" - ] - }, - { - "type": "string", - "enum": [ - "CUSTOMER_SUPPORT" - ] - }, - { - "type": "string", - "enum": [ - "DEVELOPMENT" - ] - }, - { - "type": "string", - "enum": [ - "E_COMMERCE" - ] - }, - { - "type": "string", - "enum": [ - "FINANCE" - ] - }, - { - "type": "string", - "enum": [ - "HR" - ] - }, - { - "type": "string", - "enum": [ - "IT_OPERATIONS" - ] - }, - { - "type": "string", - "enum": [ - "MARKETING" - ] - }, - { - "type": "string", - "enum": [ - "PRODUCTIVITY" - ] - }, - { - "type": "string", - "enum": [ - "SALES" - ] - } - ] + "type": "string" } }, "pieces": { @@ -14815,22 +15825,125 @@ }, "settings": {} }, - "required": [ - "name", - "valid", - "displayName", - "type", - "settings" - ] - } - ] - }, - "valid": { - "type": "boolean" - }, - "schemaVersion": { - "type": "string", - "nullable": true + "required": [ + "name", + "valid", + "displayName", + "type", + "settings" + ] + } + ] + }, + "valid": { + "type": "boolean" + }, + "schemaVersion": { + "type": "string", + "nullable": true + }, + "notes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "content": { + "type": "string" + }, + "ownerId": { + "type": "string", + "nullable": true + }, + "color": { + "anyOf": [ + { + "type": "string", + "enum": [ + "orange" + ] + }, + { + "type": "string", + "enum": [ + "red" + ] + }, + { + "type": "string", + "enum": [ + "green" + ] + }, + { + "type": "string", + "enum": [ + "blue" + ] + }, + { + "type": "string", + "enum": [ + "purple" + ] + }, + { + "type": "string", + "enum": [ + "yellow" + ] + } + ] + }, + "position": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ] + }, + "size": { + "type": "object", + "properties": { + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "required": [ + "width", + "height" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "content", + "color", + "position", + "size", + "createdAt", + "updatedAt" + ] + } }, "description": { "type": "string" @@ -14839,7 +15952,8 @@ "required": [ "displayName", "trigger", - "valid" + "valid", + "notes" ] } }, @@ -17189,7 +18303,7 @@ } } }, - "/v1/projects/{projectId}/mcp-server/{projectId}": { + "/v1/projects/{projectId}/mcp-server": { "get": { "tags": [ "mcp" @@ -17216,9 +18330,7 @@ "description": "Default Response" } } - } - }, - "/v1/projects/{projectId}/mcp-server": { + }, "post": { "tags": [ "mcp" @@ -19154,80 +20266,7 @@ "categories": { "type": "array", "items": { - "anyOf": [ - { - "type": "string", - "enum": [ - "ANALYTICS" - ] - }, - { - "type": "string", - "enum": [ - "COMMUNICATION" - ] - }, - { - "type": "string", - "enum": [ - "CONTENT" - ] - }, - { - "type": "string", - "enum": [ - "CUSTOMER_SUPPORT" - ] - }, - { - "type": "string", - "enum": [ - "DEVELOPMENT" - ] - }, - { - "type": "string", - "enum": [ - "E_COMMERCE" - ] - }, - { - "type": "string", - "enum": [ - "FINANCE" - ] - }, - { - "type": "string", - "enum": [ - "HR" - ] - }, - { - "type": "string", - "enum": [ - "IT_OPERATIONS" - ] - }, - { - "type": "string", - "enum": [ - "MARKETING" - ] - }, - { - "type": "string", - "enum": [ - "PRODUCTIVITY" - ] - }, - { - "type": "string", - "enum": [ - "SALES" - ] - } - ] + "type": "string" } }, "flows": { @@ -19377,6 +20416,109 @@ "type": "string", "nullable": true }, + "notes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "content": { + "type": "string" + }, + "ownerId": { + "type": "string", + "nullable": true + }, + "color": { + "anyOf": [ + { + "type": "string", + "enum": [ + "orange" + ] + }, + { + "type": "string", + "enum": [ + "red" + ] + }, + { + "type": "string", + "enum": [ + "green" + ] + }, + { + "type": "string", + "enum": [ + "blue" + ] + }, + { + "type": "string", + "enum": [ + "purple" + ] + }, + { + "type": "string", + "enum": [ + "yellow" + ] + } + ] + }, + "position": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ] + }, + "size": { + "type": "object", + "properties": { + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "required": [ + "width", + "height" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "content", + "color", + "position", + "size", + "createdAt", + "updatedAt" + ] + } + }, "description": { "type": "string" } @@ -19384,7 +20526,8 @@ "required": [ "displayName", "trigger", - "valid" + "valid", + "notes" ] } } @@ -19503,102 +20646,29 @@ "in": "query", "name": "pieces", "required": false - }, - { - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "in": "query", - "name": "tags", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "search", - "required": false - }, - { - "schema": { - "anyOf": [ - { - "type": "string", - "enum": [ - "ANALYTICS" - ] - }, - { - "type": "string", - "enum": [ - "COMMUNICATION" - ] - }, - { - "type": "string", - "enum": [ - "CONTENT" - ] - }, - { - "type": "string", - "enum": [ - "CUSTOMER_SUPPORT" - ] - }, - { - "type": "string", - "enum": [ - "DEVELOPMENT" - ] - }, - { - "type": "string", - "enum": [ - "E_COMMERCE" - ] - }, - { - "type": "string", - "enum": [ - "FINANCE" - ] - }, - { - "type": "string", - "enum": [ - "HR" - ] - }, - { - "type": "string", - "enum": [ - "IT_OPERATIONS" - ] - }, - { - "type": "string", - "enum": [ - "MARKETING" - ] - }, - { - "type": "string", - "enum": [ - "PRODUCTIVITY" - ] - }, - { - "type": "string", - "enum": [ - "SALES" - ] - } - ] + }, + { + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "in": "query", + "name": "tags", + "required": false + }, + { + "schema": { + "type": "string" + }, + "in": "query", + "name": "search", + "required": false + }, + { + "schema": { + "type": "string" }, "in": "query", "name": "category", @@ -19672,80 +20742,7 @@ "categories": { "type": "array", "items": { - "anyOf": [ - { - "type": "string", - "enum": [ - "ANALYTICS" - ] - }, - { - "type": "string", - "enum": [ - "COMMUNICATION" - ] - }, - { - "type": "string", - "enum": [ - "CONTENT" - ] - }, - { - "type": "string", - "enum": [ - "CUSTOMER_SUPPORT" - ] - }, - { - "type": "string", - "enum": [ - "DEVELOPMENT" - ] - }, - { - "type": "string", - "enum": [ - "E_COMMERCE" - ] - }, - { - "type": "string", - "enum": [ - "FINANCE" - ] - }, - { - "type": "string", - "enum": [ - "HR" - ] - }, - { - "type": "string", - "enum": [ - "IT_OPERATIONS" - ] - }, - { - "type": "string", - "enum": [ - "MARKETING" - ] - }, - { - "type": "string", - "enum": [ - "PRODUCTIVITY" - ] - }, - { - "type": "string", - "enum": [ - "SALES" - ] - } - ] + "type": "string" } }, "type": { @@ -19917,6 +20914,109 @@ "type": "string", "nullable": true }, + "notes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "content": { + "type": "string" + }, + "ownerId": { + "type": "string", + "nullable": true + }, + "color": { + "anyOf": [ + { + "type": "string", + "enum": [ + "orange" + ] + }, + { + "type": "string", + "enum": [ + "red" + ] + }, + { + "type": "string", + "enum": [ + "green" + ] + }, + { + "type": "string", + "enum": [ + "blue" + ] + }, + { + "type": "string", + "enum": [ + "purple" + ] + }, + { + "type": "string", + "enum": [ + "yellow" + ] + } + ] + }, + "position": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + } + }, + "required": [ + "x", + "y" + ] + }, + "size": { + "type": "object", + "properties": { + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "required": [ + "width", + "height" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "content", + "color", + "position", + "size", + "createdAt", + "updatedAt" + ] + } + }, "description": { "type": "string" } @@ -19924,7 +21024,8 @@ "required": [ "displayName", "trigger", - "valid" + "valid", + "notes" ] } } @@ -19982,57 +21083,6 @@ } } }, - "/v1/flow-templates": { - "get": { - "tags": [ - "templates" - ], - "description": "List flow templates. This endpoint is deprecated, use /v1/templates instead.", - "parameters": [ - { - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "in": "query", - "name": "pieces", - "required": false - }, - { - "schema": { - "type": "array", - "items": { - "type": "string" - } - }, - "in": "query", - "name": "tags", - "required": false - }, - { - "schema": { - "type": "string" - }, - "in": "query", - "name": "search", - "required": false - } - ], - "deprecated": true, - "security": [ - { - "apiKey": [] - } - ], - "responses": { - "200": { - "description": "Default Response" - } - } - } - }, "/v1/projects": { "post": { "tags": [ @@ -23508,75 +24558,6 @@ } } } - }, - "/v1/queue-metrics": { - "get": { - "tags": [ - "queue-metrics" - ], - "description": "Get metrics", - "security": [ - { - "apiKey": [] - } - ], - "responses": { - "200": { - "description": "Default Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "stats": { - "type": "object", - "properties": { - "active": { - "type": "number" - }, - "delayed": { - "type": "number" - }, - "prioritized": { - "type": "number" - }, - "waiting": { - "type": "number" - }, - "waiting-children": { - "type": "number" - }, - "completed": { - "type": "number" - }, - "failed": { - "type": "number" - }, - "paused": { - "type": "number" - } - }, - "required": [ - "active", - "delayed", - "prioritized", - "waiting", - "waiting-children", - "completed", - "failed", - "paused" - ] - } - }, - "required": [ - "stats" - ] - } - } - } - } - } - } } }, "servers": [ diff --git a/package.json b/package.json index 6a8e4b3765c..588f9e518af 100644 --- a/package.json +++ b/package.json @@ -122,10 +122,13 @@ "@tiptap/extension-document": "3.15.3", "@tiptap/extension-hard-break": "3.15.3", "@tiptap/extension-history": "3.15.3", + "@tiptap/extension-image": "3.15.3", "@tiptap/extension-mention": "3.15.3", "@tiptap/extension-paragraph": "3.15.3", "@tiptap/extension-placeholder": "3.15.3", + "@tiptap/extension-table": "3.15.3", "@tiptap/extension-text": "3.15.3", + "@tiptap/markdown": "3.15.3", "@tiptap/pm": "3.15.3", "@tiptap/react": "3.15.3", "@tiptap/starter-kit": "3.15.3", diff --git a/packages/ee/shared/src/lib/audit-events/index.ts b/packages/ee/shared/src/lib/audit-events/index.ts index f7355d0762a..691ff11dd1e 100644 --- a/packages/ee/shared/src/lib/audit-events/index.ts +++ b/packages/ee/shared/src/lib/audit-events/index.ts @@ -384,5 +384,11 @@ function convertUpdateActionToDetails(event: FlowUpdatedEvent) { } in flow "${event.data.flowVersion.displayName}" for the step "${ event.data.request.request.stepName }".` + case FlowOperationType.ADD_NOTE: + return `Added note to flow "${event.data.flowVersion.displayName}".` + case FlowOperationType.UPDATE_NOTE: + return `Updated note in flow "${event.data.flowVersion.displayName}".` + case FlowOperationType.DELETE_NOTE: + return `Deleted note in flow "${event.data.flowVersion.displayName}".` } } \ No newline at end of file diff --git a/packages/engine/src/lib/handler/context/flow-execution-context.ts b/packages/engine/src/lib/handler/context/flow-execution-context.ts index d51bbf67edd..e251da1d7b0 100644 --- a/packages/engine/src/lib/handler/context/flow-execution-context.ts +++ b/packages/engine/src/lib/handler/context/flow-execution-context.ts @@ -26,6 +26,7 @@ export class FlowExecutorContext { currentPath: StepExecutionPath stepNameToTest?: boolean stepsCount: number + stepsSize: Map /** * Execution time in milliseconds @@ -41,6 +42,7 @@ export class FlowExecutorContext { this.currentPath = copyFrom?.currentPath ?? StepExecutionPath.empty() this.stepNameToTest = copyFrom?.stepNameToTest ?? false this.stepsCount = copyFrom?.stepsCount ?? 0 + this.stepsSize = copyFrom?.stepsSize ?? new Map() } static empty(): FlowExecutorContext { @@ -71,11 +73,6 @@ export class FlowExecutorContext { return this } - public trimmedSteps(): Promise> { - return loggingUtils.trimExecution(this.steps) - } - - public getLoopStepOutput({ stepName }: { stepName: string }): LoopStepOutput | undefined { const stateAtPath = getStateAtPath({ currentPath: this.currentPath, steps: this.steps }) const stepOutput = stateAtPath[stepName] @@ -131,7 +128,7 @@ export class FlowExecutorContext { return new FlowExecutorContext({ ...this, - steps, + steps: this.currentPath.path.length === 0 ? loggingUtils.trimExecutionInput(steps) : steps, }) } @@ -140,8 +137,6 @@ export class FlowExecutorContext { return stateAtPath[stepName] } - - public setCurrentPath(currentStatePath: StepExecutionPath): FlowExecutorContext { return new FlowExecutorContext({ ...this, @@ -210,4 +205,7 @@ function getStateAtPath({ currentPath, steps }: { currentPath: StepExecutionPath return targetMap } +export function getPathKey(stepName: string, path: StepExecutionPath['path']): string { + return `${stepName}.${path.map(([stepName, iteration]) => `${stepName}[${iteration}]`).join('.')}` +} diff --git a/packages/engine/src/lib/helper/logging-utils.ts b/packages/engine/src/lib/helper/logging-utils.ts index d1f6523addc..18bb59cc67f 100644 --- a/packages/engine/src/lib/helper/logging-utils.ts +++ b/packages/engine/src/lib/helper/logging-utils.ts @@ -1,128 +1,117 @@ -import { isObject, StepOutput } from '@activepieces/shared' -import { Queue } from '@datastructures-js/queue' -import sizeof from 'object-sizeof' +import { FlowActionType, StepOutput } from '@activepieces/shared' import PriorityQueue from 'priority-queue-typescript' +import { utils } from '../utils' const TRUNCATION_TEXT_PLACEHOLDER = '(truncated)' const ERROR_OFFSET = 256 * 1024 const DEFAULT_MAX_LOG_SIZE_FOR_TESTING = '10' -const MAX_LOG_SIZE = Number(process.env.AP_MAX_FILE_SIZE_MB ?? DEFAULT_MAX_LOG_SIZE_FOR_TESTING) * 1024 * 1024 +const MAX_LOG_SIZE = Number(process.env.AP_MAX_FLOW_RUN_LOG_SIZE_MB ?? DEFAULT_MAX_LOG_SIZE_FOR_TESTING) const MAX_SIZE_FOR_ALL_ENTRIES = MAX_LOG_SIZE - ERROR_OFFSET -const SIZE_OF_TRUNCATION_TEXT_PLACEHOLDER = sizeof(TRUNCATION_TEXT_PLACEHOLDER) -const nonTruncatableKeys: Key[] = ['status', 'duration', 'type'] +const SIZE_OF_TRUNCATION_TEXT_PLACEHOLDER = utils.sizeof(TRUNCATION_TEXT_PLACEHOLDER) -export const loggingUtils = { - async trimExecution(steps: Record): Promise> { - const totalJsonSize = sizeof(steps) - if (!jsonExceedMaxSize(totalJsonSize)) { - return steps - } - return removeLeavesInTopologicalOrder(JSON.parse(JSON.stringify(steps))) - }, +type InputKeyEntry = { + step: StepOutput + stepName: string + inputKey: string + size: number } -function removeLeavesInTopologicalOrder(json: Record): Record { - const nodes: Node[] = traverseJsonAndConvertToNodes(json) - const leaves = new PriorityQueue( - undefined, - (a: Node, b: Node) => b.size - a.size, - ) - nodes.filter((node) => node.numberOfChildren === 0).forEach((node) => leaves.add(node)) - let totalJsonSize = sizeof(json) +export const loggingUtils = { + trimExecutionInput(steps: Record, maxSize: number = MAX_SIZE_FOR_ALL_ENTRIES): Record { + const totalJsonSize = getTotalStepsSize(steps) - while (!leaves.empty() && jsonExceedMaxSize(totalJsonSize)) { - const curNode = leaves.poll() + if (!jsonExceedMaxSize(totalJsonSize, maxSize)) { + return steps + } - const isDepthGreaterThanOne = curNode && curNode.depth > 1 - const isTruncatable = curNode && (!nonTruncatableKeys.includes(curNode.key)) + const priorityQueue = new PriorityQueue( + undefined, + (a: InputKeyEntry, b: InputKeyEntry) => a.size - b.size, + ) + traverseStepsAndCollectKeys(steps, priorityQueue) + + // calculate minimalSize: replace all input sizes with placeholder sizes . after that we will re-replace them with actual sizes from smallest until we exceed the limit. + let minimalSize = getStepsSizeWithAllInputsTruncated(totalJsonSize, priorityQueue) + + // pop smallest entries from queue, accumulating their sizes until we exceed the limit + // The keys that remain in the queue after popping are the ones we need to truncate + const keysToRemove = new Set() + while (priorityQueue.size() > 0) { + const entry = priorityQueue.poll()! + + minimalSize += entry.size - SIZE_OF_TRUNCATION_TEXT_PLACEHOLDER + + // if minimalSize exceeds the limit, stop popping + // the remaining keys in the queue and current one will be truncated + if (minimalSize > maxSize) { + keysToRemove.add(entry) + break + } + } - if (isDepthGreaterThanOne && isTruncatable) { - totalJsonSize += SIZE_OF_TRUNCATION_TEXT_PLACEHOLDER - curNode.size + while (priorityQueue.size() > 0) { + const entry = priorityQueue.poll()! + keysToRemove.add(entry) + } + + removeKeysFromSteps(keysToRemove) - const parent = curNode.parent + return steps + }, +} - parent.value[curNode.key] = TRUNCATION_TEXT_PLACEHOLDER +function getTotalStepsSize(steps: Record): number { + return utils.sizeof(steps) +} - nodes[parent.index].numberOfChildren-- - if (nodes[parent.index].numberOfChildren == 0) { - leaves.add(nodes[parent.index]) +function traverseStepsAndCollectKeys( + steps: Record, + priorityQueue: PriorityQueue, +): void { + for (const [stepName, step] of Object.entries(steps)) { + if (step?.input) { + const input = step.input as Record + for (const [inputKey, value] of Object.entries(input)) { + const valueSize = utils.sizeof(value) + priorityQueue.add({ + step, + stepName, + inputKey, + size: valueSize, + }) } } - } - return json as Record -} -function traverseJsonAndConvertToNodes(root: unknown) { - - const nodesQueue = new Queue() - nodesQueue.enqueue({ key: '', value: root, parent: { index: -1, value: {} }, depth: 0 }) - - const nodes: Node[] = [] - - while (!nodesQueue.isEmpty()) { - const curNode = nodesQueue.dequeue() - const children = findChildren(curNode.value, curNode.key === 'iterations') - - nodes.push({ - index: nodes.length, - size: children.length === 0 ? sizeof(curNode.value) : children.length * SIZE_OF_TRUNCATION_TEXT_PLACEHOLDER, - key: curNode.key, - parent: { - index: curNode.parent.index, - value: curNode.parent.value as Record, - }, - numberOfChildren: children.length, - depth: curNode.depth, - }) - - children.forEach((child) => { - const key = child[0], value = child[1] - nodesQueue.enqueue({ value, key, parent: { index: nodes.length - 1, value: curNode.value }, depth: curNode.depth + 1 }) - }) + if (step?.type === FlowActionType.LOOP_ON_ITEMS && step.output) { + const loopOutput = step.output as { iterations: Record[] } + if (loopOutput.iterations) { + for (const iteration of loopOutput.iterations) { + traverseStepsAndCollectKeys(iteration, priorityQueue) + } + } + } } - - return nodes } -function findChildren(curNode: unknown, traverseArray: boolean): [Key, unknown][] { - if (isObject(curNode)) { - return Object.entries(curNode) - } - // Array should be treated as a leaf node as If it has too many small items, It will prioritize the other steps first - if (Array.isArray(curNode) && traverseArray) { - const children: [Key, unknown][] = [] - for (let i = 0; i < curNode.length; i++) { - children.push([i, curNode[i]]) +function removeKeysFromSteps( + keysToRemove: Set, +): void { + for (const entry of keysToRemove) { + if (entry.step?.input) { + const input = entry.step.input as Record + input[entry.inputKey] = TRUNCATION_TEXT_PLACEHOLDER } - return children } - return [] -} - -const jsonExceedMaxSize = (jsonSize: number): boolean => { - return jsonSize > MAX_SIZE_FOR_ALL_ENTRIES } -type Node = { - index: number - size: number - key: Key - parent: { - index: number - value: Record +const getStepsSizeWithAllInputsTruncated = (totalSize: number, priorityQueue: PriorityQueue ): number => { + let size = totalSize + for (const entry of priorityQueue) { + size = size - entry.size + SIZE_OF_TRUNCATION_TEXT_PLACEHOLDER } - numberOfChildren: number - depth: number + return size } -type BfsNode = { - value: unknown - key: Key - parent: { - index: number - value: unknown - } - depth: number +const jsonExceedMaxSize = (jsonSize: number, maxSize: number): boolean => { + return jsonSize > maxSize } - -type Key = string | number | symbol \ No newline at end of file diff --git a/packages/engine/src/lib/operations/flow.operation.ts b/packages/engine/src/lib/operations/flow.operation.ts index 09e48fe9234..9c064f7df0c 100644 --- a/packages/engine/src/lib/operations/flow.operation.ts +++ b/packages/engine/src/lib/operations/flow.operation.ts @@ -78,6 +78,7 @@ async function getFlowExecutionState(input: ExecuteFlowOperation, flowContext: F break } case ExecutionType.RESUME: { + flowContext = flowContext.addTags(input.executionState.tags) break } } diff --git a/packages/engine/src/lib/services/progress.service.ts b/packages/engine/src/lib/services/progress.service.ts index a08f7274a3f..4dd50282ac5 100644 --- a/packages/engine/src/lib/services/progress.service.ts +++ b/packages/engine/src/lib/services/progress.service.ts @@ -48,11 +48,11 @@ export const progressService = { const { engineConstants, flowExecutorContext, stepName, stepOutput } = params return { update: async (params: { data: unknown }) => { - const trimmedSteps = await flowExecutorContext - .upsertStep(stepName, stepOutput.setOutput(params.data)) - .trimmedSteps() + const steps = flowExecutorContext + .upsertStep(stepName, stepOutput.setOutput(params.data)).steps + const stepResponse = extractStepResponse({ - steps: trimmedSteps, + steps, runId: engineConstants.flowRunId, stepName, }) @@ -90,12 +90,13 @@ const sendUpdateRunRequest = async (updateParams: UpdateStepProgressParams): Pro } lastActionExecutionTime = Date.now() const { flowExecutorContext, engineConstants } = params - const trimmedSteps = await flowExecutorContext.trimmedSteps() const executionState = await logSerializer.serialize({ - executionState: { - steps: trimmedSteps, + executionState: { + steps: flowExecutorContext.steps, + tags: Array.from(flowExecutorContext.tags), }, }) + if (isNil(engineConstants.logsUploadUrl)) { throw new EngineGenericError('LogsUploadUrlNotSetError', 'Logs upload URL is not set') } @@ -105,7 +106,7 @@ const sendUpdateRunRequest = async (updateParams: UpdateStepProgressParams): Pro } const stepResponse = extractStepResponse({ - steps: trimmedSteps, + steps: flowExecutorContext.steps, runId: engineConstants.flowRunId, stepName: engineConstants.stepNameToTest, }) diff --git a/packages/engine/src/lib/services/storage.service.ts b/packages/engine/src/lib/services/storage.service.ts index 9ec2a6bf84c..5792f8e1026 100644 --- a/packages/engine/src/lib/services/storage.service.ts +++ b/packages/engine/src/lib/services/storage.service.ts @@ -2,7 +2,6 @@ import { URL } from 'node:url' import { Store, StoreScope } from '@activepieces/pieces-framework' import { DeleteStoreEntryRequest, ExecutionError, FetchError, FlowId, isNil, PutStoreEntryRequest, StorageError, StorageInvalidKeyError, StorageLimitError, STORE_KEY_MAX_LENGTH, STORE_VALUE_MAX_SIZE, StoreEntry } from '@activepieces/shared' import { StatusCodes } from 'http-status-codes' -import sizeof from 'object-sizeof' import { utils } from '../utils' export const createStorageService = ({ engineToken, apiUrl }: CreateStorageServiceParams): StorageService => { @@ -39,7 +38,7 @@ export const createStorageService = ({ engineToken, apiUrl }: CreateStorageServi const url = buildUrl(apiUrl) const { data: storeEntry, error: storeEntryError } = await utils.tryCatchAndThrowOnEngineError((async () => { - const sizeOfValue = sizeof(request.value) + const sizeOfValue = utils.sizeof(request.value) if (sizeOfValue > STORE_VALUE_MAX_SIZE) { throw new StorageLimitError(request.key, STORE_VALUE_MAX_SIZE) } diff --git a/packages/engine/src/lib/utils.ts b/packages/engine/src/lib/utils.ts index 26250046e3e..091afb86bd6 100755 --- a/packages/engine/src/lib/utils.ts +++ b/packages/engine/src/lib/utils.ts @@ -81,6 +81,35 @@ export const utils = { }, } }, + sizeof(object: unknown): number { + const objectList = [] + const stack = [object] + let bytes = 0 + + while (stack.length) { + + const value = stack.pop() + if (typeof value === 'boolean') { + bytes += 4 + } + else if (typeof value === 'string') { + bytes += value.length * 2 + } + else if (typeof value === 'number') { + bytes += 8 + } + else if (typeof value === 'object' && objectList.indexOf( value ) === -1) { + objectList.push(value) + // if the object is not an array, add the sizes of the keys + if (Object.prototype.toString.call(value) != '[object Array]') { + for (const key in value) bytes += 2 * key.length + } + for (const key in value) stack.push(value[key as keyof typeof value]) + } + } + + return bytes + }, } function isEngineError(error: unknown): error is ExecutionError { diff --git a/packages/engine/test/helper/logging-utils.test.ts b/packages/engine/test/helper/logging-utils.test.ts index 02034d8f58c..0ec9a990e87 100644 --- a/packages/engine/test/helper/logging-utils.test.ts +++ b/packages/engine/test/helper/logging-utils.test.ts @@ -1,26 +1,130 @@ import { FlowActionType, GenericStepOutput, + LoopStepOutput, StepOutputStatus, } from '@activepieces/shared' import { loggingUtils } from '../../src/lib/helper/logging-utils' describe('Logging Utils', () => { - it('Should not truncate whole step if its log size exceeds limit', async () => { + it('Should truncate input values when total size exceeds limit', () => { + const largeValue = 'x'.repeat(2000) // Large value that will exceed the limit const steps = { - mockStep: GenericStepOutput.create({ - type: FlowActionType.CODE, + step1: GenericStepOutput.create({ + type: FlowActionType.PIECE, status: StepOutputStatus.SUCCEEDED, input: { - a: 'a'.repeat(1024 * 1024 * 12), + smallKey: 'small', + largeKey: largeValue, }, }), } // act - const result = await loggingUtils.trimExecution(steps) + const result = loggingUtils.trimExecutionInput(steps, 100) - // assert - expect((result.mockStep.input as Record).a.length).toBeLessThan(1024 * 1024 * 12) + // assert - large key should be truncated, small key might be kept + const input = result.step1.input as Record + expect(input.largeKey).toBe('(truncated)') + }) + + it('Should keep smallest input values and truncate largest ones', () => { + const steps = { + step1: GenericStepOutput.create({ + type: FlowActionType.PIECE, + status: StepOutputStatus.SUCCEEDED, + input: { + small1: 'a', + small2: 'b', + large1: 'x'.repeat(2000), + large2: 'y'.repeat(2000), + }, + }), + } + + // act + const result = loggingUtils.trimExecutionInput(steps, 1500) + + // assert - at least some large keys should be truncated + const input = result.step1.input as Record + const truncatedCount = Object.values(input).filter(v => v === '(truncated)').length + expect(truncatedCount).toEqual(2) + }) + + it('Should truncate input values in loop step iterations', () => { + const loopStep = LoopStepOutput.init({ input: {} }) + .setItemAndIndex({ item: 1, index: 1 }) + .setIterations([ + { + // First iteration + innerStep1: GenericStepOutput.create({ + type: FlowActionType.PIECE, + status: StepOutputStatus.SUCCEEDED, + input: { + small: 'a', + large: 'x'.repeat(1500), + }, + }), + }, + { + // Second iteration + innerStep2: GenericStepOutput.create({ + type: FlowActionType.PIECE, + status: StepOutputStatus.SUCCEEDED, + input: { + small: 'b', + large: 'y'.repeat(1500), + }, + }), + }, + { + // Third iteration + innerStep3: GenericStepOutput.create({ + type: FlowActionType.PIECE, + status: StepOutputStatus.SUCCEEDED, + input: { + small: 'c', + large: 'z'.repeat(1500), + }, + }), + }, + ]) + + const steps = { + loopStep, + } + + // act + const result = loggingUtils.trimExecutionInput(steps, 2000) + + // assert - verify loop step structure is preserved + const resultLoopStep = result.loopStep as LoopStepOutput + expect(resultLoopStep.output?.iterations).toBeDefined() + expect(resultLoopStep.output?.iterations.length).toBe(3) + + // assert - verify that large values in iterations are truncated + const iteration1 = resultLoopStep.output?.iterations[0] + const iteration2 = resultLoopStep.output?.iterations[1] + const iteration3 = resultLoopStep.output?.iterations[2] + + expect(iteration1).toBeDefined() + expect(iteration2).toBeDefined() + expect(iteration3).toBeDefined() + + const input1 = iteration1!['innerStep1'].input as Record + const input2 = iteration2!['innerStep2'].input as Record + const input3 = iteration3!['innerStep3'].input as Record + + // At least some large values should be truncated across all iterations + const finalInputs = [ + input1.large === '(truncated)', + input2.large === '(truncated)', + input3.large === '(truncated)', + + input1.small === 'a', + input2.small === 'b', + input3.small === 'c', + ] + expect(finalInputs.some(Boolean)).toBe(true) }) }) diff --git a/packages/pieces/community/formitable/.eslintrc.json b/packages/pieces/community/formitable/.eslintrc.json new file mode 100644 index 00000000000..4a4e695c547 --- /dev/null +++ b/packages/pieces/community/formitable/.eslintrc.json @@ -0,0 +1,33 @@ +{ + "extends": [ + "../../../../.eslintrc.base.json" + ], + "ignorePatterns": [ + "!**/*" + ], + "overrides": [ + { + "files": [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx" + ], + "rules": {} + }, + { + "files": [ + "*.ts", + "*.tsx" + ], + "rules": {} + }, + { + "files": [ + "*.js", + "*.jsx" + ], + "rules": {} + } + ] +} \ No newline at end of file diff --git a/packages/pieces/community/formitable/README.md b/packages/pieces/community/formitable/README.md new file mode 100644 index 00000000000..cf815721eb9 --- /dev/null +++ b/packages/pieces/community/formitable/README.md @@ -0,0 +1,7 @@ +# pieces-formitable + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build pieces-formitable` to build the library. diff --git a/packages/pieces/community/formitable/package.json b/packages/pieces/community/formitable/package.json new file mode 100644 index 00000000000..32b66b3df6e --- /dev/null +++ b/packages/pieces/community/formitable/package.json @@ -0,0 +1,10 @@ +{ + "name": "@activepieces/piece-formitable", + "version": "0.0.1", + "type": "commonjs", + "main": "./src/index.js", + "types": "./src/index.d.ts", + "dependencies": { + "tslib": "^2.3.0" + } +} diff --git a/packages/pieces/community/formitable/project.json b/packages/pieces/community/formitable/project.json new file mode 100644 index 00000000000..e9ca73126f7 --- /dev/null +++ b/packages/pieces/community/formitable/project.json @@ -0,0 +1,66 @@ +{ + "name": "pieces-formitable", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/pieces/community/formitable/src", + "projectType": "library", + "release": { + "version": { + "manifestRootsToUpdate": [ + "dist/{projectRoot}" + ], + "currentVersionResolver": "git-tag", + "fallbackCurrentVersionResolver": "disk" + } + }, + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": [ + "{options.outputPath}" + ], + "options": { + "outputPath": "dist/packages/pieces/community/formitable", + "tsConfig": "packages/pieces/community/formitable/tsconfig.lib.json", + "packageJson": "packages/pieces/community/formitable/package.json", + "main": "packages/pieces/community/formitable/src/index.ts", + "assets": [ + "packages/pieces/community/formitable/*.md", + { + "input": "packages/pieces/community/formitable/src/i18n", + "output": "./src/i18n", + "glob": "**/!(i18n.json)" + } + ], + "buildableProjectDepsInPackageJsonType": "dependencies", + "updateBuildableProjectDepsInPackageJson": true, + "clean": false + }, + "dependsOn": [ + "prebuild", + "^build" + ] + }, + "nx-release-publish": { + "options": { + "packageRoot": "dist/{projectRoot}" + } + }, + "prebuild": { + "dependsOn": [ + "^build" + ], + "executor": "nx:run-commands", + "options": { + "cwd": "packages/pieces/community/formitable", + "command": "bun install --no-save --silent" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": [ + "{options.outputFile}" + ] + } + } +} \ No newline at end of file diff --git a/packages/pieces/community/formitable/src/index.ts b/packages/pieces/community/formitable/src/index.ts new file mode 100644 index 00000000000..325106c64e2 --- /dev/null +++ b/packages/pieces/community/formitable/src/index.ts @@ -0,0 +1,50 @@ + +import { createPiece, PieceAuth } from '@activepieces/pieces-framework'; +import { HttpMethod, httpClient } from '@activepieces/pieces-common'; +import { PieceCategory } from '@activepieces/shared'; +import { formitableTriggers } from './lib/triggers'; + +const markdown = ` +To obtain your API key: + +1. Log in to your Formitable account +2. Go to **Settings > Team** +3. Create an API Key for your user +4. Copy and paste the key here +`; + +export const formitableAuth = PieceAuth.SecretText({ + displayName: 'API Key', + description: markdown, + required: true, + validate: async ({ auth }) => { + try { + await httpClient.sendRequest({ + method: HttpMethod.GET, + url: 'https://api.formitable.com/api/v1.2/restaurants', + headers: { + ApiKey: auth, + Accept: 'application/json', + }, + }); + return { valid: true }; + } catch (e) { + return { + valid: false, + error: 'Invalid API key', + }; + } + }, +}); + +export const formitable = createPiece({ + displayName: 'Formitable', + description: 'Restaurant reservation and guest management platform', + auth: formitableAuth, + minimumSupportedRelease: '0.36.1', + logoUrl: 'https://cdn.activepieces.com/pieces/formitable.png', + authors: ['onyedikachi-david'], + categories: [PieceCategory.SALES_AND_CRM], + actions: [], + triggers: formitableTriggers, +}); \ No newline at end of file diff --git a/packages/pieces/community/formitable/src/lib/common/index.ts b/packages/pieces/community/formitable/src/lib/common/index.ts new file mode 100644 index 00000000000..fa71ca273af --- /dev/null +++ b/packages/pieces/community/formitable/src/lib/common/index.ts @@ -0,0 +1,117 @@ +import { HttpMethod, httpClient } from '@activepieces/pieces-common'; +import { Property } from '@activepieces/pieces-framework'; +import { formitableAuth } from '../..'; + +export const formitableCommon = { + baseUrl: 'https://api.formitable.com/api/v1.2', + + restaurant: Property.Dropdown({ + displayName: 'Restaurant', + description: 'Select the restaurant to monitor', + required: true, + auth: formitableAuth, + refreshers: [], + options: async ({ auth }) => { + if (!auth) { + return { + disabled: true, + placeholder: 'Connect your account first', + options: [], + }; + } + const apiKey = (auth as { secret_text: string }).secret_text; + const restaurants = await formitableApiCall({ + apiKey, + method: HttpMethod.GET, + endpoint: '/restaurants', + }); + return { + disabled: false, + options: restaurants.map((restaurant) => ({ + label: restaurant.name, + value: restaurant.uid, + })), + }; + }, + }), +}; + +export interface Restaurant { + uid: string; + name: string; +} + +export interface WebhookResponse { + uid: string; + events: string[]; + url: string; + secretKey: string; +} + +export async function formitableApiCall({ + apiKey, + method, + endpoint, + body, + queryParams, +}: { + apiKey: string; + method: HttpMethod; + endpoint: string; + body?: unknown; + queryParams?: Record; +}): Promise { + const response = await httpClient.sendRequest({ + method, + url: `${formitableCommon.baseUrl}${endpoint}`, + headers: { + ApiKey: apiKey, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body, + queryParams, + }); + return response.body; +} + +export async function createWebhook({ + apiKey, + restaurantUid, + webhookUrl, + events, + secretKey, +}: { + apiKey: string; + restaurantUid: string; + webhookUrl: string; + events: string[]; + secretKey: string; +}): Promise { + return formitableApiCall({ + apiKey, + method: HttpMethod.POST, + endpoint: `/${restaurantUid}/webhook`, + body: { + events, + url: webhookUrl, + secretKey, + }, + }); +} + +export async function deleteWebhook({ + apiKey, + restaurantUid, + webhookUid, +}: { + apiKey: string; + restaurantUid: string; + webhookUid: string; +}): Promise { + await formitableApiCall({ + apiKey, + method: HttpMethod.DELETE, + endpoint: `/${restaurantUid}/webhook/${webhookUid}`, + }); +} diff --git a/packages/pieces/community/formitable/src/lib/triggers/booking-accepted.ts b/packages/pieces/community/formitable/src/lib/triggers/booking-accepted.ts new file mode 100644 index 00000000000..c0cb60c6d9c --- /dev/null +++ b/packages/pieces/community/formitable/src/lib/triggers/booking-accepted.ts @@ -0,0 +1,21 @@ +import { formitableRegisterTrigger } from './register-trigger'; + +export const bookingAccepted = formitableRegisterTrigger({ + name: 'booking_accepted', + displayName: 'Booking Accepted', + description: 'Triggers when a booking is accepted.', + event: 'booking.accepted', + sampleData: { + data: { + booking: { + uid: 'abc123', + date: '2024-01-15', + time: '19:00', + partySize: 4, + status: 'accepted', + }, + }, + restaurantUid: 'rest123', + event: 'booking.accepted', + }, +}); diff --git a/packages/pieces/community/formitable/src/lib/triggers/booking-canceled.ts b/packages/pieces/community/formitable/src/lib/triggers/booking-canceled.ts new file mode 100644 index 00000000000..2bfd8359dbb --- /dev/null +++ b/packages/pieces/community/formitable/src/lib/triggers/booking-canceled.ts @@ -0,0 +1,21 @@ +import { formitableRegisterTrigger } from './register-trigger'; + +export const bookingCanceled = formitableRegisterTrigger({ + name: 'booking_canceled', + displayName: 'Booking Canceled', + description: 'Triggers when a booking is canceled.', + event: 'booking.canceled', + sampleData: { + data: { + booking: { + uid: 'abc123', + date: '2024-01-15', + time: '19:00', + partySize: 4, + status: 'canceled', + }, + }, + restaurantUid: 'rest123', + event: 'booking.canceled', + }, +}); diff --git a/packages/pieces/community/formitable/src/lib/triggers/booking-changed.ts b/packages/pieces/community/formitable/src/lib/triggers/booking-changed.ts new file mode 100644 index 00000000000..5c2a1adde8d --- /dev/null +++ b/packages/pieces/community/formitable/src/lib/triggers/booking-changed.ts @@ -0,0 +1,21 @@ +import { formitableRegisterTrigger } from './register-trigger'; + +export const bookingChanged = formitableRegisterTrigger({ + name: 'booking_changed', + displayName: 'Booking Changed', + description: 'Triggers when a booking is updated.', + event: 'booking.changed', + sampleData: { + data: { + booking: { + uid: 'abc123', + date: '2024-01-15', + time: '20:00', + partySize: 6, + status: 'changed', + }, + }, + restaurantUid: 'rest123', + event: 'booking.changed', + }, +}); diff --git a/packages/pieces/community/formitable/src/lib/triggers/booking-checkin.ts b/packages/pieces/community/formitable/src/lib/triggers/booking-checkin.ts new file mode 100644 index 00000000000..1a6a7a76680 --- /dev/null +++ b/packages/pieces/community/formitable/src/lib/triggers/booking-checkin.ts @@ -0,0 +1,21 @@ +import { formitableRegisterTrigger } from './register-trigger'; + +export const bookingCheckin = formitableRegisterTrigger({ + name: 'booking_checkin', + displayName: 'Guest Checked In', + description: 'Triggers when a guest checks in at the restaurant.', + event: 'booking.checkin', + sampleData: { + data: { + booking: { + uid: 'abc123', + date: '2024-01-15', + time: '19:00', + partySize: 4, + status: 'checkin', + }, + }, + restaurantUid: 'rest123', + event: 'booking.checkin', + }, +}); diff --git a/packages/pieces/community/formitable/src/lib/triggers/booking-checkout.ts b/packages/pieces/community/formitable/src/lib/triggers/booking-checkout.ts new file mode 100644 index 00000000000..de7f8b23fa9 --- /dev/null +++ b/packages/pieces/community/formitable/src/lib/triggers/booking-checkout.ts @@ -0,0 +1,21 @@ +import { formitableRegisterTrigger } from './register-trigger'; + +export const bookingCheckout = formitableRegisterTrigger({ + name: 'booking_checkout', + displayName: 'Guest Checked Out', + description: 'Triggers when a guest checks out from the restaurant.', + event: 'booking.checkout', + sampleData: { + data: { + booking: { + uid: 'abc123', + date: '2024-01-15', + time: '19:00', + partySize: 4, + status: 'checkout', + }, + }, + restaurantUid: 'rest123', + event: 'booking.checkout', + }, +}); diff --git a/packages/pieces/community/formitable/src/lib/triggers/booking-created.ts b/packages/pieces/community/formitable/src/lib/triggers/booking-created.ts new file mode 100644 index 00000000000..1320991e8cc --- /dev/null +++ b/packages/pieces/community/formitable/src/lib/triggers/booking-created.ts @@ -0,0 +1,21 @@ +import { formitableRegisterTrigger } from './register-trigger'; + +export const bookingCreated = formitableRegisterTrigger({ + name: 'booking_created', + displayName: 'Booking Created', + description: 'Triggers when a new booking is created.', + event: 'booking.created', + sampleData: { + data: { + booking: { + uid: 'abc123', + date: '2024-01-15', + time: '19:00', + partySize: 4, + status: 'created', + }, + }, + restaurantUid: 'rest123', + event: 'booking.created', + }, +}); diff --git a/packages/pieces/community/formitable/src/lib/triggers/booking-failed.ts b/packages/pieces/community/formitable/src/lib/triggers/booking-failed.ts new file mode 100644 index 00000000000..372cb747dd6 --- /dev/null +++ b/packages/pieces/community/formitable/src/lib/triggers/booking-failed.ts @@ -0,0 +1,21 @@ +import { formitableRegisterTrigger } from './register-trigger'; + +export const bookingFailed = formitableRegisterTrigger({ + name: 'booking_failed', + displayName: 'Booking Failed', + description: 'Triggers when a booking fails.', + event: 'booking.failed', + sampleData: { + data: { + booking: { + uid: 'abc123', + date: '2024-01-15', + time: '19:00', + partySize: 4, + status: 'failed', + }, + }, + restaurantUid: 'rest123', + event: 'booking.failed', + }, +}); diff --git a/packages/pieces/community/formitable/src/lib/triggers/index.ts b/packages/pieces/community/formitable/src/lib/triggers/index.ts new file mode 100644 index 00000000000..e4f836b3755 --- /dev/null +++ b/packages/pieces/community/formitable/src/lib/triggers/index.ts @@ -0,0 +1,35 @@ +import { bookingCreated } from './booking-created'; +import { bookingAccepted } from './booking-accepted'; +import { bookingChanged } from './booking-changed'; +import { bookingCheckin } from './booking-checkin'; +import { bookingCheckout } from './booking-checkout'; +import { bookingCanceled } from './booking-canceled'; +import { bookingFailed } from './booking-failed'; +import { optionCreated } from './option-created'; +import { optionAccepted } from './option-accepted'; +import { optionCanceled } from './option-canceled'; +import { optionExpired } from './option-expired'; +import { orderOrdered } from './order-ordered'; +import { messageSent } from './message-sent'; +import { messageReceived } from './message-received'; +import { reviewCreated } from './review-created'; +import { reviewRequest } from './review-request'; + +export const formitableTriggers = [ + bookingCreated, + bookingAccepted, + bookingChanged, + bookingCheckin, + bookingCheckout, + bookingCanceled, + bookingFailed, + optionCreated, + optionAccepted, + optionCanceled, + optionExpired, + orderOrdered, + messageSent, + messageReceived, + reviewCreated, + reviewRequest, +]; diff --git a/packages/pieces/community/formitable/src/lib/triggers/message-received.ts b/packages/pieces/community/formitable/src/lib/triggers/message-received.ts new file mode 100644 index 00000000000..bb1d503c698 --- /dev/null +++ b/packages/pieces/community/formitable/src/lib/triggers/message-received.ts @@ -0,0 +1,19 @@ +import { formitableRegisterTrigger } from './register-trigger'; + +export const messageReceived = formitableRegisterTrigger({ + name: 'message_received', + displayName: 'Message Sent to Customer', + description: 'Triggers when a message is sent by the restaurant to the customer.', + event: 'message.received', + sampleData: { + data: { + message: { + uid: 'msg123', + content: 'Thank you for your inquiry. Your reservation is confirmed.', + sentAt: '2024-01-15T10:35:00Z', + }, + }, + restaurantUid: 'rest123', + event: 'message.received', + }, +}); diff --git a/packages/pieces/community/formitable/src/lib/triggers/message-sent.ts b/packages/pieces/community/formitable/src/lib/triggers/message-sent.ts new file mode 100644 index 00000000000..dbddf42cb8f --- /dev/null +++ b/packages/pieces/community/formitable/src/lib/triggers/message-sent.ts @@ -0,0 +1,19 @@ +import { formitableRegisterTrigger } from './register-trigger'; + +export const messageSent = formitableRegisterTrigger({ + name: 'message_sent', + displayName: 'Message Sent by Customer', + description: 'Triggers when a message is sent by the customer.', + event: 'message.sent', + sampleData: { + data: { + message: { + uid: 'msg123', + content: 'Hello, I have a question about my reservation.', + sentAt: '2024-01-15T10:30:00Z', + }, + }, + restaurantUid: 'rest123', + event: 'message.sent', + }, +}); diff --git a/packages/pieces/community/formitable/src/lib/triggers/option-accepted.ts b/packages/pieces/community/formitable/src/lib/triggers/option-accepted.ts new file mode 100644 index 00000000000..c093b52c342 --- /dev/null +++ b/packages/pieces/community/formitable/src/lib/triggers/option-accepted.ts @@ -0,0 +1,21 @@ +import { formitableRegisterTrigger } from './register-trigger'; + +export const optionAccepted = formitableRegisterTrigger({ + name: 'option_accepted', + displayName: 'Booking Option Accepted', + description: 'Triggers when a booking option is accepted. A booking.accepted event will also be fired.', + event: 'option.accepted', + sampleData: { + data: { + option: { + uid: 'opt123', + date: '2024-01-15', + time: '19:00', + partySize: 4, + status: 'accepted', + }, + }, + restaurantUid: 'rest123', + event: 'option.accepted', + }, +}); diff --git a/packages/pieces/community/formitable/src/lib/triggers/option-canceled.ts b/packages/pieces/community/formitable/src/lib/triggers/option-canceled.ts new file mode 100644 index 00000000000..f7ccc11dd6b --- /dev/null +++ b/packages/pieces/community/formitable/src/lib/triggers/option-canceled.ts @@ -0,0 +1,21 @@ +import { formitableRegisterTrigger } from './register-trigger'; + +export const optionCanceled = formitableRegisterTrigger({ + name: 'option_canceled', + displayName: 'Booking Option Canceled', + description: 'Triggers when a booking option is canceled.', + event: 'option.canceled', + sampleData: { + data: { + option: { + uid: 'opt123', + date: '2024-01-15', + time: '19:00', + partySize: 4, + status: 'canceled', + }, + }, + restaurantUid: 'rest123', + event: 'option.canceled', + }, +}); diff --git a/packages/pieces/community/formitable/src/lib/triggers/option-created.ts b/packages/pieces/community/formitable/src/lib/triggers/option-created.ts new file mode 100644 index 00000000000..214438e1d84 --- /dev/null +++ b/packages/pieces/community/formitable/src/lib/triggers/option-created.ts @@ -0,0 +1,21 @@ +import { formitableRegisterTrigger } from './register-trigger'; + +export const optionCreated = formitableRegisterTrigger({ + name: 'option_created', + displayName: 'Booking Option Created', + description: 'Triggers when a booking option is created.', + event: 'option.created', + sampleData: { + data: { + option: { + uid: 'opt123', + date: '2024-01-15', + time: '19:00', + partySize: 4, + status: 'created', + }, + }, + restaurantUid: 'rest123', + event: 'option.created', + }, +}); diff --git a/packages/pieces/community/formitable/src/lib/triggers/option-expired.ts b/packages/pieces/community/formitable/src/lib/triggers/option-expired.ts new file mode 100644 index 00000000000..79a77411cac --- /dev/null +++ b/packages/pieces/community/formitable/src/lib/triggers/option-expired.ts @@ -0,0 +1,21 @@ +import { formitableRegisterTrigger } from './register-trigger'; + +export const optionExpired = formitableRegisterTrigger({ + name: 'option_expired', + displayName: 'Booking Option Expired', + description: 'Triggers when a booking option expires.', + event: 'option.expired', + sampleData: { + data: { + option: { + uid: 'opt123', + date: '2024-01-15', + time: '19:00', + partySize: 4, + status: 'expired', + }, + }, + restaurantUid: 'rest123', + event: 'option.expired', + }, +}); diff --git a/packages/pieces/community/formitable/src/lib/triggers/order-ordered.ts b/packages/pieces/community/formitable/src/lib/triggers/order-ordered.ts new file mode 100644 index 00000000000..59d91f4e686 --- /dev/null +++ b/packages/pieces/community/formitable/src/lib/triggers/order-ordered.ts @@ -0,0 +1,20 @@ +import { formitableRegisterTrigger } from './register-trigger'; + +export const orderOrdered = formitableRegisterTrigger({ + name: 'order_ordered', + displayName: 'Order Placed', + description: 'Triggers when a takeaway order is made or a gift voucher is purchased.', + event: 'order.ordered', + sampleData: { + data: { + order: { + uid: 'order123', + type: 'takeaway', + total: 45.50, + status: 'ordered', + }, + }, + restaurantUid: 'rest123', + event: 'order.ordered', + }, +}); diff --git a/packages/pieces/community/formitable/src/lib/triggers/register-trigger.ts b/packages/pieces/community/formitable/src/lib/triggers/register-trigger.ts new file mode 100644 index 00000000000..98b94b5b10e --- /dev/null +++ b/packages/pieces/community/formitable/src/lib/triggers/register-trigger.ts @@ -0,0 +1,63 @@ +import { TriggerStrategy, createTrigger } from '@activepieces/pieces-framework'; +import { formitableAuth } from '../..'; +import { + formitableCommon, + createWebhook, + deleteWebhook, + WebhookResponse, +} from '../common'; + +export const formitableRegisterTrigger = ({ + name, + displayName, + description, + event, + sampleData, +}: { + name: string; + displayName: string; + description: string; + event: string; + sampleData: unknown; +}) => + createTrigger({ + auth: formitableAuth, + name: `formitable_${name}`, + displayName, + description, + props: { + restaurant: formitableCommon.restaurant, + }, + sampleData, + type: TriggerStrategy.WEBHOOK, + async onEnable(context) { + const { restaurant } = context.propsValue; + const apiKey = context.auth.secret_text; + + const webhook = await createWebhook({ + apiKey, + restaurantUid: restaurant, + webhookUrl: context.webhookUrl, + events: [event], + secretKey: `ap_${context.webhookUrl.split('/').pop()}`, + }); + + await context.store.put(`formitable_${name}_webhook`, webhook); + }, + async onDisable(context) { + const { restaurant } = context.propsValue; + const apiKey = context.auth.secret_text; + + const webhook = await context.store.get(`formitable_${name}_webhook`); + if (webhook) { + await deleteWebhook({ + apiKey, + restaurantUid: restaurant, + webhookUid: webhook.uid, + }); + } + }, + async run(context) { + return [context.payload.body]; + }, + }); diff --git a/packages/pieces/community/formitable/src/lib/triggers/review-created.ts b/packages/pieces/community/formitable/src/lib/triggers/review-created.ts new file mode 100644 index 00000000000..ce89c51b767 --- /dev/null +++ b/packages/pieces/community/formitable/src/lib/triggers/review-created.ts @@ -0,0 +1,20 @@ +import { formitableRegisterTrigger } from './register-trigger'; + +export const reviewCreated = formitableRegisterTrigger({ + name: 'review_created', + displayName: 'Review Created', + description: 'Triggers when a review is created.', + event: 'review.created', + sampleData: { + data: { + review: { + uid: 'rev123', + rating: 5, + comment: 'Excellent dining experience!', + createdAt: '2024-01-16T12:00:00Z', + }, + }, + restaurantUid: 'rest123', + event: 'review.created', + }, +}); diff --git a/packages/pieces/community/formitable/src/lib/triggers/review-request.ts b/packages/pieces/community/formitable/src/lib/triggers/review-request.ts new file mode 100644 index 00000000000..6e4901ce91c --- /dev/null +++ b/packages/pieces/community/formitable/src/lib/triggers/review-request.ts @@ -0,0 +1,20 @@ +import { formitableRegisterTrigger } from './register-trigger'; + +export const reviewRequest = formitableRegisterTrigger({ + name: 'review_request', + displayName: 'Review Request', + description: 'Triggers when a review request can be sent to the customer (typically 1 day after reservation).', + event: 'review.request', + sampleData: { + data: { + booking: { + uid: 'abc123', + date: '2024-01-15', + time: '19:00', + partySize: 4, + }, + }, + restaurantUid: 'rest123', + event: 'review.request', + }, +}); diff --git a/packages/pieces/community/formitable/tsconfig.json b/packages/pieces/community/formitable/tsconfig.json new file mode 100644 index 00000000000..eff240ac143 --- /dev/null +++ b/packages/pieces/community/formitable/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "importHelpers": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/packages/pieces/community/formitable/tsconfig.lib.json b/packages/pieces/community/formitable/tsconfig.lib.json new file mode 100644 index 00000000000..5995d959bf0 --- /dev/null +++ b/packages/pieces/community/formitable/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/pieces/community/microsoft-outlook/package.json b/packages/pieces/community/microsoft-outlook/package.json index 5767f7538ac..8f6d32eb1ae 100644 --- a/packages/pieces/community/microsoft-outlook/package.json +++ b/packages/pieces/community/microsoft-outlook/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-microsoft-outlook", - "version": "0.2.1", + "version": "0.2.2", "dependencies": { "@microsoft/microsoft-graph-client": "3.0.7", "@microsoft/microsoft-graph-types": "2.40.0" diff --git a/packages/pieces/community/microsoft-outlook/src/index.ts b/packages/pieces/community/microsoft-outlook/src/index.ts index bec2ac89917..63d109784d0 100644 --- a/packages/pieces/community/microsoft-outlook/src/index.ts +++ b/packages/pieces/community/microsoft-outlook/src/index.ts @@ -22,7 +22,7 @@ export const microsoftOutlook = createPiece({ minimumSupportedRelease: '0.36.1', logoUrl: 'https://cdn.activepieces.com/pieces/microsoft-outlook.jpg', categories: [PieceCategory.PRODUCTIVITY], - authors: ['lucaslimasouza', 'kishanprmr'], + authors: ['lucaslimasouza', 'kishanprmr','sanket-a11y'], actions: [ sendEmailAction, downloadAttachmentAction, diff --git a/packages/pieces/community/microsoft-outlook/src/lib/triggers/new-attachment.ts b/packages/pieces/community/microsoft-outlook/src/lib/triggers/new-attachment.ts index 74c4862b196..904cefacf8a 100644 --- a/packages/pieces/community/microsoft-outlook/src/lib/triggers/new-attachment.ts +++ b/packages/pieces/community/microsoft-outlook/src/lib/triggers/new-attachment.ts @@ -1,4 +1,4 @@ -import { FilesService, TriggerStrategy, createTrigger, PropertyType, Property } from '@activepieces/pieces-framework'; +import { FilesService, TriggerStrategy, createTrigger, Property } from '@activepieces/pieces-framework'; import { Client, PageCollection } from '@microsoft/microsoft-graph-client'; import { Message, FileAttachment } from '@microsoft/microsoft-graph-types'; import dayjs from 'dayjs'; @@ -11,10 +11,15 @@ async function enrichAttachments( messages: Message[], files: FilesService, nameFilter?: string, + senderFilter?: string, ): Promise[]> { const attachments: Record[] = []; for (const message of messages) { + if (senderFilter && message.sender?.emailAddress?.address && !message.sender.emailAddress.address.toLowerCase().includes(senderFilter.toLowerCase())) { + continue; + } + const attachmentResponse: PageCollection = await client .api(`/me/messages/${message.id}/attachments`) .get(); @@ -58,6 +63,11 @@ export const newAttachmentTrigger = createTrigger({ description: 'Monitor attachments in a specific folder. Leave empty to monitor all folders.', required: false, }), + sender: Property.ShortText({ + displayName: 'From (Sender Email)', + description: 'Filter emails from a specific sender (optional). Leave empty to for all senders.', + required: false, + }), attachmentNameFilter: Property.ShortText({ displayName: 'Attachment Name Filter', description: 'Filter attachments by name (contains). Leave empty to include all attachments.', @@ -73,7 +83,7 @@ export const newAttachmentTrigger = createTrigger({ // return }, async test(context) { - const { folderId, attachmentNameFilter } = context.propsValue; + const { folderId, attachmentNameFilter, sender } = context.propsValue; const client = Client.initWithMiddleware({ authProvider: { getAccessToken: () => Promise.resolve(context.auth.access_token), @@ -86,7 +96,7 @@ export const newAttachmentTrigger = createTrigger({ .top(10) .get(); - const attachments = await enrichAttachments(client, response.value as Message[], context.files, attachmentNameFilter); + const attachments = await enrichAttachments(client, response.value as Message[], context.files, attachmentNameFilter, sender); const items = attachments.map((attachment) => ({ epochMilliSeconds: dayjs(attachment['messageReceivedDateTime']).valueOf(), @@ -101,7 +111,7 @@ export const newAttachmentTrigger = createTrigger({ throw new Error("lastPoll doesn't exist in the store."); } - const { folderId, attachmentNameFilter } = context.propsValue; + const { folderId, attachmentNameFilter, sender } = context.propsValue; const client = Client.initWithMiddleware({ authProvider: { getAccessToken: () => Promise.resolve(context.auth.access_token), @@ -129,7 +139,7 @@ export const newAttachmentTrigger = createTrigger({ break; } } - const attachments = await enrichAttachments(client, messages, context.files, attachmentNameFilter); + const attachments = await enrichAttachments(client, messages, context.files, attachmentNameFilter, sender); const items = attachments.map((attachment) => ({ epochMilliSeconds: dayjs(attachment['messageReceivedDateTime']).valueOf(), diff --git a/packages/pieces/community/plausible/.eslintrc.json b/packages/pieces/community/plausible/.eslintrc.json new file mode 100644 index 00000000000..4a4e695c547 --- /dev/null +++ b/packages/pieces/community/plausible/.eslintrc.json @@ -0,0 +1,33 @@ +{ + "extends": [ + "../../../../.eslintrc.base.json" + ], + "ignorePatterns": [ + "!**/*" + ], + "overrides": [ + { + "files": [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx" + ], + "rules": {} + }, + { + "files": [ + "*.ts", + "*.tsx" + ], + "rules": {} + }, + { + "files": [ + "*.js", + "*.jsx" + ], + "rules": {} + } + ] +} \ No newline at end of file diff --git a/packages/pieces/community/plausible/README.md b/packages/pieces/community/plausible/README.md new file mode 100644 index 00000000000..ebd73dec8ac --- /dev/null +++ b/packages/pieces/community/plausible/README.md @@ -0,0 +1,7 @@ +# pieces-plausible + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build pieces-plausible` to build the library. diff --git a/packages/pieces/community/plausible/package.json b/packages/pieces/community/plausible/package.json new file mode 100644 index 00000000000..409986c40e5 --- /dev/null +++ b/packages/pieces/community/plausible/package.json @@ -0,0 +1,10 @@ +{ + "name": "@activepieces/piece-plausible", + "version": "0.0.1", + "type": "commonjs", + "main": "./src/index.js", + "types": "./src/index.d.ts", + "dependencies": { + "tslib": "^2.3.0" + } +} diff --git a/packages/pieces/community/plausible/project.json b/packages/pieces/community/plausible/project.json new file mode 100644 index 00000000000..5d39f912e33 --- /dev/null +++ b/packages/pieces/community/plausible/project.json @@ -0,0 +1,66 @@ +{ + "name": "pieces-plausible", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/pieces/community/plausible/src", + "projectType": "library", + "release": { + "version": { + "manifestRootsToUpdate": [ + "dist/{projectRoot}" + ], + "currentVersionResolver": "git-tag", + "fallbackCurrentVersionResolver": "disk" + } + }, + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": [ + "{options.outputPath}" + ], + "options": { + "outputPath": "dist/packages/pieces/community/plausible", + "tsConfig": "packages/pieces/community/plausible/tsconfig.lib.json", + "packageJson": "packages/pieces/community/plausible/package.json", + "main": "packages/pieces/community/plausible/src/index.ts", + "assets": [ + "packages/pieces/community/plausible/*.md", + { + "input": "packages/pieces/community/plausible/src/i18n", + "output": "./src/i18n", + "glob": "**/!(i18n.json)" + } + ], + "buildableProjectDepsInPackageJsonType": "dependencies", + "updateBuildableProjectDepsInPackageJson": true, + "clean": false + }, + "dependsOn": [ + "prebuild", + "^build" + ] + }, + "nx-release-publish": { + "options": { + "packageRoot": "dist/{projectRoot}" + } + }, + "prebuild": { + "dependsOn": [ + "^build" + ], + "executor": "nx:run-commands", + "options": { + "cwd": "packages/pieces/community/plausible", + "command": "bun install --no-save --silent" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": [ + "{options.outputFile}" + ] + } + } +} \ No newline at end of file diff --git a/packages/pieces/community/plausible/src/index.ts b/packages/pieces/community/plausible/src/index.ts new file mode 100644 index 00000000000..71a3388b986 --- /dev/null +++ b/packages/pieces/community/plausible/src/index.ts @@ -0,0 +1,83 @@ +import { + AuthenticationType, + HttpMethod, + httpClient, +} from '@activepieces/pieces-common'; +import { PieceAuth, createPiece } from '@activepieces/pieces-framework'; +import { PieceCategory } from '@activepieces/shared'; +import { plausibleCommon } from './lib/common'; +import { listTeams } from './lib/actions/list-teams'; +import { listSites } from './lib/actions/list-sites'; +import { getSite } from './lib/actions/get-site'; +import { createSite } from './lib/actions/create-site'; +import { updateSite } from './lib/actions/update-site'; +import { deleteSite } from './lib/actions/delete-site'; +import { createSharedLink } from './lib/actions/create-shared-link'; +import { listGoals } from './lib/actions/list-goals'; +import { createGoal } from './lib/actions/create-goal'; +import { deleteGoal } from './lib/actions/delete-goal'; +import { listCustomProperties } from './lib/actions/list-custom-properties'; +import { createCustomProperty } from './lib/actions/create-custom-property'; +import { deleteCustomProperty } from './lib/actions/delete-custom-property'; +import { listGuests } from './lib/actions/list-guests'; +import { inviteGuest } from './lib/actions/invite-guest'; +import { removeGuest } from './lib/actions/remove-guest'; + +export const plausibleAuth = PieceAuth.SecretText({ + displayName: 'API Key', + description: `To get your API key: +1. Log in to your Plausible Analytics account +2. Click your account name in the top-right menu and go to **Settings** +3. Go to **API Keys** in the left sidebar +4. Click **New API Key**, choose **Sites API**, and save the key`, + required: true, + validate: async ({ auth }) => { + try { + await httpClient.sendRequest({ + url: `${plausibleCommon.baseUrl}/sites`, + method: HttpMethod.GET, + authentication: { + type: AuthenticationType.BEARER_TOKEN, + token: auth, + }, + }); + return { + valid: true, + }; + } catch (e) { + return { + valid: false, + error: 'Invalid API key', + }; + } + }, +}); + +export const plausible = createPiece({ + displayName: 'Plausible', + description: 'Privacy-friendly web analytics', + auth: plausibleAuth, + minimumSupportedRelease: '0.36.1', + logoUrl: 'https://cdn.activepieces.com/pieces/plausible.png', + authors: ['onyedikachi-david'], + actions: [ + listTeams, + listSites, + getSite, + createSite, + updateSite, + deleteSite, + createSharedLink, + listGoals, + createGoal, + deleteGoal, + listCustomProperties, + createCustomProperty, + deleteCustomProperty, + listGuests, + inviteGuest, + removeGuest, + ], + triggers: [], + categories: [PieceCategory.MARKETING], +}); \ No newline at end of file diff --git a/packages/pieces/community/plausible/src/lib/actions/create-custom-property.ts b/packages/pieces/community/plausible/src/lib/actions/create-custom-property.ts new file mode 100644 index 00000000000..78bc91169da --- /dev/null +++ b/packages/pieces/community/plausible/src/lib/actions/create-custom-property.ts @@ -0,0 +1,31 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { plausibleAuth } from '../..'; +import { plausibleApiCall, siteIdDropdown } from '../common'; + +export const createCustomProperty = createAction({ + auth: plausibleAuth, + name: 'create_custom_property', + displayName: 'Create Custom Property', + description: 'Create a custom property for a site', + props: { + site_id: siteIdDropdown, + property: Property.ShortText({ + displayName: 'Property Name', + description: 'Name of the custom property', + required: true, + }), + }, + async run(context) { + const response = await plausibleApiCall({ + apiKey: context.auth.secret_text, + method: HttpMethod.PUT, + endpoint: '/sites/custom-props', + body: { + site_id: context.propsValue['site_id'], + property: context.propsValue['property'], + }, + }); + return response; + }, +}); diff --git a/packages/pieces/community/plausible/src/lib/actions/create-goal.ts b/packages/pieces/community/plausible/src/lib/actions/create-goal.ts new file mode 100644 index 00000000000..22ab894e19f --- /dev/null +++ b/packages/pieces/community/plausible/src/lib/actions/create-goal.ts @@ -0,0 +1,71 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { plausibleAuth } from '../..'; +import { plausibleApiCall, siteIdDropdown } from '../common'; + +export const createGoal = createAction({ + auth: plausibleAuth, + name: 'create_goal', + displayName: 'Create Goal', + description: 'Find or create a goal for a site', + props: { + site_id: siteIdDropdown, + goal_type: Property.StaticDropdown({ + displayName: 'Goal Type', + description: 'Type of goal to create', + required: true, + options: { + options: [ + { label: 'Event', value: 'event' }, + { label: 'Page', value: 'page' }, + ], + }, + }), + event_name: Property.ShortText({ + displayName: 'Event Name', + description: 'Name of the event (required if goal type is Event)', + required: false, + }), + page_path: Property.ShortText({ + displayName: 'Page Path', + description: 'Page path to track (required if goal type is Page). Supports wildcards.', + required: false, + }), + display_name: Property.ShortText({ + displayName: 'Display Name', + description: 'Custom display name for the goal in the dashboard', + required: false, + }), + }, + async run(context) { + const goal_type = context.propsValue['goal_type']; + const event_name = context.propsValue['event_name']; + const page_path = context.propsValue['page_path']; + const display_name = context.propsValue['display_name']; + + const body: Record = { + site_id: context.propsValue['site_id'], + goal_type, + }; + + if (goal_type === 'event' && event_name) { + body['event_name'] = event_name; + } + + if (goal_type === 'page' && page_path) { + body['page_path'] = page_path; + } + + if (display_name) { + body['display_name'] = display_name; + } + + const response = await plausibleApiCall({ + apiKey: context.auth.secret_text, + method: HttpMethod.PUT, + endpoint: '/sites/goals', + body, + }); + return response; + }, +}); diff --git a/packages/pieces/community/plausible/src/lib/actions/create-shared-link.ts b/packages/pieces/community/plausible/src/lib/actions/create-shared-link.ts new file mode 100644 index 00000000000..c5721356b93 --- /dev/null +++ b/packages/pieces/community/plausible/src/lib/actions/create-shared-link.ts @@ -0,0 +1,31 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { plausibleAuth } from '../..'; +import { plausibleApiCall, siteIdDropdown } from '../common'; + +export const createSharedLink = createAction({ + auth: plausibleAuth, + name: 'create_shared_link', + displayName: 'Create Shared Link', + description: 'Find or create a shared link for a site', + props: { + site_id: siteIdDropdown, + name: Property.ShortText({ + displayName: 'Link Name', + description: 'Name of the shared link (e.g., Wordpress)', + required: true, + }), + }, + async run(context) { + const response = await plausibleApiCall({ + apiKey: context.auth.secret_text, + method: HttpMethod.PUT, + endpoint: '/sites/shared-links', + body: { + site_id: context.propsValue['site_id'], + name: context.propsValue['name'], + }, + }); + return response; + }, +}); diff --git a/packages/pieces/community/plausible/src/lib/actions/create-site.ts b/packages/pieces/community/plausible/src/lib/actions/create-site.ts new file mode 100644 index 00000000000..2aa3138c9fc --- /dev/null +++ b/packages/pieces/community/plausible/src/lib/actions/create-site.ts @@ -0,0 +1,95 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { plausibleAuth } from '../..'; +import { plausibleApiCall, teamIdDropdown } from '../common'; + +export const createSite = createAction({ + auth: plausibleAuth, + name: 'create_site', + displayName: 'Create Site', + description: 'Create a new site in your Plausible account', + props: { + domain: Property.ShortText({ + displayName: 'Domain', + description: 'Domain of the site (must be globally unique)', + required: true, + }), + timezone: Property.ShortText({ + displayName: 'Timezone', + description: 'Timezone name according to IANA database (e.g., Europe/London). Defaults to Etc/UTC', + required: false, + }), + team_id: teamIdDropdown, + track_404_pages: Property.Checkbox({ + displayName: 'Track 404 Pages', + description: 'Enable tracking of 404 error pages', + required: false, + defaultValue: false, + }), + hash_based_routing: Property.Checkbox({ + displayName: 'Hash-Based Routing', + description: 'Enable hash-based routing for single-page applications', + required: false, + defaultValue: false, + }), + outbound_links: Property.Checkbox({ + displayName: 'Outbound Links', + description: 'Track clicks on outbound links', + required: false, + defaultValue: false, + }), + file_downloads: Property.Checkbox({ + displayName: 'File Downloads', + description: 'Track file downloads', + required: false, + defaultValue: false, + }), + form_submissions: Property.Checkbox({ + displayName: 'Form Submissions', + description: 'Track form submissions', + required: false, + defaultValue: false, + }), + }, + async run(context) { + const domain = context.propsValue['domain']; + const timezone = context.propsValue['timezone']; + const team_id = context.propsValue['team_id']; + const track_404_pages = context.propsValue['track_404_pages']; + const hash_based_routing = context.propsValue['hash_based_routing']; + const outbound_links = context.propsValue['outbound_links']; + const file_downloads = context.propsValue['file_downloads']; + const form_submissions = context.propsValue['form_submissions']; + + const body: Record = { + domain, + }; + + if (timezone) { + body['timezone'] = timezone; + } + + if (team_id) { + body['team_id'] = team_id; + } + + const trackerConfig: Record = {}; + if (track_404_pages) trackerConfig['track_404_pages'] = true; + if (hash_based_routing) trackerConfig['hash_based_routing'] = true; + if (outbound_links) trackerConfig['outbound_links'] = true; + if (file_downloads) trackerConfig['file_downloads'] = true; + if (form_submissions) trackerConfig['form_submissions'] = true; + + if (Object.keys(trackerConfig).length > 0) { + body['tracker_script_configuration'] = trackerConfig; + } + + const response = await plausibleApiCall({ + apiKey: context.auth.secret_text, + method: HttpMethod.POST, + endpoint: '/sites', + body, + }); + return response; + }, +}); diff --git a/packages/pieces/community/plausible/src/lib/actions/delete-custom-property.ts b/packages/pieces/community/plausible/src/lib/actions/delete-custom-property.ts new file mode 100644 index 00000000000..f644af2794e --- /dev/null +++ b/packages/pieces/community/plausible/src/lib/actions/delete-custom-property.ts @@ -0,0 +1,26 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { plausibleAuth } from '../..'; +import { plausibleApiCall, siteIdDropdown, customPropertyDropdown } from '../common'; + +export const deleteCustomProperty = createAction({ + auth: plausibleAuth, + name: 'delete_custom_property', + displayName: 'Delete Custom Property', + description: 'Delete a custom property from a site', + props: { + site_id: siteIdDropdown, + property: customPropertyDropdown, + }, + async run(context) { + const response = await plausibleApiCall({ + apiKey: context.auth.secret_text, + method: HttpMethod.DELETE, + endpoint: `/sites/custom-props/${encodeURIComponent(context.propsValue['property'] as string)}`, + body: { + site_id: context.propsValue['site_id'], + }, + }); + return response; + }, +}); diff --git a/packages/pieces/community/plausible/src/lib/actions/delete-goal.ts b/packages/pieces/community/plausible/src/lib/actions/delete-goal.ts new file mode 100644 index 00000000000..0fd3c833f00 --- /dev/null +++ b/packages/pieces/community/plausible/src/lib/actions/delete-goal.ts @@ -0,0 +1,26 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { plausibleAuth } from '../..'; +import { plausibleApiCall, siteIdDropdown, goalIdDropdown } from '../common'; + +export const deleteGoal = createAction({ + auth: plausibleAuth, + name: 'delete_goal', + displayName: 'Delete Goal', + description: 'Delete a goal from a site', + props: { + site_id: siteIdDropdown, + goal_id: goalIdDropdown, + }, + async run(context) { + const response = await plausibleApiCall({ + apiKey: context.auth.secret_text, + method: HttpMethod.DELETE, + endpoint: `/sites/goals/${encodeURIComponent(context.propsValue['goal_id'] as string)}`, + body: { + site_id: context.propsValue['site_id'], + }, + }); + return response; + }, +}); diff --git a/packages/pieces/community/plausible/src/lib/actions/delete-site.ts b/packages/pieces/community/plausible/src/lib/actions/delete-site.ts new file mode 100644 index 00000000000..c21f0f063e2 --- /dev/null +++ b/packages/pieces/community/plausible/src/lib/actions/delete-site.ts @@ -0,0 +1,22 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { plausibleAuth } from '../..'; +import { plausibleApiCall, siteIdDropdown } from '../common'; + +export const deleteSite = createAction({ + auth: plausibleAuth, + name: 'delete_site', + displayName: 'Delete Site', + description: 'Delete a site and all its data from your Plausible account. This action is permanent and may take up to 48 hours to complete.', + props: { + site_id: siteIdDropdown, + }, + async run(context) { + const response = await plausibleApiCall({ + apiKey: context.auth.secret_text, + method: HttpMethod.DELETE, + endpoint: `/sites/${encodeURIComponent(context.propsValue['site_id'])}`, + }); + return response; + }, +}); diff --git a/packages/pieces/community/plausible/src/lib/actions/get-site.ts b/packages/pieces/community/plausible/src/lib/actions/get-site.ts new file mode 100644 index 00000000000..5e10cd24f5a --- /dev/null +++ b/packages/pieces/community/plausible/src/lib/actions/get-site.ts @@ -0,0 +1,22 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { plausibleAuth } from '../..'; +import { plausibleApiCall, siteIdDropdown } from '../common'; + +export const getSite = createAction({ + auth: plausibleAuth, + name: 'get_site', + displayName: 'Get Site', + description: 'Get details of a site including tracker script configuration', + props: { + site_id: siteIdDropdown, + }, + async run(context) { + const response = await plausibleApiCall({ + apiKey: context.auth.secret_text, + method: HttpMethod.GET, + endpoint: `/sites/${encodeURIComponent(context.propsValue['site_id'])}`, + }); + return response; + }, +}); diff --git a/packages/pieces/community/plausible/src/lib/actions/invite-guest.ts b/packages/pieces/community/plausible/src/lib/actions/invite-guest.ts new file mode 100644 index 00000000000..a377bf6e5bd --- /dev/null +++ b/packages/pieces/community/plausible/src/lib/actions/invite-guest.ts @@ -0,0 +1,43 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { plausibleAuth } from '../..'; +import { plausibleApiCall, siteIdDropdown } from '../common'; + +export const inviteGuest = createAction({ + auth: plausibleAuth, + name: 'invite_guest', + displayName: 'Invite Guest', + description: 'Invite a guest to access a site or find an existing invitation', + props: { + site_id: siteIdDropdown, + email: Property.ShortText({ + displayName: 'Email', + description: "Guest's email address", + required: true, + }), + role: Property.StaticDropdown({ + displayName: 'Role', + description: 'Role to assign to the guest', + required: true, + options: { + options: [ + { label: 'Viewer', value: 'viewer' }, + { label: 'Editor', value: 'editor' }, + ], + }, + }), + }, + async run(context) { + const response = await plausibleApiCall({ + apiKey: context.auth.secret_text, + method: HttpMethod.PUT, + endpoint: '/sites/guests', + body: { + site_id: context.propsValue['site_id'], + email: context.propsValue['email'], + role: context.propsValue['role'], + }, + }); + return response; + }, +}); diff --git a/packages/pieces/community/plausible/src/lib/actions/list-custom-properties.ts b/packages/pieces/community/plausible/src/lib/actions/list-custom-properties.ts new file mode 100644 index 00000000000..6934252d6a9 --- /dev/null +++ b/packages/pieces/community/plausible/src/lib/actions/list-custom-properties.ts @@ -0,0 +1,20 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { plausibleAuth } from '../..'; +import { getCustomProperties, siteIdDropdown } from '../common'; + +export const listCustomProperties = createAction({ + auth: plausibleAuth, + name: 'list_custom_properties', + displayName: 'List Custom Properties', + description: 'Get a list of custom properties for a site', + props: { + site_id: siteIdDropdown, + }, + async run(context) { + const customProperties = await getCustomProperties( + context.auth.secret_text, + context.propsValue['site_id'] + ); + return { custom_properties: customProperties }; + }, +}); diff --git a/packages/pieces/community/plausible/src/lib/actions/list-goals.ts b/packages/pieces/community/plausible/src/lib/actions/list-goals.ts new file mode 100644 index 00000000000..8fc21da2d10 --- /dev/null +++ b/packages/pieces/community/plausible/src/lib/actions/list-goals.ts @@ -0,0 +1,20 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { plausibleAuth } from '../..'; +import { getGoals, siteIdDropdown } from '../common'; + +export const listGoals = createAction({ + auth: plausibleAuth, + name: 'list_goals', + displayName: 'List Goals', + description: 'Get a list of goals for a site', + props: { + site_id: siteIdDropdown, + }, + async run(context) { + const goals = await getGoals( + context.auth.secret_text, + context.propsValue['site_id'] + ); + return { goals }; + }, +}); diff --git a/packages/pieces/community/plausible/src/lib/actions/list-guests.ts b/packages/pieces/community/plausible/src/lib/actions/list-guests.ts new file mode 100644 index 00000000000..cede205bbfd --- /dev/null +++ b/packages/pieces/community/plausible/src/lib/actions/list-guests.ts @@ -0,0 +1,20 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { plausibleAuth } from '../..'; +import { getGuests, siteIdDropdown } from '../common'; + +export const listGuests = createAction({ + auth: plausibleAuth, + name: 'list_guests', + displayName: 'List Guests', + description: 'Get a list of guests for a site', + props: { + site_id: siteIdDropdown, + }, + async run(context) { + const guests = await getGuests( + context.auth.secret_text, + context.propsValue['site_id'] + ); + return { guests }; + }, +}); diff --git a/packages/pieces/community/plausible/src/lib/actions/list-sites.ts b/packages/pieces/community/plausible/src/lib/actions/list-sites.ts new file mode 100644 index 00000000000..c0911562a3c --- /dev/null +++ b/packages/pieces/community/plausible/src/lib/actions/list-sites.ts @@ -0,0 +1,15 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { plausibleAuth } from '../..'; +import { getSites } from '../common'; + +export const listSites = createAction({ + auth: plausibleAuth, + name: 'list_sites', + displayName: 'List Sites', + description: 'Get a list of sites your Plausible account can access', + props: {}, + async run(context) { + const sites = await getSites(context.auth.secret_text); + return { sites }; + }, +}); diff --git a/packages/pieces/community/plausible/src/lib/actions/list-teams.ts b/packages/pieces/community/plausible/src/lib/actions/list-teams.ts new file mode 100644 index 00000000000..8d1e6eb8206 --- /dev/null +++ b/packages/pieces/community/plausible/src/lib/actions/list-teams.ts @@ -0,0 +1,15 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { plausibleAuth } from '../..'; +import { getTeams } from '../common'; + +export const listTeams = createAction({ + auth: plausibleAuth, + name: 'list_teams', + displayName: 'List Teams', + description: 'Get a list of teams your Plausible account can access', + props: {}, + async run(context) { + const teams = await getTeams(context.auth.secret_text); + return { teams }; + }, +}); diff --git a/packages/pieces/community/plausible/src/lib/actions/remove-guest.ts b/packages/pieces/community/plausible/src/lib/actions/remove-guest.ts new file mode 100644 index 00000000000..9171e34370b --- /dev/null +++ b/packages/pieces/community/plausible/src/lib/actions/remove-guest.ts @@ -0,0 +1,26 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { plausibleAuth } from '../..'; +import { plausibleApiCall, siteIdDropdown, guestEmailDropdown } from '../common'; + +export const removeGuest = createAction({ + auth: plausibleAuth, + name: 'remove_guest', + displayName: 'Remove Guest', + description: 'Remove a guest or invitation from a site', + props: { + site_id: siteIdDropdown, + email: guestEmailDropdown, + }, + async run(context) { + const response = await plausibleApiCall({ + apiKey: context.auth.secret_text, + method: HttpMethod.DELETE, + endpoint: `/sites/guests/${encodeURIComponent(context.propsValue['email'] as string)}`, + body: { + site_id: context.propsValue['site_id'], + }, + }); + return response; + }, +}); diff --git a/packages/pieces/community/plausible/src/lib/actions/update-site.ts b/packages/pieces/community/plausible/src/lib/actions/update-site.ts new file mode 100644 index 00000000000..bdfd182bea5 --- /dev/null +++ b/packages/pieces/community/plausible/src/lib/actions/update-site.ts @@ -0,0 +1,88 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { plausibleAuth } from '../..'; +import { plausibleApiCall, siteIdDropdown } from '../common'; + +export const updateSite = createAction({ + auth: plausibleAuth, + name: 'update_site', + displayName: 'Update Site', + description: 'Update an existing site in your Plausible account', + props: { + site_id: siteIdDropdown, + new_domain: Property.ShortText({ + displayName: 'New Domain', + description: 'New domain name for the site (leave empty to keep current)', + required: false, + }), + track_404_pages: Property.Checkbox({ + displayName: 'Track 404 Pages', + description: 'Enable tracking of 404 error pages', + required: false, + }), + hash_based_routing: Property.Checkbox({ + displayName: 'Hash-Based Routing', + description: 'Enable hash-based routing for single-page applications', + required: false, + }), + outbound_links: Property.Checkbox({ + displayName: 'Outbound Links', + description: 'Track clicks on outbound links', + required: false, + }), + file_downloads: Property.Checkbox({ + displayName: 'File Downloads', + description: 'Track file downloads', + required: false, + }), + form_submissions: Property.Checkbox({ + displayName: 'Form Submissions', + description: 'Track form submissions', + required: false, + }), + }, + async run(context) { + const site_id = context.propsValue['site_id']; + const new_domain = context.propsValue['new_domain']; + const track_404_pages = context.propsValue['track_404_pages']; + const hash_based_routing = context.propsValue['hash_based_routing']; + const outbound_links = context.propsValue['outbound_links']; + const file_downloads = context.propsValue['file_downloads']; + const form_submissions = context.propsValue['form_submissions']; + + const body: Record = {}; + + if (new_domain) { + body['domain'] = new_domain; + } + + const trackerConfig: Record = {}; + if (track_404_pages !== undefined && track_404_pages !== null) { + trackerConfig['track_404_pages'] = track_404_pages; + } + if (hash_based_routing !== undefined && hash_based_routing !== null) { + trackerConfig['hash_based_routing'] = hash_based_routing; + } + if (outbound_links !== undefined && outbound_links !== null) { + trackerConfig['outbound_links'] = outbound_links; + } + if (file_downloads !== undefined && file_downloads !== null) { + trackerConfig['file_downloads'] = file_downloads; + } + if (form_submissions !== undefined && form_submissions !== null) { + trackerConfig['form_submissions'] = form_submissions; + } + + if (Object.keys(trackerConfig).length > 0) { + body['tracker_script_configuration'] = trackerConfig; + } + + const response = await plausibleApiCall({ + apiKey: context.auth.secret_text, + method: HttpMethod.PUT, + endpoint: `/sites/${encodeURIComponent(site_id as string)}`, + body, + }); + return response; + }, +}); diff --git a/packages/pieces/community/plausible/src/lib/common/index.ts b/packages/pieces/community/plausible/src/lib/common/index.ts new file mode 100644 index 00000000000..20e0f6786c9 --- /dev/null +++ b/packages/pieces/community/plausible/src/lib/common/index.ts @@ -0,0 +1,276 @@ +import { + AuthenticationType, + httpClient, + HttpMethod, + HttpRequest, +} from '@activepieces/pieces-common'; +import { Property } from '@activepieces/pieces-framework'; +import { plausibleAuth } from '../..'; + +export const plausibleCommon = { + baseUrl: 'https://plausible.io/api/v1', +}; + +export async function plausibleApiCall({ + apiKey, + method, + endpoint, + body, + queryParams, +}: { + apiKey: string; + method: HttpMethod; + endpoint: string; + body?: unknown; + queryParams?: Record; +}): Promise { + const request: HttpRequest = { + url: `${plausibleCommon.baseUrl}${endpoint}`, + method, + authentication: { + type: AuthenticationType.BEARER_TOKEN, + token: apiKey, + }, + body, + queryParams, + }; + const response = await httpClient.sendRequest(request); + return response.body; +} + +export async function getSites(apiKey: string) { + const response = await plausibleApiCall<{ + sites: { domain: string; timezone: string }[]; + }>({ + apiKey, + method: HttpMethod.GET, + endpoint: '/sites', + }); + return response.sites; +} + +export const siteIdDropdown = Property.Dropdown({ + displayName: 'Site', + description: 'Select a site', + required: true, + auth: plausibleAuth, + refreshers: [], + options: async ({ auth }) => { + if (!auth) { + return { + disabled: true, + placeholder: 'Connect your account first', + options: [], + }; + } + const sites = await getSites(auth.secret_text); + return { + disabled: false, + options: sites.map((site) => ({ + label: site.domain, + value: site.domain, + })), + }; + }, +}); + +export const optionalSiteIdDropdown = Property.Dropdown({ + displayName: 'Site', + description: 'Select a site', + required: false, + auth: plausibleAuth, + refreshers: [], + options: async ({ auth }) => { + if (!auth) { + return { + disabled: true, + placeholder: 'Connect your account first', + options: [], + }; + } + const sites = await getSites(auth.secret_text); + return { + disabled: false, + options: sites.map((site) => ({ + label: site.domain, + value: site.domain, + })), + }; + }, +}); + +export async function getTeams(apiKey: string) { + const response = await plausibleApiCall<{ + teams: { id: string; name: string; api_available: boolean }[]; + }>({ + apiKey, + method: HttpMethod.GET, + endpoint: '/sites/teams', + }); + return response.teams; +} + +export const teamIdDropdown = Property.Dropdown({ + displayName: 'Team', + description: 'Select a team', + required: false, + refreshers: [], + auth: plausibleAuth, + options: async ({ auth }) => { + if (!auth) { + return { + disabled: true, + placeholder: 'Connect your account first', + options: [], + }; + } + const teams = await getTeams(auth.secret_text); + return { + disabled: false, + options: teams + .filter((team) => team.api_available) + .map((team) => ({ + label: team.name, + value: team.id, + })), + }; + }, +}); + +export async function getGoals(apiKey: string, siteId: string) { + const response = await plausibleApiCall<{ + goals: { + id: string; + goal_type: string; + display_name: string; + event_name: string | null; + page_path: string | null; + }[]; + }>({ + apiKey, + method: HttpMethod.GET, + endpoint: '/sites/goals', + queryParams: { site_id: siteId }, + }); + return response.goals; +} + +export const goalIdDropdown = Property.Dropdown({ + displayName: 'Goal', + description: 'Select a goal', + required: true, + auth: plausibleAuth, + refreshers: ['site_id'], + options: async ({ auth, site_id }) => { + if (!auth) { + return { + disabled: true, + placeholder: 'Connect your account first', + options: [], + }; + } + if (!site_id) { + return { + disabled: true, + placeholder: 'Select a site first', + options: [], + }; + } + const goals = await getGoals(auth.secret_text, site_id as string); + return { + disabled: false, + options: goals.map((goal) => ({ + label: goal.display_name, + value: goal.id, + })), + }; + }, +}); + +export async function getCustomProperties(apiKey: string, siteId: string) { + const response = await plausibleApiCall<{ + custom_properties: { property: string }[]; + }>({ + apiKey, + method: HttpMethod.GET, + endpoint: '/sites/custom-props', + queryParams: { site_id: siteId }, + }); + return response.custom_properties; +} + +export const customPropertyDropdown = Property.Dropdown({ + displayName: 'Custom Property', + description: 'Select a custom property', + required: true, + auth: plausibleAuth, + refreshers: ['site_id'], + options: async ({ auth, site_id }) => { + if (!auth) { + return { + disabled: true, + placeholder: 'Connect your account first', + options: [], + }; + } + if (!site_id) { + return { + disabled: true, + placeholder: 'Select a site first', + options: [], + }; + } + const props = await getCustomProperties(auth.secret_text, site_id as string); + return { + disabled: false, + options: props.map((prop) => ({ + label: prop.property, + value: prop.property, + })), + }; + }, +}); + +export async function getGuests(apiKey: string, siteId: string) { + const response = await plausibleApiCall<{ + guests: { email: string; role: string; status: string }[]; + }>({ + apiKey, + method: HttpMethod.GET, + endpoint: '/sites/guests', + queryParams: { site_id: siteId }, + }); + return response.guests; +} + +export const guestEmailDropdown = Property.Dropdown({ + displayName: 'Guest', + description: 'Select a guest', + required: true, + auth: plausibleAuth, + refreshers: ['site_id'], + options: async ({ auth, site_id }) => { + if (!auth) { + return { + disabled: true, + placeholder: 'Connect your account first', + options: [], + }; + } + if (!site_id) { + return { + disabled: true, + placeholder: 'Select a site first', + options: [], + }; + } + const guests = await getGuests(auth.secret_text, site_id as string); + return { + disabled: false, + options: guests.map((guest) => ({ + label: `${guest.email} (${guest.role})`, + value: guest.email, + })), + }; + }, +}); diff --git a/packages/pieces/community/plausible/tsconfig.json b/packages/pieces/community/plausible/tsconfig.json new file mode 100644 index 00000000000..eff240ac143 --- /dev/null +++ b/packages/pieces/community/plausible/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "importHelpers": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/packages/pieces/community/plausible/tsconfig.lib.json b/packages/pieces/community/plausible/tsconfig.lib.json new file mode 100644 index 00000000000..5995d959bf0 --- /dev/null +++ b/packages/pieces/community/plausible/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/pieces/community/salesforce/package.json b/packages/pieces/community/salesforce/package.json index 2feada1379f..25649928dca 100644 --- a/packages/pieces/community/salesforce/package.json +++ b/packages/pieces/community/salesforce/package.json @@ -1,4 +1,4 @@ { "name": "@activepieces/piece-salesforce", - "version": "0.3.0" + "version": "0.3.1" } diff --git a/packages/pieces/community/salesforce/src/index.ts b/packages/pieces/community/salesforce/src/index.ts index df88bb3b0fa..5ca2331cf72 100644 --- a/packages/pieces/community/salesforce/src/index.ts +++ b/packages/pieces/community/salesforce/src/index.ts @@ -43,6 +43,7 @@ import { newOrUpdatedRecord } from './lib/trigger/new-updated-record'; import { newOutboundMessage } from './lib/trigger/new-outbound-message'; import { newRecord } from './lib/trigger/new-record'; import { newUpdatedFile } from './lib/trigger/new-updated-file'; +import { exportReport } from './lib/action/export-report'; export const salesforceAuth = PieceAuth.OAuth2({ props: { @@ -85,6 +86,7 @@ export const salesforce = createPiece({ 'khaledmashaly', 'abuaboud', 'Pranith124', + 'sanket-a11y' ], categories: [PieceCategory.SALES_AND_CRM], auth: salesforceAuth, @@ -102,6 +104,7 @@ export const salesforce = createPiece({ createTask, deleteOpportunity, deleteRecord, + exportReport, findChildRecords, findRecord, findRecordsByQuery, diff --git a/packages/pieces/community/salesforce/src/lib/action/export-report.ts b/packages/pieces/community/salesforce/src/lib/action/export-report.ts new file mode 100644 index 00000000000..80aefb7a90f --- /dev/null +++ b/packages/pieces/community/salesforce/src/lib/action/export-report.ts @@ -0,0 +1,50 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { + HttpMethod, + httpClient, + AuthenticationType, +} from '@activepieces/pieces-common'; +import { salesforceAuth } from '../..'; +import { callSalesforceApi, salesforcesCommon } from '../common'; + +export const exportReport = createAction({ + auth: salesforceAuth, + name: 'export_report', + displayName: 'Export Report ', + description: 'Export a Salesforce report as an Excel file.', + props: { + report_id: salesforcesCommon.report, + }, + async run(context) { + const { report_id } = context.propsValue; + const access_token = context.auth.access_token; + + const response = await httpClient.sendRequest({ + method: HttpMethod.POST, + url: `${context.auth.data['instance_url']}/services/data/v56.0/analytics/reports/${report_id}?export=1&enc=UTF-8&xf=excel`, + authentication: { + type: AuthenticationType.BEARER_TOKEN, + token: access_token, + }, + headers: { + Accept: + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }, + responseType: 'arraybuffer', + }); + + const report = (await callSalesforceApi( + HttpMethod.GET, + context.auth, + `/services/data/v56.0/analytics/reports/${report_id}`, + undefined + )) as any; + + const reportName = report.body.attributes.reportName || report_id; + + return await context.files.write({ + fileName: `report_${reportName}.xlsx`, + data: Buffer.from(response.body), + }); + }, +}); diff --git a/packages/pieces/community/slack/package.json b/packages/pieces/community/slack/package.json index 3cb28449edf..c64169892bf 100644 --- a/packages/pieces/community/slack/package.json +++ b/packages/pieces/community/slack/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-slack", - "version": "0.11.1", + "version": "0.11.2", "dependencies": { "@slack/web-api": "7.9.0", "slackify-markdown": "4.4.0" diff --git a/packages/pieces/community/slack/src/index.ts b/packages/pieces/community/slack/src/index.ts index b2b7250556d..8302cc33a36 100644 --- a/packages/pieces/community/slack/src/index.ts +++ b/packages/pieces/community/slack/src/index.ts @@ -48,6 +48,7 @@ import { newSavedMessageTrigger } from './lib/triggers/new-saved-message'; import { newTeamCustomEmojiTrigger } from './lib/triggers/new-team-custom-emoji'; import { inviteUserToChannelAction } from './lib/actions/invite-user-to-channel'; import { listUsers } from './lib/actions/list-users'; +import { deleteMessageAction } from './lib/actions/delete-message'; export const slackAuth = PieceAuth.OAuth2({ description: '', @@ -182,6 +183,7 @@ export const slack = createPiece({ findUserByIdAction, listUsers, updateMessage, + deleteMessageAction, createChannelAction, updateProfileAction, getChannelHistory, diff --git a/packages/pieces/community/slack/src/lib/actions/delete-message.ts b/packages/pieces/community/slack/src/lib/actions/delete-message.ts new file mode 100644 index 00000000000..d6d8c4b175a --- /dev/null +++ b/packages/pieces/community/slack/src/lib/actions/delete-message.ts @@ -0,0 +1,57 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { slackAuth } from '../..'; +import { singleSelectChannelInfo, slackChannel } from '../common/props'; +import { processMessageTimestamp } from '../common/utils'; +import { WebClient } from '@slack/web-api'; + +export const deleteMessageAction = createAction({ + name: 'delete-message', + displayName: 'Delete Message', + description: `Deletes a specific message from a channel using the message's timestamp.`, + auth: slackAuth, + props: { + info: singleSelectChannelInfo, + channel: slackChannel(true), + ts: Property.ShortText({ + displayName: 'Message Timestamp', + description: + 'Please provide the timestamp of the message you wish to retrieve, such as `1710304378.475129`. Alternatively, you can easily obtain the message link by clicking on the three dots next to the message and selecting the `Copy link` option.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const messageTimestamp = processMessageTimestamp(propsValue.ts); + if (!messageTimestamp) { + throw new Error('Invalid Timestamp Value.'); + } + + const userAccessToken = auth.data?.authed_user?.access_token; + + const client = new WebClient(userAccessToken); + + const historyResponse = await client.conversations.history({ + channel: propsValue.channel, + oldest: messageTimestamp, + limit: 1, + inclusive: true, + }); + + const message = historyResponse.messages?.[0]; + + if (!message) { + throw new Error('No message found for the provided timestamp.'); + } + + + if (!userAccessToken) { + throw new Error('User access token is missing.'); + } + + // const userClient = new WebClient(userAccessToken); + + return client.chat.delete({ + channel: propsValue.channel, + ts: messageTimestamp, + }); + }, +}); diff --git a/packages/pieces/community/slack/src/lib/actions/send-message-action.ts b/packages/pieces/community/slack/src/lib/actions/send-message-action.ts index b0e0425efcb..b66e65c6b5a 100644 --- a/packages/pieces/community/slack/src/lib/actions/send-message-action.ts +++ b/packages/pieces/community/slack/src/lib/actions/send-message-action.ts @@ -26,6 +26,11 @@ export const slackSendMessageAction = createAction({ description: 'The text of your message. When using Block Kit blocks, this is used as a fallback for notifications.', required: false, }), + sendAsBot:Property.Checkbox({ + displayName:'Send as a bot?', + required:true, + defaultValue:true + }), threadTs, username, profilePicture, @@ -49,10 +54,11 @@ export const slackSendMessageAction = createAction({ blocks, }, async run(context) { - const token = context.auth.access_token; - const { text, channel, username, profilePicture, threadTs, file, mentionOriginFlow, blocks, replyBroadcast, unfurlLinks } = + const { text, channel,sendAsBot, username, profilePicture, threadTs, file, mentionOriginFlow, blocks, replyBroadcast, unfurlLinks } = context.propsValue; + const token = sendAsBot ?context.auth.access_token :context.auth.data?.authed_user?.access_token ; + if (!text && (!blocks || !Array.isArray(blocks) || blocks.length === 0)) { throw new Error('Either Message or Block Kit blocks must be provided'); } diff --git a/packages/pieces/community/woodpecker/.eslintrc.json b/packages/pieces/community/woodpecker/.eslintrc.json new file mode 100644 index 00000000000..4a4e695c547 --- /dev/null +++ b/packages/pieces/community/woodpecker/.eslintrc.json @@ -0,0 +1,33 @@ +{ + "extends": [ + "../../../../.eslintrc.base.json" + ], + "ignorePatterns": [ + "!**/*" + ], + "overrides": [ + { + "files": [ + "*.ts", + "*.tsx", + "*.js", + "*.jsx" + ], + "rules": {} + }, + { + "files": [ + "*.ts", + "*.tsx" + ], + "rules": {} + }, + { + "files": [ + "*.js", + "*.jsx" + ], + "rules": {} + } + ] +} \ No newline at end of file diff --git a/packages/pieces/community/woodpecker/README.md b/packages/pieces/community/woodpecker/README.md new file mode 100644 index 00000000000..18d648348db --- /dev/null +++ b/packages/pieces/community/woodpecker/README.md @@ -0,0 +1,7 @@ +# pieces-woodpecker + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build pieces-woodpecker` to build the library. diff --git a/packages/pieces/community/woodpecker/package.json b/packages/pieces/community/woodpecker/package.json new file mode 100644 index 00000000000..4feb056271f --- /dev/null +++ b/packages/pieces/community/woodpecker/package.json @@ -0,0 +1,10 @@ +{ + "name": "@activepieces/piece-woodpecker", + "version": "0.0.1", + "type": "commonjs", + "main": "./src/index.js", + "types": "./src/index.d.ts", + "dependencies": { + "tslib": "^2.3.0" + } +} diff --git a/packages/pieces/community/woodpecker/project.json b/packages/pieces/community/woodpecker/project.json new file mode 100644 index 00000000000..b032162a5ea --- /dev/null +++ b/packages/pieces/community/woodpecker/project.json @@ -0,0 +1,66 @@ +{ + "name": "pieces-woodpecker", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/pieces/community/woodpecker/src", + "projectType": "library", + "release": { + "version": { + "manifestRootsToUpdate": [ + "dist/{projectRoot}" + ], + "currentVersionResolver": "git-tag", + "fallbackCurrentVersionResolver": "disk" + } + }, + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": [ + "{options.outputPath}" + ], + "options": { + "outputPath": "dist/packages/pieces/community/woodpecker", + "tsConfig": "packages/pieces/community/woodpecker/tsconfig.lib.json", + "packageJson": "packages/pieces/community/woodpecker/package.json", + "main": "packages/pieces/community/woodpecker/src/index.ts", + "assets": [ + "packages/pieces/community/woodpecker/*.md", + { + "input": "packages/pieces/community/woodpecker/src/i18n", + "output": "./src/i18n", + "glob": "**/!(i18n.json)" + } + ], + "buildableProjectDepsInPackageJsonType": "dependencies", + "updateBuildableProjectDepsInPackageJson": true, + "clean": false + }, + "dependsOn": [ + "prebuild", + "^build" + ] + }, + "nx-release-publish": { + "options": { + "packageRoot": "dist/{projectRoot}" + } + }, + "prebuild": { + "dependsOn": [ + "^build" + ], + "executor": "nx:run-commands", + "options": { + "cwd": "packages/pieces/community/woodpecker", + "command": "bun install --no-save --silent" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": [ + "{options.outputFile}" + ] + } + } +} \ No newline at end of file diff --git a/packages/pieces/community/woodpecker/src/index.ts b/packages/pieces/community/woodpecker/src/index.ts new file mode 100644 index 00000000000..197c2255682 --- /dev/null +++ b/packages/pieces/community/woodpecker/src/index.ts @@ -0,0 +1,107 @@ + +import { PieceAuth, createPiece } from '@activepieces/pieces-framework'; +import { createCustomApiCallAction, httpClient, HttpMethod } from '@activepieces/pieces-common'; +import { PieceCategory } from '@activepieces/shared'; +import { addProspectToCampaign } from './lib/actions/add-prospect-to-campaign'; +import { addProspectToList } from './lib/actions/add-prospect-to-list'; +import { blacklistDomain } from './lib/actions/blacklist-domain'; +import { getProspectResponses } from './lib/actions/get-prospect-responses'; +import { findProspectByEmail } from './lib/actions/find-prospect-by-email'; +import { prospectReplied } from './lib/triggers/prospect-replied'; +import { prospectBlacklisted } from './lib/triggers/prospect-blacklisted'; +import { prospectOptout } from './lib/triggers/prospect-optout'; +import { prospectBounced } from './lib/triggers/prospect-bounced'; +import { prospectInvalid } from './lib/triggers/prospect-invalid'; +import { prospectAutoreplied } from './lib/triggers/prospect-autoreplied'; +import { prospectSaved } from './lib/triggers/prospect-saved'; +import { prospectNonresponsive } from './lib/triggers/prospect-nonresponsive'; +import { linkClicked } from './lib/triggers/link-clicked'; +import { emailOpened } from './lib/triggers/email-opened'; +import { prospectInterested } from './lib/triggers/prospect-interested'; +import { prospectMaybeLater } from './lib/triggers/prospect-maybe-later'; +import { prospectNotInterested } from './lib/triggers/prospect-not-interested'; +import { emailSent } from './lib/triggers/email-sent'; +import { followupAfterAutoreply } from './lib/triggers/followup-after-autoreply'; +import { secondaryReplied } from './lib/triggers/secondary-replied'; +import { campaignCompleted } from './lib/triggers/campaign-completed'; +import { taskCreated } from './lib/triggers/task-created'; +import { taskDone } from './lib/triggers/task-done'; +import { taskIgnored } from './lib/triggers/task-ignored'; + +export const woodpeckerAuth = PieceAuth.SecretText({ + displayName: 'API Key', + description: ` +To obtain your API key: +1. Log into your Woodpecker account +2. Go to Marketplace (top-right) → Integrations → API keys +3. Click **Create a key** +4. Copy the key and paste it here +`, + required: true, + validate: async ({ auth }) => { + try { + await httpClient.sendRequest({ + method: HttpMethod.GET, + url: 'https://api.woodpecker.co/rest/v2/users', + headers: { + 'x-api-key': auth, + }, + }); + return { + valid: true, + }; + } catch (e) { + return { + valid: false, + error: 'Invalid API Key.', + }; + } + }, +}); + +export const woodpecker = createPiece({ + displayName: 'Woodpecker', + description: 'Cold email automation tool for sales teams to send personalized outreach campaigns', + auth: woodpeckerAuth, + minimumSupportedRelease: '0.36.1', + logoUrl: 'https://cdn.activepieces.com/pieces/woodpecker.png', + authors: ['onyedikachi-david'], + categories: [PieceCategory.SALES_AND_CRM], + actions: [ + addProspectToCampaign, + addProspectToList, + blacklistDomain, + getProspectResponses, + findProspectByEmail, + createCustomApiCallAction({ + auth: woodpeckerAuth, + baseUrl: () => 'https://api.woodpecker.co/rest', + authMapping: async (auth) => ({ + 'x-api-key': auth.secret_text, + }), + }), + ], + triggers: [ + prospectReplied, + prospectBlacklisted, + prospectOptout, + prospectBounced, + prospectInvalid, + prospectAutoreplied, + prospectSaved, + prospectNonresponsive, + linkClicked, + emailOpened, + prospectInterested, + prospectMaybeLater, + prospectNotInterested, + emailSent, + followupAfterAutoreply, + secondaryReplied, + campaignCompleted, + taskCreated, + taskDone, + taskIgnored, + ], +}); + \ No newline at end of file diff --git a/packages/pieces/community/woodpecker/src/lib/actions/add-prospect-to-campaign.ts b/packages/pieces/community/woodpecker/src/lib/actions/add-prospect-to-campaign.ts new file mode 100644 index 00000000000..9c6dfedbba2 --- /dev/null +++ b/packages/pieces/community/woodpecker/src/lib/actions/add-prospect-to-campaign.ts @@ -0,0 +1,171 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { woodpeckerAuth } from '../..'; +import { campaignsDropdown, woodpeckerClient } from '../common'; + +export const addProspectToCampaign = createAction({ + auth: woodpeckerAuth, + name: 'add_prospect_to_campaign', + displayName: 'Create/Update Prospect in Campaign', + description: 'Adds a new prospect or updates existing prospect data in a campaign', + props: { + campaign_id: campaignsDropdown, + email: Property.ShortText({ + displayName: 'Email', + description: 'Prospect email address', + required: true, + }), + first_name: Property.ShortText({ + displayName: 'First Name', + required: false, + }), + last_name: Property.ShortText({ + displayName: 'Last Name', + required: false, + }), + company: Property.ShortText({ + displayName: 'Company', + required: false, + }), + website: Property.ShortText({ + displayName: 'Website', + required: false, + }), + linkedin_url: Property.ShortText({ + displayName: 'LinkedIn URL', + required: false, + }), + tags: Property.ShortText({ + displayName: 'Tags', + description: 'Tags starting with # separated by spaces (e.g. #VC #Startup)', + required: false, + }), + title: Property.ShortText({ + displayName: 'Job Title', + required: false, + }), + phone: Property.ShortText({ + displayName: 'Phone', + required: false, + }), + address: Property.ShortText({ + displayName: 'Address', + required: false, + }), + city: Property.ShortText({ + displayName: 'City', + required: false, + }), + state: Property.ShortText({ + displayName: 'State', + required: false, + }), + country: Property.ShortText({ + displayName: 'Country', + required: false, + }), + industry: Property.ShortText({ + displayName: 'Industry', + required: false, + }), + status: Property.StaticDropdown({ + displayName: 'Status', + description: 'Prospect status in the campaign', + required: false, + options: { + options: [ + { label: 'Active', value: 'ACTIVE' }, + { label: 'Paused', value: 'PAUSED' }, + { label: 'To Review', value: 'TO-REVIEW' }, + { label: 'To Check', value: 'TO-CHECK' }, + ], + }, + }), + snippets: Property.Array({ + displayName: 'Snippets', + description: 'Custom snippets for personalization (supports HTML). Max 15 snippets.', + required: false, + }), + send_after: Property.DateTime({ + displayName: 'Send After', + description: 'Earliest date and time the prospect can be contacted', + required: false, + }), + force: Property.Checkbox({ + displayName: 'Force Add', + description: 'Add prospect even if their global status is not ACTIVE (use with caution)', + required: false, + defaultValue: false, + }), + file_name: Property.ShortText({ + displayName: 'Import Batch Name', + description: 'Name of the import batch (visible in the imported column)', + required: false, + }), + }, + async run({ auth, propsValue }) { + const prospect: Record = { + email: propsValue.email, + }; + + const optionalFields = [ + 'first_name', + 'last_name', + 'company', + 'website', + 'linkedin_url', + 'tags', + 'title', + 'phone', + 'address', + 'city', + 'state', + 'country', + 'industry', + 'status', + ] as const; + + for (const field of optionalFields) { + if (propsValue[field]) { + prospect[field] = propsValue[field]; + } + } + + if (propsValue.snippets && Array.isArray(propsValue.snippets)) { + const snippets = propsValue.snippets.slice(0, 15); + snippets.forEach((value, index) => { + if (value) { + prospect[`snippet${index + 1}`] = value; + } + }); + } + + const campaign: Record = { + campaign_id: propsValue.campaign_id, + }; + + if (propsValue.send_after) { + campaign['send_after'] = propsValue.send_after; + } + + const body: Record = { + campaign, + prospects: [prospect], + }; + + if (propsValue.force) { + body['force'] = true; + } + + if (propsValue.file_name) { + body['file_name'] = propsValue.file_name; + } + + return await woodpeckerClient.makeRequest( + auth.secret_text, + HttpMethod.POST, + '/v1/add_prospects_campaign', + body + ); + }, +}); diff --git a/packages/pieces/community/woodpecker/src/lib/actions/add-prospect-to-list.ts b/packages/pieces/community/woodpecker/src/lib/actions/add-prospect-to-list.ts new file mode 100644 index 00000000000..65d03160d61 --- /dev/null +++ b/packages/pieces/community/woodpecker/src/lib/actions/add-prospect-to-list.ts @@ -0,0 +1,166 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { woodpeckerAuth } from '../..'; +import { woodpeckerClient } from '../common'; + +export const addProspectToList = createAction({ + auth: woodpeckerAuth, + name: 'add_prospect_to_list', + displayName: 'Create/Update Prospect', + description: 'Adds a new prospect or updates existing prospect in the global prospect list', + props: { + email: Property.ShortText({ + displayName: 'Email', + description: 'Prospect email address', + required: true, + }), + update: Property.Checkbox({ + displayName: 'Update Existing', + description: 'If enabled, updates existing prospect data. If disabled, existing prospects remain unchanged.', + required: false, + defaultValue: false, + }), + first_name: Property.ShortText({ + displayName: 'First Name', + required: false, + }), + last_name: Property.ShortText({ + displayName: 'Last Name', + required: false, + }), + company: Property.ShortText({ + displayName: 'Company', + required: false, + }), + website: Property.ShortText({ + displayName: 'Website', + required: false, + }), + linkedin_url: Property.ShortText({ + displayName: 'LinkedIn URL', + required: false, + }), + tags: Property.ShortText({ + displayName: 'Tags', + description: 'Tags starting with # separated by spaces (e.g. #VC #Startup). Appends to existing tags when updating.', + required: false, + }), + set_tags: Property.ShortText({ + displayName: 'Replace Tags', + description: 'Replaces all existing tags with these (only when updating). Use empty string to clear all tags.', + required: false, + }), + title: Property.ShortText({ + displayName: 'Job Title', + required: false, + }), + phone: Property.ShortText({ + displayName: 'Phone', + required: false, + }), + address: Property.ShortText({ + displayName: 'Address', + required: false, + }), + city: Property.ShortText({ + displayName: 'City', + required: false, + }), + state: Property.ShortText({ + displayName: 'State', + required: false, + }), + country: Property.ShortText({ + displayName: 'Country', + required: false, + }), + industry: Property.ShortText({ + displayName: 'Industry', + required: false, + }), + status: Property.StaticDropdown({ + displayName: 'Status', + description: 'Prospect status', + required: false, + options: { + options: [ + { label: 'Active', value: 'ACTIVE' }, + { label: 'Blacklist', value: 'BLACKLIST' }, + { label: 'Bounced', value: 'BOUNCED' }, + { label: 'Invalid', value: 'INVALID' }, + { label: 'Replied', value: 'REPLIED' }, + ], + }, + }), + snippets: Property.Array({ + displayName: 'Snippets', + description: 'Custom snippets for personalization (supports HTML). Max 15 snippets.', + required: false, + }), + file_name: Property.ShortText({ + displayName: 'Import Batch Name', + description: 'Name of the import batch (visible in the imported column)', + required: false, + }), + }, + async run({ auth, propsValue }) { + const prospect: Record = { + email: propsValue.email, + }; + + const optionalFields = [ + 'first_name', + 'last_name', + 'company', + 'website', + 'linkedin_url', + 'tags', + 'title', + 'phone', + 'address', + 'city', + 'state', + 'country', + 'industry', + 'status', + ] as const; + + for (const field of optionalFields) { + if (propsValue[field]) { + prospect[field] = propsValue[field]; + } + } + + if (propsValue.update && propsValue.set_tags !== undefined) { + prospect['set_tags'] = propsValue.set_tags; + } + + if (propsValue.snippets && Array.isArray(propsValue.snippets)) { + const snippets = propsValue.snippets.slice(0, 15); + snippets.forEach((value, index) => { + if (value) { + prospect[`snippet${index + 1}`] = value; + } + }); + } + + const body: Record = { + prospects: [prospect], + }; + + if (propsValue.update) { + body['update'] = true; + } + + if (propsValue.file_name) { + body['file_name'] = propsValue.file_name; + } + + return await woodpeckerClient.makeRequest( + auth.secret_text, + HttpMethod.POST, + '/v1/add_prospects_list', + body + ); + }, +}); diff --git a/packages/pieces/community/woodpecker/src/lib/actions/blacklist-domain.ts b/packages/pieces/community/woodpecker/src/lib/actions/blacklist-domain.ts new file mode 100644 index 00000000000..e0da08da684 --- /dev/null +++ b/packages/pieces/community/woodpecker/src/lib/actions/blacklist-domain.ts @@ -0,0 +1,28 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { woodpeckerAuth } from '../..'; +import { woodpeckerClient } from '../common'; + +export const blacklistDomain = createAction({ + auth: woodpeckerAuth, + name: 'blacklist_domain', + displayName: 'Blacklist Domain', + description: 'Add a domain to the blacklist to block sending to all prospects within that domain', + props: { + domain: Property.ShortText({ + displayName: 'Domain', + description: 'Domain to blacklist (e.g. example.com)', + required: true, + }), + }, + async run({ auth, propsValue }) { + return await woodpeckerClient.makeRequest( + auth.secret_text, + HttpMethod.POST, + '/v2/blacklist/domains', + { + domains: [propsValue.domain], + } + ); + }, +}); diff --git a/packages/pieces/community/woodpecker/src/lib/actions/find-prospect-by-email.ts b/packages/pieces/community/woodpecker/src/lib/actions/find-prospect-by-email.ts new file mode 100644 index 00000000000..1101eb903e3 --- /dev/null +++ b/packages/pieces/community/woodpecker/src/lib/actions/find-prospect-by-email.ts @@ -0,0 +1,41 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { woodpeckerAuth } from '../..'; +import { woodpeckerClient } from '../common'; + +export const findProspectByEmail = createAction({ + auth: woodpeckerAuth, + name: 'find_prospect_by_email', + displayName: 'Find Prospect by Email', + description: 'Search for a prospect by their email address', + props: { + email: Property.ShortText({ + displayName: 'Email', + description: 'Email address to search for (exact match or partial)', + required: true, + }), + include_campaign_details: Property.Checkbox({ + displayName: 'Include Campaign Details', + description: 'Include information about campaigns the prospect is enrolled in', + required: false, + defaultValue: true, + }), + }, + async run({ auth, propsValue }) { + const queryParams: Record = { + search: `email=${propsValue.email}`, + }; + + if (propsValue.include_campaign_details) { + queryParams['campaigns_details'] = 'true'; + } + + return await woodpeckerClient.makeRequest( + auth.secret_text, + HttpMethod.GET, + '/v1/prospects', + undefined, + queryParams + ); + }, +}); diff --git a/packages/pieces/community/woodpecker/src/lib/actions/get-prospect-responses.ts b/packages/pieces/community/woodpecker/src/lib/actions/get-prospect-responses.ts new file mode 100644 index 00000000000..92b0439433d --- /dev/null +++ b/packages/pieces/community/woodpecker/src/lib/actions/get-prospect-responses.ts @@ -0,0 +1,64 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { httpClient, HttpMethod } from '@activepieces/pieces-common'; +import { woodpeckerAuth } from '../..'; +import { campaignsDropdown, woodpeckerClient } from '../common'; + +export const getProspectResponses = createAction({ + auth: woodpeckerAuth, + name: 'get_prospect_responses', + displayName: 'Get Prospect Responses', + description: 'Fetch all responses from a specified prospect', + props: { + prospect_id: Property.Number({ + displayName: 'Prospect ID', + description: 'The unique ID of the prospect', + required: true, + }), + campaign_id: Property.Dropdown({ + displayName: 'Campaign', + description: 'Filter responses by campaign (optional)', + required: false, + auth: woodpeckerAuth, + refreshers: [], + options: async ({ auth }) => { + if (!auth) { + return { + disabled: true, + options: [], + placeholder: 'Please authenticate first', + }; + } + const response = await httpClient.sendRequest>({ + method: HttpMethod.GET, + url: 'https://api.woodpecker.co/rest/v1/campaign_list', + headers: { + 'x-api-key': (auth as { secret_text: string }).secret_text, + }, + }); + const campaigns = response.body ?? []; + return { + disabled: false, + options: campaigns.map((campaign) => ({ + label: `${campaign.name} (${campaign.status})`, + value: campaign.id, + })), + }; + }, + }), + }, + async run({ auth, propsValue }) { + const queryParams: Record = {}; + + if (propsValue.campaign_id) { + queryParams['campaign_id'] = String(propsValue.campaign_id); + } + + return await woodpeckerClient.makeRequest( + auth.secret_text, + HttpMethod.GET, + `/v2/prospects/${propsValue.prospect_id}/responses`, + undefined, + Object.keys(queryParams).length > 0 ? queryParams : undefined + ); + }, +}); diff --git a/packages/pieces/community/woodpecker/src/lib/common/index.ts b/packages/pieces/community/woodpecker/src/lib/common/index.ts new file mode 100644 index 00000000000..64e91afb24d --- /dev/null +++ b/packages/pieces/community/woodpecker/src/lib/common/index.ts @@ -0,0 +1,129 @@ +import { HttpMethod, httpClient, HttpRequest } from '@activepieces/pieces-common'; +import { Property, TriggerStrategy } from '@activepieces/pieces-framework'; +import { TriggerHookContext } from '@activepieces/pieces-framework'; +import { woodpeckerAuth } from '../..'; + +export const API_BASE_URL = 'https://api.woodpecker.co/rest'; + +export const WEBHOOK_EVENTS = { + PROSPECT_REPLIED: 'prospect_replied', + PROSPECT_BLACKLISTED: 'prospect_blacklisted', + PROSPECT_OPTOUT: 'prospect_opt_out', + PROSPECT_BOUNCED: 'prospect_bounced', + PROSPECT_INVALID: 'prospect_invalid', + PROSPECT_AUTOREPLIED: 'prospect_autoreplied', + PROSPECT_SAVED: 'prospect_saved', + PROSPECT_NONRESPONSIVE: 'prospect_non_responsive', + LINK_CLICKED: 'link_clicked', + EMAIL_OPENED: 'email_opened', + PROSPECT_INTERESTED: 'prospect_interested', + PROSPECT_MAYBE_LATER: 'prospect_maybe_later', + PROSPECT_NOT_INTERESTED: 'prospect_not_interested', + EMAIL_SENT: 'campaign_sent', + FOLLOWUP_AFTER_AUTOREPLY: 'followup_after_autoreply', + SECONDARY_REPLIED: 'secondary_replied', + CAMPAIGN_COMPLETED: 'campaign_completed', + TASK_CREATED: 'task_created', + TASK_DONE: 'task_done', + TASK_IGNORED: 'task_ignored', +} as const; + +export type WebhookEvent = (typeof WEBHOOK_EVENTS)[keyof typeof WEBHOOK_EVENTS]; + +export async function subscribeWebhook( + auth: string, + webhookUrl: string, + event: WebhookEvent +): Promise { + await httpClient.sendRequest({ + method: HttpMethod.POST, + url: `${API_BASE_URL}/v1/webhooks/subscribe`, + headers: { + 'x-api-key': auth, + 'Content-Type': 'application/json', + }, + body: { + target_url: webhookUrl, + event: event, + }, + }); +} + +export async function unsubscribeWebhook( + auth: string, + webhookUrl: string, + event: WebhookEvent +): Promise { + await httpClient.sendRequest({ + method: HttpMethod.POST, + url: `${API_BASE_URL}/v1/webhooks/unsubscribe`, + headers: { + 'x-api-key': auth, + 'Content-Type': 'application/json', + }, + body: { + target_url: webhookUrl, + event: event, + }, + }); +} + +type Campaign = { + id: number; + name: string; + status: string; +}; + +export const campaignsDropdown = Property.Dropdown({ + displayName: 'Campaign', + description: 'Select the campaign to add prospects to', + required: true, + refreshers: [], + auth: woodpeckerAuth, + options: async ({ auth }) => { + if (!auth) { + return { + disabled: true, + options: [], + placeholder: 'Please authenticate first', + }; + } + const response = await httpClient.sendRequest({ + method: HttpMethod.GET, + url: `${API_BASE_URL}/v1/campaign_list`, + headers: { + 'x-api-key': auth.secret_text, + }, + }); + const campaigns = response.body ?? []; + return { + disabled: false, + options: campaigns.map((campaign) => ({ + label: `${campaign.name} (${campaign.status})`, + value: campaign.id, + })), + }; + }, +}); + +export const woodpeckerClient = { + async makeRequest( + auth: string, + method: HttpMethod, + endpoint: string, + body?: unknown, + queryParams?: Record + ): Promise { + const request: HttpRequest = { + method, + url: `${API_BASE_URL}${endpoint}`, + headers: { + 'x-api-key': auth, + }, + body, + queryParams, + }; + const response = await httpClient.sendRequest(request); + return response.body; + }, +}; diff --git a/packages/pieces/community/woodpecker/src/lib/triggers/campaign-completed.ts b/packages/pieces/community/woodpecker/src/lib/triggers/campaign-completed.ts new file mode 100644 index 00000000000..6aa5a064331 --- /dev/null +++ b/packages/pieces/community/woodpecker/src/lib/triggers/campaign-completed.ts @@ -0,0 +1,37 @@ +import { createTrigger, TriggerStrategy } from '@activepieces/pieces-framework'; +import { woodpeckerAuth } from '../..'; +import { WEBHOOK_EVENTS, subscribeWebhook, unsubscribeWebhook } from '../common'; + +export const campaignCompleted = createTrigger({ + auth: woodpeckerAuth, + name: 'campaign_completed', + displayName: 'Campaign Completed', + description: 'Triggers when a campaign is completed', + props: {}, + sampleData: { + method: 'campaign_completed', + campaign: { + id: 123456, + name: 'SaaS in America', + }, + timestamp: '2025-03-21T20:47:47+0100', + }, + type: TriggerStrategy.WEBHOOK, + async onEnable(context) { + await subscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.CAMPAIGN_COMPLETED + ); + }, + async onDisable(context) { + await unsubscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.CAMPAIGN_COMPLETED + ); + }, + async run(context) { + return [context.payload.body]; + }, +}); diff --git a/packages/pieces/community/woodpecker/src/lib/triggers/email-opened.ts b/packages/pieces/community/woodpecker/src/lib/triggers/email-opened.ts new file mode 100644 index 00000000000..1aacd7bdf8b --- /dev/null +++ b/packages/pieces/community/woodpecker/src/lib/triggers/email-opened.ts @@ -0,0 +1,40 @@ +import { createTrigger, TriggerStrategy } from '@activepieces/pieces-framework'; +import { woodpeckerAuth } from '../..'; +import { WEBHOOK_EVENTS, subscribeWebhook, unsubscribeWebhook } from '../common'; + +export const emailOpened = createTrigger({ + auth: woodpeckerAuth, + name: 'email_opened', + displayName: 'Prospect Opened an Email', + description: 'Triggers when a prospect opens an email', + props: {}, + sampleData: { + method: 'email_opened', + prospect: { + id: 1234567890, + email: 'erlich@bachmanity.com', + first_name: 'Erlich', + last_name: 'Bachman', + company: 'Bachmanity', + }, + timestamp: '2025-03-21T20:47:47+0100', + }, + type: TriggerStrategy.WEBHOOK, + async onEnable(context) { + await subscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.EMAIL_OPENED + ); + }, + async onDisable(context) { + await unsubscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.EMAIL_OPENED + ); + }, + async run(context) { + return [context.payload.body]; + }, +}); diff --git a/packages/pieces/community/woodpecker/src/lib/triggers/email-sent.ts b/packages/pieces/community/woodpecker/src/lib/triggers/email-sent.ts new file mode 100644 index 00000000000..e7ce4c11559 --- /dev/null +++ b/packages/pieces/community/woodpecker/src/lib/triggers/email-sent.ts @@ -0,0 +1,42 @@ +import { createTrigger, TriggerStrategy } from '@activepieces/pieces-framework'; +import { woodpeckerAuth } from '../..'; +import { WEBHOOK_EVENTS, subscribeWebhook, unsubscribeWebhook } from '../common'; + +export const emailSent = createTrigger({ + auth: woodpeckerAuth, + name: 'email_sent', + displayName: 'Campaign Email Sent', + description: 'Triggers when a campaign email is sent to a prospect', + props: {}, + sampleData: { + method: 'email_sent', + prospect: { + id: 1234567890, + email: 'erlich@bachmanity.com', + first_name: 'Erlich', + last_name: 'Bachman', + company: 'Bachmanity', + campaign_id: 123456, + campaign_name: 'SaaS in America', + }, + timestamp: '2025-03-21T20:47:47+0100', + }, + type: TriggerStrategy.WEBHOOK, + async onEnable(context) { + await subscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.EMAIL_SENT + ); + }, + async onDisable(context) { + await unsubscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.EMAIL_SENT + ); + }, + async run(context) { + return [context.payload.body]; + }, +}); diff --git a/packages/pieces/community/woodpecker/src/lib/triggers/followup-after-autoreply.ts b/packages/pieces/community/woodpecker/src/lib/triggers/followup-after-autoreply.ts new file mode 100644 index 00000000000..711e0fefe75 --- /dev/null +++ b/packages/pieces/community/woodpecker/src/lib/triggers/followup-after-autoreply.ts @@ -0,0 +1,40 @@ +import { createTrigger, TriggerStrategy } from '@activepieces/pieces-framework'; +import { woodpeckerAuth } from '../..'; +import { WEBHOOK_EVENTS, subscribeWebhook, unsubscribeWebhook } from '../common'; + +export const followupAfterAutoreply = createTrigger({ + auth: woodpeckerAuth, + name: 'followup_after_autoreply', + displayName: 'Follow-up After Autoreply', + description: 'Triggers when a follow-up is scheduled after an autoreply', + props: {}, + sampleData: { + method: 'followup_after_autoreply', + prospect: { + id: 1234567890, + email: 'erlich@bachmanity.com', + first_name: 'Erlich', + last_name: 'Bachman', + company: 'Bachmanity', + }, + timestamp: '2025-03-21T20:47:47+0100', + }, + type: TriggerStrategy.WEBHOOK, + async onEnable(context) { + await subscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.FOLLOWUP_AFTER_AUTOREPLY + ); + }, + async onDisable(context) { + await unsubscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.FOLLOWUP_AFTER_AUTOREPLY + ); + }, + async run(context) { + return [context.payload.body]; + }, +}); diff --git a/packages/pieces/community/woodpecker/src/lib/triggers/link-clicked.ts b/packages/pieces/community/woodpecker/src/lib/triggers/link-clicked.ts new file mode 100644 index 00000000000..15982b35360 --- /dev/null +++ b/packages/pieces/community/woodpecker/src/lib/triggers/link-clicked.ts @@ -0,0 +1,40 @@ +import { createTrigger, TriggerStrategy } from '@activepieces/pieces-framework'; +import { woodpeckerAuth } from '../..'; +import { WEBHOOK_EVENTS, subscribeWebhook, unsubscribeWebhook } from '../common'; + +export const linkClicked = createTrigger({ + auth: woodpeckerAuth, + name: 'link_clicked', + displayName: 'Prospect Clicked a Link', + description: 'Triggers when a prospect clicks a link in an email', + props: {}, + sampleData: { + method: 'link_clicked', + prospect: { + id: 1234567890, + email: 'erlich@bachmanity.com', + first_name: 'Erlich', + last_name: 'Bachman', + company: 'Bachmanity', + }, + timestamp: '2025-03-21T20:47:47+0100', + }, + type: TriggerStrategy.WEBHOOK, + async onEnable(context) { + await subscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.LINK_CLICKED + ); + }, + async onDisable(context) { + await unsubscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.LINK_CLICKED + ); + }, + async run(context) { + return [context.payload.body]; + }, +}); diff --git a/packages/pieces/community/woodpecker/src/lib/triggers/prospect-autoreplied.ts b/packages/pieces/community/woodpecker/src/lib/triggers/prospect-autoreplied.ts new file mode 100644 index 00000000000..fd2dc0bac83 --- /dev/null +++ b/packages/pieces/community/woodpecker/src/lib/triggers/prospect-autoreplied.ts @@ -0,0 +1,41 @@ +import { createTrigger, TriggerStrategy } from '@activepieces/pieces-framework'; +import { woodpeckerAuth } from '../..'; +import { WEBHOOK_EVENTS, subscribeWebhook, unsubscribeWebhook } from '../common'; + +export const prospectAutoreplied = createTrigger({ + auth: woodpeckerAuth, + name: 'prospect_autoreplied', + displayName: 'Prospect Autoreplied', + description: 'Triggers when an autoreply is detected from a prospect', + props: {}, + sampleData: { + method: 'prospect_autoreplied', + prospect: { + id: 1234567890, + email: 'erlich@bachmanity.com', + first_name: 'Erlich', + last_name: 'Bachman', + company: 'Bachmanity', + status: 'AUTOREPLIED', + }, + timestamp: '2025-03-21T20:47:47+0100', + }, + type: TriggerStrategy.WEBHOOK, + async onEnable(context) { + await subscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.PROSPECT_AUTOREPLIED + ); + }, + async onDisable(context) { + await unsubscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.PROSPECT_AUTOREPLIED + ); + }, + async run(context) { + return [context.payload.body]; + }, +}); diff --git a/packages/pieces/community/woodpecker/src/lib/triggers/prospect-blacklisted.ts b/packages/pieces/community/woodpecker/src/lib/triggers/prospect-blacklisted.ts new file mode 100644 index 00000000000..11cf4c37301 --- /dev/null +++ b/packages/pieces/community/woodpecker/src/lib/triggers/prospect-blacklisted.ts @@ -0,0 +1,41 @@ +import { createTrigger, TriggerStrategy } from '@activepieces/pieces-framework'; +import { woodpeckerAuth } from '../..'; +import { WEBHOOK_EVENTS, subscribeWebhook, unsubscribeWebhook } from '../common'; + +export const prospectBlacklisted = createTrigger({ + auth: woodpeckerAuth, + name: 'prospect_blacklisted', + displayName: 'Prospect Blacklisted', + description: 'Triggers when a prospect is added to the blacklist', + props: {}, + sampleData: { + method: 'prospect_blacklisted', + prospect: { + id: 1234567890, + email: 'erlich@bachmanity.com', + first_name: 'Erlich', + last_name: 'Bachman', + company: 'Bachmanity', + status: 'BLACKLIST', + }, + timestamp: '2025-03-21T20:47:47+0100', + }, + type: TriggerStrategy.WEBHOOK, + async onEnable(context) { + await subscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.PROSPECT_BLACKLISTED + ); + }, + async onDisable(context) { + await unsubscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.PROSPECT_BLACKLISTED + ); + }, + async run(context) { + return [context.payload.body]; + }, +}); diff --git a/packages/pieces/community/woodpecker/src/lib/triggers/prospect-bounced.ts b/packages/pieces/community/woodpecker/src/lib/triggers/prospect-bounced.ts new file mode 100644 index 00000000000..72376bc4ea2 --- /dev/null +++ b/packages/pieces/community/woodpecker/src/lib/triggers/prospect-bounced.ts @@ -0,0 +1,41 @@ +import { createTrigger, TriggerStrategy } from '@activepieces/pieces-framework'; +import { woodpeckerAuth } from '../..'; +import { WEBHOOK_EVENTS, subscribeWebhook, unsubscribeWebhook } from '../common'; + +export const prospectBounced = createTrigger({ + auth: woodpeckerAuth, + name: 'prospect_bounced', + displayName: 'Prospect Bounced', + description: 'Triggers when an email to a prospect bounces', + props: {}, + sampleData: { + method: 'prospect_bounced', + prospect: { + id: 1234567890, + email: 'erlich@bachmanity.com', + first_name: 'Erlich', + last_name: 'Bachman', + company: 'Bachmanity', + status: 'BOUNCED', + }, + timestamp: '2025-03-21T20:47:47+0100', + }, + type: TriggerStrategy.WEBHOOK, + async onEnable(context) { + await subscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.PROSPECT_BOUNCED + ); + }, + async onDisable(context) { + await unsubscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.PROSPECT_BOUNCED + ); + }, + async run(context) { + return [context.payload.body]; + }, +}); diff --git a/packages/pieces/community/woodpecker/src/lib/triggers/prospect-interested.ts b/packages/pieces/community/woodpecker/src/lib/triggers/prospect-interested.ts new file mode 100644 index 00000000000..392e9cf817e --- /dev/null +++ b/packages/pieces/community/woodpecker/src/lib/triggers/prospect-interested.ts @@ -0,0 +1,41 @@ +import { createTrigger, TriggerStrategy } from '@activepieces/pieces-framework'; +import { woodpeckerAuth } from '../..'; +import { WEBHOOK_EVENTS, subscribeWebhook, unsubscribeWebhook } from '../common'; + +export const prospectInterested = createTrigger({ + auth: woodpeckerAuth, + name: 'prospect_interested', + displayName: 'Prospect Interested', + description: 'Triggers when a prospect is marked as interested', + props: {}, + sampleData: { + method: 'prospect_interested', + prospect: { + id: 1234567890, + email: 'erlich@bachmanity.com', + first_name: 'Erlich', + last_name: 'Bachman', + company: 'Bachmanity', + interested: 'INTERESTED', + }, + timestamp: '2025-03-21T20:47:47+0100', + }, + type: TriggerStrategy.WEBHOOK, + async onEnable(context) { + await subscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.PROSPECT_INTERESTED + ); + }, + async onDisable(context) { + await unsubscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.PROSPECT_INTERESTED + ); + }, + async run(context) { + return [context.payload.body]; + }, +}); diff --git a/packages/pieces/community/woodpecker/src/lib/triggers/prospect-invalid.ts b/packages/pieces/community/woodpecker/src/lib/triggers/prospect-invalid.ts new file mode 100644 index 00000000000..7abf6c4a894 --- /dev/null +++ b/packages/pieces/community/woodpecker/src/lib/triggers/prospect-invalid.ts @@ -0,0 +1,41 @@ +import { createTrigger, TriggerStrategy } from '@activepieces/pieces-framework'; +import { woodpeckerAuth } from '../..'; +import { WEBHOOK_EVENTS, subscribeWebhook, unsubscribeWebhook } from '../common'; + +export const prospectInvalid = createTrigger({ + auth: woodpeckerAuth, + name: 'prospect_invalid', + displayName: 'Prospect Invalid', + description: 'Triggers when a prospect email is marked as invalid', + props: {}, + sampleData: { + method: 'prospect_invalid', + prospect: { + id: 1234567890, + email: 'erlich@bachmanity.com', + first_name: 'Erlich', + last_name: 'Bachman', + company: 'Bachmanity', + status: 'INVALID', + }, + timestamp: '2025-03-21T20:47:47+0100', + }, + type: TriggerStrategy.WEBHOOK, + async onEnable(context) { + await subscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.PROSPECT_INVALID + ); + }, + async onDisable(context) { + await unsubscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.PROSPECT_INVALID + ); + }, + async run(context) { + return [context.payload.body]; + }, +}); diff --git a/packages/pieces/community/woodpecker/src/lib/triggers/prospect-maybe-later.ts b/packages/pieces/community/woodpecker/src/lib/triggers/prospect-maybe-later.ts new file mode 100644 index 00000000000..544bda8c19f --- /dev/null +++ b/packages/pieces/community/woodpecker/src/lib/triggers/prospect-maybe-later.ts @@ -0,0 +1,41 @@ +import { createTrigger, TriggerStrategy } from '@activepieces/pieces-framework'; +import { woodpeckerAuth } from '../..'; +import { WEBHOOK_EVENTS, subscribeWebhook, unsubscribeWebhook } from '../common'; + +export const prospectMaybeLater = createTrigger({ + auth: woodpeckerAuth, + name: 'prospect_maybe_later', + displayName: 'Prospect Maybe Later', + description: 'Triggers when a prospect is marked as maybe later', + props: {}, + sampleData: { + method: 'prospect_maybe_later', + prospect: { + id: 1234567890, + email: 'erlich@bachmanity.com', + first_name: 'Erlich', + last_name: 'Bachman', + company: 'Bachmanity', + interested: 'MAYBE_LATER', + }, + timestamp: '2025-03-21T20:47:47+0100', + }, + type: TriggerStrategy.WEBHOOK, + async onEnable(context) { + await subscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.PROSPECT_MAYBE_LATER + ); + }, + async onDisable(context) { + await unsubscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.PROSPECT_MAYBE_LATER + ); + }, + async run(context) { + return [context.payload.body]; + }, +}); diff --git a/packages/pieces/community/woodpecker/src/lib/triggers/prospect-nonresponsive.ts b/packages/pieces/community/woodpecker/src/lib/triggers/prospect-nonresponsive.ts new file mode 100644 index 00000000000..fcdf152891d --- /dev/null +++ b/packages/pieces/community/woodpecker/src/lib/triggers/prospect-nonresponsive.ts @@ -0,0 +1,40 @@ +import { createTrigger, TriggerStrategy } from '@activepieces/pieces-framework'; +import { woodpeckerAuth } from '../..'; +import { WEBHOOK_EVENTS, subscribeWebhook, unsubscribeWebhook } from '../common'; + +export const prospectNonresponsive = createTrigger({ + auth: woodpeckerAuth, + name: 'prospect_nonresponsive', + displayName: 'Prospect Nonresponsive', + description: 'Triggers when a prospect is marked as nonresponsive', + props: {}, + sampleData: { + method: 'prospect_nonresponsive', + prospect: { + id: 1234567890, + email: 'erlich@bachmanity.com', + first_name: 'Erlich', + last_name: 'Bachman', + company: 'Bachmanity', + }, + timestamp: '2025-03-21T20:47:47+0100', + }, + type: TriggerStrategy.WEBHOOK, + async onEnable(context) { + await subscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.PROSPECT_NONRESPONSIVE + ); + }, + async onDisable(context) { + await unsubscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.PROSPECT_NONRESPONSIVE + ); + }, + async run(context) { + return [context.payload.body]; + }, +}); diff --git a/packages/pieces/community/woodpecker/src/lib/triggers/prospect-not-interested.ts b/packages/pieces/community/woodpecker/src/lib/triggers/prospect-not-interested.ts new file mode 100644 index 00000000000..5ff20a9e116 --- /dev/null +++ b/packages/pieces/community/woodpecker/src/lib/triggers/prospect-not-interested.ts @@ -0,0 +1,41 @@ +import { createTrigger, TriggerStrategy } from '@activepieces/pieces-framework'; +import { woodpeckerAuth } from '../..'; +import { WEBHOOK_EVENTS, subscribeWebhook, unsubscribeWebhook } from '../common'; + +export const prospectNotInterested = createTrigger({ + auth: woodpeckerAuth, + name: 'prospect_not_interested', + displayName: 'Prospect Not Interested', + description: 'Triggers when a prospect is marked as not interested', + props: {}, + sampleData: { + method: 'prospect_not_interested', + prospect: { + id: 1234567890, + email: 'erlich@bachmanity.com', + first_name: 'Erlich', + last_name: 'Bachman', + company: 'Bachmanity', + interested: 'NOT_INTERESTED', + }, + timestamp: '2025-03-21T20:47:47+0100', + }, + type: TriggerStrategy.WEBHOOK, + async onEnable(context) { + await subscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.PROSPECT_NOT_INTERESTED + ); + }, + async onDisable(context) { + await unsubscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.PROSPECT_NOT_INTERESTED + ); + }, + async run(context) { + return [context.payload.body]; + }, +}); diff --git a/packages/pieces/community/woodpecker/src/lib/triggers/prospect-optout.ts b/packages/pieces/community/woodpecker/src/lib/triggers/prospect-optout.ts new file mode 100644 index 00000000000..49ef142244c --- /dev/null +++ b/packages/pieces/community/woodpecker/src/lib/triggers/prospect-optout.ts @@ -0,0 +1,41 @@ +import { createTrigger, TriggerStrategy } from '@activepieces/pieces-framework'; +import { woodpeckerAuth } from '../..'; +import { WEBHOOK_EVENTS, subscribeWebhook, unsubscribeWebhook } from '../common'; + +export const prospectOptout = createTrigger({ + auth: woodpeckerAuth, + name: 'prospect_optout', + displayName: 'Prospect Opt-out', + description: 'Triggers when a prospect opts out from receiving emails', + props: {}, + sampleData: { + method: 'prospect_optout', + prospect: { + id: 1234567890, + email: 'erlich@bachmanity.com', + first_name: 'Erlich', + last_name: 'Bachman', + company: 'Bachmanity', + status: 'OPTOUT', + }, + timestamp: '2025-03-21T20:47:47+0100', + }, + type: TriggerStrategy.WEBHOOK, + async onEnable(context) { + await subscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.PROSPECT_OPTOUT + ); + }, + async onDisable(context) { + await unsubscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.PROSPECT_OPTOUT + ); + }, + async run(context) { + return [context.payload.body]; + }, +}); diff --git a/packages/pieces/community/woodpecker/src/lib/triggers/prospect-replied.ts b/packages/pieces/community/woodpecker/src/lib/triggers/prospect-replied.ts new file mode 100644 index 00000000000..e932ef47d8a --- /dev/null +++ b/packages/pieces/community/woodpecker/src/lib/triggers/prospect-replied.ts @@ -0,0 +1,48 @@ +import { createTrigger, TriggerStrategy } from '@activepieces/pieces-framework'; +import { woodpeckerAuth } from '../..'; +import { WEBHOOK_EVENTS, subscribeWebhook, unsubscribeWebhook } from '../common'; + +export const prospectReplied = createTrigger({ + auth: woodpeckerAuth, + name: 'prospect_replied', + displayName: 'Prospect Replied', + description: 'Triggers when a prospect replies to an email or their status is manually set to RESPONDED', + props: {}, + sampleData: { + method: 'prospect_replied', + prospect: { + id: 1234567890, + email: 'erlich@bachmanity.com', + first_name: 'Erlich', + last_name: 'Bachman', + company: 'Bachmanity', + status: 'REPLIED', + campaign_id: 123456, + campaign_name: 'SaaS in America', + }, + email: { + id: 191867492, + subject: 'Reply message subject', + message: 'reply content', + }, + timestamp: '2025-03-21T20:47:47+0100', + }, + type: TriggerStrategy.WEBHOOK, + async onEnable(context) { + await subscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.PROSPECT_REPLIED + ); + }, + async onDisable(context) { + await unsubscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.PROSPECT_REPLIED + ); + }, + async run(context) { + return [context.payload.body]; + }, +}); diff --git a/packages/pieces/community/woodpecker/src/lib/triggers/prospect-saved.ts b/packages/pieces/community/woodpecker/src/lib/triggers/prospect-saved.ts new file mode 100644 index 00000000000..7135378a3e6 --- /dev/null +++ b/packages/pieces/community/woodpecker/src/lib/triggers/prospect-saved.ts @@ -0,0 +1,40 @@ +import { createTrigger, TriggerStrategy } from '@activepieces/pieces-framework'; +import { woodpeckerAuth } from '../..'; +import { WEBHOOK_EVENTS, subscribeWebhook, unsubscribeWebhook } from '../common'; + +export const prospectSaved = createTrigger({ + auth: woodpeckerAuth, + name: 'prospect_saved', + displayName: 'Prospect Saved', + description: 'Triggers when a prospect is saved', + props: {}, + sampleData: { + method: 'prospect_saved', + prospect: { + id: 1234567890, + email: 'erlich@bachmanity.com', + first_name: 'Erlich', + last_name: 'Bachman', + company: 'Bachmanity', + }, + timestamp: '2025-03-21T20:47:47+0100', + }, + type: TriggerStrategy.WEBHOOK, + async onEnable(context) { + await subscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.PROSPECT_SAVED + ); + }, + async onDisable(context) { + await unsubscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.PROSPECT_SAVED + ); + }, + async run(context) { + return [context.payload.body]; + }, +}); diff --git a/packages/pieces/community/woodpecker/src/lib/triggers/secondary-replied.ts b/packages/pieces/community/woodpecker/src/lib/triggers/secondary-replied.ts new file mode 100644 index 00000000000..09f7faa8f14 --- /dev/null +++ b/packages/pieces/community/woodpecker/src/lib/triggers/secondary-replied.ts @@ -0,0 +1,40 @@ +import { createTrigger, TriggerStrategy } from '@activepieces/pieces-framework'; +import { woodpeckerAuth } from '../..'; +import { WEBHOOK_EVENTS, subscribeWebhook, unsubscribeWebhook } from '../common'; + +export const secondaryReplied = createTrigger({ + auth: woodpeckerAuth, + name: 'secondary_replied', + displayName: 'Secondary Replied', + description: 'Triggers when a secondary email address replies', + props: {}, + sampleData: { + method: 'secondary_replied', + prospect: { + id: 1234567890, + email: 'erlich@bachmanity.com', + first_name: 'Erlich', + last_name: 'Bachman', + company: 'Bachmanity', + }, + timestamp: '2025-03-21T20:47:47+0100', + }, + type: TriggerStrategy.WEBHOOK, + async onEnable(context) { + await subscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.SECONDARY_REPLIED + ); + }, + async onDisable(context) { + await unsubscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.SECONDARY_REPLIED + ); + }, + async run(context) { + return [context.payload.body]; + }, +}); diff --git a/packages/pieces/community/woodpecker/src/lib/triggers/task-created.ts b/packages/pieces/community/woodpecker/src/lib/triggers/task-created.ts new file mode 100644 index 00000000000..137235d1238 --- /dev/null +++ b/packages/pieces/community/woodpecker/src/lib/triggers/task-created.ts @@ -0,0 +1,40 @@ +import { createTrigger, TriggerStrategy } from '@activepieces/pieces-framework'; +import { woodpeckerAuth } from '../..'; +import { WEBHOOK_EVENTS, subscribeWebhook, unsubscribeWebhook } from '../common'; + +export const taskCreated = createTrigger({ + auth: woodpeckerAuth, + name: 'task_created', + displayName: 'Task Created', + description: 'Triggers when a task is created', + props: {}, + sampleData: { + method: 'task_created', + prospect: { + id: 1234567890, + email: 'erlich@bachmanity.com', + first_name: 'Erlich', + last_name: 'Bachman', + company: 'Bachmanity', + }, + timestamp: '2025-03-21T20:47:47+0100', + }, + type: TriggerStrategy.WEBHOOK, + async onEnable(context) { + await subscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.TASK_CREATED + ); + }, + async onDisable(context) { + await unsubscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.TASK_CREATED + ); + }, + async run(context) { + return [context.payload.body]; + }, +}); diff --git a/packages/pieces/community/woodpecker/src/lib/triggers/task-done.ts b/packages/pieces/community/woodpecker/src/lib/triggers/task-done.ts new file mode 100644 index 00000000000..75ffb738a63 --- /dev/null +++ b/packages/pieces/community/woodpecker/src/lib/triggers/task-done.ts @@ -0,0 +1,40 @@ +import { createTrigger, TriggerStrategy } from '@activepieces/pieces-framework'; +import { woodpeckerAuth } from '../..'; +import { WEBHOOK_EVENTS, subscribeWebhook, unsubscribeWebhook } from '../common'; + +export const taskDone = createTrigger({ + auth: woodpeckerAuth, + name: 'task_done', + displayName: 'Task Done', + description: 'Triggers when a task is marked as done', + props: {}, + sampleData: { + method: 'task_done', + prospect: { + id: 1234567890, + email: 'erlich@bachmanity.com', + first_name: 'Erlich', + last_name: 'Bachman', + company: 'Bachmanity', + }, + timestamp: '2025-03-21T20:47:47+0100', + }, + type: TriggerStrategy.WEBHOOK, + async onEnable(context) { + await subscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.TASK_DONE + ); + }, + async onDisable(context) { + await unsubscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.TASK_DONE + ); + }, + async run(context) { + return [context.payload.body]; + }, +}); diff --git a/packages/pieces/community/woodpecker/src/lib/triggers/task-ignored.ts b/packages/pieces/community/woodpecker/src/lib/triggers/task-ignored.ts new file mode 100644 index 00000000000..3b6cc8602a5 --- /dev/null +++ b/packages/pieces/community/woodpecker/src/lib/triggers/task-ignored.ts @@ -0,0 +1,40 @@ +import { createTrigger, TriggerStrategy } from '@activepieces/pieces-framework'; +import { woodpeckerAuth } from '../..'; +import { WEBHOOK_EVENTS, subscribeWebhook, unsubscribeWebhook } from '../common'; + +export const taskIgnored = createTrigger({ + auth: woodpeckerAuth, + name: 'task_ignored', + displayName: 'Task Ignored', + description: 'Triggers when a task is ignored', + props: {}, + sampleData: { + method: 'task_ignored', + prospect: { + id: 1234567890, + email: 'erlich@bachmanity.com', + first_name: 'Erlich', + last_name: 'Bachman', + company: 'Bachmanity', + }, + timestamp: '2025-03-21T20:47:47+0100', + }, + type: TriggerStrategy.WEBHOOK, + async onEnable(context) { + await subscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.TASK_IGNORED + ); + }, + async onDisable(context) { + await unsubscribeWebhook( + context.auth.secret_text, + context.webhookUrl, + WEBHOOK_EVENTS.TASK_IGNORED + ); + }, + async run(context) { + return [context.payload.body]; + }, +}); diff --git a/packages/pieces/community/woodpecker/tsconfig.json b/packages/pieces/community/woodpecker/tsconfig.json new file mode 100644 index 00000000000..eff240ac143 --- /dev/null +++ b/packages/pieces/community/woodpecker/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "importHelpers": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/packages/pieces/community/woodpecker/tsconfig.lib.json b/packages/pieces/community/woodpecker/tsconfig.lib.json new file mode 100644 index 00000000000..5995d959bf0 --- /dev/null +++ b/packages/pieces/community/woodpecker/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/react-ui/src/app/builder/builder-header/flow-status/index.tsx b/packages/react-ui/src/app/builder/builder-header/flow-status/index.tsx index 9f5d1a56a18..d3dab94fb33 100644 --- a/packages/react-ui/src/app/builder/builder-header/flow-status/index.tsx +++ b/packages/react-ui/src/app/builder/builder-header/flow-status/index.tsx @@ -3,7 +3,7 @@ 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 { FlowVersionState, isNil } from '@activepieces/shared'; const BuilderFlowStatusSection = React.memo(() => { const [flowVersion, flow] = useBuilderStateContext((state) => [ @@ -19,7 +19,8 @@ const BuilderFlowStatusSection = React.memo(() => { publishedVersionId={flow.publishedVersionId} > {(flow.publishedVersionId === flowVersion.id || - flowVersion.state === FlowVersionState.DRAFT) && ( + (flowVersion.state === FlowVersionState.DRAFT && + !isNil(flow.publishedVersionId))) && ( )} diff --git a/packages/react-ui/src/app/builder/builder-hooks.ts b/packages/react-ui/src/app/builder/builder-hooks.ts index cdc9846e091..f8704d46883 100644 --- a/packages/react-ui/src/app/builder/builder-hooks.ts +++ b/packages/react-ui/src/app/builder/builder-hooks.ts @@ -4,6 +4,7 @@ import { create, useStore } from 'zustand'; import { CanvasState, createCanvasState } from './state/canvas-state'; import { ChatState, createChatState } from './state/chat-state'; import { createFlowState, FlowState } from './state/flow-state'; +import { createNotesState, NotesState } from './state/notes-state'; import { createPieceSelectorState, PieceSelectorState, @@ -27,7 +28,8 @@ export type BuilderState = FlowState & RunState & ChatState & CanvasState & - StepFormState; + StepFormState & + NotesState; export type BuilderInitialState = Pick< BuilderState, | 'flow' @@ -48,8 +50,10 @@ export const createBuilderStore = (initialState: BuilderInitialState) => const chatState = createChatState(set); const canvasState = createCanvasState(initialState, set); const stepFormState = createStepFormState(set); + const notesState = createNotesState(get, set); return { ...flowState, + ...notesState, ...runState, ...pieceSelectorState, ...chatState, diff --git a/packages/react-ui/src/app/builder/flow-canvas/canvas-controls.tsx b/packages/react-ui/src/app/builder/flow-canvas/canvas-controls.tsx index b3b208584d7..652b04c57e8 100644 --- a/packages/react-ui/src/app/builder/flow-canvas/canvas-controls.tsx +++ b/packages/react-ui/src/app/builder/flow-canvas/canvas-controls.tsx @@ -1,6 +1,14 @@ import { Node, useKeyPress, useReactFlow } from '@xyflow/react'; import { t } from 'i18next'; -import { Fullscreen, Hand, Map, Minus, MousePointer, Plus } from 'lucide-react'; +import { + Fullscreen, + Hand, + Map, + Minus, + MousePointer, + Plus, + StickerIcon, +} from 'lucide-react'; import { useCallback, useEffect } from 'react'; import { Button } from '@/components/ui/button'; @@ -13,6 +21,7 @@ import { import { isMac } from '@/lib/utils'; import { useBuilderStateContext } from '../builder-hooks'; +import { NoteDragOverlayMode } from '../state/notes-state'; import { flowCanvasConsts } from './utils/consts'; import { flowCanvasUtils } from './utils/flow-canvas-utils'; @@ -161,14 +170,17 @@ const CanvasControls = ({ }); } }; - - const [setPanningMode, panningMode, showMinimap, setShowMinimap] = + const [noteDragOverlayMode, setDraggedNote] = useBuilderStateContext( + (state) => [state.noteDragOverlayMode, state.setDraggedNote], + ); + const [setPanningMode, panningMode, showMinimap, setShowMinimap, readonly] = useBuilderStateContext((state) => { return [ state.setPanningMode, state.panningMode, state.showMinimap, state.setShowMinimap, + state.readonly, ]; }); const spacePressed = useKeyPress('Space'); @@ -238,6 +250,37 @@ const CanvasControls = ({ + {!readonly && ( + + + + )}
diff --git a/packages/react-ui/src/app/builder/flow-canvas/context-menu/canvas-context-menu-content.tsx b/packages/react-ui/src/app/builder/flow-canvas/context-menu/canvas-context-menu-content.tsx index 11b34ac55de..e94c612d65b 100644 --- a/packages/react-ui/src/app/builder/flow-canvas/context-menu/canvas-context-menu-content.tsx +++ b/packages/react-ui/src/app/builder/flow-canvas/context-menu/canvas-context-menu-content.tsx @@ -11,6 +11,7 @@ import { } from 'lucide-react'; import { + ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuSub, @@ -27,6 +28,7 @@ import { } from '@activepieces/shared'; import { useBuilderStateContext } from '../../builder-hooks'; +import { CanvasShortcuts } from '../../shortcuts'; import { copySelectedNodes, deleteSelectedNodes, @@ -35,11 +37,7 @@ import { toggleSkipSelectedNodes, } from '../utils/bulk-actions'; -import { - CanvasContextMenuProps, - CanvasShortcuts, - ContextMenuType, -} from './canvas-context-menu'; +import { CanvasContextMenuProps, ContextMenuType } from './canvas-context-menu'; const ShortcutWrapper = ({ children, @@ -128,7 +126,6 @@ export const CanvasContextMenuContent = ({ !readonly && contextMenuType === ContextMenuType.STEP && !isTriggerTheOnlySelectedNode; - const duplicateStep = () => { applyOperation({ type: FlowOperationType.DUPLICATE_ACTION, @@ -137,8 +134,22 @@ export const CanvasContextMenuContent = ({ }, }); }; + const showContextMenuContent = + showReplace || + showCopy || + showDuplicate || + showSkip || + showPasteAsFirstLoopAction || + showPasteAsBranchChild || + showPasteAfterCurrentStep || + showPasteAfterLastStep || + showDelete; + if (!showContextMenuContent) { + return null; + } + return ( - <> + {showReplace && ( )} - + ); }; diff --git a/packages/react-ui/src/app/builder/flow-canvas/context-menu/canvas-context-menu.tsx b/packages/react-ui/src/app/builder/flow-canvas/context-menu/canvas-context-menu.tsx index 8300da8d1c7..666cc24afb0 100644 --- a/packages/react-ui/src/app/builder/flow-canvas/context-menu/canvas-context-menu.tsx +++ b/packages/react-ui/src/app/builder/flow-canvas/context-menu/canvas-context-menu.tsx @@ -1,44 +1,13 @@ -import { - ContextMenu, - ContextMenuContent, - ContextMenuTrigger, -} from '@/components/ui/context-menu'; +import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu'; import { ShortcutProps } from '@/components/ui/shortcut'; import { CanvasContextMenuContent } from './canvas-context-menu-content'; export type CanvasShortcutsProps = Record< - 'Minimap' | 'Paste' | 'Delete' | 'Copy' | 'Skip', + 'Minimap' | 'Paste' | 'Delete' | 'Copy' | 'Skip' | 'ExitDrag', ShortcutProps >; -export const CanvasShortcuts: CanvasShortcutsProps = { - Minimap: { - withCtrl: true, - withShift: false, - shortcutKey: 'm', - }, - 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', @@ -52,13 +21,11 @@ export const CanvasContextMenu = ({ children, }: CanvasContextMenuProps) => { return ( - + {children} - - - + ); }; diff --git a/packages/react-ui/src/app/builder/flow-canvas/cursor-position-context.tsx b/packages/react-ui/src/app/builder/flow-canvas/cursor-position-context.tsx new file mode 100644 index 00000000000..95658014337 --- /dev/null +++ b/packages/react-ui/src/app/builder/flow-canvas/cursor-position-context.tsx @@ -0,0 +1,46 @@ +import { createContext, useContext, useEffect, useRef } from 'react'; + +const CursorPositionContext = createContext<{ + cursorPosition: { x: number; y: number }; + setCursorPosition: (position: { x: number; y: number }) => void; +}>({ + cursorPosition: { x: 0, y: 0 }, + setCursorPosition: () => {}, +}); + +export const useCursorPosition = () => { + return useContext(CursorPositionContext); +}; + +export const CursorPositionProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const cursorPositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + const setCursorPosition = (position: { x: number; y: number }) => { + cursorPositionRef.current = position; + }; + return ( + + {children} + + ); +}; + +//Use this only in the component you want to re-render when the cursor position changes, i.e dragged step or note +export const useCursorPositionEffect = ( + callback: (position: { x: number; y: number }) => void, +) => { + useEffect(() => { + const handleMouseMove = (event: MouseEvent) => { + callback({ x: event.clientX, y: event.clientY }); + }; + window.addEventListener('pointermove', handleMouseMove); + return () => { + window.removeEventListener('pointermove', handleMouseMove); + }; + }, [callback]); +}; diff --git a/packages/react-ui/src/app/builder/flow-canvas/flow-drag-layer.tsx b/packages/react-ui/src/app/builder/flow-canvas/flow-drag-layer.tsx index a00c6452e4a..428955e4480 100644 --- a/packages/react-ui/src/app/builder/flow-canvas/flow-drag-layer.tsx +++ b/packages/react-ui/src/app/builder/flow-canvas/flow-drag-layer.tsx @@ -9,7 +9,7 @@ import { useSensor, useSensors, } from '@dnd-kit/core'; -import { useViewport } from '@xyflow/react'; +import { ReactFlowInstance, useReactFlow, useViewport } from '@xyflow/react'; import { t } from 'i18next'; import { useCallback, useState } from 'react'; import { toast } from 'sonner'; @@ -18,11 +18,15 @@ import { FlowOperationType, StepLocationRelativeToParent, flowStructureUtil, + isNil, } from '@activepieces/shared'; -import { useBuilderStateContext } from '../builder-hooks'; +import { BuilderState, useBuilderStateContext } from '../builder-hooks'; +import { NoteDragOverlayMode } from '../state/notes-state'; -import StepDragOverlay from './step-drag-overlay'; +import NoteDragOverlay from './nodes/note-node/note-drag-overlay'; +import StepDragOverlay from './nodes/step-node/step-drag-overlay'; +import { flowCanvasConsts } from './utils/consts'; import { ApButtonData } from './utils/types'; const FlowDragLayer = ({ children }: { children: React.ReactNode }) => { @@ -33,11 +37,17 @@ const FlowDragLayer = ({ children }: { children: React.ReactNode }) => { applyOperation, flowVersion, activeDraggingStep, + setDraggedNote, + getNoteById, + moveNote, ] = useBuilderStateContext((state) => [ state.setActiveDraggingStep, state.applyOperation, state.flowVersion, state.activeDraggingStep, + state.setDraggedNote, + state.getNoteById, + state.moveNote, ]); const fixCursorSnapOffset = useCallback( @@ -72,60 +82,29 @@ const FlowDragLayer = ({ children }: { children: React.ReactNode }) => { const draggedStep = activeDraggingStep ? flowStructureUtil.getStep(activeDraggingStep, flowVersion.trigger) : undefined; - const handleDragStart = (e: DragStartEvent) => { - setActiveDraggingStep(e.active.id.toString()); + if (e.active.data.current?.type === flowCanvasConsts.DRAGGED_STEP_TAG) { + setActiveDraggingStep(e.active.id.toString()); + } + if (e.active.data.current?.type === flowCanvasConsts.DRAGGED_NOTE_TAG) { + const draggedNote = getNoteById(e.active.id.toString()); + if (draggedNote) { + setDraggedNote(draggedNote, NoteDragOverlayMode.MOVE); + } + } setPreviousViewPort(viewport); }; const handleDragCancel = () => { setActiveDraggingStep(null); + setDraggedNote(null, null); }; - + const reactFlow = useReactFlow(); 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, - }, - }); - } - } + setDraggedNote(null, null); + handleStepDragEnd({ e, applyOperation, activeDraggingStep, flowVersion }); + handleNoteDragEnd({ e, getNoteById, moveNote, reactFlow }); }; const sensors = useSensors( @@ -150,8 +129,86 @@ const FlowDragLayer = ({ children }: { children: React.ReactNode }) => { {draggedStep && } + ); }; export { FlowDragLayer }; + +function handleStepDragEnd({ + e, + applyOperation, + activeDraggingStep, + flowVersion, +}: { e: DragEndEvent } & Pick< + BuilderState, + 'applyOperation' | 'activeDraggingStep' | 'flowVersion' +>) { + const draggedStep = activeDraggingStep + ? flowStructureUtil.getStep(activeDraggingStep, flowVersion.trigger) + : undefined; + const isOverSomething = + !isNil(e.over?.data?.current) && + e.over.data.current.accepts === e.active.data?.current?.type; + if (isOverSomething) { + const droppedAtNodeData: ApButtonData | undefined = e.over?.data + .current as unknown as ApButtonData | undefined; + if ( + 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, + }, + }); + } + } +} + +function handleNoteDragEnd({ + e, + getNoteById, + moveNote, + reactFlow, +}: { e: DragEndEvent } & Pick & { + reactFlow: ReactFlowInstance; + }) { + if (e.active.data.current?.type === flowCanvasConsts.DRAGGED_NOTE_TAG) { + const draggedNote = getNoteById(e.active.id.toString()); + if (draggedNote) { + const element = document.getElementById(e.active.id.toString()); + if (element) { + const positionOnCanvas = reactFlow.screenToFlowPosition({ + x: element.getBoundingClientRect().left, + y: element.getBoundingClientRect().top, + }); + moveNote(draggedNote.id, positionOnCanvas); + } + } + } +} diff --git a/packages/react-ui/src/app/builder/flow-canvas/index.tsx b/packages/react-ui/src/app/builder/flow-canvas/index.tsx index 36829664f84..cb309184e17 100644 --- a/packages/react-ui/src/app/builder/flow-canvas/index.tsx +++ b/packages/react-ui/src/app/builder/flow-canvas/index.tsx @@ -13,12 +13,12 @@ import { import '@xyflow/react/dist/style.css'; import React, { useCallback, useMemo, useRef, useState } from 'react'; -import { RightSideBarType } from '@/lib/types'; import { FlowActionType, flowStructureUtil, FlowVersion, isNil, + Note, Step, } from '@activepieces/shared'; @@ -52,6 +52,7 @@ export const FlowCanvas = React.memo( panningMode, selectStepByName, rightSidebar, + notes, ] = useBuilderStateContext((state) => { return [ state.flowVersion, @@ -61,6 +62,7 @@ export const FlowCanvas = React.memo( state.panningMode, state.selectStepByName, state.rightSidebar, + state.flowVersion.notes, ]; }); const containerRef = useRef(null); @@ -81,13 +83,14 @@ export const FlowCanvas = React.memo( }, [setSelectedNodes, selectedStep], ); - const graphKey = createGraphKey(flowVersion); + const graphKey = createGraphKey(flowVersion, notes); const graph = useMemo(() => { - return flowCanvasUtils.convertFlowVersionToGraph(flowVersion); + return flowCanvasUtils.createFlowGraph(flowVersion, notes); }, [graphKey]); const [contextMenuType, setContextMenuType] = useState( ContextMenuType.CANVAS, ); + const onContextMenu = useCallback( (ev: React.MouseEvent) => { if ( @@ -114,11 +117,7 @@ export const FlowCanvas = React.memo( const showStepContextMenu = stepElement || targetIsSelectionRect || targetIsSelectionChevron; if (showStepContextMenu) { - if (rightSidebar === RightSideBarType.NONE) { - setTimeout(() => setContextMenuType(ContextMenuType.STEP), 10000); - } else { - setContextMenuType(ContextMenuType.STEP); - } + setContextMenuType(ContextMenuType.STEP); } else { setContextMenuType(ContextMenuType.CANVAS); } @@ -137,9 +136,9 @@ export const FlowCanvas = React.memo( ); const onSelectionEnd = useCallback(() => { - const selectedSteps = selectedNodes.map((node) => - flowStructureUtil.getStepOrThrow(node, flowVersion.trigger), - ); + const selectedSteps = selectedNodes + .map((node) => flowStructureUtil.getStep(node, flowVersion.trigger)) + .filter((step) => !isNil(step)); selectedSteps.forEach((step) => { if ( step.type === FlowActionType.LOOP_ON_ITEMS || @@ -181,6 +180,7 @@ export const FlowCanvas = React.memo( ]; return extent; }, [graphKey]); + return (
{ return ''; } }; -const createGraphKey = (flowVersion: FlowVersion) => { - return flowStructureUtil +const createGraphKey = (flowVersion: FlowVersion, notes: Note[]) => { + const flowGraphKey = flowStructureUtil .getAllSteps(flowVersion.trigger) .reduce((acc, step) => { const branchesNames = @@ -279,4 +279,8 @@ const createGraphKey = (flowVersion: FlowVersion) => { step.type === FlowActionType.PIECE ? step.settings.pieceName : '' }-${branchesNames}-${childrenKey}}`; }, ''); + const notesGraphKey = notes + .map((note) => `${note.id}-${note.position.x}-${note.position.y}`) + .join('-'); + return `${flowVersion.id}-${flowGraphKey}-${notesGraphKey}`; }; diff --git a/packages/react-ui/src/app/builder/flow-canvas/nodes/note-node/index.tsx b/packages/react-ui/src/app/builder/flow-canvas/nodes/note-node/index.tsx new file mode 100644 index 00000000000..7fc5cf35993 --- /dev/null +++ b/packages/react-ui/src/app/builder/flow-canvas/nodes/note-node/index.tsx @@ -0,0 +1,203 @@ +import { useDraggable } from '@dnd-kit/core'; +import { Editor } from '@tiptap/core'; +import { NodeProps, NodeResizeControl } from '@xyflow/react'; +import { t } from 'i18next'; +import { useRef, useState } from 'react'; +import { useDebouncedCallback } from 'use-debounce'; + +import { MarkdownInput } from '@/components/ui/markdown-input'; +import { cn } from '@/lib/utils'; +import { Note, NoteColorVariant } from '@activepieces/shared'; + +import { useBuilderStateContext } from '../../../builder-hooks'; +import { flowCanvasConsts } from '../../utils/consts'; +import { ApNoteNode } from '../../utils/types'; + +import { NoteFooter } from './note-footer'; +import { NoteTools } from './note-tools'; + +const ApNoteCanvasNode = (props: NodeProps & Omit) => { + const [draggedNote, resizeNote, note, readonly] = useBuilderStateContext( + (state) => [ + state.draggedNote, + state.resizeNote, + state.getNoteById(props.id), + state.readonly, + ], + ); + const { attributes, listeners, setNodeRef } = useDraggable({ + id: props.id, + data: { + type: flowCanvasConsts.DRAGGED_NOTE_TAG, + }, + }); + + const [size, setSize] = useState(props.data.size); + if (draggedNote?.id === props.id || note === null) { + return null; + } + return ( +
+ { + // update the size locally means that we don't re-render the whole graph + setSize({ width: params.width, height: params.height }); + }} + onResizeEnd={(_, params) => { + resizeNote(props.id, { + width: params.width, + height: params.height, + }); + }} + > + + + +
+ +
+
+ ); +}; +ApNoteCanvasNode.displayName = 'ApNoteCanvasNode'; + +const NoteContent = ({ note, isDragging }: NoteContentProps) => { + const { id, ownerId: creatorId, color, size } = note; + const { width, height } = size; + const [localNote, setLocalNote] = useState(note); + const [updateContent, readonly] = useBuilderStateContext((state) => [ + state.updateContent, + state.readonly, + ]); + const debouncedUpdateContent = useDebouncedCallback( + (id: string, content: string) => { + updateContent(id, content); + }, + 500, + ); + const editorRef = useRef(null); + return ( +
+ {!isDragging && !readonly && editorRef.current && ( +
+ +
+ )} +
+
e.stopPropagation()} + className="grow h-full overflow-auto " + onDoubleClick={(e) => { + e.stopPropagation(); + editorRef.current?.commands.focus(); + }} + > + { + setLocalNote({ ...localNote, content: value }); + debouncedUpdateContent(id, value); + }} + /> +
+ +
+
+ ); +}; +export { ApNoteCanvasNode, NoteContent }; +type NoteContentProps = { + note: Note; + isDragging: boolean; +}; + +const NoteColorVariantClassName = { + [NoteColorVariant.YELLOW]: + 'dark:bg-[oklch(0.3052_0.0455_83.74)] dark:text-[oklch(0.8826_0.1328_86.23)] bg-[oklch(0.9638_0.0522_92.93)] text-[oklch(0.4784_0.1089_63.21)]', + [NoteColorVariant.ORANGE]: + 'dark:bg-[oklch(0.2968_0.0566_51.71)] dark:text-[oklch(0.8717_0.0836_58.75)] text-[oklch(0.4905_0.140461_44.9084)] bg-[oklch(0.9583_0.0245_61.65)]', + [NoteColorVariant.RED]: + 'dark:bg-[oklch(0.3046_0.0779_7.16)] dark:text-[oklch(0.9002_0.052_18.16)] bg-[oklch(0.956_0.0218_17.54)] text-[oklch(0.5141_0.1849_26.72)]', + [NoteColorVariant.GREEN]: + 'dark:bg-[oklch(0.3411_0.0464_168.94)] dark:text-[oklch(0.9025_0.0888_163.86)] text-[oklch(0.5208_0.115675_161.168)] bg-[oklch(0.9667_0.0353_162.37)]', + [NoteColorVariant.BLUE]: + 'dark:bg-[oklch(0.3086_0.0738_264.7)] dark:text-[oklch(0.8746_0.061_264.64)] bg-[oklch(0.9474_0.0249_263.33)] text-[oklch(0.4975_0.1752_261.14)]', + [NoteColorVariant.PURPLE]: + 'dark:bg-[oklch(0.2936_0.1027_291.89)] dark:text-[oklch(0.8565_0.0834_300.16)] text-[oklch(0.4647_0.186_293.18)] bg-[oklch(0.9633_0.0206_301.15)]', +}; + +const FocusedBorderClassName = { + [NoteColorVariant.YELLOW]: + 'dark:group-focus-within:text-[oklch(0.8826_0.1328_86.23)] group-focus-within:border-[oklch(0.4784_0.1089_63.21)]', + [NoteColorVariant.ORANGE]: + 'dark:group-focus-within:text-[oklch(0.8717_0.0836_58.75)] group-focus-within:border-[oklch(0.4905_0.140461_44.9084)]', + [NoteColorVariant.RED]: + 'dark:group-focus-within:text-[oklch(0.9002_0.052_18.16)] group-focus-within:border-[oklch(0.5141_0.1849_26.72)]', + [NoteColorVariant.GREEN]: + 'dark:group-focus-within:text-[oklch(0.9025_0.0888_163.86)] group-focus-within:border-[oklch(0.5208_0.115675_161.168)]', + [NoteColorVariant.BLUE]: + 'dark:group-focus-within:text-[oklch(0.8746_0.061_264.64)] group-focus-within:border-[oklch(0.4975_0.1752_261.14)]', + [NoteColorVariant.PURPLE]: + 'dark:group-focus-within:text-[oklch(0.8565_0.0834_300.16)] group-focus-within:border-[oklch(0.4647_0.186_293.18)]', +}; diff --git a/packages/react-ui/src/app/builder/flow-canvas/nodes/note-node/note-drag-overlay.tsx b/packages/react-ui/src/app/builder/flow-canvas/nodes/note-node/note-drag-overlay.tsx new file mode 100644 index 00000000000..a4c165c99f8 --- /dev/null +++ b/packages/react-ui/src/app/builder/flow-canvas/nodes/note-node/note-drag-overlay.tsx @@ -0,0 +1,95 @@ +import { useReactFlow } from '@xyflow/react'; +import { useRef, useState } from 'react'; + +import { useSidebar } from '@/components/ui/sidebar-shadcn'; +import { isNil } from '@activepieces/shared'; + +import { useBuilderStateContext } from '../../../builder-hooks'; +import { NoteDragOverlayMode } from '../../../state/notes-state'; +import { + useCursorPosition, + useCursorPositionEffect, +} from '../../cursor-position-context'; +import { flowCanvasConsts } from '../../utils/consts'; + +import { NoteContent } from '.'; + +const NoteDragOverlay = () => { + const { open } = useSidebar(); + const { cursorPosition } = useCursorPosition(); + const [overlayPosition, setOverlayPosition] = + useState(cursorPosition); + const builderNavigationBar = document.getElementById( + flowCanvasConsts.BUILDER_NAVIGATION_SIDEBAR_ID, + ); + const [draggedNote, noteDragOverlayMode, addNote] = useBuilderStateContext( + (state) => [state.draggedNote, state.noteDragOverlayMode, state.addNote], + ); + const reactFlow = useReactFlow(); + const containerRef = useRef(null); + const builderNavigationBarWidth = open + ? builderNavigationBar?.clientWidth ?? 0 + : 0; + + const nodeSizeWithZoom = { + width: (draggedNote?.size.width ?? 0) * reactFlow.getZoom(), + height: (draggedNote?.size.height ?? 0) * reactFlow.getZoom(), + }; + + const left = `${ + overlayPosition.x - nodeSizeWithZoom.width / 2 - builderNavigationBarWidth + }px`; + const top = `${overlayPosition.y - 50 - nodeSizeWithZoom.height / 2}px`; + useCursorPositionEffect((position) => { + setOverlayPosition(position); + }); + const hideOverlay = isNil(draggedNote) || isNil(noteDragOverlayMode); + + if (hideOverlay) { + return null; + } + return ( +
{ + if (noteDragOverlayMode === NoteDragOverlayMode.CREATE) { + const rect = containerRef.current?.getBoundingClientRect(); + if (rect) { + const positionOnCanvas = reactFlow.screenToFlowPosition({ + x: rect.left, + y: rect.top, + }); + addNote({ + content: flowCanvasConsts.DEFAULT_NOTE_CONTENT, + position: positionOnCanvas, + size: draggedNote.size, + color: flowCanvasConsts.DEFAULT_NOTE_COLOR, + }); + } + } + }} + style={{ + left, + top, + height: `${draggedNote.size.height * reactFlow.getZoom()}px`, + width: `${draggedNote.size.width * reactFlow.getZoom()}px`, + }} + > + +
+ ); +}; + +export default NoteDragOverlay; diff --git a/packages/react-ui/src/app/builder/flow-canvas/nodes/note-node/note-footer.tsx b/packages/react-ui/src/app/builder/flow-canvas/nodes/note-node/note-footer.tsx new file mode 100644 index 00000000000..1e5f468412a --- /dev/null +++ b/packages/react-ui/src/app/builder/flow-canvas/nodes/note-node/note-footer.tsx @@ -0,0 +1,30 @@ +import { TextWithTooltip } from '@/components/custom/text-with-tooltip'; +import { userHooks } from '@/hooks/user-hooks'; +import { isNil } from '@activepieces/shared'; + +export const NoteFooter = ({ creatorId }: NoteFooterProps) => { + const { data: user } = userHooks.useUserById(creatorId ?? null); + const creator = + user?.firstName && user?.lastName + ? `${user.firstName} ${user.lastName}` + : user?.email; + + return ( +
+
+ {!isNil(creator) && ( + +
{creator}
+
+ )} +
+
+ ); +}; +NoteFooter.displayName = 'NoteFooter'; + +type NoteFooterProps = { + id: string; + isDragging?: boolean; + creatorId: string | null | undefined; +}; diff --git a/packages/react-ui/src/app/builder/flow-canvas/nodes/note-node/note-tools.tsx b/packages/react-ui/src/app/builder/flow-canvas/nodes/note-node/note-tools.tsx new file mode 100644 index 00000000000..a84bcaf715d --- /dev/null +++ b/packages/react-ui/src/app/builder/flow-canvas/nodes/note-node/note-tools.tsx @@ -0,0 +1,118 @@ +import { Editor } from '@tiptap/core'; +import { TrashIcon } from 'lucide-react'; +import { useRef } from 'react'; + +import { useBuilderStateContext } from '@/app/builder/builder-hooks'; +import { Button } from '@/components/ui/button'; +import { MarkdownTools } from '@/components/ui/markdown-input/tools'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { Separator } from '@/components/ui/separator'; +import { cn } from '@/lib/utils'; +import { NoteColorVariant } from '@activepieces/shared'; + +export const NoteTools = ({ editor, currentColor, id }: NoteToolsProps) => { + const containerRef = useRef(null); + const [updateNoteColor, deleteNote] = useBuilderStateContext((state) => [ + state.updateNoteColor, + state.deleteNote, + ]); + return ( +
+
+
+ { + updateNoteColor(id, color); + }} + container={containerRef.current} + /> + + + +
+
+
+ ); +}; + +const NoteColorPickerClassName = { + [NoteColorVariant.YELLOW]: 'bg-amber-400', + [NoteColorVariant.ORANGE]: 'bg-orange-400', + [NoteColorVariant.RED]: 'bg-red-400', + [NoteColorVariant.GREEN]: 'bg-green-400', + [NoteColorVariant.BLUE]: 'bg-blue-400', + [NoteColorVariant.PURPLE]: 'bg-purple-400', +}; + +const NoteColorPicker = ({ + currentColor, + setCurrentColor, + container, +}: NoteColorPickerProps) => { + return ( + + +
+
+ +
+ {Object.values(NoteColorVariant).map((color) => ( +
{ + setCurrentColor(color); + }} + > +
+
+ ))} +
+
+
+ ); +}; +NoteTools.displayName = 'NoteTools'; + +type NoteToolsProps = { + editor: Editor; + currentColor: NoteColorVariant; + id: string; +}; + +type NoteColorPickerProps = { + currentColor: NoteColorVariant; + setCurrentColor: (color: NoteColorVariant) => void; + container: HTMLDivElement | null; +}; diff --git a/packages/react-ui/src/app/builder/flow-canvas/step-drag-overlay.tsx b/packages/react-ui/src/app/builder/flow-canvas/nodes/step-node/step-drag-overlay.tsx similarity index 86% rename from packages/react-ui/src/app/builder/flow-canvas/step-drag-overlay.tsx rename to packages/react-ui/src/app/builder/flow-canvas/nodes/step-node/step-drag-overlay.tsx index 9612a275e3b..169fa135c01 100644 --- a/packages/react-ui/src/app/builder/flow-canvas/step-drag-overlay.tsx +++ b/packages/react-ui/src/app/builder/flow-canvas/nodes/step-node/step-drag-overlay.tsx @@ -8,9 +8,8 @@ import { FlowAction, FlowTrigger } from '@activepieces/shared'; import { useCursorPosition, useCursorPositionEffect, -} from '../state/cursor-position-context'; - -import { flowCanvasConsts } from './utils/consts'; +} from '../../../state/cursor-position-context'; +import { flowCanvasConsts } from '../../utils/consts'; const StepDragOverlay = ({ step }: { step: FlowAction | FlowTrigger }) => { const { open } = useSidebar(); @@ -40,7 +39,7 @@ const StepDragOverlay = ({ step }: { step: FlowAction | FlowTrigger }) => { return (
, + Exclude, { height: number; width: number } > = { [ApNodeType.BIG_ADD_BUTTON]: { @@ -82,6 +88,7 @@ export const flowCanvasConsts = { LABEL_HEIGHT, ARC_LEFT_UP, VERTICAL_OFFSET_BETWEEN_ROUTER_AND_CHILD, + doesNodeAffectBoundingBox: doesNodeAffectBoundingBoxWidth, edgeTypes: { [ApEdgeType.STRAIGHT_LINE]: ApStraightLineCanvasEdge, @@ -95,16 +102,22 @@ export const flowCanvasConsts = { [ApNodeType.LOOP_RETURN_NODE]: ApLoopReturnCanvasNode, [ApNodeType.BIG_ADD_BUTTON]: ApBigAddButtonCanvasNode, [ApNodeType.GRAPH_END_WIDGET]: ApGraphEndWidgetNode, + [ApNodeType.NOTE]: ApNoteCanvasNode, }, DRAGGED_STEP_TAG, + DRAGGED_NOTE_TAG, HORIZONTAL_SPACE_BETWEEN_NODES, HANDLE_STYLING: { opacity: 0, cursor: 'default' }, LABEL_VERTICAL_PADDING, STEP_DRAG_OVERLAY_WIDTH, STEP_DRAG_OVERLAY_HEIGHT, + NOTE_CREATION_OVERLAY_WIDTH, + NOTE_CREATION_OVERLAY_HEIGHT, STEP_CONTEXT_MENU_ATTRIBUTE: 'step-context-menu', SELECTION_RECT_CHEVRON_ATTRIBUTE: 'selection-rect-chevron', BUILDER_NAVIGATION_SIDEBAR_ID: 'builder-navigation-sidebar', NODE_SELECTION_RECT_CLASS_NAME: 'react-flow__nodesselection-rect', SIDEBAR_ANIMATION_DURATION: 200, + DEFAULT_NOTE_CONTENT: '
', + DEFAULT_NOTE_COLOR: NoteColorVariant.BLUE, }; diff --git a/packages/react-ui/src/app/builder/flow-canvas/utils/flow-canvas-utils.ts b/packages/react-ui/src/app/builder/flow-canvas/utils/flow-canvas-utils.ts index 5d7c8ea53ea..3dfc4be32eb 100644 --- a/packages/react-ui/src/app/builder/flow-canvas/utils/flow-canvas-utils.ts +++ b/packages/react-ui/src/app/builder/flow-canvas/utils/flow-canvas-utils.ts @@ -15,6 +15,7 @@ import { StepLocationRelativeToParent, FlowTrigger, FlowTriggerType, + Note, } from '@activepieces/shared'; import { flowCanvasConsts } from './consts'; @@ -124,9 +125,9 @@ const createStepGraph: ( }; }; -const buildGraph: (step: FlowAction | FlowTrigger | undefined) => ApGraph = ( - step, -) => { +const buildFlowGraph: ( + step: FlowAction | FlowTrigger | undefined, +) => ApGraph = (step) => { if (isNil(step)) { return { nodes: [], @@ -147,7 +148,7 @@ const buildGraph: (step: FlowAction | FlowTrigger | undefined) => ApGraph = ( : null; const graphWithChild = childGraph ? mergeGraph(graph, childGraph) : graph; - const nextStepGraph = buildGraph(step.nextAction); + const nextStepGraph = buildFlowGraph(step.nextAction); return mergeGraph( graphWithChild, offsetGraph(nextStepGraph, { @@ -219,7 +220,7 @@ const calculateGraphBoundingBox = (graph: ApGraph) => { const buildLoopChildGraph: (step: LoopOnItemsAction) => ApGraph = (step) => { const childGraph = step.firstLoopAction - ? buildGraph(step.firstLoopAction) + ? buildFlowGraph(step.firstLoopAction) : createBigAddButtonGraph(step, { parentStepName: step.name, stepLocationRelativeToParent: StepLocationRelativeToParent.INSIDE_LOOP, @@ -312,7 +313,7 @@ const buildLoopChildGraph: (step: LoopOnItemsAction) => ApGraph = (step) => { const buildRouterChildGraph = (step: RouterAction) => { const childGraphs = step.children.map((branch, index) => { return branch - ? buildGraph(branch) + ? buildFlowGraph(branch) : createBigAddButtonGraph(step, { parentStepName: step.name, stepLocationRelativeToParent: @@ -491,6 +492,23 @@ const getStepStatus = ( ); return stepOutput?.status; }; +function buildNotesGraph(notes: Note[]): ApGraph { + return { + nodes: notes.map((note) => ({ + id: note.id, + type: ApNodeType.NOTE, + draggable: true, + position: note.position, + data: { + content: note.content, + creatorId: note.ownerId, + color: note.color, + size: note.size, + }, + })), + edges: [], + }; +} function determineInitiallySelectedStep( failedStepNameInRun: string | null, @@ -516,9 +534,10 @@ const doesSelectionRectangleExist = () => { ); }; export const flowCanvasUtils = { - convertFlowVersionToGraph(version: FlowVersion): ApGraph { - const graph = buildGraph(version.trigger); - const graphEndWidget = graph.nodes.findLast( + createFlowGraph(version: FlowVersion, notes: Note[]): ApGraph { + const stepsGraph = buildFlowGraph(version.trigger); + const notesGraph = buildNotesGraph(notes); + const graphEndWidget = stepsGraph.nodes.findLast( (node) => node.type === ApNodeType.GRAPH_END_WIDGET, ) as ApGraphEndNode; if (graphEndWidget) { @@ -526,7 +545,7 @@ export const flowCanvasUtils = { } else { console.warn('Flow end widget not found'); } - return graph; + return mergeGraph(stepsGraph, notesGraph); }, createFocusStepInGraphParams, calculateGraphBoundingBox, diff --git a/packages/react-ui/src/app/builder/flow-canvas/utils/types.ts b/packages/react-ui/src/app/builder/flow-canvas/utils/types.ts index e43cd43638c..6aac234c0db 100644 --- a/packages/react-ui/src/app/builder/flow-canvas/utils/types.ts +++ b/packages/react-ui/src/app/builder/flow-canvas/utils/types.ts @@ -4,6 +4,7 @@ import { FlowAction, StepLocationRelativeToParent, FlowTrigger, + Note, } from '@activepieces/shared'; export enum ApNodeType { @@ -14,6 +15,7 @@ export enum ApNodeType { GRAPH_START_WIDGET = 'GRAPH_START_WIDGET', /**Used for calculating the loop graph width */ LOOP_RETURN_NODE = 'LOOP_RETURN_NODE', + NOTE = 'NOTE', } export type ApBoundingBox = { width: number; @@ -37,6 +39,16 @@ export type ApStepNode = { draggable?: boolean; }; +export type ApNoteNode = { + id: string; + type: ApNodeType.NOTE; + position: { + x: number; + y: number; + }; + data: Pick; +}; + export type ApLoopReturnNode = { id: string; type: ApNodeType.LOOP_RETURN_NODE; @@ -93,7 +105,8 @@ export type ApNode = | ApStepNode | ApGraphEndNode | ApBigAddButtonNode - | ApLoopReturnNode; + | ApLoopReturnNode + | ApNoteNode; export enum ApEdgeType { STRAIGHT_LINE = 'ApStraightLineEdge', diff --git a/packages/react-ui/src/app/builder/flow-canvas/widgets/publish-flow-reminder-widget.tsx b/packages/react-ui/src/app/builder/flow-canvas/widgets/publish-flow-reminder-widget.tsx index 8a9f820444e..53c76450119 100644 --- a/packages/react-ui/src/app/builder/flow-canvas/widgets/publish-flow-reminder-widget.tsx +++ b/packages/react-ui/src/app/builder/flow-canvas/widgets/publish-flow-reminder-widget.tsx @@ -90,10 +90,12 @@ const PublishFlowReminderWidget = () => { if (!showShouldPublishButton) { return null; } - const showLoading = isPublishing || isDiscardingChanges; - const loadingText = isDiscardingChanges - ? t('Discarding changes...') - : t('Publishing...'); + const showLoading = isPublishing || isDiscardingChanges || isSaving; + const loadingText = pickLoadingText({ + isDiscardingChanges, + isPublishing, + isSaving, + }); return (
@@ -164,3 +166,24 @@ const useShouldShowPublishButton = ({ isValid ); }; + +function pickLoadingText({ + isDiscardingChanges, + isPublishing, + isSaving, +}: { + isDiscardingChanges: boolean; + isPublishing: boolean; + isSaving: boolean; +}) { + if (isSaving) { + return t('Saving...'); + } + if (isDiscardingChanges) { + return t('Discarding changes...'); + } + if (isPublishing) { + return t('Publishing...'); + } + return ''; +} diff --git a/packages/react-ui/src/app/builder/index.tsx b/packages/react-ui/src/app/builder/index.tsx index b3ce87996dc..cfbbffe376d 100644 --- a/packages/react-ui/src/app/builder/index.tsx +++ b/packages/react-ui/src/app/builder/index.tsx @@ -164,6 +164,7 @@ const BuilderPage = () => {
+
); diff --git a/packages/react-ui/src/app/builder/piece-properties/text-input-with-mentions/index.tsx b/packages/react-ui/src/app/builder/piece-properties/text-input-with-mentions/index.tsx index a7781675961..4057278e378 100644 --- a/packages/react-ui/src/app/builder/piece-properties/text-input-with-mentions/index.tsx +++ b/packages/react-ui/src/app/builder/piece-properties/text-input-with-mentions/index.tsx @@ -3,7 +3,6 @@ import { Placeholder } from '@tiptap/extension-placeholder'; import { useEditor, EditorContent } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; -import './tip-tap.css'; import { stepsHooks } from '@/features/pieces/lib/steps-hooks'; import { cn } from '@/lib/utils'; import { flowStructureUtil, isNil } from '@activepieces/shared'; @@ -29,6 +28,7 @@ const extensions = (placeholder?: string) => { }), Placeholder.configure({ placeholder, + emptyNodeClass: 'before:text-muted-foreground opacity-75', }), Mention.configure({ suggestion: { diff --git a/packages/react-ui/src/app/builder/piece-properties/text-input-with-mentions/tip-tap.css b/packages/react-ui/src/app/builder/piece-properties/text-input-with-mentions/tip-tap.css deleted file mode 100644 index 41635ca0a0f..00000000000 --- a/packages/react-ui/src/app/builder/piece-properties/text-input-with-mentions/tip-tap.css +++ /dev/null @@ -1,62 +0,0 @@ -.tiptap p.is-editor-empty:first-child::before { - color: #adb5bd; - content: attr(data-placeholder); - float: left; - height: 0; - pointer-events: none; - } - -.tiptap { - overflow-wrap: break-word; -} - -.tiptap > :first-child { - margin-top: 0; -} - -.tiptap ul { - list-style-type: disc; - list-style-position: outside; - padding-left: 1.5rem; - margin: 1.25rem 1rem 1.25rem 0.4rem; -} - -.tiptap ol { - list-style-type: decimal; - list-style-position: outside; - padding-left: 1.5rem; - margin: 1.25rem 1rem 1.25rem 0.4rem; -} - -.tiptap li p { - margin: 0.25em 0; -} - -.tiptap h1, -.tiptap h2, -.tiptap h3, -.tiptap h4, -.tiptap h5, -.tiptap h6 { - line-height: 1; - margin-top: 1rem; - text-wrap: pretty; -} - -.tiptap h1 { - font-size: 1.4rem; -} - -.tiptap h2 { - font-size: 1.2rem; -} - -.tiptap h3 { - font-size: 1.1rem; -} - -.tiptap h4, -.tiptap h5, -.tiptap h6 { - font-size: 1rem; -} \ No newline at end of file diff --git a/packages/react-ui/src/app/builder/run-details/run-step-card-item.tsx b/packages/react-ui/src/app/builder/run-details/run-step-card-item.tsx deleted file mode 100644 index cebcfc6abfb..00000000000 --- a/packages/react-ui/src/app/builder/run-details/run-step-card-item.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import { useReactFlow } from '@xyflow/react'; -import { t } from 'i18next'; -import { ChevronRight } from 'lucide-react'; -import React, { useMemo } from 'react'; - -import { useBuilderStateContext } from '@/app/builder/builder-hooks'; -import { CardListItem } from '@/components/custom/card-list'; -import { Button } from '@/components/ui/button'; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '@/components/ui/collapsible'; -import { stepsHooks } from '@/features/pieces/lib/steps-hooks'; -import { cn, formatUtils } from '@/lib/utils'; -import { FlowActionType, flowStructureUtil } from '@activepieces/shared'; - -import { StepStatusIcon } from '../../../features/flow-runs/components/step-status-icon'; -import { flowRunUtils } from '../../../features/flow-runs/lib/flow-run-utils'; -import { flowCanvasUtils } from '../flow-canvas/utils/flow-canvas-utils'; - -import { LoopIterationInput } from './loop-iteration-input'; -type RunStepCardProps = { - stepName: string; - depth: number; -}; - -const RunStepCardItem = ({ stepName, depth }: RunStepCardProps) => { - const [ - loopsIndexes, - step, - selectedStep, - stepIndex, - selectStepByName, - run, - flowVersion, - ] = useBuilderStateContext((state) => { - const step = flowStructureUtil.getStepOrThrow( - stepName, - state.flowVersion.trigger, - ); - const stepIndex = flowStructureUtil - .getAllSteps(state.flowVersion.trigger) - .findIndex((s) => s.name === stepName); - - return [ - state.loopsIndexes, - step, - state.selectedStep, - stepIndex, - state.selectStepByName, - state.run, - state.flowVersion, - ]; - }); - const { fitView } = useReactFlow(); - const isChildSelected = useMemo(() => { - return step?.type === FlowActionType.LOOP_ON_ITEMS && selectedStep - ? flowStructureUtil.isChildOf(step, selectedStep) - : false; - }, [step, selectedStep]); - - const stepOutput = useMemo(() => { - return run && run.steps - ? flowRunUtils.extractStepOutput( - stepName, - loopsIndexes, - run.steps, - flowVersion.trigger, - ) - : null; - }, [loopsIndexes, run, stepName, flowVersion.trigger]); - - const isStepSelected = selectedStep === stepName; - - const children = - stepOutput && - stepOutput.output && - stepOutput.type === FlowActionType.LOOP_ON_ITEMS && - stepOutput.output.iterations[loopsIndexes[stepName]] - ? Object.keys(stepOutput.output.iterations[loopsIndexes[stepName]]) - : []; - const { stepMetadata } = stepsHooks.useStepMetadata({ - step: step, - }); - const [isOpen, setIsOpen] = React.useState(true); - - const isLoopStep = - stepOutput && stepOutput.type === FlowActionType.LOOP_ON_ITEMS; - const loopHasNoIterations = - isLoopStep && stepOutput.output?.iterations.length === 0; - - return ( - - - { - if (!isStepSelected) { - selectStepByName(stepName); - fitView(flowCanvasUtils.createFocusStepInGraphParams(stepName)); - setIsOpen(true); - } else { - setIsOpen(!isOpen); - } - }} - className={cn('cursor-pointer select-none px-4 py-3 h-14', { - 'bg-accent text-accent-foreground': isStepSelected, - })} - > -
-
- {children.length > 0 && ( - - )} - {stepMetadata?.displayName} -
{`${ - stepIndex + 1 - }. ${step?.displayName}`}
-
-
- {isLoopStep && isStepSelected && ( - - {loopHasNoIterations - ? t('No Iterations') - : t('All Iterations')} - - )} - {isLoopStep && !isStepSelected && ( -
- -
- )} - {(!isLoopStep || - (isLoopStep && !isChildSelected && !isStepSelected)) && ( -
- - {formatUtils.formatDuration( - stepOutput?.duration ?? 0, - true, - )} - - {stepOutput && stepOutput.status && ( - - )} -
- )} -
-
-
-
- - {children.map((stepName) => ( - - ))} - -
- ); -}; - -RunStepCardItem.displayName = 'RunStepCardItem'; -export { RunStepCardItem as FlowStepDetailsCardItem }; diff --git a/packages/react-ui/src/app/builder/shortcuts.ts b/packages/react-ui/src/app/builder/shortcuts.ts index dcb9882e8ea..94d030eb72f 100644 --- a/packages/react-ui/src/app/builder/shortcuts.ts +++ b/packages/react-ui/src/app/builder/shortcuts.ts @@ -2,15 +2,11 @@ import { useCallback, useEffect } from 'react'; import { flowStructureUtil, - isNil, StepLocationRelativeToParent, } from '@activepieces/shared'; import { useBuilderStateContext } from './builder-hooks'; -import { - CanvasShortcuts, - CanvasShortcutsProps, -} from './flow-canvas/context-menu/canvas-context-menu'; +import { CanvasShortcutsProps } from './flow-canvas/context-menu/canvas-context-menu'; import { canvasBulkActions } from './flow-canvas/utils/bulk-actions'; import { flowCanvasConsts } from './flow-canvas/utils/consts'; @@ -24,6 +20,8 @@ export const useHandleKeyPressOnCanvas = () => { readonly, setShowMinimap, showMinimap, + setDraggedNote, + setDraggedStep, ] = useBuilderStateContext((state) => [ state.selectedNodes, state.flowVersion, @@ -33,83 +31,108 @@ export const useHandleKeyPressOnCanvas = () => { state.readonly, state.setShowMinimap, state.showMinimap, + state.setDraggedNote, + state.setActiveDraggingStep, ]); const handleKeyDown = useCallback( (e: KeyboardEvent) => { - if ( + const insideSelectionRect = e.target instanceof HTMLElement && - (e.target === document.body || - e.target.classList.contains( - flowCanvasConsts.NODE_SELECTION_RECT_CLASS_NAME, - ) || - e.target.closest( - `[data-${flowCanvasConsts.STEP_CONTEXT_MENU_ATTRIBUTE}]`, - )) && - !readonly - ) { - const selectedNodesWithoutTrigger = selectedNodes.filter( - (node) => node !== flowVersion.trigger.name, + e.target.classList.contains( + flowCanvasConsts.NODE_SELECTION_RECT_CLASS_NAME, ); - shortcutHandler(e, { - Minimap: () => { - setShowMinimap(!showMinimap); - }, - Copy: () => { - if ( - selectedNodesWithoutTrigger.length > 0 && - document.getSelection()?.toString() === '' - ) { - canvasBulkActions.copySelectedNodes({ - selectedNodes: selectedNodesWithoutTrigger, - flowVersion, - }); - } - }, - Delete: () => { - if (selectedNodes.length > 0) { - canvasBulkActions.deleteSelectedNodes({ - exitStepSettings, - selectedStep, - selectedNodes, - applyOperation, - }); - } - }, - Skip: () => { - if (selectedNodesWithoutTrigger.length > 0) { - canvasBulkActions.toggleSkipSelectedNodes({ - selectedNodes: selectedNodesWithoutTrigger, + const insideStep = + e.target instanceof HTMLElement && + e.target.closest( + `[data-${flowCanvasConsts.STEP_CONTEXT_MENU_ATTRIBUTE}]`, + ); + const insideBody = e.target === document.body; + const selectedNodesWithoutTrigger = selectedNodes.filter( + (node) => node !== flowVersion.trigger.name, + ); + + shortcutHandler(e, { + Minimap: () => { + setShowMinimap(!showMinimap); + }, + Copy: () => { + if ( + selectedNodesWithoutTrigger.length > 0 && + document.getSelection()?.toString() === '' + ) { + e.stopPropagation(); + e.preventDefault(); + + canvasBulkActions.copySelectedNodes({ + selectedNodes: selectedNodesWithoutTrigger, + flowVersion, + }); + } + }, + Delete: () => { + if (readonly) { + return; + } + if (selectedNodes.length > 0) { + e.stopPropagation(); + e.preventDefault(); + canvasBulkActions.deleteSelectedNodes({ + exitStepSettings, + selectedStep, + selectedNodes, + applyOperation, + }); + } + }, + Skip: () => { + if (readonly) { + return; + } + if (selectedNodesWithoutTrigger.length > 0) { + canvasBulkActions.toggleSkipSelectedNodes({ + selectedNodes: selectedNodesWithoutTrigger, + flowVersion, + applyOperation, + }); + } + }, + ExitDrag: () => { + setDraggedNote(null, null); + setDraggedStep(null); + }, + Paste: () => { + if ( + readonly || + (!insideSelectionRect && !insideStep && !insideBody) + ) { + return; + } + e.stopPropagation(); + e.preventDefault(); + canvasBulkActions.getActionsInClipboard().then((actions) => { + if (actions.length > 0) { + const lastStep = [ + flowVersion.trigger, + ...flowStructureUtil.getAllNextActionsWithoutChildren( + flowVersion.trigger, + ), + ].at(-1)!.name; + const lastSelectedNode = + selectedNodes.length === 1 ? selectedNodes[0] : null; + canvasBulkActions.pasteNodes( flowVersion, + { + parentStepName: lastSelectedNode ?? lastStep, + stepLocationRelativeToParent: + StepLocationRelativeToParent.AFTER, + }, applyOperation, - }); + ); } - }, - Paste: () => { - canvasBulkActions.getActionsInClipboard().then((actions) => { - if (actions.length > 0) { - const lastStep = [ - flowVersion.trigger, - ...flowStructureUtil.getAllNextActionsWithoutChildren( - flowVersion.trigger, - ), - ].at(-1)!.name; - const lastSelectedNode = - selectedNodes.length === 1 ? selectedNodes[0] : null; - canvasBulkActions.pasteNodes( - flowVersion, - { - parentStepName: lastSelectedNode ?? lastStep, - stepLocationRelativeToParent: - StepLocationRelativeToParent.AFTER, - }, - applyOperation, - ); - } - }); - }, - }); - } + }); + }, + }); }, [ selectedNodes, @@ -120,6 +143,8 @@ export const useHandleKeyPressOnCanvas = () => { readonly, setShowMinimap, showMinimap, + setDraggedNote, + setDraggedStep, ], ); @@ -142,13 +167,39 @@ const shortcutHandler = ( !!shortcut.withShift === event.shiftKey, ); if (shortcutActivated) { - if ( - isNil(shortcutActivated[1].shouldNotPreventDefault) || - !shortcutActivated[1].shouldNotPreventDefault - ) { - event.preventDefault(); - } - event.stopPropagation(); handlers[shortcutActivated[0] as keyof CanvasShortcutsProps](); } }; + +export const CanvasShortcuts: CanvasShortcutsProps = { + ExitDrag: { + withCtrl: false, + withShift: false, + shortcutKey: 'Escape', + }, + Minimap: { + withCtrl: true, + withShift: false, + shortcutKey: 'm', + }, + Paste: { + withCtrl: true, + withShift: false, + shortcutKey: 'v', + }, + Delete: { + withCtrl: false, + withShift: true, + shortcutKey: 'Delete', + }, + Copy: { + withCtrl: true, + withShift: false, + shortcutKey: 'c', + }, + Skip: { + withCtrl: true, + withShift: false, + shortcutKey: 'e', + }, +}; diff --git a/packages/react-ui/src/app/builder/state/flow-state.ts b/packages/react-ui/src/app/builder/state/flow-state.ts index d556669f8df..bd881e84efa 100644 --- a/packages/react-ui/src/app/builder/state/flow-state.ts +++ b/packages/react-ui/src/app/builder/state/flow-state.ts @@ -155,6 +155,15 @@ export const createFlowState = ( applyOperation: (operation: FlowOperationRequest, onSuccess?: () => void) => set((state) => { if (state.readonly) { + if (operation.type === FlowOperationType.UPDATE_NOTE) { + const newFlowVersion = flowOperations.apply( + state.flowVersion, + operation, + ); + return { + flowVersion: newFlowVersion, + }; + } console.warn('Cannot apply operation while readonly'); return state; } @@ -228,6 +237,12 @@ export const createFlowState = ( ); break; } + case FlowOperationType.UPDATE_NOTE: + case FlowOperationType.DELETE_NOTE: + case FlowOperationType.ADD_NOTE: { + debouncedAddToFlowUpdatesQueue(operation.request.id, updateRequest); + break; + } default: { flowUpdatesQueue.add(updateRequest); } diff --git a/packages/react-ui/src/app/builder/state/notes-state.tsx b/packages/react-ui/src/app/builder/state/notes-state.tsx new file mode 100644 index 00000000000..be52ee812b1 --- /dev/null +++ b/packages/react-ui/src/app/builder/state/notes-state.tsx @@ -0,0 +1,156 @@ +import { StoreApi } from 'zustand'; + +import { authenticationSession } from '@/lib/authentication-session'; +import { + AddNoteRequest, + FlowOperationType, + NoteColorVariant, + Note, + apId, +} from '@activepieces/shared'; + +import { BuilderState } from '../builder-hooks'; + +export enum NoteDragOverlayMode { + CREATE = 'create', + MOVE = 'move', +} + +export type NotesState = { + addNote: (request: Omit) => void; + deleteNote: (id: string) => void; + moveNote: (id: string, position: { x: number; y: number }) => void; + resizeNote: (id: string, size: { width: number; height: number }) => void; + draggedNote: Note | null; + updateContent: (id: string, content: string) => void; + updateNoteColor: (id: string, color: NoteColorVariant) => void; + setDraggedNote: (note: Note | null, mode: NoteDragOverlayMode | null) => void; + noteDragOverlayMode: NoteDragOverlayMode | null; + setNoteDragOverlayMode: ( + noteDragOverlayMode: NoteDragOverlayMode | null, + ) => void; + getNoteById: (id: string) => Note | null; +}; + +export const createNotesState = ( + get: StoreApi['getState'], + set: StoreApi['setState'], +): NotesState => { + return { + noteDragOverlayMode: null, + setNoteDragOverlayMode: ( + noteDragOverlayMode: NoteDragOverlayMode | null, + ) => { + set({ noteDragOverlayMode }); + }, + addNote: (request: Omit) => { + const id = apId(); + get().applyOperation({ + type: FlowOperationType.ADD_NOTE, + request: { + ...request, + id, + }, + }); + const notes = get().flowVersion.notes; + const noteIndex = notes.findIndex((note) => note.id === id); + if (noteIndex !== -1) { + notes[noteIndex] = { + ...notes[noteIndex], + ownerId: authenticationSession.getCurrentUserId() ?? null, + }; + } + notes[noteIndex].ownerId = + authenticationSession.getCurrentUserId() ?? null; + set(() => { + return { + flowVersion: { + ...get().flowVersion, + notes, + }, + draggedNote: null, + noteDragOverlayMode: null, + }; + }); + }, + updateContent: (id: string, content: string) => { + const note = get().getNoteById(id); + if (!note) { + return; + } + get().applyOperation({ + type: FlowOperationType.UPDATE_NOTE, + request: { + ...note, + content, + }, + }); + }, + deleteNote: (id: string) => { + get().applyOperation({ + type: FlowOperationType.DELETE_NOTE, + request: { + id: id, + }, + }); + }, + moveNote: (id: string, position: { x: number; y: number }) => { + set(() => { + return { + noteDragOverlayMode: null, + draggedNote: null, + }; + }); + const note = get().getNoteById(id); + if (!note) { + return; + } + get().applyOperation({ + type: FlowOperationType.UPDATE_NOTE, + request: { + ...note, + position, + }, + }); + }, + resizeNote: (id: string, size: { width: number; height: number }) => { + set(() => { + return { + noteDragOverlayMode: null, + draggedNote: null, + }; + }); + const note = get().getNoteById(id); + if (!note) { + return; + } + get().applyOperation({ + type: FlowOperationType.UPDATE_NOTE, + request: { + ...note, + size, + }, + }); + }, + draggedNote: null, + setDraggedNote: (note: Note | null, mode: NoteDragOverlayMode | null) => { + set({ draggedNote: note, noteDragOverlayMode: mode }); + }, + getNoteById: (id: string) => { + return get().flowVersion.notes.find((note) => note.id === id) ?? null; + }, + updateNoteColor: (id: string, color: NoteColorVariant) => { + const note = get().getNoteById(id); + if (!note) { + return; + } + get().applyOperation({ + type: FlowOperationType.UPDATE_NOTE, + request: { + ...note, + color, + }, + }); + }, + }; +}; diff --git a/packages/react-ui/src/app/components/flow-actions-menu.tsx b/packages/react-ui/src/app/components/flow-actions-menu.tsx index d117ad463ec..533f0734f95 100644 --- a/packages/react-ui/src/app/components/flow-actions-menu.tsx +++ b/packages/react-ui/src/app/components/flow-actions-menu.tsx @@ -119,6 +119,7 @@ const FlowActionMenu: React.FC = ({ displayName: modifiedFlowVersion.displayName, trigger: modifiedFlowVersion.trigger, schemaVersion: modifiedFlowVersion.schemaVersion, + notes: modifiedFlowVersion.notes, }, }); return updatedFlow; diff --git a/packages/react-ui/src/app/routes/project-release/view-release.tsx b/packages/react-ui/src/app/routes/project-release/view-release.tsx index 5c4971195ac..d15cfefe93f 100644 --- a/packages/react-ui/src/app/routes/project-release/view-release.tsx +++ b/packages/react-ui/src/app/routes/project-release/view-release.tsx @@ -84,28 +84,26 @@ const ViewRelease = () => {

{release?.name}

-
- - - { - navigate('/releases'); - }} - variant="ghost" - className="size-8 p-0" - request={{ - projectId: authenticationSession.getProjectId()!, - type: ProjectReleaseType.ROLLBACK, - projectReleaseId: release?.id || '', - }} - defaultName={release?.name} - > - - - - {t('Rollback')} - -
+ + + { + navigate('/releases'); + }} + variant="ghost" + className=" p-0" + request={{ + projectId: authenticationSession.getProjectId()!, + type: ProjectReleaseType.ROLLBACK, + projectReleaseId: release?.id || '', + }} + defaultName={release?.name} + > + + + + {t('Rollback')} +

{t('Created')}: {timeAgo} diff --git a/packages/react-ui/src/app/routes/templates/id/piece-card.tsx b/packages/react-ui/src/app/routes/templates/id/piece-card.tsx index 643d5c12464..3cb9f3fad7a 100644 --- a/packages/react-ui/src/app/routes/templates/id/piece-card.tsx +++ b/packages/react-ui/src/app/routes/templates/id/piece-card.tsx @@ -14,7 +14,7 @@ export const PieceCard = ({ pieceName }: PieceCardProps) => { return ( - + {pieceModel?.displayName || diff --git a/packages/react-ui/src/components/ui/markdown-input/index.tsx b/packages/react-ui/src/components/ui/markdown-input/index.tsx new file mode 100644 index 00000000000..3c0c7e769a3 --- /dev/null +++ b/packages/react-ui/src/components/ui/markdown-input/index.tsx @@ -0,0 +1,152 @@ +import { Bold } from '@tiptap/extension-bold'; +import Document from '@tiptap/extension-document'; +import { Image } from '@tiptap/extension-image'; +import { Italic } from '@tiptap/extension-italic'; +import { BulletList, ListItem, OrderedList } from '@tiptap/extension-list'; +import { Paragraph } from '@tiptap/extension-paragraph'; +import { Strike } from '@tiptap/extension-strike'; +import { TableKit } from '@tiptap/extension-table'; +import Text from '@tiptap/extension-text'; +import { Underline } from '@tiptap/extension-underline'; +import { Focus, UndoRedo } from '@tiptap/extensions'; +import { Markdown } from '@tiptap/markdown'; +import { Editor, EditorContent, useEditor } from '@tiptap/react'; +import React, { useImperativeHandle } from 'react'; + +import { cn } from '@/lib/utils'; + +export const MarkdownInput = React.forwardRef< + Editor | null, + MarkdownInputProps +>( + ( + { + initialValue, + onChange, + className, + disabled, + placeholder, + placeholderClassName, + onlyEditableOnDoubleClick, + }: MarkdownInputProps, + ref: React.Ref, + ) => { + const editor = useEditor({ + extensions: [ + Document, + BulletList, + OrderedList, + Text, + ListItem, + Focus.configure({ + className: 'has-focus', + mode: 'all', + }), + Markdown, + Image.configure({ + inline: true, + }), + TableKit, + Strike, + Bold, + Italic, + Underline, + EmptyLineParagraphExtension, + UndoRedo.configure({ + depth: 10, + }), + ], + content: initialValue, + contentType: 'markdown', + editable: !disabled && !onlyEditableOnDoubleClick, + onUpdate: ({ editor }) => { + onChange(editor.getMarkdown()); + }, + editorProps: { + attributes: { + class: cn( + 'bg-transparent text-inherit outline-none border-none p-0 m-0', + className, + ), + spellcheck: 'false', + }, + }, + onBlur: () => { + window.getSelection()?.removeAllRanges(); + if (onlyEditableOnDoubleClick) { + editor.setEditable(false, false); + } + }, + parseOptions: { + preserveWhitespace: 'full', + }, + }); + useImperativeHandle(ref, () => editor, [editor]); + + // Stop all events from bubbling to prevent dnd-kit/React Flow interference with selection + const stopEventPropagation = (e: React.SyntheticEvent) => { + if (disabled || !editor.isEditable) return; + e.stopPropagation(); + }; + + return ( + //gotta add this nodrag nopan to prevent dnd-kit and React Flow interference with selection +

{ + if (onlyEditableOnDoubleClick && !disabled) { + editor.setEditable(true, false); + } + }} + > + {/**didn't use tiptap placeholder because it disappears when the editor is not editable */} + {editor.getText().trim() === '' && !disabled && ( +
+ {' '} + {placeholder}{' '} +
+ )} + +
+ ); + }, +); + +type MarkdownInputProps = { + initialValue: string; + onChange: (value: string) => void; + className?: string; + disabled?: boolean; + placeholder?: string; + onlyEditableOnDoubleClick?: boolean; + placeholderClassName?: string; +}; + +MarkdownInput.displayName = 'MarkdownInput'; + +//https://github.com/ueberdosis/tiptap/issues/7269#issuecomment-3669021079 +const EmptyLineParagraphExtension = Paragraph.extend({ + renderMarkdown: (node, helpers) => { + const view = helpers.renderChildren(node.content ?? []); + if (!view || view.trim() === '') { + return '
'; + } + return view; + }, +}); diff --git a/packages/react-ui/src/components/ui/markdown-input/tools.tsx b/packages/react-ui/src/components/ui/markdown-input/tools.tsx new file mode 100644 index 00000000000..5b316b86124 --- /dev/null +++ b/packages/react-ui/src/components/ui/markdown-input/tools.tsx @@ -0,0 +1,164 @@ +import { Editor } from '@tiptap/react'; +import { + ImageIcon, + UnderlineIcon, + ItalicIcon, + Strikethrough, + BoldIcon, + ArrowDown, +} from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; + +import { Button } from '../button'; +import { Input } from '../input'; +import { Popover, PopoverContent, PopoverTrigger } from '../popover'; + +export const MarkdownTools = ({ editor }: { editor: Editor }) => { + const isStrikeActive = editor.isActive('strike'); + const isBoldActive = editor.isActive('bold'); + const isItalicActive = editor.isActive('italic'); + const isUnderlineActive = editor.isActive('underline'); + //because tiptap doesn't instantly set the active state, we need to use a state to track it + const [activeState, setActiveState] = useState({ + isStrikeActive, + isBoldActive, + isItalicActive, + isUnderlineActive, + }); + const handleStrike = () => { + editor.setEditable(true); + editor.chain().focus().toggleStrike().run(); + editor.commands.focus(); + setActiveState({ + ...activeState, + isStrikeActive: !isStrikeActive, + }); + }; + const handleBold = () => { + editor.setEditable(true); + editor.chain().focus().toggleBold().run(); + editor.commands.focus(); + setActiveState({ + ...activeState, + isBoldActive: !isBoldActive, + }); + }; + const handleItalic = () => { + editor.setEditable(true); + editor.chain().focus().toggleItalic().run(); + editor.commands.focus(); + setActiveState({ + ...activeState, + isItalicActive: !isItalicActive, + }); + }; + const handleUnderline = () => { + editor.setEditable(true); + editor.chain().focus().toggleUnderline().run(); + editor.commands.focus(); + setActiveState({ + ...activeState, + isUnderlineActive: !isUnderlineActive, + }); + }; + useEffect(() => { + setActiveState({ + isStrikeActive, + isBoldActive, + isItalicActive, + isUnderlineActive, + }); + }, [isStrikeActive, isBoldActive, isItalicActive, isUnderlineActive]); + const containerRef = useRef(null); + return ( +
+ + + + + +
+ ); +}; + +const ImageTool = ({ + editor, + containerRef, +}: { + editor: Editor; + containerRef: React.RefObject; +}) => { + const [open, setOpen] = useState(false); + const [imageUrl, setImageUrl] = useState(''); + const handleAddImage = () => { + editor + .chain() + .focus() + .setImage({ src: imageUrl, alt: 'note-img-' + Date.now() }) + .run(); + editor.commands.focus(); + setImageUrl(''); + setOpen(false); + }; + return ( + + + + + +
+ ev.stopPropagation()} + onKeyDown={(ev) => ev.key === 'Enter' && handleAddImage()} + type="text" + placeholder="Enter image URL" + value={imageUrl} + onChange={(e) => setImageUrl(e.target.value)} + /> + +
+
+
+ ); +}; diff --git a/packages/react-ui/src/components/ui/popover.tsx b/packages/react-ui/src/components/ui/popover.tsx index c9a852cd597..c6e0a1a3c15 100644 --- a/packages/react-ui/src/components/ui/popover.tsx +++ b/packages/react-ui/src/components/ui/popover.tsx @@ -11,21 +11,28 @@ const PopoverAnchor = PopoverPrimitive.Anchor; const PopoverContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( - - - -)); + React.ComponentPropsWithoutRef & { + container?: HTMLElement | null; + } +>( + ( + { className, align = 'center', sideOffset = 4, container, ...props }, + ref, + ) => ( + + + + ), +); PopoverContent.displayName = PopoverPrimitive.Content.displayName; export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/packages/react-ui/src/components/ui/shortcut.tsx b/packages/react-ui/src/components/ui/shortcut.tsx index 319a2799f8b..1dd2b1cceae 100644 --- a/packages/react-ui/src/components/ui/shortcut.tsx +++ b/packages/react-ui/src/components/ui/shortcut.tsx @@ -12,7 +12,6 @@ export type ShortcutProps = { shortcutKey: string; withCtrl?: boolean; withShift?: boolean; - shouldNotPreventDefault?: boolean; }; export const Shortcut = ({ diff --git a/packages/react-ui/src/features/flows/lib/Import-flow-button.tsx b/packages/react-ui/src/features/flows/lib/Import-flow-button.tsx index 9e4b702ccb7..02c28c18c09 100644 --- a/packages/react-ui/src/features/flows/lib/Import-flow-button.tsx +++ b/packages/react-ui/src/features/flows/lib/Import-flow-button.tsx @@ -1,5 +1,5 @@ import { t } from 'i18next'; -import { Upload } from 'lucide-react'; +import { Import } from 'lucide-react'; import { PermissionNeededTooltip } from '@/components/custom/permission-needed-tooltip'; import { useEmbedding } from '@/components/embed-provider'; @@ -58,10 +58,10 @@ export const ImportFlowButton = ({ data-testid="import-flow-button" > {variant === 'small' ? ( - + ) : ( <> - + {t('Import')} )} diff --git a/packages/react-ui/src/features/flows/lib/flow-hooks.tsx b/packages/react-ui/src/features/flows/lib/flow-hooks.tsx index 73442ad7991..164aa34d640 100644 --- a/packages/react-ui/src/features/flows/lib/flow-hooks.tsx +++ b/packages/react-ui/src/features/flows/lib/flow-hooks.tsx @@ -434,6 +434,7 @@ export const flowHooks = { displayName: templateFlow.displayName, trigger: updatedTrigger, schemaVersion: templateFlow.schemaVersion, + notes: templateFlow.notes, }, }); }, diff --git a/packages/react-ui/src/hooks/user-hooks.ts b/packages/react-ui/src/hooks/user-hooks.ts index 4b8c01b7917..6760e0e6906 100644 --- a/packages/react-ui/src/hooks/user-hooks.ts +++ b/packages/react-ui/src/hooks/user-hooks.ts @@ -34,7 +34,12 @@ export const userHooks = { return useQuery({ queryKey: ['user', id], queryFn: async () => { - return await userApi.getUserById(id!); + try { + return await userApi.getUserById(id!); + } catch (error) { + console.error(error); + return null; + } }, enabled: !isNil(id), staleTime: Infinity, diff --git a/packages/react-ui/src/styles.css b/packages/react-ui/src/styles.css index 1ad03f82bf3..9b213fba811 100644 --- a/packages/react-ui/src/styles.css +++ b/packages/react-ui/src/styles.css @@ -453,6 +453,10 @@ --xy-selection-background-color-default: hsl( var(--primary-100) / 0.25 ) !important; + --xy-resize-background-color:transparent !important; + .react-flow__resize-control.handle{ + border: 0 !important; + } } .react-flow__pane.selection, @@ -488,4 +492,92 @@ html { @apply font-sans; } +} +.tiptap p.is-editor-empty:first-child::before { + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; +} + + + +.note-node,.note-drag-overlay { + ::-webkit-scrollbar { + width: 4px; + height: 8px; + } + + ::-webkit-scrollbar-track { + background: transparent; + } + ::-webkit-scrollbar-thumb { + background: transparent; + border-radius: 100px; + border: 1px; + } + +} + +.note-node:hover { + ::-webkit-scrollbar-thumb { + background: hsla(0, 1%, 66%, 0.328); + border-radius: 100px; + border: 1px; + } +} + +.tiptap { + overflow-wrap: break-word; +} + +.tiptap > :first-child { + margin-top: 0; +} + +.tiptap ul { + list-style-type: disc; + list-style-position: outside; + padding-left: 1.5rem; + margin: 1.25rem 1rem 1.25rem 0.4rem; +} + +.tiptap ol { + list-style-type: decimal; + list-style-position: outside; + padding-left: 1.5rem; + margin: 1.25rem 1rem 1.25rem 0.4rem; +} + +.tiptap li p { + margin: 0.25em 0; +} + +.tiptap h1, +.tiptap h2, +.tiptap h3, +.tiptap h4, +.tiptap h5, +.tiptap h6 { + line-height: 1; + margin-top: 1rem; + text-wrap: pretty; +} + +.tiptap h1 { + font-size: 1.4rem; +} + +.tiptap h2 { + font-size: 1.2rem; +} + +.tiptap h3 { + font-size: 1.1rem; +} + +.tiptap h4, +.tiptap h5, +.tiptap h6 { + font-size: 1rem; } \ No newline at end of file diff --git a/packages/server/api/src/app/database/migration/postgres/1768130030028-AddNotesToFlowVersion.ts b/packages/server/api/src/app/database/migration/postgres/1768130030028-AddNotesToFlowVersion.ts new file mode 100644 index 00000000000..c50a86b3e48 --- /dev/null +++ b/packages/server/api/src/app/database/migration/postgres/1768130030028-AddNotesToFlowVersion.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class AddNotesToFlowVersion1768130030028 implements MigrationInterface { + name = 'AddNotesToFlowVersion1768130030028' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "flow_version" + ADD "notes" jsonb + `) + await queryRunner.query(` + UPDATE "flow_version" + SET "notes" = '[]' + WHERE "notes" IS NULL + `) + await queryRunner.query(` + ALTER TABLE "flow_version" + ALTER COLUMN "notes" SET NOT NULL + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "flow_version" DROP COLUMN "notes" + `) + } + +} diff --git a/packages/server/api/src/app/database/postgres-connection.ts b/packages/server/api/src/app/database/postgres-connection.ts index 2fce87a127a..a5c396972c1 100644 --- a/packages/server/api/src/app/database/postgres-connection.ts +++ b/packages/server/api/src/app/database/postgres-connection.ts @@ -327,6 +327,7 @@ import { MigrateOldTemplateCategoriesToDynamicOne1767624311536 } from './migrati import { AddTriggeredBy1767697998391 } from './migration/postgres/1767697998391-AddTriggeredBy' import { UpdateCacheStructure1767904545112 } from './migration/postgres/1767904545112-UpdateCacheStructure' import { AddOutdatedToReport1767994436597 } from './migration/postgres/1767994436597-AddOutdatedToReport' +import { AddNotesToFlowVersion1768130030028 } from './migration/postgres/1768130030028-AddNotesToFlowVersion' const getSslConfig = (): boolean | TlsOptions => { const useSsl = system.get(AppSystemProp.POSTGRES_USE_SSL) @@ -669,6 +670,7 @@ export const getMigrations = (): (new () => MigrationInterface)[] => { AddTriggeredBy1767697998391, UpdateCacheStructure1767904545112, AddOutdatedToReport1767994436597, + AddNotesToFlowVersion1768130030028, MigrateOldTemplateCategoriesToDynamicOne1767624311536, ] return migrations diff --git a/packages/server/api/src/app/ee/authentication/project-role/rbac-middleware.ts b/packages/server/api/src/app/ee/authentication/project-role/rbac-middleware.ts index c44a7a645b0..cf79d12527e 100644 --- a/packages/server/api/src/app/ee/authentication/project-role/rbac-middleware.ts +++ b/packages/server/api/src/app/ee/authentication/project-role/rbac-middleware.ts @@ -68,7 +68,10 @@ export async function assertUserHasPermissionToFlow( case FlowOperationType.DUPLICATE_BRANCH: case FlowOperationType.UPDATE_METADATA: case FlowOperationType.SET_SKIP_ACTION: - case FlowOperationType.MOVE_BRANCH: { + case FlowOperationType.MOVE_BRANCH: + case FlowOperationType.ADD_NOTE: + case FlowOperationType.UPDATE_NOTE: + case FlowOperationType.DELETE_NOTE: { await assertRoleHasPermission(principal, projectId, Permission.WRITE_FLOW, log) break } diff --git a/packages/server/api/src/app/ee/authentication/project-role/rbac-service.ts b/packages/server/api/src/app/ee/authentication/project-role/rbac-service.ts index 316a1a6492a..8b456c57b13 100644 --- a/packages/server/api/src/app/ee/authentication/project-role/rbac-service.ts +++ b/packages/server/api/src/app/ee/authentication/project-role/rbac-service.ts @@ -104,7 +104,10 @@ export const rbacService = (log: FastifyBaseLogger) => ({ case FlowOperationType.UPDATE_METADATA: case FlowOperationType.UPDATE_OWNER: case FlowOperationType.SET_SKIP_ACTION: - case FlowOperationType.MOVE_BRANCH: { + case FlowOperationType.MOVE_BRANCH: + case FlowOperationType.ADD_NOTE: + case FlowOperationType.UPDATE_NOTE: + case FlowOperationType.DELETE_NOTE: { await this.assertPrinicpalAccessToProject({ principal, permission: Permission.WRITE_FLOW, projectId }) break } diff --git a/packages/server/api/src/app/ee/projects/project-release/project-state/diff/flow-diff.service.ts b/packages/server/api/src/app/ee/projects/project-release/project-state/diff/flow-diff.service.ts index bd05044b734..b1a2071e1aa 100644 --- a/packages/server/api/src/app/ee/projects/project-release/project-state/diff/flow-diff.service.ts +++ b/packages/server/api/src/app/ee/projects/project-release/project-state/diff/flow-diff.service.ts @@ -1,4 +1,4 @@ -import { assertNotNullOrUndefined, DEFAULT_SAMPLE_DATA_SETTINGS, FlowActionType, flowPieceUtil, FlowProjectOperationType, FlowState, flowStructureUtil, FlowTriggerType, FlowVersion, isNil, ProjectOperation, ProjectState, Step } from '@activepieces/shared' +import { assertNotNullOrUndefined, DEFAULT_SAMPLE_DATA_SETTINGS, FlowActionType, flowPieceUtil, FlowProjectOperationType, FlowState, flowStructureUtil, FlowTriggerType, FlowVersion, isNil, mapsAreSame, ProjectOperation, ProjectState, Step } from '@activepieces/shared' import deepEqual from 'deep-equal' import semver from 'semver' @@ -79,23 +79,33 @@ function isSameVersion(versionOne: string, versionTwo: string): boolean { } async function isFlowChanged(fromFlow: FlowState, targetFlow: FlowState): Promise { - const versionSetOne = new Map() - const versionSetTwo = new Map() - + const stepsPieceVersionsFrom = new Map() + const stepsPiecesVersionTo = new Map() + const notesFrom = new Map() + const notesTo = new Map() + flowStructureUtil.getAllSteps(fromFlow.version.trigger).forEach((step) => { if ([FlowActionType.PIECE, FlowTriggerType.PIECE].includes(step.type)) { - versionSetOne.set(step.name, step.settings.pieceVersion) + stepsPieceVersionsFrom.set(step.name, step.settings.pieceVersion) } }) flowStructureUtil.getAllSteps(targetFlow.version.trigger).forEach((step) => { if ([FlowActionType.PIECE, FlowTriggerType.PIECE].includes(step.type)) { - versionSetTwo.set(step.name, step.settings.pieceVersion) + stepsPiecesVersionTo.set(step.name, step.settings.pieceVersion) } }) - const isMatched = Array.from(versionSetOne.entries()).every(([key, value]) => { - const versionTwo = versionSetTwo.get(key) + fromFlow.version.notes.forEach((note) => { + notesFrom.set(note.id, note.content) + }) + targetFlow.version.notes.forEach((note) => { + notesTo.set(note.id, note.content) + }) + const notesMatched = mapsAreSame(notesFrom, notesTo) + + const isMatched = Array.from(stepsPieceVersionsFrom.entries()).every(([key, value]) => { + const versionTwo = stepsPiecesVersionTo.get(key) if (isNil(versionTwo) || isNil(value)) { return false } @@ -105,7 +115,7 @@ async function isFlowChanged(fromFlow: FlowState, targetFlow: FlowState): Promis const normalizedFromFlow = await normalize(fromFlow.version) const normalizedTargetFlow = await normalize(targetFlow.version) return normalizedFromFlow.displayName !== normalizedTargetFlow.displayName - || !deepEqual(normalizedFromFlow.trigger, normalizedTargetFlow.trigger) || !isMatched + || !deepEqual(normalizedFromFlow.trigger, normalizedTargetFlow.trigger) || !isMatched || !notesMatched } async function normalize(flowVersion: FlowVersion): Promise { @@ -128,4 +138,4 @@ async function normalize(flowVersion: FlowVersion): Promise { type DiffParams = { currentState: ProjectState newState: ProjectState -} \ No newline at end of file +} diff --git a/packages/server/api/src/app/ee/projects/project-release/project-state/project-state-helper.ts b/packages/server/api/src/app/ee/projects/project-release/project-state/project-state-helper.ts index 8eda4bbb161..c265c7dddcc 100644 --- a/packages/server/api/src/app/ee/projects/project-release/project-state/project-state-helper.ts +++ b/packages/server/api/src/app/ee/projects/project-release/project-state/project-state-helper.ts @@ -39,6 +39,7 @@ export const projectStateHelper = (log: FastifyBaseLogger) => ({ displayName: newFlow.version.displayName, trigger: newFlowVersion.trigger, schemaVersion: newFlow.version.schemaVersion, + notes: newFlow.version.notes, }, }, }) diff --git a/packages/server/api/src/app/flows/flow-run/flow-run-service.ts b/packages/server/api/src/app/flows/flow-run/flow-run-service.ts index edf128d6657..16c2d55e0a4 100644 --- a/packages/server/api/src/app/flows/flow-run/flow-run-service.ts +++ b/packages/server/api/src/app/flows/flow-run/flow-run-service.ts @@ -604,6 +604,7 @@ async function queueOrCreateInstantly(params: CreateParams, log: FastifyBaseLogg stepNameToTest: params.stepNameToTest, created: now, updated: now, + tags: [], steps: {}, triggeredBy: params.triggeredBy, } diff --git a/packages/server/api/src/app/flows/flow-version/flow-version-entity.ts b/packages/server/api/src/app/flows/flow-version/flow-version-entity.ts index c371109bcd4..9f8d3ee37ee 100644 --- a/packages/server/api/src/app/flows/flow-version/flow-version-entity.ts +++ b/packages/server/api/src/app/flows/flow-version/flow-version-entity.ts @@ -50,6 +50,10 @@ export const FlowVersionEntity = new EntitySchema({ type: 'jsonb', nullable: true, }, + notes: { + type: 'jsonb', + nullable: false, + }, }, indices: [ { diff --git a/packages/server/api/src/app/flows/flow-version/flow-version-validator-util.ts b/packages/server/api/src/app/flows/flow-version/flow-version-validator-util.ts index fc38c812645..359ea1eaf85 100644 --- a/packages/server/api/src/app/flows/flow-version/flow-version-validator-util.ts +++ b/packages/server/api/src/app/flows/flow-version/flow-version-validator-util.ts @@ -14,6 +14,7 @@ import { PieceTriggerSettings, PlatformId, RouterActionSettingsWithValidation, + UserId, } from '@activepieces/shared' import { Type } from '@sinclair/typebox' import { TypeCompiler } from '@sinclair/typebox/compiler' @@ -33,7 +34,7 @@ type ValidationResult = { } export const flowVersionValidationUtil = (log: FastifyBaseLogger) => ({ - async prepareRequest({ platformId, request }: PrepareRequestParams): Promise { + async prepareRequest({ platformId, request, userId }: PrepareRequestParams): Promise { const clonedRequest: FlowOperationRequest = JSON.parse(JSON.stringify(request)) switch (clonedRequest.type) { @@ -108,6 +109,16 @@ export const flowVersionValidationUtil = (log: FastifyBaseLogger) => ({ } } break + case FlowOperationType.IMPORT_FLOW:{ + const notes = clonedRequest.request.notes + if (!isNil(notes)) { + clonedRequest.request.notes = notes.map(note => ({ + ...note, + ownerId: userId, + })) + } + break + } default: break } @@ -194,6 +205,7 @@ function validateProps( type PrepareRequestParams = { platformId?: PlatformId request: FlowOperationRequest + userId: UserId | null } type ValidateActionParams = { diff --git a/packages/server/api/src/app/flows/flow-version/flow-version.service.ts b/packages/server/api/src/app/flows/flow-version/flow-version.service.ts index 01008581c4e..0e20c41d316 100644 --- a/packages/server/api/src/app/flows/flow-version/flow-version.service.ts +++ b/packages/server/api/src/app/flows/flow-version/flow-version.service.ts @@ -17,6 +17,7 @@ import { FlowVersionState, isNil, LATEST_FLOW_SCHEMA_VERSION, + Note, PlatformId, ProjectId, sanitizeObjectForPostgresql, @@ -100,6 +101,7 @@ export const flowVersionService = (log: FastifyBaseLogger) => ({ trigger: previousVersion.trigger, displayName: previousVersion.displayName, schemaVersion: previousVersion.schemaVersion, + notes: previousVersion.notes, }, }] break @@ -149,7 +151,14 @@ export const flowVersionService = (log: FastifyBaseLogger) => ({ operation, platformId, log, + userId, ) + if (operation.type === FlowOperationType.ADD_NOTE) { + const noteIndex = mutatedFlowVersion.notes.findIndex((note) => note.id === operation.request.id) + if (noteIndex !== -1) { + mutatedFlowVersion.notes[noteIndex] = { ...mutatedFlowVersion.notes[noteIndex], ownerId: userId } + } + } } mutatedFlowVersion.updated = dayjs().toISOString() @@ -292,6 +301,7 @@ export const flowVersionService = (log: FastifyBaseLogger) => ({ flowId: FlowId, request: { displayName: string + notes: Note[] }, ): Promise { const flowVersion: NewFlowVersion = { @@ -310,6 +320,7 @@ export const flowVersionService = (log: FastifyBaseLogger) => ({ agentIds: [], valid: false, state: FlowVersionState.DRAFT, + notes: request.notes, } return flowVersionRepo().save(flowVersion) }, @@ -350,13 +361,14 @@ async function applySingleOperation( operation: FlowOperationRequest, platformId: PlatformId, log: FastifyBaseLogger, + userId: UserId | null, ): Promise { await flowVersionSideEffects(log).preApplyOperation({ projectId, flowVersion, operation, }) - const preparedOperation = await flowVersionValidationUtil(log).prepareRequest({ platformId, request: operation }) + const preparedOperation = await flowVersionValidationUtil(log).prepareRequest({ platformId, request: operation, userId }) const updatedFlowVersion = flowOperations.apply(flowVersion, preparedOperation) return updatedFlowVersion } diff --git a/packages/server/api/src/app/flows/flow-version/migrations/index.ts b/packages/server/api/src/app/flows/flow-version/migrations/index.ts index cda8640c990..fcffc61b573 100644 --- a/packages/server/api/src/app/flows/flow-version/migrations/index.ts +++ b/packages/server/api/src/app/flows/flow-version/migrations/index.ts @@ -45,7 +45,7 @@ export const flowMigrations = { }, } -export const migrateFlowVersionTemplate = async (trigger: FlowVersion['trigger'], schemaVersion: FlowVersion['schemaVersion']): Promise => { +export const migrateFlowVersionTemplate = async (trigger: FlowVersion['trigger'], schemaVersion: FlowVersion['schemaVersion'], notes: FlowVersion['notes']): Promise => { return flowMigrations.apply({ agentIds: [], connectionIds: [], @@ -59,5 +59,6 @@ export const migrateFlowVersionTemplate = async (trigger: FlowVersion['trigger'] trigger, state: FlowVersionState.DRAFT, schemaVersion, + notes, }) } \ No newline at end of file diff --git a/packages/server/api/src/app/flows/flow/flow.controller.ts b/packages/server/api/src/app/flows/flow/flow.controller.ts index 9725e2f3185..2737fe9e5b2 100644 --- a/packages/server/api/src/app/flows/flow/flow.controller.ts +++ b/packages/server/api/src/app/flows/flow/flow.controller.ts @@ -80,11 +80,12 @@ export const flowController: FastifyPluginAsyncTypebox = async (app) => { }, preValidation: async (request) => { if (request.body?.type === FlowOperationType.IMPORT_FLOW) { - const migratedFlowTemplate = await migrateFlowVersionTemplate(request.body.request.trigger, request.body.request.schemaVersion) + const migratedFlowTemplate = await migrateFlowVersionTemplate(request.body.request.trigger, request.body.request.schemaVersion, request.body.request.notes ?? []) request.body.request = { ...request.body.request, trigger: migratedFlowTemplate.trigger, schemaVersion: migratedFlowTemplate.schemaVersion, + notes: migratedFlowTemplate.notes, } } }, diff --git a/packages/server/api/src/app/flows/flow/flow.service.ts b/packages/server/api/src/app/flows/flow/flow.service.ts index 7796501cd17..65cb8f2e81e 100644 --- a/packages/server/api/src/app/flows/flow/flow.service.ts +++ b/packages/server/api/src/app/flows/flow/flow.service.ts @@ -69,6 +69,7 @@ export const flowService = (log: FastifyBaseLogger) => ({ savedFlow.id, { displayName: request.displayName, + notes: [], }, ) @@ -333,11 +334,11 @@ export const flowService = (log: FastifyBaseLogger) => ({ id, projectId, }) - if (flow.operationStatus !== FlowOperationStatus.NONE) { + if (flow.operationStatus === FlowOperationStatus.DELETING) { throw new ActivepiecesError({ code: ErrorCode.FLOW_OPERATION_IN_PROGRESS, params: { - message: `Flow is busy with ${flow.operationStatus.toLocaleLowerCase()} operation. Please try again in a moment.`, + message: `This flow is getting deleted.`, }, }) } @@ -403,6 +404,24 @@ export const flowService = (log: FastifyBaseLogger) => ({ }) break } + case FlowOperationType.ADD_NOTE: + case FlowOperationType.UPDATE_NOTE: + case FlowOperationType.DELETE_NOTE: { + const lastVersion = await flowVersionService( + log, + ).getFlowVersionOrThrow({ + flowId: id, + versionId: undefined, + }) + await flowVersionService(log).applyOperation({ + userId, + projectId, + platformId, + flowVersion: lastVersion, + userOperation: operation, + }) + break + } default: { let lastVersion = await flowVersionService( log, @@ -423,8 +442,8 @@ export const flowService = (log: FastifyBaseLogger) => ({ log, ).createEmptyVersion(id, { displayName: lastVersionWithArtifacts.displayName, + notes: lastVersionWithArtifacts.notes, }) - // Duplicate the artifacts from the previous version, otherwise they will be deleted during update operation lastVersion = await flowVersionService(log).applyOperation({ userId, diff --git a/packages/server/api/src/app/helper/system-validator.ts b/packages/server/api/src/app/helper/system-validator.ts index bad1e4d932c..03b187b565c 100644 --- a/packages/server/api/src/app/helper/system-validator.ts +++ b/packages/server/api/src/app/helper/system-validator.ts @@ -56,6 +56,7 @@ const systemPropValidators: { [AppSystemProp.PAUSED_FLOW_TIMEOUT_DAYS]: numberValidator, [AppSystemProp.APP_WEBHOOK_SECRETS]: stringValidator, [AppSystemProp.MAX_FILE_SIZE_MB]: numberValidator, + [AppSystemProp.MAX_FLOW_RUN_LOG_SIZE_MB]: numberValidator, [AppSystemProp.SANDBOX_MEMORY_LIMIT]: numberValidator, [AppSystemProp.SANDBOX_PROPAGATED_ENV_VARS]: stringValidator, [AppSystemProp.SENTRY_DSN]: urlValidator, diff --git a/packages/server/api/src/app/helper/system/system.ts b/packages/server/api/src/app/helper/system/system.ts index 02f0f69b830..ce77b96eeda 100644 --- a/packages/server/api/src/app/helper/system/system.ts +++ b/packages/server/api/src/app/helper/system/system.ts @@ -38,7 +38,8 @@ const systemPropDefaultValues: Partial> = { [AppSystemProp.LOG_LEVEL]: 'info', [AppSystemProp.LOG_PRETTY]: 'false', [AppSystemProp.S3_USE_SIGNED_URLS]: 'false', - [AppSystemProp.MAX_FILE_SIZE_MB]: '4', + [AppSystemProp.MAX_FILE_SIZE_MB]: '25', + [AppSystemProp.MAX_FLOW_RUN_LOG_SIZE_MB]: '25', [AppSystemProp.FILE_STORAGE_LOCATION]: FileLocation.DB, [AppSystemProp.SANDBOX_MEMORY_LIMIT]: '1048576', [AppSystemProp.FLOW_TIMEOUT_SECONDS]: '600', diff --git a/packages/server/api/src/app/mcp/mcp-server-controller.ts b/packages/server/api/src/app/mcp/mcp-server-controller.ts index e1343d6accc..1bdb0fe1d60 100644 --- a/packages/server/api/src/app/mcp/mcp-server-controller.ts +++ b/packages/server/api/src/app/mcp/mcp-server-controller.ts @@ -7,7 +7,7 @@ import { mcpServerService } from './mcp-service' export const mcpServerController: FastifyPluginAsyncTypebox = async (app) => { - app.get('/:projectId', GetMcpRequest, async (req) => { + app.get('/', GetMcpRequest, async (req) => { return mcpServerService(req.log).getPopulatedByProjectId(req.projectId) }) diff --git a/packages/server/api/src/app/server.ts b/packages/server/api/src/app/server.ts index 6d2cfb406f3..b7c577a7351 100644 --- a/packages/server/api/src/app/server.ts +++ b/packages/server/api/src/app/server.ts @@ -29,8 +29,8 @@ export const setupServer = async (): Promise => { } async function setupBaseApp(): Promise { - const MAX_FILE_SIZE_MB = system.getNumberOrThrow(AppSystemProp.MAX_FILE_SIZE_MB) - const fileSizeLimit = Math.max(25 * 1024 * 1024, (MAX_FILE_SIZE_MB + 4) * 1024 * 1024) + const fileSizeLimit = system.getNumberOrThrow(AppSystemProp.MAX_FILE_SIZE_MB) + const flowRunLogSizeLimit = system.getNumberOrThrow(AppSystemProp.MAX_FLOW_RUN_LOG_SIZE_MB) const app = fastify({ disableRequestLogging: true, querystringParser: qs.parse, @@ -38,7 +38,7 @@ async function setupBaseApp(): Promise { ignoreTrailingSlash: true, pluginTimeout: 30000, // Default 100MB, also set in nginx.conf - bodyLimit: fileSizeLimit, + bodyLimit: Math.max(fileSizeLimit + 4, flowRunLogSizeLimit + 4, 25) * 1024 * 1024, genReqId: () => { return `req_${apId()}` }, diff --git a/packages/server/api/src/app/template/template-validator.ts b/packages/server/api/src/app/template/template-validator.ts index b8614d11464..71f28bdfeed 100644 --- a/packages/server/api/src/app/template/template-validator.ts +++ b/packages/server/api/src/app/template/template-validator.ts @@ -49,7 +49,7 @@ export const templateValidator = { const validator = flowVersionValidationUtil(log) - await validator.prepareRequest({ platformId, request: importOperation }) + await validator.prepareRequest({ platformId, request: importOperation, userId: null }) flowOperations.apply(minimalFlowVersion, importOperation) })) diff --git a/packages/server/api/src/app/template/template.controller.ts b/packages/server/api/src/app/template/template.controller.ts index fb52a72213a..1d0a0898e29 100644 --- a/packages/server/api/src/app/template/template.controller.ts +++ b/packages/server/api/src/app/template/template.controller.ts @@ -72,7 +72,7 @@ export const templateController: FastifyPluginAsyncTypebox = async (app) => { ...CreateParams, preValidation: async (request) => { const migratedFlows = await Promise.all((request.body.flows ?? []).map(async (flow: FlowVersionTemplate) => { - const migratedFlow = await migrateFlowVersionTemplate(flow.trigger, flow.schemaVersion) + const migratedFlow = await migrateFlowVersionTemplate(flow.trigger, flow.schemaVersion, flow.notes) return { ...flow, trigger: migratedFlow.trigger, diff --git a/packages/server/api/src/app/workers/machine/machine-service.ts b/packages/server/api/src/app/workers/machine/machine-service.ts index 7700415bc71..d3106a1100b 100644 --- a/packages/server/api/src/app/workers/machine/machine-service.ts +++ b/packages/server/api/src/app/workers/machine/machine-service.ts @@ -51,6 +51,7 @@ export const machineService = (log: FastifyBaseLogger) => { LOG_PRETTY: system.getOrThrow(AppSystemProp.LOG_PRETTY), ENVIRONMENT: system.getOrThrow(AppSystemProp.ENVIRONMENT), APP_WEBHOOK_SECRETS: system.getOrThrow(AppSystemProp.APP_WEBHOOK_SECRETS), + MAX_FLOW_RUN_LOG_SIZE_MB: system.getNumberOrThrow(AppSystemProp.MAX_FLOW_RUN_LOG_SIZE_MB), MAX_FILE_SIZE_MB: system.getNumberOrThrow(AppSystemProp.MAX_FILE_SIZE_MB), SANDBOX_MEMORY_LIMIT: system.getOrThrow(AppSystemProp.SANDBOX_MEMORY_LIMIT), SANDBOX_PROPAGATED_ENV_VARS: system.get(AppSystemProp.SANDBOX_PROPAGATED_ENV_VARS)?.split(',').map(f => f.trim()) ?? [], diff --git a/packages/server/api/test/helpers/flow-generator.ts b/packages/server/api/test/helpers/flow-generator.ts index 4cd85a0ebc2..7db482e198d 100644 --- a/packages/server/api/test/helpers/flow-generator.ts +++ b/packages/server/api/test/helpers/flow-generator.ts @@ -1,4 +1,4 @@ -import { apId, FlowAction, FlowActionType, FlowStatus, FlowTrigger, FlowTriggerType, FlowVersion, FlowVersionState, PopulatedFlow } from '@activepieces/shared' +import { apId, FlowAction, FlowActionType, FlowOperationStatus, FlowStatus, FlowTrigger, FlowTriggerType, FlowVersion, FlowVersionState, PopulatedFlow } from '@activepieces/shared' import { faker } from '@faker-js/faker' @@ -8,14 +8,14 @@ export const flowGenerator = { }, randomizeMetadata(externalId: string | undefined, version: Omit): PopulatedFlow { const flowId = apId() - const result = { + const result: PopulatedFlow = { externalId: externalId ?? flowId, version: { ...version, trigger: randomizeTriggerMetadata(version.trigger), flowId, }, - schedule: null, + operationStatus: FlowOperationStatus.NONE, status: faker.helpers.enumValue(FlowStatus), id: flowId, projectId: apId(), @@ -43,6 +43,7 @@ const flowVersionGenerator = { state: FlowVersionState.DRAFT, connectionIds: [], agentIds: [], + notes: [], } }, } diff --git a/packages/server/api/test/helpers/mocks/index.ts b/packages/server/api/test/helpers/mocks/index.ts index 324a87a8420..2f0c9bfd01b 100644 --- a/packages/server/api/test/helpers/mocks/index.ts +++ b/packages/server/api/test/helpers/mocks/index.ts @@ -530,6 +530,7 @@ export const createMockFlowVersion = ( state: flowVersion?.state ?? faker.helpers.enumValue(FlowVersionState), updatedBy: flowVersion?.updatedBy, valid: flowVersion?.valid ?? faker.datatype.boolean(), + notes: flowVersion?.notes ?? [], } } diff --git a/packages/server/api/test/integration/ce/flows/flow.test.ts b/packages/server/api/test/integration/ce/flows/flow.test.ts index ce29d4a6ac7..d6194b5170f 100644 --- a/packages/server/api/test/integration/ce/flows/flow.test.ts +++ b/packages/server/api/test/integration/ce/flows/flow.test.ts @@ -86,7 +86,7 @@ describe('Flow API', () => { expect(responseBody?.metadata).toMatchObject({ foo: 'bar' }) expect(responseBody?.operationStatus).toBeDefined() - expect(Object.keys(responseBody?.version)).toHaveLength(13) + expect(Object.keys(responseBody?.version)).toHaveLength(14) expect(responseBody?.version?.id).toHaveLength(21) expect(responseBody?.version?.created).toBeDefined() expect(responseBody?.version?.updated).toBeDefined() @@ -210,7 +210,7 @@ describe('Flow API', () => { expect(responseBody.publishedVersionId).toBe(mockFlowVersion.id) expect(responseBody.metadata).toBeNull() expect(responseBody.operationStatus).toBe('ENABLING') - expect(Object.keys(responseBody.version)).toHaveLength(13) + expect(Object.keys(responseBody.version)).toHaveLength(14) expect(responseBody.version.id).toBe(mockFlowVersion.id) } }) @@ -277,7 +277,7 @@ describe('Flow API', () => { expect(responseBody?.metadata).toBeNull() expect(responseBody?.operationStatus).toBe('DISABLING') - expect(Object.keys(responseBody?.version)).toHaveLength(13) + expect(Object.keys(responseBody?.version)).toHaveLength(14) expect(responseBody?.version?.id).toBe(mockFlowVersion.id) }) }) @@ -389,7 +389,7 @@ describe('Flow API', () => { expect(responseBody.publishedVersionId).toBe(mockFlowVersion.id) expect(responseBody.metadata).toBeNull() expect(responseBody.operationStatus).toBe('DISABLING') - expect(Object.keys(responseBody.version)).toHaveLength(13) + expect(Object.keys(responseBody.version)).toHaveLength(14) expect(responseBody.version.id).toBe(mockFlowVersion.id) expect(responseBody.version.state).toBe('LOCKED') } diff --git a/packages/server/api/test/integration/cloud/core/authorization-v2.test.ts b/packages/server/api/test/integration/cloud/core/authorization-v2.test.ts index 9de306e2f30..c77c19a791b 100644 --- a/packages/server/api/test/integration/cloud/core/authorization-v2.test.ts +++ b/packages/server/api/test/integration/cloud/core/authorization-v2.test.ts @@ -1,3 +1,4 @@ + import { AuthorizationRouteSecurity, AuthorizationType, RouteKind } from '@activepieces/server-shared' import { ActivepiecesError, @@ -35,7 +36,7 @@ beforeAll(async () => { afterAll(async () => { await databaseConnection().destroy() await app?.close() -}) +}, 600000) describe('authorizeOrThrow', () => { describe('PUBLIC routes', () => { diff --git a/packages/server/shared/src/lib/system-props.ts b/packages/server/shared/src/lib/system-props.ts index 53aa566e850..57acb7ab966 100644 --- a/packages/server/shared/src/lib/system-props.ts +++ b/packages/server/shared/src/lib/system-props.ts @@ -52,6 +52,7 @@ export enum AppSystemProp { MAX_CONCURRENT_JOBS_PER_PROJECT = 'MAX_CONCURRENT_JOBS_PER_PROJECT', MAX_FIELDS_PER_TABLE = 'MAX_FIELDS_PER_TABLE', MAX_FILE_SIZE_MB = 'MAX_FILE_SIZE_MB', + MAX_FLOW_RUN_LOG_SIZE_MB = 'MAX_FLOW_RUN_LOG_SIZE_MB', MAX_RECORDS_PER_TABLE = 'MAX_RECORDS_PER_TABLE', OTEL_ENABLED = 'OTEL_ENABLED', PAUSED_FLOW_TIMEOUT_DAYS = 'PAUSED_FLOW_TIMEOUT_DAYS', diff --git a/packages/server/worker/package.json b/packages/server/worker/package.json index 37e20d3f5ba..503f913e479 100644 --- a/packages/server/worker/package.json +++ b/packages/server/worker/package.json @@ -7,7 +7,7 @@ "dependencies": { "@activepieces/pieces-framework": "0.23.0", "@activepieces/server-shared": "0.0.2", - "@activepieces/shared": "0.30.4", + "@activepieces/shared": "0.31.0", "write-file-atomic": "5.0.1", "tslib": "2.6.2", "@opentelemetry/api": "1.9.0", diff --git a/packages/server/worker/src/lib/consume/executors/flow-job-executor.ts b/packages/server/worker/src/lib/consume/executors/flow-job-executor.ts index ac6c5cfee6f..4db46e9ed15 100644 --- a/packages/server/worker/src/lib/consume/executors/flow-job-executor.ts +++ b/packages/server/worker/src/lib/consume/executors/flow-job-executor.ts @@ -25,7 +25,7 @@ async function prepareInput( > { const previousExecutionFile = (jobData.executionType === ExecutionType.RESUME || attempsStarted > 1) ? await flowRunLogs.get(jobData.logsUploadUrl) : null const steps = !isNil(previousExecutionFile) ? previousExecutionFile?.executionState?.steps : {} - + const tags = !isNil(previousExecutionFile) ? previousExecutionFile?.executionState?.tags : [] switch (jobData.executionType) { case ExecutionType.BEGIN: { return { @@ -38,6 +38,7 @@ async function prepareInput( executionType: ExecutionType.BEGIN, executionState: { steps, + tags, }, sampleData: jobData.sampleData, executeTrigger: jobData.executeTrigger ?? false, @@ -61,6 +62,7 @@ async function prepareInput( executionType: ExecutionType.RESUME, executionState: { steps, + tags, }, runEnvironment: jobData.environment, httpRequestId: jobData.httpRequestId ?? null, diff --git a/packages/server/worker/src/lib/utils/machine.ts b/packages/server/worker/src/lib/utils/machine.ts index 52181d5d88d..b404715fe33 100644 --- a/packages/server/worker/src/lib/utils/machine.ts +++ b/packages/server/worker/src/lib/utils/machine.ts @@ -153,6 +153,7 @@ function getEnvironmentVariables(): Record { AP_EXECUTION_MODE: workerMachine.getSettings().EXECUTION_MODE, AP_DEV_PIECES: workerMachine.getSettings().DEV_PIECES.join(','), AP_MAX_FILE_SIZE_MB: workerMachine.getSettings().MAX_FILE_SIZE_MB.toString(), + AP_MAX_FLOW_RUN_LOG_SIZE_MB: workerMachine.getSettings().MAX_FLOW_RUN_LOG_SIZE_MB.toString(), AP_FILE_STORAGE_LOCATION: workerMachine.getSettings().FILE_STORAGE_LOCATION, AP_S3_USE_SIGNED_URLS: workerMachine.getSettings().S3_USE_SIGNED_URLS, } diff --git a/packages/shared/package.json b/packages/shared/package.json index f08e0aa51e4..87f25ed1bf2 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,5 +1,5 @@ { "name": "@activepieces/shared", - "version": "0.30.4", + "version": "0.31.0", "type": "commonjs" } diff --git a/packages/shared/src/lib/common/utils/utils.ts b/packages/shared/src/lib/common/utils/utils.ts index bd9d7e6a024..a4a8042cdba 100644 --- a/packages/shared/src/lib/common/utils/utils.ts +++ b/packages/shared/src/lib/common/utils/utils.ts @@ -144,4 +144,13 @@ export function partition(array: T[], predicate: (item: T, index: number, arr export function unique(array: T[]): T[] { return array.filter((item, index, self) => index === self.findIndex(other => JSON.stringify(other) === JSON.stringify(item))) +} + +export function mapsAreSame(a: Map, b: Map): boolean { + if (a.size !== b.size) return false + for (const [key, value] of a) { + if (!b.has(key)) return false + if (b.get(key) !== value) return false + } + return true } \ No newline at end of file diff --git a/packages/shared/src/lib/flow-run/execution/execution-output.ts b/packages/shared/src/lib/flow-run/execution/execution-output.ts index 37e31ea0903..a4a7c073c1c 100755 --- a/packages/shared/src/lib/flow-run/execution/execution-output.ts +++ b/packages/shared/src/lib/flow-run/execution/execution-output.ts @@ -9,10 +9,12 @@ export enum ExecutionType { export type ExecutionState = { steps: Record + tags: string[] } export const ExecutionState = Type.Object({ steps: Type.Record(Type.String(), Type.Unknown()), + tags: Type.Array(Type.String()), }) export type ExecutioOutputFile = { diff --git a/packages/shared/src/lib/flows/flow-version.ts b/packages/shared/src/lib/flows/flow-version.ts index ed96d70821b..a210e13b43a 100755 --- a/packages/shared/src/lib/flows/flow-version.ts +++ b/packages/shared/src/lib/flows/flow-version.ts @@ -2,6 +2,7 @@ import { Static, Type } from '@sinclair/typebox' import { BaseModelSchema, Nullable } from '../common/base-model' import { ApId } from '../common/id-generator' import { UserWithMetaInformation } from '../user' +import { Note } from './note' import { FlowTrigger } from './triggers/trigger' export type FlowVersionId = ApId @@ -25,6 +26,7 @@ export const FlowVersion = Type.Object({ state: Type.Enum(FlowVersionState), connectionIds: Type.Array(Type.String()), backupFiles: Nullable(Type.Record(Type.String(), Type.String())), + notes: Type.Array(Note), }) export type FlowVersion = Static diff --git a/packages/shared/src/lib/flows/index.ts b/packages/shared/src/lib/flows/index.ts index 8b66c1954c6..ee8e6e1778e 100644 --- a/packages/shared/src/lib/flows/index.ts +++ b/packages/shared/src/lib/flows/index.ts @@ -4,4 +4,5 @@ export * from './sample-data' export * from './flow' export * from './test-trigger' export * from './properties' -export * from './operations' \ No newline at end of file +export * from './operations' +export * from './note' \ No newline at end of file diff --git a/packages/shared/src/lib/flows/note.ts b/packages/shared/src/lib/flows/note.ts new file mode 100644 index 00000000000..af088e56032 --- /dev/null +++ b/packages/shared/src/lib/flows/note.ts @@ -0,0 +1,27 @@ +import { Static, Type } from '@sinclair/typebox' +import { Nullable } from '../common' +export enum NoteColorVariant { + ORANGE = 'orange', + RED = 'red', + GREEN = 'green', + BLUE = 'blue', + PURPLE = 'purple', + YELLOW = 'yellow', +} +export const Note = Type.Object({ + id: Type.String(), + content: Type.String(), + ownerId: Nullable(Type.String()), + color: Type.Enum(NoteColorVariant), + position: Type.Object({ + x: Type.Number(), + y: Type.Number(), + }), + size: Type.Object({ + width: Type.Number(), + height: Type.Number(), + }), + createdAt: Type.String(), + updatedAt: Type.String(), +}) +export type Note = Static \ No newline at end of file diff --git a/packages/shared/src/lib/flows/operations/import-flow.ts b/packages/shared/src/lib/flows/operations/import-flow.ts index a171abbc8e8..7ca3cf6cbc7 100644 --- a/packages/shared/src/lib/flows/operations/import-flow.ts +++ b/packages/shared/src/lib/flows/operations/import-flow.ts @@ -3,7 +3,7 @@ import { FlowAction, FlowActionType } from '../actions/action' import { FlowVersion } from '../flow-version' import { FlowTrigger, FlowTriggerType } from '../triggers/trigger' import { flowStructureUtil } from '../util/flow-structure-util' -import { FlowOperationRequest, FlowOperationType, ImportFlowRequest, StepLocationRelativeToParent } from './index' +import { AddNoteRequest, DeleteNoteRequest, FlowOperationRequest, FlowOperationType, ImportFlowRequest, StepLocationRelativeToParent } from './index' function createDeleteActionOperation(actionName: string): FlowOperationRequest { return { @@ -26,7 +26,7 @@ function createChangeNameOperation(displayName: string): FlowOperationRequest { } } -function _getImportOperations(step: FlowAction | FlowTrigger | undefined): FlowOperationRequest[] { +function _getImportOperationsForSteps(step: FlowAction | FlowTrigger | undefined): FlowOperationRequest[] { const steps: FlowOperationRequest[] = [] while (step) { if (step.nextAction) { @@ -50,7 +50,7 @@ function _getImportOperations(step: FlowAction | FlowTrigger | undefined): FlowO action: removeAnySubsequentAction(step.firstLoopAction), }, }) - steps.push(..._getImportOperations(step.firstLoopAction)) + steps.push(..._getImportOperationsForSteps(step.firstLoopAction)) } break } @@ -67,7 +67,7 @@ function _getImportOperations(step: FlowAction | FlowTrigger | undefined): FlowO action: removeAnySubsequentAction(child), }, }) - steps.push(..._getImportOperations(child)) + steps.push(..._getImportOperationsForSteps(child)) } } } @@ -86,6 +86,25 @@ function _getImportOperations(step: FlowAction | FlowTrigger | undefined): FlowO return steps } +function _getImportOperationsForNotes(flowVersion: FlowVersion, request: ImportFlowRequest): FlowOperationRequest[] { + + const deleteOperations: DeleteNoteRequest[] = flowVersion.notes.map(note => ({ + id: note.id, + })) + const addOperations: AddNoteRequest[] = (request.notes || []).map(note => (note)) + + const operations: FlowOperationRequest[] = [ + ...deleteOperations.map(operation => ({ + type: FlowOperationType.DELETE_NOTE as const, + request: operation, + })), + ...addOperations.map(operation => ({ + type: FlowOperationType.ADD_NOTE as const, + request: operation, + })), + ] + return operations +} function removeAnySubsequentAction(action: FlowAction): FlowAction { const clonedAction: FlowAction = JSON.parse(JSON.stringify(action)) switch (clonedAction.type) { @@ -117,14 +136,15 @@ function _importFlow(flowVersion: FlowVersion, request: ImportFlowRequest): Flow createDeleteActionOperation(action.name), ) - const importOperations = _getImportOperations(request.trigger) - + const importOperations = _getImportOperationsForSteps(request.trigger) + return [ createChangeNameOperation(request.displayName), ...deleteOperations, createUpdateTriggerOperation(request.trigger), ...importOperations, + ..._getImportOperationsForNotes(flowVersion, request), ] } -export { _importFlow, _getImportOperations } \ No newline at end of file +export { _importFlow, _getImportOperationsForSteps as _getImportOperations } \ No newline at end of file diff --git a/packages/shared/src/lib/flows/operations/index.ts b/packages/shared/src/lib/flows/operations/index.ts index a7f07ea778d..d730cac2b3e 100644 --- a/packages/shared/src/lib/flows/operations/index.ts +++ b/packages/shared/src/lib/flows/operations/index.ts @@ -4,6 +4,7 @@ import { Metadata } from '../../common/metadata' import { BranchCondition, CodeActionSchema, LoopOnItemsActionSchema, PieceActionSchema, RouterActionSchema } from '../actions/action' import { FlowStatus } from '../flow' import { FlowVersion, FlowVersionState } from '../flow-version' +import { Note } from '../note' import { SaveSampleDataRequest } from '../sample-data' import { EmptyTrigger, FlowTrigger, FlowTriggerType, PieceTrigger } from '../triggers/trigger' import { flowPieceUtil } from '../util/flow-piece-util' @@ -17,6 +18,7 @@ import { _duplicateBranch, _duplicateStep } from './duplicate-step' import { _importFlow } from './import-flow' import { _moveAction } from './move-action' import { _moveBranch } from './move-branch' +import { notesOperations } from './notes-operations' import { _getOperationsForPaste } from './paste-operations' import { _skipAction } from './skip-action' import { _updateAction } from './update-action' @@ -46,12 +48,22 @@ export enum FlowOperationType { SAVE_SAMPLE_DATA = 'SAVE_SAMPLE_DATA', UPDATE_MINUTES_SAVED = 'UPDATE_MINUTES_SAVED', UPDATE_OWNER = 'UPDATE_OWNER', + UPDATE_NOTE = 'UPDATE_NOTE', + DELETE_NOTE = 'DELETE_NOTE', + ADD_NOTE = 'ADD_NOTE', } export const DeleteBranchRequest = Type.Object({ branchIndex: Type.Number(), stepName: Type.String(), }) + +export const UpdateNoteRequest = Type.Omit(Note, [ 'createdAt', 'updatedAt']) +export const DeleteNoteRequest = Type.Object({ + id: Type.String(), +}) +export const AddNoteRequest = Type.Omit(Note, ['createdAt', 'updatedAt', 'ownerId']) + export const AddBranchRequest = Type.Object({ branchIndex: Type.Number(), stepName: Type.String(), @@ -79,6 +91,9 @@ export const DuplicateBranchRequest = Type.Object({ export type DeleteBranchRequest = Static export type AddBranchRequest = Static export type DuplicateBranchRequest = Static +export type UpdateNoteRequest = Static +export type DeleteNoteRequest = Static +export type AddNoteRequest = Static export enum StepLocationRelativeToParent { AFTER = 'AFTER', @@ -99,6 +114,7 @@ export const ImportFlowRequest = Type.Object({ displayName: Type.String({}), trigger: FlowTrigger, schemaVersion: Nullable(Type.String()), + notes: Nullable(Type.Array(Note)), }) export type ImportFlowRequest = Static @@ -380,6 +396,33 @@ export const FlowOperationRequest = Type.Union([ title: 'Update Owner', }, ), + Type.Object( + { + type: Type.Literal(FlowOperationType.UPDATE_NOTE), + request: UpdateNoteRequest, + }, + { + title: 'Update Note', + }, + ), + Type.Object( + { + type: Type.Literal(FlowOperationType.DELETE_NOTE), + request: DeleteNoteRequest, + }, + { + title: 'Delete Note', + }, + ), + Type.Object( + { + type: Type.Literal(FlowOperationType.ADD_NOTE), + request: AddNoteRequest, + }, + { + title: 'Add Note', + }, + ), ]) export type FlowOperationRequest = Static @@ -457,7 +500,6 @@ export const flowOperations = { } case FlowOperationType.SET_SKIP_ACTION: { clonedVersion = _skipAction(clonedVersion, operation.request) - clonedVersion = flowPieceUtil.makeFlowAutoUpgradable(clonedVersion) break } case FlowOperationType.MOVE_BRANCH: { @@ -465,6 +507,18 @@ export const flowOperations = { clonedVersion = flowPieceUtil.makeFlowAutoUpgradable(clonedVersion) break } + case FlowOperationType.UPDATE_NOTE: { + clonedVersion = notesOperations.updateNote(clonedVersion, operation.request) + break + } + case FlowOperationType.DELETE_NOTE: { + clonedVersion = notesOperations.deleteNote(clonedVersion, operation.request) + break + } + case FlowOperationType.ADD_NOTE: { + clonedVersion = notesOperations.addNote(clonedVersion, operation.request) + break + } default: break } diff --git a/packages/shared/src/lib/flows/operations/notes-operations.ts b/packages/shared/src/lib/flows/operations/notes-operations.ts new file mode 100644 index 00000000000..574cd5d9cfc --- /dev/null +++ b/packages/shared/src/lib/flows/operations/notes-operations.ts @@ -0,0 +1,32 @@ +import { FlowVersion } from '../flow-version' +import { Note } from '../note' +import { AddNoteRequest, DeleteNoteRequest, UpdateNoteRequest } from '.' + +const _updateNote = (flowVersion: FlowVersion, request: UpdateNoteRequest): FlowVersion => { + const newFlowVersion = JSON.parse(JSON.stringify(flowVersion)) + newFlowVersion.notes = newFlowVersion.notes.map((note: Note) => { + if (note.id === request.id) { + return { ...note, ...request, updatedAt: new Date().toISOString() } + } + return note + }) + return newFlowVersion +} + +const _deleteNote = (flowVersion: FlowVersion, request: DeleteNoteRequest): FlowVersion => { + const newFlowVersion = JSON.parse(JSON.stringify(flowVersion)) + newFlowVersion.notes = newFlowVersion.notes.filter((note: Note) => note.id !== request.id) + return newFlowVersion +} + +const _addNote = (flowVersion: FlowVersion, request: AddNoteRequest): FlowVersion => { + const newFlowVersion = JSON.parse(JSON.stringify(flowVersion)) + newFlowVersion.notes.push({ ...request, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }) + return newFlowVersion +} + +export const notesOperations = { + updateNote: _updateNote, + deleteNote: _deleteNote, + addNote: _addNote, +} \ No newline at end of file diff --git a/packages/shared/src/lib/workers/index.ts b/packages/shared/src/lib/workers/index.ts index 5e8ef4490ea..ea0b36a2091 100644 --- a/packages/shared/src/lib/workers/index.ts +++ b/packages/shared/src/lib/workers/index.ts @@ -81,6 +81,7 @@ export const WorkerSettingsResponse = Type.Object({ LOG_PRETTY: Type.String(), ENVIRONMENT: Type.String(), APP_WEBHOOK_SECRETS: Type.String(), + MAX_FLOW_RUN_LOG_SIZE_MB: Type.Number(), MAX_FILE_SIZE_MB: Type.Number(), SANDBOX_MEMORY_LIMIT: Type.String(), SANDBOX_PROPAGATED_ENV_VARS: Type.Array(Type.String()), diff --git a/packages/shared/test/flow/flow-helper.test.ts b/packages/shared/test/flow/flow-helper.test.ts index cb17c5e81f7..0951c0d8fd2 100644 --- a/packages/shared/test/flow/flow-helper.test.ts +++ b/packages/shared/test/flow/flow-helper.test.ts @@ -24,6 +24,7 @@ const flowVersionWithBranching: FlowVersion = { updatedBy: '', displayName: 'Standup Reminder', agentIds: [], + notes: [], trigger: { name: 'trigger', type: FlowTriggerType.PIECE, @@ -146,6 +147,7 @@ function createCodeAction(name: string): FlowAction { } } const emptyScheduleFlowVersion: FlowVersion = { + notes: [], id: 'pj0KQ7Aypoa9OQGHzmKDl', created: '2023-05-24T00:16:41.353Z', updated: '2023-05-24T00:16:41.353Z', @@ -198,6 +200,7 @@ describe('Flow Helper', () => { } const result = flowOperations.apply(flowVersionWithBranching, operation) const expectedFlowVersion: FlowVersion = { + notes: [], id: 'pj0KQ7Aypoa9OQGHzmKDl', updatedBy: '', created: '2023-05-24T00:16:41.353Z', @@ -348,6 +351,7 @@ describe('Flow Helper', () => { test('Duplicate Flow With Loops using Import', () => { const flowVersion: FlowVersion = { + notes: [], id: '2XuLcKZWSgKkiHh6RqWXg', created: '2023-05-23T00:14:47.809Z', updated: '2023-05-23T00:14:47.809Z', diff --git a/tsconfig.base.json b/tsconfig.base.json index f079403b6d5..8da790bcbce 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -473,8 +473,17 @@ "@activepieces/piece-time-ops": [ "packages/pieces/community/time-ops/src/index.ts" ], + "@activepieces/piece-formitable": [ + "packages/pieces/community/formitable/src/index.ts" + ], "@activepieces/piece-gender-api": [ "packages/pieces/community/gender-api/src/index.ts" + ], + "@activepieces/piece-plausible": [ + "packages/pieces/community/plausible/src/index.ts" + ], + "@activepieces/piece-woodpecker": [ + "packages/pieces/community/woodpecker/src/index.ts" ] }, "resolveJsonModule": true