Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
504141e1b9 | ||
|
|
6e5e7963b5 | ||
|
|
a2acf88a57 | ||
|
|
c002bc52bd | ||
|
|
127fb2a470 | ||
|
|
3d49c17e34 | ||
|
|
b454ad2aea | ||
|
|
fb47e4497a | ||
|
|
00fad657f4 | ||
|
|
c800eccc65 | ||
|
|
a26fe3bb65 | ||
|
|
8d81496625 | ||
|
|
f1b7c21b6c | ||
|
|
f630ce34fe | ||
|
|
7ebcbe8801 | ||
|
|
b821061526 | ||
|
|
f3aadbbb66 | ||
|
|
e494bc7006 | ||
|
|
91e0e07af4 | ||
|
|
7ab6f5719f | ||
|
|
d17393e91d | ||
|
|
658bdf3e57 | ||
|
|
24a836cc7d | ||
|
|
1a9f786e42 |
@@ -106,24 +106,24 @@ Replace `{VERSION}` with the actual version number (e.g., `1.9.0`)
|
||||
### 2. Create File Structure
|
||||
|
||||
**Create changelog file:**
|
||||
- Path: `packages/twenty-website-new/src/content/releases/{VERSION}.mdx`
|
||||
- Example: `packages/twenty-website-new/src/content/releases/1.9.0.mdx`
|
||||
- Path: `packages/twenty-website/src/content/releases/{VERSION}.mdx`
|
||||
- Example: `packages/twenty-website/src/content/releases/1.9.0.mdx`
|
||||
|
||||
**Create image folder:**
|
||||
- Path: `packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/`
|
||||
- Example for version 1.9.0: `packages/twenty-website-new/public/images/releases/1.9/`
|
||||
- Example for version 2.0.0: `packages/twenty-website-new/public/images/releases/2.0/`
|
||||
- Path: `packages/twenty-website/public/images/releases/{MINOR_VERSION}/`
|
||||
- Example for version 1.9.0: `packages/twenty-website/public/images/releases/1.9/`
|
||||
- Example for version 2.0.0: `packages/twenty-website/public/images/releases/2.0/`
|
||||
|
||||
```bash
|
||||
# Create the image folder
|
||||
mkdir -p packages/twenty-website-new/public/images/releases/{MINOR_VERSION}
|
||||
mkdir -p packages/twenty-website/public/images/releases/{MINOR_VERSION}
|
||||
```
|
||||
|
||||
### 3. Move Illustration Files
|
||||
|
||||
**Source:** `/Users/thomascolasdesfrancs/Downloads/🆕`
|
||||
|
||||
**Destination:** `packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/`
|
||||
**Destination:** `packages/twenty-website/public/images/releases/{MINOR_VERSION}/`
|
||||
|
||||
**Naming Convention:** `{VERSION}-descriptive-name.webp`
|
||||
|
||||
@@ -133,8 +133,8 @@ Examples:
|
||||
|
||||
```bash
|
||||
# Move and rename source files, then convert to webp if needed
|
||||
cp ~/Downloads/🆕/source-file.png packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-name.png
|
||||
cd packages/twenty-website-new && node scripts/convert-png-to-webp.mjs
|
||||
cp ~/Downloads/🆕/source-file.png packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-name.png
|
||||
cd packages/twenty-website && node scripts/convert-png-to-webp.mjs
|
||||
```
|
||||
|
||||
### 4. Research Features (if needed)
|
||||
@@ -183,7 +183,7 @@ Description of the third feature.
|
||||
- **NEVER mention the brand name "Twenty"** in changelog text - use "your workspace", "the platform", or similar neutral references instead
|
||||
|
||||
**Reference Previous Changelogs:**
|
||||
- Check `packages/twenty-website-new/src/content/releases/` for examples
|
||||
- Check `packages/twenty-website/src/content/releases/` for examples
|
||||
- Recent releases: 1.7.0.mdx, 1.6.0.mdx, 1.5.0.mdx
|
||||
|
||||
### 6. Review
|
||||
@@ -191,10 +191,10 @@ Description of the third feature.
|
||||
Open the changelog file for review:
|
||||
```bash
|
||||
# Open in Cursor
|
||||
cursor packages/twenty-website-new/src/content/releases/{VERSION}.mdx
|
||||
cursor packages/twenty-website/src/content/releases/{VERSION}.mdx
|
||||
|
||||
# Open image folder to verify illustrations
|
||||
open packages/twenty-website-new/public/images/releases/{MINOR_VERSION}
|
||||
open packages/twenty-website/public/images/releases/{MINOR_VERSION}
|
||||
```
|
||||
|
||||
Review checklist:
|
||||
@@ -222,8 +222,8 @@ I've created the changelog for version {VERSION}. Here's the content for your re
|
||||
[Show full MDX content]
|
||||
|
||||
Images moved to:
|
||||
- packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-1.webp
|
||||
- packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-2.webp
|
||||
- packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-1.webp
|
||||
- packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-2.webp
|
||||
|
||||
Please review the content. Once you approve, I'll commit the changes and create the pull request.
|
||||
```
|
||||
@@ -242,8 +242,8 @@ Possible user responses:
|
||||
git status
|
||||
|
||||
# Add files
|
||||
git add packages/twenty-website-new/src/content/releases/{VERSION}.mdx
|
||||
git add packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/
|
||||
git add packages/twenty-website/src/content/releases/{VERSION}.mdx
|
||||
git add packages/twenty-website/public/images/releases/{MINOR_VERSION}/
|
||||
|
||||
# Commit
|
||||
git commit -m "Add {VERSION} release changelog"
|
||||
@@ -266,7 +266,7 @@ This release includes:
|
||||
- Feature 2
|
||||
- Feature 3
|
||||
|
||||
Changelog file: \`packages/twenty-website-new/src/content/releases/{VERSION}.mdx\`
|
||||
Changelog file: \`packages/twenty-website/src/content/releases/{VERSION}.mdx\`
|
||||
Release date: {DATE}" \
|
||||
--base main \
|
||||
--head {VERSION}
|
||||
@@ -280,13 +280,13 @@ Or visit: `https://github.com/twentyhq/twenty/pull/new/{VERSION}`
|
||||
- **Format**: `{MAJOR}.{MINOR}.{PATCH}.mdx`
|
||||
- **Convention**: One file per complete version
|
||||
- **Examples**: `1.6.0.mdx`, `1.7.0.mdx`, `2.0.0.mdx`
|
||||
- **Location**: `packages/twenty-website-new/src/content/releases/`
|
||||
- **Location**: `packages/twenty-website/src/content/releases/`
|
||||
|
||||
### Image Folders
|
||||
- **Format**: `{MAJOR}.{MINOR}/`
|
||||
- **Convention**: One folder per minor version (shared across patches)
|
||||
- **Examples**: `1.6/`, `1.7/`, `2.0/`
|
||||
- **Location**: `packages/twenty-website-new/public/images/releases/`
|
||||
- **Location**: `packages/twenty-website/public/images/releases/`
|
||||
|
||||
### Image Files
|
||||
- **Format**: `{VERSION}-descriptive-name.webp`
|
||||
@@ -311,8 +311,8 @@ Features to document:
|
||||
3. ___________________________
|
||||
|
||||
Branch name: {VERSION}
|
||||
Changelog path: packages/twenty-website-new/src/content/releases/{VERSION}.mdx
|
||||
Images path: packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/
|
||||
Changelog path: packages/twenty-website/src/content/releases/{VERSION}.mdx
|
||||
Images path: packages/twenty-website/public/images/releases/{MINOR_VERSION}/
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#
|
||||
# Crowdin CLI configuration for Website translations (twenty-website-new)
|
||||
# Crowdin CLI configuration for Website translations (twenty-website)
|
||||
# Project ID: 4
|
||||
# See https://crowdin.github.io/crowdin-cli/configuration for more information
|
||||
#
|
||||
@@ -16,7 +16,7 @@ files:
|
||||
#
|
||||
# Source file - PO file for Lingui
|
||||
#
|
||||
- source: packages/twenty-website-new/src/locales/en.po
|
||||
- source: packages/twenty-website/src/locales/en.po
|
||||
#
|
||||
# Translation files path
|
||||
#
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
files: |
|
||||
package.json
|
||||
yarn.lock
|
||||
packages/twenty-website-new/**
|
||||
packages/twenty-website/**
|
||||
packages/twenty-shared/**
|
||||
website-task:
|
||||
needs: changed-files-check
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
steps:
|
||||
- name: Trigger preview environment workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
# Strict mode fails if there are missing website translations.
|
||||
- name: Compile website translations
|
||||
id: compile_translations_strict
|
||||
run: npx nx run twenty-website-new:lingui:compile --strict
|
||||
run: npx nx run twenty-website:lingui:compile --strict
|
||||
continue-on-error: true
|
||||
|
||||
- name: Stash any changes before pulling translations
|
||||
@@ -71,8 +71,8 @@ jobs:
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
source: 'packages/twenty-website-new/src/locales/en.po'
|
||||
translation: 'packages/twenty-website-new/src/locales/%locale%.po'
|
||||
source: 'packages/twenty-website/src/locales/en.po'
|
||||
translation: 'packages/twenty-website/src/locales/%locale%.po'
|
||||
export_only_approved: false
|
||||
localization_branch_name: i18n-website
|
||||
base_url: 'https://twenty.api.crowdin.com'
|
||||
@@ -101,9 +101,9 @@ jobs:
|
||||
- name: Compile website translations
|
||||
id: compile_translations
|
||||
run: |
|
||||
npx nx run twenty-website-new:lingui:compile
|
||||
npx nx run twenty-website:lingui:compile
|
||||
git status
|
||||
git add packages/twenty-website-new/src/locales
|
||||
git add packages/twenty-website/src/locales
|
||||
if ! git diff --staged --quiet --exit-code; then
|
||||
git commit -m "chore: compile website translations"
|
||||
echo "changes_detected=true" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
push:
|
||||
branches: ['main']
|
||||
paths:
|
||||
- 'packages/twenty-website-new/**'
|
||||
- 'packages/twenty-website/**'
|
||||
- '.github/crowdin-website.yml'
|
||||
- '.github/workflows/website-i18n-push.yaml'
|
||||
|
||||
@@ -41,14 +41,14 @@ jobs:
|
||||
run: npx nx build twenty-shared
|
||||
|
||||
- name: Extract website translations
|
||||
run: npx nx run twenty-website-new:lingui:extract
|
||||
run: npx nx run twenty-website:lingui:extract
|
||||
|
||||
- name: Check and commit extracted files
|
||||
id: check_extract_changes
|
||||
run: |
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@twenty.com'
|
||||
git add packages/twenty-website-new/src/locales
|
||||
git add packages/twenty-website/src/locales
|
||||
if ! git diff --staged --quiet --exit-code; then
|
||||
git commit -m "chore: extract website translations"
|
||||
echo "changes_detected=true" >> $GITHUB_OUTPUT
|
||||
@@ -57,14 +57,14 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Compile website translations
|
||||
run: npx nx run twenty-website-new:lingui:compile
|
||||
run: npx nx run twenty-website:lingui:compile
|
||||
|
||||
- name: Check and commit compiled files
|
||||
id: check_compile_changes
|
||||
run: |
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@twenty.com'
|
||||
git add packages/twenty-website-new/src/locales/generated
|
||||
git add packages/twenty-website/src/locales/generated
|
||||
if ! git diff --staged --quiet --exit-code; then
|
||||
git commit -m "chore: compile website translations"
|
||||
echo "changes_detected=true" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
name: 'Website Preview Dispatch'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, closed, labeled]
|
||||
paths:
|
||||
- packages/twenty-website/**
|
||||
- .github/workflows/website-preview-dispatch.yaml
|
||||
|
||||
concurrency:
|
||||
# Keyed on PR number so independent PRs don't cancel each other. `github.ref`
|
||||
# would resolve to the base branch under pull_request and collide.
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
trigger-build:
|
||||
# Same fork PRs from outside the org don't have `secrets.*` so the dispatch
|
||||
# call would fail anyway — skip explicitly to avoid noise.
|
||||
if: |
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.action != 'closed' && (
|
||||
(github.event.action == 'labeled' && github.event.label.name == 'preview-website') ||
|
||||
(
|
||||
(
|
||||
github.event.pull_request.author_association == 'MEMBER' ||
|
||||
github.event.pull_request.author_association == 'OWNER' ||
|
||||
github.event.pull_request.author_association == 'COLLABORATOR'
|
||||
) && contains(fromJSON('["opened","synchronize","reopened"]'), github.event.action)
|
||||
)
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Dispatch website-preview-build to ci-privileged
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
gh api repos/twentyhq/ci-privileged/dispatches \
|
||||
-f event_type=website-preview-build \
|
||||
-f "client_payload[pr_number]=$PR_NUMBER" \
|
||||
-f "client_payload[pr_head_sha]=$PR_HEAD_SHA" \
|
||||
-f "client_payload[pr_head_ref]=$PR_HEAD_REF"
|
||||
|
||||
trigger-cleanup:
|
||||
# Covers both merge and close-without-merge — pull_request `closed` fires
|
||||
# for both. PRs left open forever are covered by OpenNext's
|
||||
# `maxVersionAgeDays: 14` + `maxNumberOfVersions: 50` auto-pruning in
|
||||
# open-next.config.ts, so nothing leaks even if cleanup never runs.
|
||||
if: |
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.event.action == 'closed'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Dispatch website-preview-cleanup to ci-privileged
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
gh api repos/twentyhq/ci-privileged/dispatches \
|
||||
-f event_type=website-preview-cleanup \
|
||||
-f "client_payload[pr_number]=$PR_NUMBER"
|
||||
Vendored
+2
-2
@@ -81,8 +81,8 @@
|
||||
"path": "../packages/twenty-sdk"
|
||||
},
|
||||
{
|
||||
"name": "packages/twenty-website-new",
|
||||
"path": "../packages/twenty-website-new"
|
||||
"name": "packages/twenty-website",
|
||||
"path": "../packages/twenty-website"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
|
||||
@@ -110,7 +110,7 @@ packages/
|
||||
├── twenty-ui/ # Shared UI components library
|
||||
├── twenty-shared/ # Common types and utilities
|
||||
├── twenty-emails/ # Email templates with React Email
|
||||
├── twenty-website-new/ # Next.js marketing website
|
||||
├── twenty-website/ # Next.js marketing website
|
||||
├── twenty-docs/ # Documentation website
|
||||
├── twenty-zapier/ # Zapier integration
|
||||
└── twenty-e2e-testing/ # Playwright E2E tests
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<p align="center">
|
||||
<a href="https://www.twenty.com">
|
||||
<img src="./packages/twenty-website-new/public/images/core/logo.svg" width="100px" alt="Twenty logo" />
|
||||
<img src="./packages/twenty-website/public/images/core/logo.svg" width="100px" alt="Twenty logo" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h2 align="center" >The #1 Open-Source CRM</h2>
|
||||
|
||||
<p align="center"><a href="https://twenty.com"><img src="./packages/twenty-website-new/public/images/readme/globe-icon.svg" width="12" height="12"/> Website</a> · <a href="https://docs.twenty.com"><img src="./packages/twenty-website-new/public/images/readme/book-icon.svg" width="12" height="12"/> Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website-new/public/images/readme/map-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website-new/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website-new/public/images/readme/figma-icon.webp" width="12" height="12"/> Figma</a></p>
|
||||
<p align="center"><a href="https://twenty.com"><img src="./packages/twenty-website/public/images/readme/globe-icon.svg" width="12" height="12"/> Website</a> · <a href="https://docs.twenty.com"><img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="12" height="12"/> Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website/public/images/readme/map-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website/public/images/readme/figma-icon.webp" width="12" height="12"/> Figma</a></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.twenty.com">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/github-cover-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/github-cover-light.webp" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/github-cover-light.webp" alt="Twenty banner" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/github-cover-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/github-cover-light.webp" />
|
||||
<img src="./packages/twenty-website/public/images/readme/github-cover-light.webp" alt="Twenty banner" />
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
@@ -24,17 +24,17 @@
|
||||
|
||||
Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves. Twenty is the CRM you build, ship, and version like the rest of your stack.
|
||||
|
||||
<a href="https://twenty.com/resources/why-twenty"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="14" height="14"/> Learn more about why we built Twenty</a>
|
||||
<a href="https://twenty.com/resources/why-twenty"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="14" height="14"/> Learn more about why we built Twenty</a>
|
||||
|
||||
<br />
|
||||
|
||||
# Installation
|
||||
|
||||
### <img src="./packages/twenty-website-new/public/images/readme/globe-icon.svg" width="14" height="14"/> Cloud
|
||||
### <img src="./packages/twenty-website/public/images/readme/globe-icon.svg" width="14" height="14"/> Cloud
|
||||
|
||||
The fastest way to get started. Sign up at [twenty.com](https://twenty.com) and spin up a workspace in under a minute, with no infrastructure to manage and always up to date.
|
||||
|
||||
### <img src="./packages/twenty-website-new/public/images/readme/book-icon.svg" width="14" height="14"/> Build an app
|
||||
### <img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="14" height="14"/> Build an app
|
||||
|
||||
Scaffold a new app with the Twenty CLI:
|
||||
|
||||
@@ -68,7 +68,7 @@ npx twenty deploy
|
||||
|
||||
See the [app development guide](https://docs.twenty.com/developers/extend/apps/getting-started) for objects, views, agents, and logic functions.
|
||||
|
||||
### <img src="./packages/twenty-website-new/public/images/readme/rocket-icon.svg" width="14" height="14"/> Self-hosting
|
||||
### <img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="14" height="14"/> Self-hosting
|
||||
|
||||
Run Twenty on your own infrastructure with [Docker Compose](https://docs.twenty.com/developers/self-host/capabilities/docker-compose), or contribute locally via the [local setup guide](https://docs.twenty.com/developers/contribute/capabilities/local-setup).
|
||||
|
||||
@@ -79,61 +79,61 @@ Run Twenty on your own infrastructure with [Docker Compose](https://docs.twenty.
|
||||
|
||||
Twenty gives you the building blocks of a modern CRM (objects, views, workflows, and agents) and lets you extend them as code. Here's a tour of what's in the box.
|
||||
|
||||
Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website-new/public/images/readme/planner-icon.svg" width="14" height="14"/> User Guide</a> for product walkthroughs, or the <a href="https://docs.twenty.com"><img src="./packages/twenty-website-new/public/images/readme/book-icon.svg" width="14" height="14"/> Documentation</a> for developer reference.
|
||||
Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="14" height="14"/> User Guide</a> for product walkthroughs, or the <a href="https://docs.twenty.com"><img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="14" height="14"/> Documentation</a> for developer reference.
|
||||
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-build-apps-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-build-apps-light.webp" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-build-apps-light.webp" alt="Create your apps" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-build-apps-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-build-apps-light.webp" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-build-apps-light.webp" alt="Create your apps" />
|
||||
</picture>
|
||||
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/getting-started"><img src="./packages/twenty-website-new/public/images/readme/code-icon.svg" width="16" height="16"/> Learn more about apps in doc</a></p>
|
||||
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/getting-started"><img src="./packages/twenty-website/public/images/readme/code-icon.svg" width="16" height="16"/> Learn more about apps in doc</a></p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-version-control-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-version-control-light.webp" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-version-control-light.webp" alt="Stay on top with version control" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-version-control-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-version-control-light.webp" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-version-control-light.webp" alt="Stay on top with version control" />
|
||||
</picture>
|
||||
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/publishing"><img src="./packages/twenty-website-new/public/images/readme/monitor-icon.svg" width="16" height="16"/> Learn more about version control in doc</a></p>
|
||||
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/publishing"><img src="./packages/twenty-website/public/images/readme/monitor-icon.svg" width="16" height="16"/> Learn more about version control in doc</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-all-tools-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-all-tools-light.webp" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-all-tools-light.webp" alt="All the tools you need to build anything" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-all-tools-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-all-tools-light.webp" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-all-tools-light.webp" alt="All the tools you need to build anything" />
|
||||
</picture>
|
||||
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/building"><img src="./packages/twenty-website-new/public/images/readme/rocket-icon.svg" width="16" height="16"/> Learn more about primitives in doc</a></p>
|
||||
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/building"><img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="16" height="16"/> Learn more about primitives in doc</a></p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-tools-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-tools-light.webp" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-tools-light.webp" alt="Customize your layouts" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-tools-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-tools-light.webp" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-tools-light.webp" alt="Customize your layouts" />
|
||||
</picture>
|
||||
<p align="center"><a href="https://docs.twenty.com/user-guide/layout/overview"><img src="./packages/twenty-website-new/public/images/readme/planner-icon.svg" width="16" height="16"/> Learn more about layouts in doc</a></p>
|
||||
<p align="center"><a href="https://docs.twenty.com/user-guide/layout/overview"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="16" height="16"/> Learn more about layouts in doc</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-ai-agents-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-ai-agents-light.webp" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-ai-agents-light.webp" alt="AI agents and chats" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-ai-agents-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-ai-agents-light.webp" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-ai-agents-light.webp" alt="AI agents and chats" />
|
||||
</picture>
|
||||
<p align="center"><a href="https://docs.twenty.com/user-guide/ai/overview"><img src="./packages/twenty-website-new/public/images/readme/message-icon.svg" width="16" height="16"/> Learn more about AI in doc</a></p>
|
||||
<p align="center"><a href="https://docs.twenty.com/user-guide/ai/overview"><img src="./packages/twenty-website/public/images/readme/message-icon.svg" width="16" height="16"/> Learn more about AI in doc</a></p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-crm-tools-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-crm-tools-light.webp" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-crm-tools-light.webp" alt="Plus all the tools of a good CRM" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-crm-tools-dark.webp" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-crm-tools-light.webp" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-crm-tools-light.webp" alt="Plus all the tools of a good CRM" />
|
||||
</picture>
|
||||
<p align="center"><a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="16" height="16"/> Learn more about CRM features in doc</a></p>
|
||||
<p align="center"><a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="16" height="16"/> Learn more about CRM features in doc</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -142,23 +142,23 @@ Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduc
|
||||
|
||||
# Stack
|
||||
|
||||
- <a href="https://www.typescriptlang.org/"><img src="./packages/twenty-website-new/public/images/readme/stack-typescript.svg" width="14" height="14"/> TypeScript</a>
|
||||
- <a href="https://nx.dev/"><img src="./packages/twenty-website-new/public/images/readme/stack-nx.svg" width="14" height="14"/> Nx</a>
|
||||
- <a href="https://nestjs.com/"><img src="./packages/twenty-website-new/public/images/readme/stack-nestjs.svg" width="14" height="14"/> NestJS</a>, with <a href="https://bullmq.io/">BullMQ</a>, <a href="https://www.postgresql.org/"><img src="./packages/twenty-website-new/public/images/readme/stack-postgresql.svg" width="14" height="14"/> PostgreSQL</a>, <a href="https://redis.io/"><img src="./packages/twenty-website-new/public/images/readme/stack-redis.svg" width="14" height="14"/> Redis</a>
|
||||
- <a href="https://reactjs.org/"><img src="./packages/twenty-website-new/public/images/readme/stack-react.svg" width="14" height="14"/> React</a>, with <a href="https://jotai.org/">Jotai</a>, <a href="https://linaria.dev/">Linaria</a> and <a href="https://lingui.dev/">Lingui</a>
|
||||
- <a href="https://www.typescriptlang.org/"><img src="./packages/twenty-website/public/images/readme/stack-typescript.svg" width="14" height="14"/> TypeScript</a>
|
||||
- <a href="https://nx.dev/"><img src="./packages/twenty-website/public/images/readme/stack-nx.svg" width="14" height="14"/> Nx</a>
|
||||
- <a href="https://nestjs.com/"><img src="./packages/twenty-website/public/images/readme/stack-nestjs.svg" width="14" height="14"/> NestJS</a>, with <a href="https://bullmq.io/">BullMQ</a>, <a href="https://www.postgresql.org/"><img src="./packages/twenty-website/public/images/readme/stack-postgresql.svg" width="14" height="14"/> PostgreSQL</a>, <a href="https://redis.io/"><img src="./packages/twenty-website/public/images/readme/stack-redis.svg" width="14" height="14"/> Redis</a>
|
||||
- <a href="https://reactjs.org/"><img src="./packages/twenty-website/public/images/readme/stack-react.svg" width="14" height="14"/> React</a>, with <a href="https://jotai.org/">Jotai</a>, <a href="https://linaria.dev/">Linaria</a> and <a href="https://lingui.dev/">Lingui</a>
|
||||
|
||||
|
||||
|
||||
# Thanks
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website-new/public/images/readme/chromatic.webp" height="28" alt="Chromatic" /></a>
|
||||
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website/public/images/readme/chromatic.webp" height="28" alt="Chromatic" /></a>
|
||||
|
||||
<a href="https://greptile.com"><img src="./packages/twenty-website-new/public/images/readme/greptile.webp" height="28" alt="Greptile" /></a>
|
||||
<a href="https://greptile.com"><img src="./packages/twenty-website/public/images/readme/greptile.webp" height="28" alt="Greptile" /></a>
|
||||
|
||||
<a href="https://sentry.io/"><img src="./packages/twenty-website-new/public/images/readme/sentry.webp" height="28" alt="Sentry" /></a>
|
||||
<a href="https://sentry.io/"><img src="./packages/twenty-website/public/images/readme/sentry.webp" height="28" alt="Sentry" /></a>
|
||||
|
||||
<a href="https://crowdin.com/"><img src="./packages/twenty-website-new/public/images/readme/crowdin.webp" height="28" alt="Crowdin" /></a>
|
||||
<a href="https://crowdin.com/"><img src="./packages/twenty-website/public/images/readme/crowdin.webp" height="28" alt="Crowdin" /></a>
|
||||
</p>
|
||||
|
||||
Thanks to these amazing services that we use and recommend for UI testing (Chromatic), code review (Greptile), catching bugs (Sentry) and translating (Crowdin).
|
||||
@@ -166,4 +166,4 @@ Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduc
|
||||
|
||||
# Join the Community
|
||||
|
||||
<p><a href="https://github.com/twentyhq/twenty"><img src="./packages/twenty-website-new/public/images/readme/star-icon.svg" width="12" height="12"/> Star the repo</a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website-new/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://github.com/twentyhq/twenty/discussions"><img src="./packages/twenty-website-new/public/images/readme/message-icon.svg" width="12" height="12"/> Feature requests</a> · <a href="https://github.com/orgs/twentyhq/projects/1/views/35"><img src="./packages/twenty-website-new/public/images/readme/rocket-icon.svg" width="12" height="12"/> Releases</a> · <a href="https://twitter.com/twentycrm"><img src="./packages/twenty-website-new/public/images/readme/x-icon.svg" width="12" height="12"/> X</a> · <a href="https://www.linkedin.com/company/twenty/"><img src="./packages/twenty-website-new/public/images/readme/linkedin-icon.svg" width="12" height="12"/> LinkedIn</a> · <a href="https://twenty.crowdin.com/twenty"><img src="./packages/twenty-website-new/public/images/readme/language-icon.svg" width="12" height="12"/> Crowdin</a> · <a href="https://github.com/twentyhq/twenty/contribute"><img src="./packages/twenty-website-new/public/images/readme/code-icon.svg" width="12" height="12"/> Contribute</a></p>
|
||||
<p><a href="https://github.com/twentyhq/twenty"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="12" height="12"/> Star the repo</a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://github.com/twentyhq/twenty/discussions"><img src="./packages/twenty-website/public/images/readme/message-icon.svg" width="12" height="12"/> Feature requests</a> · <a href="https://github.com/orgs/twentyhq/projects/1/views/35"><img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="12" height="12"/> Releases</a> · <a href="https://twitter.com/twentycrm"><img src="./packages/twenty-website/public/images/readme/x-icon.svg" width="12" height="12"/> X</a> · <a href="https://www.linkedin.com/company/twenty/"><img src="./packages/twenty-website/public/images/readme/linkedin-icon.svg" width="12" height="12"/> LinkedIn</a> · <a href="https://twenty.crowdin.com/twenty"><img src="./packages/twenty-website/public/images/readme/language-icon.svg" width="12" height="12"/> Crowdin</a> · <a href="https://github.com/twentyhq/twenty/contribute"><img src="./packages/twenty-website/public/images/readme/code-icon.svg" width="12" height="12"/> Contribute</a></p>
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@
|
||||
"packages/twenty-ui",
|
||||
"packages/twenty-utils",
|
||||
"packages/twenty-zapier",
|
||||
"packages/twenty-website-new",
|
||||
"packages/twenty-website",
|
||||
"packages/twenty-docs",
|
||||
"packages/twenty-e2e-testing",
|
||||
"packages/twenty-shared",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div align="center">
|
||||
<a href="https://twenty.com">
|
||||
<picture>
|
||||
<img alt="Twenty logo" src="https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-website-new/public/images/core/logo.svg" height="128">
|
||||
<img alt="Twenty logo" src="https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-website/public/images/core/logo.svg" height="128">
|
||||
</picture>
|
||||
</a>
|
||||
<h1>Create Twenty App</h1>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "2.5.1",
|
||||
"version": "2.6.0",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
|
||||
@@ -74,12 +74,17 @@ const program = new Command(packageJson.name)
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceUrl = (options?.workspaceUrl ?? options?.apiUrl)?.replace(
|
||||
/\/+$/,
|
||||
'',
|
||||
);
|
||||
|
||||
await new CreateAppCommand().execute({
|
||||
directory,
|
||||
name: options?.name,
|
||||
displayName: options?.displayName,
|
||||
description: options?.description,
|
||||
workspaceUrl: options?.workspaceUrl ?? options?.apiUrl,
|
||||
workspaceUrl,
|
||||
authenticationMethod: options?.authenticationMethod,
|
||||
});
|
||||
},
|
||||
|
||||
+28
-16
@@ -1,4 +1,11 @@
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import {
|
||||
Avatar,
|
||||
IconBox,
|
||||
IconHierarchy,
|
||||
IconLayout,
|
||||
IconSettingsAutomation,
|
||||
} from 'twenty-sdk/ui';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
@@ -30,7 +37,7 @@ const CATEGORIES = [
|
||||
href: `${DOCS_BASE_URL}/logic/logic-functions`,
|
||||
},
|
||||
{
|
||||
label: 'SERVERLESS FUNCT.',
|
||||
label: 'LOGIC FUNCTION',
|
||||
href: `${DOCS_BASE_URL}/logic/logic-functions`,
|
||||
},
|
||||
{
|
||||
@@ -59,15 +66,6 @@ const CATEGORIES = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
const ItemIcon = ({ color }: { color: string }) => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<rect x="2" y="2" width="5" height="5" rx="1" fill={color} />
|
||||
<rect x="9" y="2" width="5" height="5" rx="1" fill={color} opacity="0.6" />
|
||||
<rect x="2" y="9" width="5" height="5" rx="1" fill={color} opacity="0.6" />
|
||||
<rect x="9" y="9" width="5" height="5" rx="1" fill={color} opacity="0.3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ArrowUpRight = ({ color = '#999' }: { color?: string }) => (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path
|
||||
@@ -93,6 +91,18 @@ const CategoryCard = ({
|
||||
}) => {
|
||||
const [hoveredItem, setHoveredItem] = useState<string | null>(null);
|
||||
|
||||
const CategoryIcon = () => {
|
||||
if (title === 'Data model') {
|
||||
return <IconHierarchy color={color} size={'20px'} />;
|
||||
}
|
||||
if (title === 'Logic') {
|
||||
return <IconSettingsAutomation color={color} size={'20px'} />;
|
||||
}
|
||||
if (title === 'Layout') {
|
||||
return <IconLayout color={color} size={'20px'} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -111,8 +121,12 @@ const CategoryCard = ({
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
background: `${color}22`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<CategoryIcon />
|
||||
<span
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
@@ -154,7 +168,7 @@ const CategoryCard = ({
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
>
|
||||
<ItemIcon color={color} />
|
||||
<IconBox color={color} size={'20px'} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
@@ -190,13 +204,11 @@ const MainPage = () => {
|
||||
padding: '40px',
|
||||
}}
|
||||
>
|
||||
{/*<Avatar
|
||||
avatarUrl={getPublicAssetUrl('logo.svg')}
|
||||
<Avatar
|
||||
placeholder={APP_DISPLAY_NAME}
|
||||
placeholderColorSeed={APP_DISPLAY_NAME}
|
||||
type="squared"
|
||||
size="xl"
|
||||
/>*/}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
@@ -220,7 +232,7 @@ const MainPage = () => {
|
||||
You can now add content to your app.
|
||||
</span>
|
||||
<a
|
||||
href="/settings/applications"
|
||||
href="/settings/applications#installed"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
DEV_API_URL,
|
||||
serverStart,
|
||||
} from 'twenty-sdk/cli';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isDefined, normalizeUrl } from 'twenty-shared/utils';
|
||||
import {
|
||||
getDockerInstallInstructions,
|
||||
isDockerInstalled,
|
||||
@@ -389,7 +389,7 @@ export class CreateAppCommand {
|
||||
|
||||
const frontUrl = urls.customUrl ?? urls.subdomainUrl;
|
||||
|
||||
return frontUrl?.replace(/\/+$/, '') ?? null;
|
||||
return frontUrl ? normalizeUrl(frontUrl) : null;
|
||||
}
|
||||
|
||||
private async readMainPageLayoutUniversalIdentifier(
|
||||
|
||||
@@ -17,6 +17,7 @@ const UNIVERSAL_IDENTIFIERS_PATH = join(
|
||||
'constants',
|
||||
'universal-identifiers.ts',
|
||||
);
|
||||
const YARNRC_PATH = 'yarnrc.yml';
|
||||
|
||||
// Template content matching template/src/constants/universal-identifiers.ts
|
||||
const TEMPLATE_UNIVERSAL_IDENTIFIERS = `export const APP_DISPLAY_NAME = 'DISPLAY-NAME-TO-BE-GENERATED';
|
||||
@@ -173,6 +174,27 @@ describe('copyBaseApplicationProject', () => {
|
||||
expect(publicDirectoryContents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should rename yarnrc.yml to .yarnrc.yml in the scaffolded project', async () => {
|
||||
await fs.writeFile(
|
||||
join(testAppDirectory, YARNRC_PATH),
|
||||
'nodeLinker: node-modules',
|
||||
);
|
||||
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
});
|
||||
|
||||
expect(await fs.pathExists(join(testAppDirectory, YARNRC_PATH))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(await fs.pathExists(join(testAppDirectory, '.yarnrc.yml'))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty description', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
|
||||
@@ -22,7 +22,7 @@ export const copyBaseApplicationProject = async ({
|
||||
onProgress?.('Copying base template');
|
||||
await fs.copy(join(__dirname, './constants/template'), appDirectory);
|
||||
|
||||
onProgress?.('Configuring dotfiles (.gitignore, .github)');
|
||||
onProgress?.('Configuring dotfiles (.gitignore, .github, .yarnrc.yml)');
|
||||
await renameDotfiles({ appDirectory });
|
||||
|
||||
onProgress?.('Mirroring AGENTS.md to CLAUDE.md');
|
||||
@@ -47,6 +47,7 @@ const renameDotfiles = async ({ appDirectory }: { appDirectory: string }) => {
|
||||
const renames = [
|
||||
{ from: 'gitignore', to: '.gitignore' },
|
||||
{ from: 'github', to: '.github' },
|
||||
{ from: 'yarnrc.yml', to: '.yarnrc.yml' },
|
||||
];
|
||||
|
||||
for (const { from, to } of renames) {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript"],
|
||||
"categories": {
|
||||
"correctness": "off"
|
||||
},
|
||||
"ignorePatterns": ["node_modules", "dist"],
|
||||
"rules": {
|
||||
"no-unused-vars": "off",
|
||||
"typescript/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"argsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"typescript/no-explicit-any": "off"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
# twenty-slack
|
||||
|
||||
Slack tools for **Twenty workflows** and **agents** (the same logic functions
|
||||
are available as workflow steps and as tools where your deployment exposes
|
||||
them). Uses the official
|
||||
[`@slack/web-api`](https://github.com/slackapi/node-slack-sdk) `WebClient`
|
||||
(Slack retries and error types).
|
||||
|
||||
## What you can do
|
||||
|
||||
Once the app is installed and Slack is **connected** (see **Twenty setup**
|
||||
below):
|
||||
|
||||
- **Workflow steps** — post, update, or delete bot messages; send ephemerals;
|
||||
add reactions; list channels. Pick a **workspace shared** or **just for me**
|
||||
connection; steps run with that token.
|
||||
- **Agents / AI** — when your Twenty instance surfaces app tools to the model,
|
||||
these functions can be invoked the same way as other app logic functions.
|
||||
- **Quick-send** — command menu **Send Slack message** opens a side panel to
|
||||
pick a channel and post (same Slack connection as workflows).
|
||||
|
||||
## Tools
|
||||
|
||||
| Name | Slack API |
|
||||
|------|-----------|
|
||||
| `slack-post-message` | `chat.postMessage` |
|
||||
| `slack-post-ephemeral-message` | `chat.postEphemeral` |
|
||||
| `slack-update-message` | `chat.update` |
|
||||
| `slack-delete-message` | `chat.delete` |
|
||||
| `slack-add-reaction` | `reactions.add` |
|
||||
| `slack-list-channels` | `conversations.list` |
|
||||
|
||||
### Workflow field names (for authors)
|
||||
|
||||
Fields use camelCase names in the step UI, for example **`slackChannelId`** (Slack channel or DM: **name** or **ID**), **`messageText`**, and **`messageTimestamp`** (Slack’s per-message id — same value as tool output **`slackTs`** when chaining steps). Optional **`parentMessageTimestamp`** is only for **thread replies**. Post / update / ephemeral steps support optional **`messageFormat`**: **`markdown`** sends the body as Slack **`markdown_text`** (e.g. **`**bold**`**), **`plain`** sends **`text`** with markup disabled, omit uses Slack’s default for **`text`**. Ephemeral steps use **`recipientSlackUserId`**; reactions use **`emojiName`** (Slack shortcode, for example `white_check_mark`). Updating a message uses **`newMessageText`**.
|
||||
|
||||
### Quick-send command menu item
|
||||
|
||||
This app also ships a global command menu item — **Send Slack message** — that opens a side-panel form to pick a channel (from `conversations.list`) and post a message via `chat.postMessage`. The form is backed by two HTTP routes exposed by the app:
|
||||
|
||||
- `GET /slack/channels` — lists channels visible to the bot (mirrors `slack-list-channels`).
|
||||
- `POST /slack/messages` — posts a message (mirrors `slack-post-message`).
|
||||
|
||||
Both routes require an authenticated Twenty user and use the same shared Slack connection as the workflow tools.
|
||||
|
||||
### Prerequisites (Slack workspace)
|
||||
|
||||
- You can **install** the Slack app on a workspace you administer (or get an
|
||||
admin to approve it).
|
||||
- For **posting**: invite the bot to the channel, **or** rely on
|
||||
**`chat:write.public`** (included in OAuth) to post to **public** channels
|
||||
without joining — private channels still require membership.
|
||||
- **`slack-list-channels`** and the quick-send channel picker need
|
||||
**`channels:read`** / **`groups:read`** on the token (requested at connect
|
||||
time; see below).
|
||||
|
||||
## Slack app setup
|
||||
|
||||
1. Create a Slack app at [api.slack.com/apps](https://api.slack.com/apps)
|
||||
(dedicated to this Twenty app — do not reuse for other Twenty apps).
|
||||
2. **OAuth & Permissions** → **Bot Token Scopes**. Twenty uses Slack’s **bot**
|
||||
OAuth (`oauth/v2/authorize` with `scope=…`). You must add scopes here — not
|
||||
only under **User Token Scopes** — or Slack will refuse install with *“doesn’t
|
||||
have a bot user to install”* until at least one bot scope exists.
|
||||
|
||||
The scopes **requested at connect time** are defined in
|
||||
`src/connection-providers/slack-connection.ts` and must also appear under
|
||||
**Bot Token Scopes** on the Slack app (Slack validates the set). Current
|
||||
list:
|
||||
|
||||
- `channels:read` — `conversations.list` / channel picker (public)
|
||||
- `chat:write` — post, update, delete, ephemeral
|
||||
- `chat:write.public` — post to public channels without the bot joining
|
||||
- `groups:read` — list private channels the bot is in
|
||||
- `reactions:write` — add reactions
|
||||
|
||||
If you **add or remove** scopes in that file or in the Slack app, existing
|
||||
installs must **re-authorize** (disconnect and **Add connection** again, or
|
||||
reinstall the Slack app to the workspace) so the token picks up new scopes.
|
||||
|
||||
3. Set the **Redirect URL** on the Slack app to
|
||||
`<YOUR_TWENTY_SERVER_URL>/apps/oauth/callback` — the same origin your
|
||||
Twenty **server** uses for API routes (the callback is not served by the SPA
|
||||
alone). Local monorepo dev often uses `http://localhost:3000` (confirm the
|
||||
port your `twenty-server` / `SERVER_URL` actually uses).
|
||||
|
||||
**Slack “PKCE” app setting vs `localhost`:** If you turn on **PKCE** for the
|
||||
Slack app under **OAuth & Permissions**, Slack treats `http://localhost…`
|
||||
redirect URLs as **desktop** redirects. **Desktop redirects cannot request
|
||||
bot scopes**, so OAuth will fail for this integration while you use a
|
||||
localhost callback. For local dev you can either **leave Slack’s PKCE
|
||||
opt-in disabled** on that Slack app, or use an **`https://` redirect** (for
|
||||
example a tunnel such as ngrok or Cloudflare Tunnel to your local server),
|
||||
register that URL in the Slack app, and point Twenty’s `SERVER_URL` at the
|
||||
same public base URL. See Slack’s [Using
|
||||
PKCE](https://docs.slack.dev/authentication/using-pkce) docs (this is
|
||||
separate from Twenty sending a PKCE challenge on the authorize request).
|
||||
|
||||
4. Copy the Slack **Client ID** and **Client Secret**.
|
||||
|
||||
## Twenty setup
|
||||
|
||||
1. Register / install this app on your Twenty server (`twenty-slack`).
|
||||
2. In **Settings → Applications → Twenty Slack**, open the **Application registration**
|
||||
tab (admin-only) and set:
|
||||
- `SLACK_CLIENT_ID`
|
||||
- `SLACK_CLIENT_SECRET`
|
||||
3. In the same app, open the **Connections** tab and click **Add connection**.
|
||||
Choose **Just for me** or **Workspace shared**, then complete the Slack sign-in.
|
||||
|
||||
Once connected, workflow steps use the connection access token: a
|
||||
**workspace** connection is preferred when present; otherwise the first
|
||||
connection returned for the Slack provider is used (see
|
||||
`src/logic-functions/utils/get-slack-connection.ts`).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cd packages/twenty-apps/internal/twenty-slack
|
||||
yarn install
|
||||
yarn lint
|
||||
yarn test
|
||||
```
|
||||
|
||||
Use `yarn twenty dev` from this directory to develop against a local Twenty
|
||||
instance (see other internal apps in this monorepo).
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "twenty-slack",
|
||||
"version": "0.1.0",
|
||||
"description": "Slack workflow connector for Twenty",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
"npm": "please-use-yarn",
|
||||
"yarn": ">=4.0.2"
|
||||
},
|
||||
"keywords": [
|
||||
"twenty-app"
|
||||
],
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"scripts": {
|
||||
"twenty": "twenty",
|
||||
"lint": "oxlint -c .oxlintrc.json .",
|
||||
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@slack/web-api": "^7.8.0",
|
||||
"twenty-sdk": "2.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/react": "^18.2.0",
|
||||
"oxlint": "^0.16.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.1.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 127 127"
|
||||
fill="none"
|
||||
role="img"
|
||||
>
|
||||
<title>Slack</title>
|
||||
<path
|
||||
fill="#E01E5A"
|
||||
d="M27.2 80c0 7.3-5.9 13.2-13.2 13.2C6.7 93.2.8 87.3.8 80c0-7.3 5.9-13.2 13.2-13.2h13.2V80zm6.6 0c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2v33c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V80z"
|
||||
/>
|
||||
<path
|
||||
fill="#36C5F0"
|
||||
d="M47 27c-7.3 0-13.2-5.9-13.2-13.2C33.8 6.5 39.7.6 47 .6c7.3 0 13.2 5.9 13.2 13.2V27H47zm0 6.7c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H13.9C6.6 60.1.7 54.2.7 46.9c0-7.3 5.9-13.2 13.2-13.2H47z"
|
||||
/>
|
||||
<path
|
||||
fill="#2EB67D"
|
||||
d="M99.9 46.9c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H99.9V46.9zm-6.6 0c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V13.8C66.9 6.5 72.8.6 80.1.6c7.3 0 13.2 5.9 13.2 13.2v33.1z"
|
||||
/>
|
||||
<path
|
||||
fill="#ECB22E"
|
||||
d="M80.1 99.8c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V99.8h13.2zm0-6.6c-7.3 0-13.2-5.9-13.2-13.2 0-7.3 5.9-13.2 13.2-13.2h33.1c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H80.1z"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,37 @@
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
displayName: 'Twenty Slack',
|
||||
description:
|
||||
'Connect Slack to Twenty. Each workspace member (or a shared workspace connection) can authenticate Slack; workflow steps then post messages, ephemerals, updates, deletes, and reactions on behalf of that connection.',
|
||||
logoUrl: 'public/twenty-slack.svg',
|
||||
author: 'Twenty',
|
||||
category: 'Communication',
|
||||
aboutDescription:
|
||||
'Official Slack connector for Twenty CRM. Install a Slack app on api.slack.com, add the OAuth client ID and secret as server variables, then connect Slack per member or as a shared workspace connection. Use workflow actions to post, update, or delete messages, send ephemeral notes, and add reactions using the connected account.',
|
||||
websiteUrl: 'https://docs.twenty.com/developers/extend/apps/getting-started',
|
||||
termsUrl: 'https://www.twenty.com/terms',
|
||||
emailSupport: 'contact@twenty.com',
|
||||
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
serverVariables: {
|
||||
SLACK_CLIENT_ID: {
|
||||
description:
|
||||
'OAuth client ID from your Slack app (api.slack.com/apps). Public in OAuth flows; only the client secret must stay confidential.',
|
||||
isSecret: false,
|
||||
isRequired: true,
|
||||
},
|
||||
SLACK_CLIENT_SECRET: {
|
||||
description:
|
||||
'OAuth client secret from your Slack app. Stored encrypted; never exposed in API responses.',
|
||||
isSecret: true,
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { defineCommandMenuItem } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
SEND_SLACK_MESSAGE_COMMAND_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: SEND_SLACK_MESSAGE_COMMAND_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Send Slack message',
|
||||
shortLabel: 'Slack message',
|
||||
icon: 'IconBrandSlack',
|
||||
isPinned: false,
|
||||
availabilityType: 'GLOBAL',
|
||||
frontComponentUniversalIdentifier:
|
||||
SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
+673
@@ -0,0 +1,673 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type SyntheticEvent,
|
||||
} from 'react';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import {
|
||||
closeSidePanel,
|
||||
enqueueSnackbar,
|
||||
unmountFrontComponent,
|
||||
} from 'twenty-sdk/front-component';
|
||||
import { themeCssVariables } from 'twenty-sdk/ui';
|
||||
|
||||
import { SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
type SlackChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
isPrivate: boolean;
|
||||
isArchived: boolean;
|
||||
isMember: boolean;
|
||||
numMembers: number;
|
||||
topic: string;
|
||||
purpose: string;
|
||||
};
|
||||
|
||||
type ListChannelsResponse = {
|
||||
success: boolean;
|
||||
channels?: SlackChannel[];
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type PostMessageResponse = {
|
||||
success: boolean;
|
||||
slackTs?: string;
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type PostedMessage = {
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
slackTs?: string;
|
||||
};
|
||||
|
||||
type MessageFormat = 'plain' | 'markdown';
|
||||
|
||||
const FORMAT_OPTIONS: { value: MessageFormat; label: string }[] = [
|
||||
{ value: 'plain', label: 'Plain text' },
|
||||
{ value: 'markdown', label: 'Markdown' },
|
||||
];
|
||||
|
||||
const isMessageFormat = (value: string): value is MessageFormat =>
|
||||
value === 'plain' || value === 'markdown';
|
||||
|
||||
const readSerializedValue = (
|
||||
event: SyntheticEvent<HTMLElement>,
|
||||
): string | undefined => {
|
||||
const object = event as {
|
||||
detail?: { value?: string };
|
||||
value?: string;
|
||||
target?: { value?: string };
|
||||
};
|
||||
|
||||
if (typeof object.detail?.value === 'string') {
|
||||
return object.detail.value;
|
||||
}
|
||||
if (typeof object.value === 'string') {
|
||||
return object.value;
|
||||
}
|
||||
if (typeof object.target?.value === 'string') {
|
||||
return object.target.value;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const onValueChange =
|
||||
(setValue: (value: string) => void) =>
|
||||
(event: SyntheticEvent<HTMLElement>) => {
|
||||
const value = readSerializedValue(event);
|
||||
|
||||
if (typeof value === 'string') {
|
||||
setValue(value);
|
||||
}
|
||||
};
|
||||
|
||||
const callAppRoute = async <TResponse,>(
|
||||
path: string,
|
||||
method: 'GET' | 'POST',
|
||||
body?: Record<string, unknown>,
|
||||
): Promise<TResponse> => {
|
||||
const apiBaseUrl = process.env.TWENTY_API_URL;
|
||||
const token =
|
||||
process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
|
||||
|
||||
if (!apiBaseUrl || !token) {
|
||||
throw new Error('App is missing API URL or access token configuration.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/s${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
|
||||
let errorMessage: string | undefined;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text) as {
|
||||
messages?: string[];
|
||||
message?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
errorMessage = parsed.messages?.[0] ?? parsed.message ?? parsed.error;
|
||||
} catch {
|
||||
// Body is not JSON; fall through to raw text or status message.
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
errorMessage ??
|
||||
(text.length > 0
|
||||
? text.slice(0, 200)
|
||||
: `Request failed with status ${response.status}.`),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<TResponse>;
|
||||
};
|
||||
|
||||
const sortChannels = (channels: SlackChannel[]): SlackChannel[] =>
|
||||
[...channels].sort((a, b) => {
|
||||
if (a.isMember !== b.isMember) {
|
||||
return a.isMember ? -1 : 1;
|
||||
}
|
||||
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const formatChannelOptionLabel = (channel: SlackChannel): string => {
|
||||
const prefix = channel.isPrivate ? '🔒' : '#';
|
||||
const suffix = channel.isMember ? '' : ' — bot is not a member';
|
||||
|
||||
return `${prefix} ${channel.name}${suffix}`;
|
||||
};
|
||||
|
||||
const getChannelHelperText = (
|
||||
selectedChannel: SlackChannel | undefined,
|
||||
): string => {
|
||||
if (selectedChannel === undefined) {
|
||||
return 'Pick a channel to post to.';
|
||||
}
|
||||
|
||||
if (selectedChannel.isMember) {
|
||||
return selectedChannel.isPrivate
|
||||
? 'Private channel · bot is a member.'
|
||||
: 'Public channel · bot is a member.';
|
||||
}
|
||||
|
||||
return 'Bot is not a member of this channel — it must be invited before it can post.';
|
||||
};
|
||||
|
||||
const getStyles = (): Record<string, CSSProperties> => ({
|
||||
container: {
|
||||
fontFamily: themeCssVariables.font.family,
|
||||
fontSize: themeCssVariables.font.size.sm,
|
||||
color: themeCssVariables.font.color.primary,
|
||||
background: themeCssVariables.background.primary,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
header: {
|
||||
padding: themeCssVariables.spacing[4],
|
||||
borderBottom: `1px solid ${themeCssVariables.border.color.light}`,
|
||||
flexShrink: 0,
|
||||
},
|
||||
headerTitleBlock: {
|
||||
marginBottom: themeCssVariables.spacing[4],
|
||||
},
|
||||
pageTitle: {
|
||||
fontSize: themeCssVariables.font.size.md,
|
||||
fontWeight: themeCssVariables.font.weight.semiBold,
|
||||
color: themeCssVariables.font.color.primary,
|
||||
margin: 0,
|
||||
},
|
||||
pageSubtitle: {
|
||||
fontSize: themeCssVariables.font.size.md,
|
||||
fontWeight: themeCssVariables.font.weight.regular,
|
||||
color: themeCssVariables.font.color.tertiary,
|
||||
margin: 0,
|
||||
marginTop: themeCssVariables.spacing[2],
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
padding: themeCssVariables.spacing[4],
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: themeCssVariables.spacing[4],
|
||||
overflowY: 'auto',
|
||||
},
|
||||
field: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: themeCssVariables.spacing[1],
|
||||
},
|
||||
fieldGrowing: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: themeCssVariables.spacing[1],
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
label: {
|
||||
fontSize: themeCssVariables.font.size.xs,
|
||||
fontWeight: themeCssVariables.font.weight.medium,
|
||||
color: themeCssVariables.font.color.secondary,
|
||||
},
|
||||
helperText: {
|
||||
fontSize: themeCssVariables.font.size.xs,
|
||||
color: themeCssVariables.font.color.tertiary,
|
||||
},
|
||||
select: {
|
||||
appearance: 'none',
|
||||
WebkitAppearance: 'none',
|
||||
background: themeCssVariables.background.secondary,
|
||||
border: `1px solid ${themeCssVariables.border.color.medium}`,
|
||||
borderRadius: themeCssVariables.border.radius.sm,
|
||||
padding: `${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[3]}`,
|
||||
color: themeCssVariables.font.color.primary,
|
||||
fontSize: themeCssVariables.font.size.sm,
|
||||
fontFamily: themeCssVariables.font.family,
|
||||
height: themeCssVariables.spacing[8],
|
||||
cursor: 'pointer',
|
||||
outline: 'none',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
textarea: {
|
||||
background: themeCssVariables.background.secondary,
|
||||
border: `1px solid ${themeCssVariables.border.color.medium}`,
|
||||
borderRadius: themeCssVariables.border.radius.sm,
|
||||
padding: themeCssVariables.spacing[3],
|
||||
color: themeCssVariables.font.color.primary,
|
||||
fontSize: themeCssVariables.font.size.sm,
|
||||
fontFamily: themeCssVariables.font.family,
|
||||
lineHeight: 1.5,
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
outline: 'none',
|
||||
resize: 'none',
|
||||
flex: 1,
|
||||
minHeight: themeCssVariables.spacing[20],
|
||||
},
|
||||
footer: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
gap: themeCssVariables.spacing[2],
|
||||
padding: themeCssVariables.spacing[3],
|
||||
borderTop: `1px solid ${themeCssVariables.border.color.light}`,
|
||||
flexShrink: 0,
|
||||
},
|
||||
buttonBase: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: themeCssVariables.spacing[1],
|
||||
height: themeCssVariables.spacing[8],
|
||||
padding: `0 ${themeCssVariables.spacing[3]}`,
|
||||
borderRadius: themeCssVariables.border.radius.sm,
|
||||
fontSize: themeCssVariables.font.size.sm,
|
||||
fontFamily: themeCssVariables.font.family,
|
||||
fontWeight: themeCssVariables.font.weight.medium,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid transparent',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
secondaryButton: {
|
||||
background: themeCssVariables.background.secondary,
|
||||
color: themeCssVariables.font.color.secondary,
|
||||
border: `1px solid ${themeCssVariables.border.color.medium}`,
|
||||
},
|
||||
primaryButton: {
|
||||
background: themeCssVariables.color.blue,
|
||||
color: themeCssVariables.font.color.inverted,
|
||||
},
|
||||
primaryButtonDisabled: {
|
||||
background: themeCssVariables.accent.accent4060,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
centeredState: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: themeCssVariables.spacing[4],
|
||||
height: '100%',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
stateBlock: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: themeCssVariables.spacing[3],
|
||||
maxWidth: '320px',
|
||||
textAlign: 'center',
|
||||
},
|
||||
stateTitle: {
|
||||
fontSize: themeCssVariables.font.size.md,
|
||||
fontWeight: themeCssVariables.font.weight.medium,
|
||||
color: themeCssVariables.font.color.primary,
|
||||
margin: 0,
|
||||
},
|
||||
stateDescription: {
|
||||
fontSize: themeCssVariables.font.size.sm,
|
||||
color: themeCssVariables.font.color.tertiary,
|
||||
margin: 0,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
stateError: {
|
||||
fontSize: themeCssVariables.font.size.sm,
|
||||
color: themeCssVariables.font.color.danger,
|
||||
margin: 0,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
});
|
||||
|
||||
const SendMessageForm = () => {
|
||||
const [channels, setChannels] = useState<SlackChannel[]>([]);
|
||||
const [channelsLoading, setChannelsLoading] = useState(true);
|
||||
const [channelsError, setChannelsError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [postedMessage, setPostedMessage] = useState<PostedMessage | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [channelId, setChannelId] = useState('');
|
||||
const [messageText, setMessageText] = useState('');
|
||||
const [messageFormat, setMessageFormat] = useState<MessageFormat>('markdown');
|
||||
|
||||
const styles = getStyles();
|
||||
|
||||
const fetchChannels = useCallback(async () => {
|
||||
try {
|
||||
setChannelsError(null);
|
||||
setChannelsLoading(true);
|
||||
|
||||
const result = await callAppRoute<ListChannelsResponse>(
|
||||
'/slack/channels?limit=200',
|
||||
'GET',
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
setChannelsError(
|
||||
result.error ?? result.message ?? 'Failed to load Slack channels.',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const sorted = sortChannels(result.channels ?? []);
|
||||
|
||||
setChannels(sorted);
|
||||
|
||||
const firstMemberChannel = sorted.find((channel) => channel.isMember);
|
||||
|
||||
if (firstMemberChannel !== undefined) {
|
||||
setChannelId(firstMemberChannel.id);
|
||||
} else if (sorted.length === 1) {
|
||||
setChannelId(sorted[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
setChannelsError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to load Slack channels.',
|
||||
);
|
||||
} finally {
|
||||
setChannelsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchChannels();
|
||||
}, [fetchChannels]);
|
||||
|
||||
const handleClose = () => {
|
||||
unmountFrontComponent();
|
||||
closeSidePanel();
|
||||
};
|
||||
|
||||
const handleSendAnother = () => {
|
||||
setMessageText('');
|
||||
setPostedMessage(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmedMessage = messageText.trim();
|
||||
|
||||
if (channelId === '' || trimmedMessage === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const result = await callAppRoute<PostMessageResponse>(
|
||||
'/slack/messages',
|
||||
'POST',
|
||||
{
|
||||
slackChannelId: channelId,
|
||||
messageText: trimmedMessage,
|
||||
messageFormat,
|
||||
},
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
await enqueueSnackbar({
|
||||
message:
|
||||
result.error ?? result.message ?? 'Failed to send Slack message.',
|
||||
variant: 'error',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = channels.find(
|
||||
(channelItem) => channelItem.id === channelId,
|
||||
);
|
||||
|
||||
setPostedMessage({
|
||||
channelId,
|
||||
channelName: channel?.name ?? channelId,
|
||||
slackTs: result.slackTs,
|
||||
});
|
||||
|
||||
await enqueueSnackbar({
|
||||
message: `Message sent to #${channel?.name ?? channelId}`,
|
||||
variant: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
await enqueueSnackbar({
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to send Slack message.',
|
||||
variant: 'error',
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (channelsLoading) {
|
||||
return (
|
||||
<div style={styles.centeredState}>
|
||||
<div style={styles.stateBlock}>
|
||||
<h2 style={styles.stateTitle}>Loading channels…</h2>
|
||||
<p style={styles.stateDescription}>
|
||||
Fetching the list of Slack channels your bot can post to.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (channelsError !== null) {
|
||||
return (
|
||||
<div style={styles.centeredState}>
|
||||
<div style={styles.stateBlock}>
|
||||
<h2 style={styles.stateTitle}>Couldn't load channels</h2>
|
||||
<p style={styles.stateError}>{channelsError}</p>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...styles.buttonBase, ...styles.secondaryButton }}
|
||||
onClick={fetchChannels}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (channels.length === 0) {
|
||||
return (
|
||||
<div style={styles.centeredState}>
|
||||
<div style={styles.stateBlock}>
|
||||
<h2 style={styles.stateTitle}>No channels available</h2>
|
||||
<p style={styles.stateDescription}>
|
||||
The bot doesn't have access to any Slack channels yet. Invite it to
|
||||
a channel and try again.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...styles.buttonBase, ...styles.secondaryButton }}
|
||||
onClick={fetchChannels}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (postedMessage !== null) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<div style={styles.headerTitleBlock}>
|
||||
<h2 style={styles.pageTitle}>Message sent</h2>
|
||||
<p style={styles.pageSubtitle}>
|
||||
{`Posted to #${postedMessage.channelName}.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style={styles.body}>
|
||||
<p style={styles.stateDescription}>
|
||||
Your message is now visible in Slack.
|
||||
</p>
|
||||
</div>
|
||||
<div style={styles.footer}>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...styles.buttonBase, ...styles.secondaryButton }}
|
||||
onClick={handleClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...styles.buttonBase, ...styles.primaryButton }}
|
||||
onClick={handleSendAnother}
|
||||
>
|
||||
Send another
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const trimmedMessage = messageText.trim();
|
||||
const canSubmit = channelId !== '' && trimmedMessage !== '' && !submitting;
|
||||
const selectedChannel = channels.find(
|
||||
(channelItem) => channelItem.id === channelId,
|
||||
);
|
||||
const channelHelperText = getChannelHelperText(selectedChannel);
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<div style={styles.headerTitleBlock}>
|
||||
<h2 style={styles.pageTitle}>Send Slack message</h2>
|
||||
<p style={styles.pageSubtitle}>
|
||||
Post a message to any channel your bot has access to.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.body}>
|
||||
<div style={styles.field}>
|
||||
<label htmlFor="slack-channel-select" style={styles.label}>
|
||||
Channel
|
||||
</label>
|
||||
<select
|
||||
id="slack-channel-select"
|
||||
value={channelId}
|
||||
onChange={onValueChange(setChannelId)}
|
||||
style={styles.select}
|
||||
disabled={submitting}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select a channel…
|
||||
</option>
|
||||
{channels.map((channel) => (
|
||||
<option key={channel.id} value={channel.id}>
|
||||
{formatChannelOptionLabel(channel)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span style={styles.helperText}>{channelHelperText}</span>
|
||||
</div>
|
||||
|
||||
<div style={styles.field}>
|
||||
<label htmlFor="slack-message-format-select" style={styles.label}>
|
||||
Format
|
||||
</label>
|
||||
<select
|
||||
id="slack-message-format-select"
|
||||
value={messageFormat}
|
||||
onChange={onValueChange((value) => {
|
||||
if (isMessageFormat(value)) {
|
||||
setMessageFormat(value);
|
||||
}
|
||||
})}
|
||||
style={styles.select}
|
||||
disabled={submitting}
|
||||
>
|
||||
{FORMAT_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span style={styles.helperText}>
|
||||
{messageFormat === 'markdown'
|
||||
? 'Markdown is rendered using Slack mrkdwn (bold, italics, code, links).'
|
||||
: 'The message will be sent exactly as written, with no formatting.'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={styles.fieldGrowing}>
|
||||
<label htmlFor="slack-message-textarea" style={styles.label}>
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
id="slack-message-textarea"
|
||||
value={messageText}
|
||||
onInput={onValueChange(setMessageText)}
|
||||
onChange={onValueChange(setMessageText)}
|
||||
placeholder="Write your message…"
|
||||
style={styles.textarea}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.footer}>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...styles.buttonBase, ...styles.secondaryButton }}
|
||||
onClick={handleClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
...styles.buttonBase,
|
||||
...styles.primaryButton,
|
||||
...(canSubmit ? {} : styles.primaryButtonDisabled),
|
||||
}}
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{submitting ? 'Sending…' : 'Send message'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'send-slack-message-form',
|
||||
description:
|
||||
'Form to send a Slack message to any channel the bot can post to, with plain-text or markdown formatting.',
|
||||
component: SendMessageForm,
|
||||
});
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { defineConnectionProvider } from 'twenty-sdk/define';
|
||||
|
||||
import { SLACK_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineConnectionProvider({
|
||||
universalIdentifier: SLACK_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack',
|
||||
displayName: 'Slack',
|
||||
type: 'oauth',
|
||||
oauth: {
|
||||
authorizationEndpoint: 'https://slack.com/oauth/v2/authorize',
|
||||
tokenEndpoint: 'https://slack.com/api/oauth.v2.access',
|
||||
revokeEndpoint: 'https://slack.com/api/auth.revoke',
|
||||
scopes: [
|
||||
'channels:read',
|
||||
'chat:write',
|
||||
'chat:write.public',
|
||||
'groups:read',
|
||||
'reactions:write',
|
||||
],
|
||||
clientIdVariable: 'SLACK_CLIENT_ID',
|
||||
clientSecretVariable: 'SLACK_CLIENT_SECRET',
|
||||
tokenRequestContentType: 'form-urlencoded',
|
||||
usePkce: true,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
export const APPLICATION_UNIVERSAL_IDENTIFIER =
|
||||
'a8c47f21-3b9e-4d2a-8f61-9c0e7d4a2b51';
|
||||
|
||||
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
|
||||
'b7d36e10-2a8d-4c1b-9e50-8bfd6c3a1940';
|
||||
|
||||
export const SLACK_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER =
|
||||
'8b6c6fd9-8d61-4b6f-9f25-3d92a0f2cc5b';
|
||||
|
||||
export const SLACK_POST_MESSAGE_UNIVERSAL_IDENTIFIER =
|
||||
'c6f25d09-1b7c-4e3f-ad42-7aec5b29830f';
|
||||
|
||||
export const SLACK_POST_EPHEMERAL_MESSAGE_UNIVERSAL_IDENTIFIER =
|
||||
'd5e14c98-0a6b-4e2e-ac31-69db4a18720e';
|
||||
|
||||
export const SLACK_UPDATE_MESSAGE_UNIVERSAL_IDENTIFIER =
|
||||
'e4d03b87-9a5b-4f1d-8b20-58ca3917620d';
|
||||
|
||||
export const SLACK_DELETE_MESSAGE_UNIVERSAL_IDENTIFIER =
|
||||
'f3c92a76-8b4a-4a09-ba19-47b9280651c9';
|
||||
|
||||
export const SLACK_ADD_REACTION_UNIVERSAL_IDENTIFIER =
|
||||
'2a8c7f91-4d3e-5b6f-a7c8-9d0e1f2a3b4c';
|
||||
|
||||
export const SLACK_LIST_CHANNELS_UNIVERSAL_IDENTIFIER =
|
||||
'3b7d8a92-5e4f-4c7a-b8d9-0e1f2a3b4c5d';
|
||||
|
||||
export const SLACK_LIST_CHANNELS_ROUTE_UNIVERSAL_IDENTIFIER =
|
||||
'6e0c3d5f-9a4f-4f62-bc0e-3d5a7f9b4c6e';
|
||||
|
||||
export const SLACK_POST_MESSAGE_ROUTE_UNIVERSAL_IDENTIFIER =
|
||||
'7f1d4e60-ab50-4273-9d1f-4e6b8a0c5d7f';
|
||||
|
||||
export const SEND_MESSAGE_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
|
||||
'4c8a1b3f-7e2d-4f50-9a8c-1b3e5d7f2a4c';
|
||||
|
||||
export const SEND_SLACK_MESSAGE_COMMAND_UNIVERSAL_IDENTIFIER =
|
||||
'5d9b2c4e-8f3e-4f50-ab9d-2c4f6e8a3b5d';
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { type SlackAddReactionInput } from 'src/logic-functions/types/slack-add-reaction-input.type';
|
||||
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
|
||||
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
|
||||
import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure';
|
||||
|
||||
export const slackAddReactionHandler = async (
|
||||
parameters: SlackAddReactionInput,
|
||||
): Promise<SlackToolResult> => {
|
||||
const slackClientResult = await getSlackClient();
|
||||
|
||||
if (!slackClientResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Slack is not connected',
|
||||
error: slackClientResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const { client } = slackClientResult;
|
||||
|
||||
try {
|
||||
await client.reactions.add({
|
||||
channel: parameters.slackChannelId,
|
||||
timestamp: parameters.messageTimestamp,
|
||||
name: parameters.emojiName.trim(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Reaction "${parameters.emojiName.trim()}" added to the message.`,
|
||||
slackTs: parameters.messageTimestamp,
|
||||
channel: parameters.slackChannelId,
|
||||
};
|
||||
} catch (error) {
|
||||
return slackToolFailure('Failed to add Slack reaction', error);
|
||||
}
|
||||
};
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { type SlackDeleteMessageInput } from 'src/logic-functions/types/slack-delete-message-input.type';
|
||||
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
|
||||
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
|
||||
import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure';
|
||||
|
||||
export const slackDeleteMessageHandler = async (
|
||||
parameters: SlackDeleteMessageInput,
|
||||
): Promise<SlackToolResult> => {
|
||||
const slackClientResult = await getSlackClient();
|
||||
|
||||
if (!slackClientResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Slack is not connected',
|
||||
error: slackClientResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const { client } = slackClientResult;
|
||||
|
||||
try {
|
||||
await client.chat.delete({
|
||||
channel: parameters.slackChannelId,
|
||||
ts: parameters.messageTimestamp,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Slack message deleted.',
|
||||
slackTs: parameters.messageTimestamp,
|
||||
channel: parameters.slackChannelId,
|
||||
};
|
||||
} catch (error) {
|
||||
return slackToolFailure('Failed to delete Slack message', error);
|
||||
}
|
||||
};
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
type SlackChannelType,
|
||||
type SlackListChannelsInput,
|
||||
} from 'src/logic-functions/types/slack-list-channels-input.type';
|
||||
import {
|
||||
type SlackListChannelsResult,
|
||||
type SlackListChannelsResultChannel,
|
||||
} from 'src/logic-functions/types/slack-list-channels-result.type';
|
||||
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
|
||||
|
||||
const DEFAULT_LIMIT = 200;
|
||||
const MAX_LIMIT = 1000;
|
||||
const SLACK_PAGE_SIZE = 200;
|
||||
|
||||
const SLACK_TYPES_BY_CHANNEL_TYPE = {
|
||||
Public: 'public_channel',
|
||||
Private: 'private_channel',
|
||||
All: 'public_channel,private_channel',
|
||||
} as const satisfies Record<SlackChannelType, string>;
|
||||
|
||||
export const slackListChannelsHandler = async (
|
||||
parameters: SlackListChannelsInput,
|
||||
): Promise<SlackListChannelsResult> => {
|
||||
const slackClientResult = await getSlackClient();
|
||||
|
||||
if (!slackClientResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
channels: [],
|
||||
count: 0,
|
||||
error: slackClientResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const { client } = slackClientResult;
|
||||
|
||||
const limit = Math.min(
|
||||
Math.max(parameters.limit ?? DEFAULT_LIMIT, 1),
|
||||
MAX_LIMIT,
|
||||
);
|
||||
const excludeArchived = parameters.excludeArchived ?? true;
|
||||
const types = SLACK_TYPES_BY_CHANNEL_TYPE[parameters.channelType ?? 'All'];
|
||||
|
||||
const channels: SlackListChannelsResultChannel[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
try {
|
||||
while (channels.length < limit) {
|
||||
const remaining = limit - channels.length;
|
||||
const response = await client.conversations.list({
|
||||
types,
|
||||
exclude_archived: excludeArchived,
|
||||
limit: Math.min(SLACK_PAGE_SIZE, remaining),
|
||||
cursor,
|
||||
});
|
||||
|
||||
const page = response.channels ?? [];
|
||||
|
||||
for (const channel of page) {
|
||||
if (channels.length >= limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!isDefined(channel.id) || !isDefined(channel.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
channels.push({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
isPrivate: channel.is_private === true,
|
||||
isArchived: channel.is_archived === true,
|
||||
isMember: channel.is_member === true,
|
||||
numMembers: channel.num_members ?? 0,
|
||||
topic: channel.topic?.value ?? '',
|
||||
purpose: channel.purpose?.value ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
const nextCursor = response.response_metadata?.next_cursor;
|
||||
|
||||
if (!isDefined(nextCursor) || nextCursor.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
cursor = nextCursor;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
channels,
|
||||
count: channels.length,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
channels: [],
|
||||
count: 0,
|
||||
error: error instanceof Error ? error.message : 'Slack request failed',
|
||||
};
|
||||
}
|
||||
};
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { type SlackPostEphemeralMessageInput } from 'src/logic-functions/types/slack-post-ephemeral-message-input.type';
|
||||
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
|
||||
import { getSlackChatMessageBodyFields } from 'src/logic-functions/utils/get-slack-chat-message-body-fields';
|
||||
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
|
||||
import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure';
|
||||
|
||||
export const slackPostEphemeralMessageHandler = async (
|
||||
parameters: SlackPostEphemeralMessageInput,
|
||||
): Promise<SlackToolResult> => {
|
||||
const slackClientResult = await getSlackClient();
|
||||
|
||||
if (!slackClientResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Slack is not connected',
|
||||
error: slackClientResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const { client } = slackClientResult;
|
||||
|
||||
try {
|
||||
const bodyFields = getSlackChatMessageBodyFields(
|
||||
parameters.messageText,
|
||||
parameters.messageFormat,
|
||||
);
|
||||
|
||||
const postEphemeralPayload = {
|
||||
channel: parameters.slackChannelId,
|
||||
user: parameters.recipientSlackUserId,
|
||||
...bodyFields,
|
||||
};
|
||||
|
||||
await client.chat.postEphemeral(postEphemeralPayload);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Ephemeral message sent to the user in the channel.',
|
||||
channel: parameters.slackChannelId,
|
||||
};
|
||||
} catch (error) {
|
||||
return slackToolFailure('Failed to post Slack ephemeral message', error);
|
||||
}
|
||||
};
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type SlackPostMessageInput } from 'src/logic-functions/types/slack-post-message-input.type';
|
||||
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
|
||||
import { getSlackChatMessageBodyFields } from 'src/logic-functions/utils/get-slack-chat-message-body-fields';
|
||||
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
|
||||
import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure';
|
||||
|
||||
export const slackPostMessageHandler = async (
|
||||
parameters: SlackPostMessageInput,
|
||||
): Promise<SlackToolResult> => {
|
||||
const slackClientResult = await getSlackClient();
|
||||
|
||||
if (!slackClientResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Slack is not connected',
|
||||
error: slackClientResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const { client } = slackClientResult;
|
||||
|
||||
const parentTimestamp = parameters.parentMessageTimestamp;
|
||||
|
||||
try {
|
||||
const bodyFields = getSlackChatMessageBodyFields(
|
||||
parameters.messageText,
|
||||
parameters.messageFormat,
|
||||
);
|
||||
|
||||
const data = await client.chat.postMessage({
|
||||
channel: parameters.slackChannelId,
|
||||
thread_ts:
|
||||
isDefined(parentTimestamp) && parentTimestamp.trim().length > 0
|
||||
? parentTimestamp.trim()
|
||||
: undefined,
|
||||
...bodyFields,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: data.ts
|
||||
? `Message posted to Slack (ts=${data.ts}).`
|
||||
: 'Message posted to Slack.',
|
||||
slackTs: data.ts,
|
||||
channel: data.channel,
|
||||
};
|
||||
} catch (error) {
|
||||
return slackToolFailure('Failed to post Slack message', error);
|
||||
}
|
||||
};
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
|
||||
import { type SlackUpdateMessageInput } from 'src/logic-functions/types/slack-update-message-input.type';
|
||||
import { getSlackChatMessageBodyFields } from 'src/logic-functions/utils/get-slack-chat-message-body-fields';
|
||||
import { getSlackClient } from 'src/logic-functions/utils/get-slack-client';
|
||||
import { slackToolFailure } from 'src/logic-functions/utils/slack-tool-failure';
|
||||
|
||||
export const slackUpdateMessageHandler = async (
|
||||
parameters: SlackUpdateMessageInput,
|
||||
): Promise<SlackToolResult> => {
|
||||
const slackClientResult = await getSlackClient();
|
||||
|
||||
if (!slackClientResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Slack is not connected',
|
||||
error: slackClientResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
const { client } = slackClientResult;
|
||||
|
||||
try {
|
||||
const bodyFields = getSlackChatMessageBodyFields(
|
||||
parameters.newMessageText,
|
||||
parameters.messageFormat,
|
||||
);
|
||||
|
||||
const updatePayload = {
|
||||
channel: parameters.slackChannelId,
|
||||
ts: parameters.messageTimestamp,
|
||||
...bodyFields,
|
||||
};
|
||||
|
||||
const data = await client.chat.update(updatePayload);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Slack message updated.',
|
||||
slackTs: data.ts,
|
||||
channel: parameters.slackChannelId,
|
||||
};
|
||||
} catch (error) {
|
||||
return slackToolFailure('Failed to update Slack message', error);
|
||||
}
|
||||
};
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const slackAddReactionInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slackChannelId: {
|
||||
type: 'string',
|
||||
label: 'Slack channel (name or ID)',
|
||||
description:
|
||||
'Channel where the message lives (Slack channel ID like C…).',
|
||||
},
|
||||
messageTimestamp: {
|
||||
type: 'string',
|
||||
label: 'Message timestamp',
|
||||
description:
|
||||
'The message to react to: use the **timestamp** from Slack (same value as `slackTs` after posting, or visible in “Copy link” URLs as the last number).',
|
||||
},
|
||||
emojiName: {
|
||||
type: 'string',
|
||||
label: 'Emoji name',
|
||||
description:
|
||||
'Emoji to add, **without** colons — for example `thumbsup`, `eyes`, or `white_check_mark`. Do not paste `:thumbsup:` style names.',
|
||||
},
|
||||
},
|
||||
required: ['slackChannelId', 'messageTimestamp', 'emojiName'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const slackDeleteMessageInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slackChannelId: {
|
||||
type: 'string',
|
||||
label: 'Slack channel (name or ID)',
|
||||
description:
|
||||
'Channel that contains the message (Slack channel ID like C…).',
|
||||
},
|
||||
messageTimestamp: {
|
||||
type: 'string',
|
||||
label: 'Message timestamp',
|
||||
description:
|
||||
'Which message to remove: its **timestamp** from Slack (same value as `slackTs` when the bot posted it). Only messages sent by this bot can be deleted.',
|
||||
},
|
||||
},
|
||||
required: ['slackChannelId', 'messageTimestamp'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const slackListChannelsInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
channelType: {
|
||||
type: 'string',
|
||||
label: 'Channel type',
|
||||
enum: ['Public', 'Private', 'All'],
|
||||
description:
|
||||
'Optional. Which kinds of channels to include. `All` returns both public and private channels. Default: `All`.',
|
||||
},
|
||||
excludeArchived: {
|
||||
type: 'boolean',
|
||||
label: 'Exclude archived',
|
||||
description:
|
||||
'Optional. Hide archived channels from the list. Default: `true`.',
|
||||
},
|
||||
limit: {
|
||||
type: 'integer',
|
||||
label: 'Max results',
|
||||
minimum: 1,
|
||||
maximum: 1000,
|
||||
description:
|
||||
'Optional. Cap the total number of channels returned across pagination. Default: `200`. Max: `1000`. Larger workspaces may not be fully covered within the function timeout.',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const slackPostEphemeralMessageInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slackChannelId: {
|
||||
type: 'string',
|
||||
label: 'Slack channel (name or ID)',
|
||||
description:
|
||||
'Channel where the note appears (Slack channel ID like C…). The person you choose must already be in this channel, or they will not see the message.',
|
||||
},
|
||||
recipientSlackUserId: {
|
||||
type: 'string',
|
||||
label: 'Recipient (Slack user ID)',
|
||||
description:
|
||||
'Who can see this message: their Slack **member ID** (starts with U…). Only that person sees it; everyone else in the channel does not. In Slack: click their profile → three dots → Copy member ID (wording may vary by client).',
|
||||
},
|
||||
messageText: {
|
||||
type: 'string',
|
||||
label: 'Message',
|
||||
multiline: true,
|
||||
description:
|
||||
'Short note shown only to the recipient above — for example a private hint, validation result, or next step.',
|
||||
},
|
||||
messageFormat: {
|
||||
type: 'string',
|
||||
label: 'Message format',
|
||||
enum: ['plain', 'markdown'],
|
||||
description:
|
||||
'Optional. Same as **Send Slack Message**: `markdown` / `plain` / omit — see that action’s `messageFormat` description.',
|
||||
},
|
||||
},
|
||||
required: ['slackChannelId', 'recipientSlackUserId', 'messageText'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const slackPostMessageInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slackChannelId: {
|
||||
type: 'string',
|
||||
label: 'Slack channel (name or ID)',
|
||||
description:
|
||||
'Where to post: the Slack channel or DM ID (looks like C0123…, G… for private channels, or D… for DMs). In Slack: open the channel → channel name → scroll down → copy Channel ID. Using the ID is more reliable than a channel name.',
|
||||
},
|
||||
messageText: {
|
||||
type: 'string',
|
||||
label: 'Message',
|
||||
multiline: true,
|
||||
description:
|
||||
'The message people will read in Slack. Keep it concise; very long messages may be rejected by Slack.',
|
||||
},
|
||||
parentMessageTimestamp: {
|
||||
type: 'string',
|
||||
label: 'Parent message timestamp',
|
||||
description:
|
||||
'Optional. Only when you want a **thread reply**: paste the **Message timestamp** from an earlier Slack step (the value returned as `slackTs` after posting). Leave empty for a normal new message at the bottom of the channel.',
|
||||
},
|
||||
messageFormat: {
|
||||
type: 'string',
|
||||
label: 'Message format',
|
||||
enum: ['plain', 'markdown'],
|
||||
description:
|
||||
'Optional. `markdown`: send as Slack `markdown_text` so usual Markdown in `messageText` works (**bold**, lists, fenced code). `plain`: send as `text` with `mrkdwn: false` (no markup). Omit: send as `text` and let Slack use its default (legacy mrkdwn may still apply in `text`).',
|
||||
},
|
||||
},
|
||||
required: ['slackChannelId', 'messageText'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { type InputJsonSchema } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const slackUpdateMessageInputSchema: InputJsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slackChannelId: {
|
||||
type: 'string',
|
||||
label: 'Slack channel (name or ID)',
|
||||
description:
|
||||
'Channel that contains the message (Slack channel ID like C…).',
|
||||
},
|
||||
messageTimestamp: {
|
||||
type: 'string',
|
||||
label: 'Message timestamp',
|
||||
description:
|
||||
'Which message to edit: its **timestamp** from Slack (same value as `slackTs` returned when the bot posted the message). You cannot edit other people’s messages—only ones this bot sent.',
|
||||
},
|
||||
newMessageText: {
|
||||
type: 'string',
|
||||
label: 'New message',
|
||||
multiline: true,
|
||||
description:
|
||||
'Replacement text shown in Slack instead of the old content.',
|
||||
},
|
||||
messageFormat: {
|
||||
type: 'string',
|
||||
label: 'Message format',
|
||||
enum: ['plain', 'markdown'],
|
||||
description:
|
||||
'Optional. Same as **Send Slack Message**: `markdown` → `markdown_text`, `plain` → plain `text` without markup, omit → Slack default on `text`.',
|
||||
},
|
||||
},
|
||||
required: ['slackChannelId', 'messageTimestamp', 'newMessageText'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { SLACK_ADD_REACTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackAddReactionHandler } from 'src/logic-functions/handlers/slack-add-reaction-handler';
|
||||
import { slackAddReactionInputSchema } from './schemas/slack-add-reaction-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_ADD_REACTION_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-add-reaction',
|
||||
description:
|
||||
'Add an emoji reaction to a message (for example a checkmark as `white_check_mark`) so the channel can see status at a glance.',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: slackAddReactionInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'Add Slack Reaction',
|
||||
icon: 'IconBrandSlack',
|
||||
inputSchema: jsonSchemaToInputSchema(slackAddReactionInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
slackTs: { type: 'string' },
|
||||
channel: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: slackAddReactionHandler,
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { SLACK_DELETE_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackDeleteMessageHandler } from 'src/logic-functions/handlers/slack-delete-message-handler';
|
||||
import { slackDeleteMessageInputSchema } from './schemas/slack-delete-message-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_DELETE_MESSAGE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-delete-message',
|
||||
description:
|
||||
'Remove a message this bot sent (for example after a mistake or when a workflow is cancelled).',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: slackDeleteMessageInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'Delete Slack Message',
|
||||
icon: 'IconBrandSlack',
|
||||
inputSchema: jsonSchemaToInputSchema(slackDeleteMessageInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
slackTs: { type: 'string' },
|
||||
channel: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: slackDeleteMessageHandler,
|
||||
});
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import type { RoutePayload } from 'twenty-sdk/define';
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
|
||||
import { SLACK_LIST_CHANNELS_ROUTE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackListChannelsHandler } from 'src/logic-functions/handlers/slack-list-channels-handler';
|
||||
import { type SlackChannelType } from 'src/logic-functions/types/slack-list-channels-input.type';
|
||||
|
||||
const VALID_CHANNEL_TYPES: SlackChannelType[] = ['Public', 'Private', 'All'];
|
||||
|
||||
const isSlackChannelType = (value: unknown): value is SlackChannelType =>
|
||||
typeof value === 'string' &&
|
||||
VALID_CHANNEL_TYPES.includes(value as SlackChannelType);
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const params = event.queryStringParameters ?? {};
|
||||
|
||||
const rawLimit = params.limit;
|
||||
const parsedLimit =
|
||||
typeof rawLimit === 'string' && rawLimit.length > 0
|
||||
? Number(rawLimit)
|
||||
: undefined;
|
||||
|
||||
return slackListChannelsHandler({
|
||||
channelType: isSlackChannelType(params.channelType)
|
||||
? params.channelType
|
||||
: undefined,
|
||||
excludeArchived: params.excludeArchived === 'false' ? false : undefined,
|
||||
limit:
|
||||
typeof parsedLimit === 'number' && Number.isFinite(parsedLimit)
|
||||
? parsedLimit
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_LIST_CHANNELS_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-list-channels-route',
|
||||
timeoutSeconds: 30,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/slack/channels',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: true,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { SLACK_LIST_CHANNELS_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackListChannelsHandler } from 'src/logic-functions/handlers/slack-list-channels-handler';
|
||||
import { slackListChannelsInputSchema } from './schemas/slack-list-channels-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_LIST_CHANNELS_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-list-channels',
|
||||
description:
|
||||
'List Slack channels visible to the bot, with basic metadata (id, name, privacy, archive state, membership, member count, topic, purpose). Useful for picking a channel by name or branching a workflow on whether the bot is a member.',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: slackListChannelsInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'List Slack Channels',
|
||||
icon: 'IconBrandSlack',
|
||||
inputSchema: jsonSchemaToInputSchema(slackListChannelsInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
channels: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
isPrivate: { type: 'boolean' },
|
||||
isArchived: { type: 'boolean' },
|
||||
isMember: { type: 'boolean' },
|
||||
numMembers: { type: 'number' },
|
||||
topic: { type: 'string' },
|
||||
purpose: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
count: { type: 'number' },
|
||||
error: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: slackListChannelsHandler,
|
||||
});
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { SLACK_POST_EPHEMERAL_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackPostEphemeralMessageHandler } from 'src/logic-functions/handlers/slack-post-ephemeral-message-handler';
|
||||
import { slackPostEphemeralMessageInputSchema } from './schemas/slack-post-ephemeral-message-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_POST_EPHEMERAL_MESSAGE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-post-ephemeral-message',
|
||||
description:
|
||||
'Send a private-on-channel note: only the chosen teammate sees it in that channel (not a DM broadcast to everyone).',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: slackPostEphemeralMessageInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'Send Slack Ephemeral Message',
|
||||
icon: 'IconBrandSlack',
|
||||
inputSchema: jsonSchemaToInputSchema(slackPostEphemeralMessageInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
channel: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: slackPostEphemeralMessageHandler,
|
||||
});
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/define';
|
||||
|
||||
import { SLACK_POST_MESSAGE_ROUTE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackPostMessageHandler } from 'src/logic-functions/handlers/slack-post-message-handler';
|
||||
import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type';
|
||||
|
||||
const VALID_MESSAGE_FORMATS: SlackMessageBodyFormat[] = ['plain', 'markdown'];
|
||||
|
||||
const isSlackMessageBodyFormat = (
|
||||
value: unknown,
|
||||
): value is SlackMessageBodyFormat =>
|
||||
typeof value === 'string' &&
|
||||
VALID_MESSAGE_FORMATS.includes(value as SlackMessageBodyFormat);
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const body = event.body as Record<string, unknown> | null;
|
||||
|
||||
return slackPostMessageHandler({
|
||||
slackChannelId: (body?.slackChannelId as string | undefined) ?? '',
|
||||
messageText: (body?.messageText as string | undefined) ?? '',
|
||||
parentMessageTimestamp: body?.parentMessageTimestamp as string | undefined,
|
||||
messageFormat: isSlackMessageBodyFormat(body?.messageFormat)
|
||||
? body.messageFormat
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_POST_MESSAGE_ROUTE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-post-message-route',
|
||||
timeoutSeconds: 30,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/slack/messages',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: true,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { SLACK_POST_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackPostMessageHandler } from 'src/logic-functions/handlers/slack-post-message-handler';
|
||||
import { slackPostMessageInputSchema } from './schemas/slack-post-message-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_POST_MESSAGE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-post-message',
|
||||
description:
|
||||
'Send a message to a Slack channel or DM. Optionally reply inside an existing thread using the parent message’s timestamp from a previous step.',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: slackPostMessageInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'Send Slack Message',
|
||||
icon: 'IconBrandSlack',
|
||||
inputSchema: jsonSchemaToInputSchema(slackPostMessageInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
slackTs: { type: 'string' },
|
||||
channel: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: slackPostMessageHandler,
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { jsonSchemaToInputSchema } from 'twenty-shared/logic-function';
|
||||
|
||||
import { SLACK_UPDATE_MESSAGE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { slackUpdateMessageHandler } from 'src/logic-functions/handlers/slack-update-message-handler';
|
||||
import { slackUpdateMessageInputSchema } from './schemas/slack-update-message-input.schema';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: SLACK_UPDATE_MESSAGE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'slack-update-message',
|
||||
description:
|
||||
'Change the text of a message this bot already sent. You need the channel ID and the message’s timestamp from when it was posted.',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: slackUpdateMessageInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'Update Slack Message',
|
||||
icon: 'IconBrandSlack',
|
||||
inputSchema: jsonSchemaToInputSchema(slackUpdateMessageInputSchema),
|
||||
outputSchema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
slackTs: { type: 'string' },
|
||||
channel: { type: 'string' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
handler: slackUpdateMessageHandler,
|
||||
});
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export type SlackAddReactionInput = {
|
||||
slackChannelId: string;
|
||||
messageTimestamp: string;
|
||||
emojiName: string;
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type SlackDeleteMessageInput = {
|
||||
slackChannelId: string;
|
||||
messageTimestamp: string;
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export type SlackChannelType = 'Public' | 'Private' | 'All';
|
||||
|
||||
export type SlackListChannelsInput = {
|
||||
channelType?: SlackChannelType;
|
||||
excludeArchived?: boolean;
|
||||
limit?: number;
|
||||
};
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
export type SlackListChannelsResultChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
isPrivate: boolean;
|
||||
isArchived: boolean;
|
||||
isMember: boolean;
|
||||
numMembers: number;
|
||||
topic: string;
|
||||
purpose: string;
|
||||
};
|
||||
|
||||
export type SlackListChannelsResult = {
|
||||
success: boolean;
|
||||
channels: SlackListChannelsResultChannel[];
|
||||
count: number;
|
||||
error?: string;
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export type SlackMessageBodyFormat = 'plain' | 'markdown';
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type';
|
||||
|
||||
export type SlackPostEphemeralMessageInput = {
|
||||
slackChannelId: string;
|
||||
recipientSlackUserId: string;
|
||||
messageText: string;
|
||||
messageFormat?: SlackMessageBodyFormat;
|
||||
};
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type';
|
||||
|
||||
export type SlackPostMessageInput = {
|
||||
slackChannelId: string;
|
||||
messageText: string;
|
||||
parentMessageTimestamp?: string;
|
||||
messageFormat?: SlackMessageBodyFormat;
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export type SlackToolResult = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
error?: string;
|
||||
slackTs?: string;
|
||||
channel?: string;
|
||||
};
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type';
|
||||
|
||||
export type SlackUpdateMessageInput = {
|
||||
slackChannelId: string;
|
||||
messageTimestamp: string;
|
||||
newMessageText: string;
|
||||
messageFormat?: SlackMessageBodyFormat;
|
||||
};
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { type SlackMessageBodyFormat } from 'src/logic-functions/types/slack-message-body-format.type';
|
||||
|
||||
type SlackChatMessageBodyFields =
|
||||
| { markdown_text: string; text?: never; mrkdwn?: never }
|
||||
| { text: string; markdown_text?: never; mrkdwn?: boolean };
|
||||
|
||||
export const getSlackChatMessageBodyFields = (
|
||||
messageText: string,
|
||||
messageFormat: SlackMessageBodyFormat | undefined,
|
||||
): SlackChatMessageBodyFields => {
|
||||
switch (messageFormat) {
|
||||
case 'markdown':
|
||||
return { markdown_text: messageText };
|
||||
case 'plain':
|
||||
return { text: messageText, mrkdwn: false };
|
||||
default:
|
||||
return { text: messageText };
|
||||
}
|
||||
};
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { WebClient } from '@slack/web-api';
|
||||
|
||||
import { getSlackConnection } from 'src/logic-functions/utils/get-slack-connection';
|
||||
|
||||
export const getSlackClient = async (): Promise<
|
||||
{ success: true; client: WebClient } | { success: false; error: string }
|
||||
> => {
|
||||
const connectionResult = await getSlackConnection();
|
||||
|
||||
if (!connectionResult.success) {
|
||||
return connectionResult;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
client: new WebClient(connectionResult.accessToken),
|
||||
};
|
||||
};
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { listConnections } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const getSlackConnection = async (): Promise<
|
||||
{ success: true; accessToken: string } | { success: false; error: string }
|
||||
> => {
|
||||
try {
|
||||
const connections = await listConnections({ providerName: 'slack' });
|
||||
const connection =
|
||||
connections.find((item) => item.visibility === 'workspace') ??
|
||||
connections[0];
|
||||
|
||||
if (!connection) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'Slack is not connected. Open the Slack app settings and click "Add connection" first.',
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, accessToken: connection.accessToken };
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to load Slack connection.';
|
||||
|
||||
return { success: false, error: message };
|
||||
}
|
||||
};
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { type SlackToolResult } from 'src/logic-functions/types/slack-tool-result.type';
|
||||
|
||||
export const slackToolFailure = (
|
||||
message: string,
|
||||
error: unknown,
|
||||
): SlackToolResult => ({
|
||||
success: false,
|
||||
message,
|
||||
error: error instanceof Error ? error.message : 'Slack request failed',
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineRole } from 'twenty-sdk/define';
|
||||
|
||||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineRole({
|
||||
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Twenty Slack tools role',
|
||||
description:
|
||||
'No CRM data access — tools only forward requests to Slack using the configured connected account.',
|
||||
canReadAllObjectRecords: false,
|
||||
canUpdateAllObjectRecords: false,
|
||||
canSoftDeleteAllObjectRecords: false,
|
||||
canDestroyAllObjectRecords: false,
|
||||
canUpdateAllSettings: false,
|
||||
canBeAssignedToAgents: false,
|
||||
canBeAssignedToUsers: false,
|
||||
canBeAssignedToApiKeys: false,
|
||||
objectPermissions: [],
|
||||
fieldPermissions: [],
|
||||
permissionFlags: [],
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"allowUnreachableCode": false,
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": false,
|
||||
"target": "es2020",
|
||||
"module": "esnext",
|
||||
"lib": ["es2020", "dom"],
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"src/*": ["./src/*"],
|
||||
"~/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-client-sdk",
|
||||
"version": "2.5.1",
|
||||
"version": "2.6.0",
|
||||
"sideEffects": false,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
|
||||
@@ -2998,8 +2998,6 @@ type Query {
|
||||
myMessageFolders(messageChannelId: UUID): [MessageFolder!]!
|
||||
myMessageChannels(connectedAccountId: UUID): [MessageChannel!]!
|
||||
myConnectedAccounts: [ConnectedAccountPublicDTO!]!
|
||||
connectedAccountById(id: UUID!): ConnectedAccountPublicDTO
|
||||
connectedAccounts: [ConnectedAccountPublicDTO!]!
|
||||
myCalendarChannels(connectedAccountId: UUID): [CalendarChannel!]!
|
||||
webhooks: [Webhook!]!
|
||||
webhook(id: UUID!): Webhook
|
||||
|
||||
@@ -2598,8 +2598,6 @@ export interface Query {
|
||||
myMessageFolders: MessageFolder[]
|
||||
myMessageChannels: MessageChannel[]
|
||||
myConnectedAccounts: ConnectedAccountPublicDTO[]
|
||||
connectedAccountById?: ConnectedAccountPublicDTO
|
||||
connectedAccounts: ConnectedAccountPublicDTO[]
|
||||
myCalendarChannels: CalendarChannel[]
|
||||
webhooks: Webhook[]
|
||||
webhook?: Webhook
|
||||
@@ -5631,8 +5629,6 @@ export interface QueryGenqlSelection{
|
||||
myMessageFolders?: (MessageFolderGenqlSelection & { __args?: {messageChannelId?: (Scalars['UUID'] | null)} })
|
||||
myMessageChannels?: (MessageChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
|
||||
myConnectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
|
||||
connectedAccountById?: (ConnectedAccountPublicDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
connectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
|
||||
myCalendarChannels?: (CalendarChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
|
||||
webhooks?: WebhookGenqlSelection
|
||||
webhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
|
||||
@@ -6176,18 +6176,6 @@ export default {
|
||||
"myConnectedAccounts": [
|
||||
269
|
||||
],
|
||||
"connectedAccountById": [
|
||||
269,
|
||||
{
|
||||
"id": [
|
||||
3,
|
||||
"UUID!"
|
||||
]
|
||||
}
|
||||
],
|
||||
"connectedAccounts": [
|
||||
269
|
||||
],
|
||||
"myCalendarChannels": [
|
||||
300,
|
||||
{
|
||||
|
||||
@@ -26,12 +26,6 @@ prod-run:
|
||||
prod-postgres-run:
|
||||
@docker run -d -p 5432:5432 -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres --name twenty-postgres twenty-postgres:$(TAG)
|
||||
|
||||
prod-website-new-build:
|
||||
@cd ../.. && docker build -f ./packages/twenty-docker/twenty-website-new/Dockerfile --platform $(PLATFORM) --tag twenty-website-new:$(TAG) . && cd -
|
||||
|
||||
prod-website-new-run:
|
||||
@docker run -d -p 3000:3000 --name twenty-website-new twenty-website-new:$(TAG)
|
||||
|
||||
# =============================================================================
|
||||
# Local Development Services
|
||||
# Run these from the repository root: make -C packages/twenty-docker <target>
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
FROM node:24.15.0-alpine3.23@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f AS twenty-website-new-build
|
||||
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./package.json .
|
||||
COPY ./yarn.lock .
|
||||
COPY ./.yarnrc.yml .
|
||||
COPY ./tsconfig.base.json .
|
||||
COPY ./nx.json .
|
||||
COPY ./.yarn/releases /app/.yarn/releases
|
||||
COPY ./.yarn/patches /app/.yarn/patches
|
||||
COPY ./packages/twenty-oxlint-rules /app/packages/twenty-oxlint-rules
|
||||
COPY ./packages/twenty-shared/package.json /app/packages/twenty-shared/package.json
|
||||
COPY ./packages/twenty-website-new/package.json /app/packages/twenty-website-new/package.json
|
||||
|
||||
RUN yarn
|
||||
|
||||
COPY ./packages/twenty-shared /app/packages/twenty-shared
|
||||
COPY ./packages/twenty-website-new /app/packages/twenty-website-new
|
||||
RUN npx nx build twenty-website-new
|
||||
|
||||
FROM node:24.15.0-alpine3.23@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f AS twenty-website-new
|
||||
|
||||
WORKDIR /app/packages/twenty-website-new
|
||||
|
||||
COPY --from=twenty-website-new-build /app /app
|
||||
|
||||
WORKDIR /app/packages/twenty-website-new
|
||||
|
||||
LABEL org.opencontainers.image.source=https://github.com/twentyhq/twenty
|
||||
LABEL org.opencontainers.image.description="This image provides a consistent and reproducible environment for the new marketing website."
|
||||
|
||||
RUN chown -R 1000 /app
|
||||
|
||||
# Use non root user with uid 1000
|
||||
USER 1000
|
||||
|
||||
CMD ["/bin/sh", "-c", "npx nx start twenty-website-new"]
|
||||
@@ -13,8 +13,8 @@ Each function file uses `defineLogicFunction()` to export a configuration with a
|
||||
|
||||
```ts src/logic-functions/createPostCard.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk/define';
|
||||
import { CoreApiClient, type Person } from 'twenty-client-sdk/core';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
const handler = async (params: RoutePayload) => {
|
||||
const client = new CoreApiClient();
|
||||
@@ -79,10 +79,10 @@ yarn twenty logs
|
||||
|
||||
When a route trigger invokes your logic function, it receives a `RoutePayload` object that follows the
|
||||
[AWS HTTP API v2 format](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html).
|
||||
Import the `RoutePayload` type from `twenty-sdk`:
|
||||
Import the `RoutePayload` type from `twenty-sdk/logic-function`:
|
||||
|
||||
```ts
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const { headers, queryStringParameters, pathParameters, body } = event;
|
||||
@@ -141,6 +141,115 @@ const handler = async (event: RoutePayload) => {
|
||||
Header names are normalized to lowercase. Access them using lowercase keys (e.g., `event.headers['content-type']`).
|
||||
</Note>
|
||||
|
||||
#### Database event trigger payload
|
||||
|
||||
When a database event trigger invokes your logic function, it receives one `DatabaseEventPayload` per changed record. The payload combines metadata about the source workspace and object with the record-level event.
|
||||
|
||||
```ts
|
||||
import type {
|
||||
DatabaseEventPayload,
|
||||
ObjectRecordCreateEvent,
|
||||
ObjectRecordDestroyEvent,
|
||||
ObjectRecordUpdateEvent,
|
||||
} from 'twenty-sdk/logic-function';
|
||||
|
||||
type Person = {
|
||||
id: string;
|
||||
emails?: { primaryEmail?: string };
|
||||
};
|
||||
```
|
||||
|
||||
The payload includes:
|
||||
|
||||
| Property | Description |
|
||||
|----------|-------------|
|
||||
| `name` | Event name, such as `person.updated`. |
|
||||
| `workspaceId` | Workspace where the event happened. |
|
||||
| `objectMetadata` | Metadata for the object that changed. |
|
||||
| `recordId` | Id of the changed record. |
|
||||
| `userId`, `userWorkspaceId`, `workspaceMemberId` | Actor fields when the event was caused by a workspace user. |
|
||||
| `properties` | Record data for the event, with `before`, `after`, `diff`, and `updatedFields` depending on the operation. |
|
||||
|
||||
| Event | Record data |
|
||||
|-------|-------------|
|
||||
| `person.created` | `event.properties.after` |
|
||||
| `person.updated` | `event.properties.before`, `event.properties.after`, `event.properties.diff`, `event.properties.updatedFields` |
|
||||
| `person.destroyed` | `event.properties.before` |
|
||||
|
||||
For soft deletes, `.deleted` follows the update-style shape because the record's `deletedAt` field changes.
|
||||
For permanent deletes, use `.destroyed`.
|
||||
|
||||
<Note>
|
||||
`databaseEventTriggerSettings.updatedFields` filters which update events trigger the function.
|
||||
`event.properties.updatedFields` tells you which fields actually changed on the current event.
|
||||
</Note>
|
||||
|
||||
Created event example:
|
||||
|
||||
```ts
|
||||
type PersonCreatedEvent = DatabaseEventPayload<
|
||||
ObjectRecordCreateEvent<Person>
|
||||
>;
|
||||
|
||||
const handler = async (event: PersonCreatedEvent) => {
|
||||
const person = event.properties.after;
|
||||
|
||||
return {
|
||||
personId: event.recordId,
|
||||
email: person.emails?.primaryEmail,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Updated event example:
|
||||
|
||||
```ts
|
||||
type PersonUpdatedEvent = DatabaseEventPayload<
|
||||
ObjectRecordUpdateEvent<Person>
|
||||
>;
|
||||
|
||||
const handler = async (event: PersonUpdatedEvent) => {
|
||||
const { before, after, diff, updatedFields } = event.properties;
|
||||
|
||||
return {
|
||||
personId: event.recordId,
|
||||
updatedFields,
|
||||
previousEmail: before.emails?.primaryEmail,
|
||||
currentEmail: after.emails?.primaryEmail,
|
||||
emailDiff: diff.emails,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Trigger only on email updates:
|
||||
|
||||
```ts
|
||||
export default defineLogicFunction({
|
||||
...,
|
||||
databaseEventTriggerSettings: {
|
||||
eventName: 'person.updated',
|
||||
updatedFields: ['emails'],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Destroyed event example:
|
||||
|
||||
```ts
|
||||
type PersonDestroyedEvent = DatabaseEventPayload<
|
||||
ObjectRecordDestroyEvent<Person>
|
||||
>;
|
||||
|
||||
const handler = async (event: PersonDestroyedEvent) => {
|
||||
const personBeforeDestroy = event.properties.before;
|
||||
|
||||
return {
|
||||
personId: event.recordId,
|
||||
email: personBeforeDestroy.emails?.primaryEmail,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
#### Exposing a function as an AI tool or workflow action
|
||||
|
||||
Logic functions can be exposed on two surfaces, each with its own trigger:
|
||||
|
||||
@@ -13,8 +13,8 @@ Jede Funktionsdatei verwendet `defineLogicFunction()`, um eine Konfiguration mit
|
||||
|
||||
```ts src/logic-functions/createPostCard.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk/define';
|
||||
import { CoreApiClient, type Person } from 'twenty-client-sdk/core';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
const handler = async (params: RoutePayload) => {
|
||||
const client = new CoreApiClient();
|
||||
@@ -78,10 +78,10 @@ yarn twenty logs
|
||||
#### Routen-Trigger-Payload
|
||||
|
||||
Wenn ein Route-Trigger Ihre Logikfunktion aufruft, erhält sie ein `RoutePayload`-Objekt, das dem [AWS-HTTP-API-v2-Format](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html) folgt.
|
||||
Importieren Sie den Typ `RoutePayload` aus `twenty-sdk`:
|
||||
Importieren Sie den Typ `RoutePayload` aus `twenty-sdk/logic-function`:
|
||||
|
||||
```ts
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const { headers, queryStringParameters, pathParameters, body } = event;
|
||||
@@ -140,6 +140,115 @@ const handler = async (event: RoutePayload) => {
|
||||
Header-Namen werden in Kleinbuchstaben normalisiert. Greifen Sie mit Schlüsseln in Kleinbuchstaben darauf zu (z. B. `event.headers['content-type']`).
|
||||
</Note>
|
||||
|
||||
#### Datenbank-Event-Trigger-Payload
|
||||
|
||||
Wenn ein Datenbank-Event-Trigger Ihre Logic Function aufruft, erhält sie eine `DatabaseEventPayload` pro geändertem Datensatz. Die Payload kombiniert Metadaten über den Quell-Workspace und das Objekt mit dem Ereignis auf Datensatzebene.
|
||||
|
||||
```ts
|
||||
import type {
|
||||
DatabaseEventPayload,
|
||||
ObjectRecordCreateEvent,
|
||||
ObjectRecordDestroyEvent,
|
||||
ObjectRecordUpdateEvent,
|
||||
} from 'twenty-sdk/logic-function';
|
||||
|
||||
type Person = {
|
||||
id: string;
|
||||
emails?: { primaryEmail?: string };
|
||||
};
|
||||
```
|
||||
|
||||
Die Nutzlast umfasst:
|
||||
|
||||
| Eigenschaft | Beschreibung |
|
||||
| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `name` | Ereignisname, z. B. `person.updated`. |
|
||||
| `workspaceId` | Arbeitsbereich, in dem das Ereignis stattgefunden hat. |
|
||||
| `objectMetadata` | Metadaten für das Objekt, das geändert wurde. |
|
||||
| `recordId` | ID des geänderten Datensatzes. |
|
||||
| `userId`, `userWorkspaceId`, `workspaceMemberId` | Akteurfelder, wenn das Ereignis von einem Benutzer des Arbeitsbereichs ausgelöst wurde. |
|
||||
| `properties` | Datensatzdaten für das Ereignis mit `before`, `after`, `diff` und `updatedFields`, abhängig von der Operation. |
|
||||
|
||||
| Ereignis | Datensatzdaten |
|
||||
| ------------------ | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `person.created` | `event.properties.after` |
|
||||
| `person.updated` | `event.properties.before`, `event.properties.after`, `event.properties.diff`, `event.properties.updatedFields` |
|
||||
| `person.destroyed` | `event.properties.before` |
|
||||
|
||||
Bei Soft Deletes folgt `.deleted` der Aktualisierungsstruktur, da sich das Feld `deletedAt` des Datensatzes ändert.
|
||||
Für dauerhafte Löschvorgänge verwende `.destroyed`.
|
||||
|
||||
<Note>
|
||||
`databaseEventTriggerSettings.updatedFields` filtert, welche Aktualisierungsereignisse die Funktion auslösen.
|
||||
`event.properties.updatedFields` gibt an, welche Felder beim aktuellen Ereignis tatsächlich geändert wurden.
|
||||
</Note>
|
||||
|
||||
Beispiel für ein Created-Ereignis:
|
||||
|
||||
```ts
|
||||
type PersonCreatedEvent = DatabaseEventPayload<
|
||||
ObjectRecordCreateEvent<Person>
|
||||
>;
|
||||
|
||||
const handler = async (event: PersonCreatedEvent) => {
|
||||
const person = event.properties.after;
|
||||
|
||||
return {
|
||||
personId: event.recordId,
|
||||
email: person.emails?.primaryEmail,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Beispiel für ein Updated-Ereignis:
|
||||
|
||||
```ts
|
||||
type PersonUpdatedEvent = DatabaseEventPayload<
|
||||
ObjectRecordUpdateEvent<Person>
|
||||
>;
|
||||
|
||||
const handler = async (event: PersonUpdatedEvent) => {
|
||||
const { before, after, diff, updatedFields } = event.properties;
|
||||
|
||||
return {
|
||||
personId: event.recordId,
|
||||
updatedFields,
|
||||
previousEmail: before.emails?.primaryEmail,
|
||||
currentEmail: after.emails?.primaryEmail,
|
||||
emailDiff: diff.emails,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Nur bei E-Mail-Aktualisierungen auslösen:
|
||||
|
||||
```ts
|
||||
export default defineLogicFunction({
|
||||
...,
|
||||
databaseEventTriggerSettings: {
|
||||
eventName: 'person.updated',
|
||||
updatedFields: ['emails'],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Beispiel für ein Destroyed-Ereignis:
|
||||
|
||||
```ts
|
||||
type PersonDestroyedEvent = DatabaseEventPayload<
|
||||
ObjectRecordDestroyEvent<Person>
|
||||
>;
|
||||
|
||||
const handler = async (event: PersonDestroyedEvent) => {
|
||||
const personBeforeDestroy = event.properties.before;
|
||||
|
||||
return {
|
||||
personId: event.recordId,
|
||||
email: personBeforeDestroy.emails?.primaryEmail,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
#### Eine Funktion als KI-Tool oder Workflow-Aktion verfügbar machen
|
||||
|
||||
Logikfunktionen können auf zwei Oberflächen verfügbar gemacht werden, jeweils mit eigenem Trigger:
|
||||
|
||||
@@ -13,8 +13,8 @@ Cada arquivo de função usa `defineLogicFunction()` para exportar uma configura
|
||||
|
||||
```ts src/logic-functions/createPostCard.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk/define';
|
||||
import { CoreApiClient, type Person } from 'twenty-client-sdk/core';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
const handler = async (params: RoutePayload) => {
|
||||
const client = new CoreApiClient();
|
||||
@@ -78,10 +78,10 @@ yarn twenty logs
|
||||
#### Payload de gatilho de rota
|
||||
|
||||
Quando um gatilho de rota invoca sua função de lógica, ela recebe um objeto `RoutePayload` que segue o [formato HTTP API v2 da AWS](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html).
|
||||
Importe o tipo `RoutePayload` de `twenty-sdk`:
|
||||
Importe o tipo `RoutePayload` de `twenty-sdk/logic-function`:
|
||||
|
||||
```ts
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const { headers, queryStringParameters, pathParameters, body } = event;
|
||||
@@ -140,6 +140,115 @@ const handler = async (event: RoutePayload) => {
|
||||
Os nomes dos cabeçalhos são normalizados para minúsculas. Acesse-os usando chaves em minúsculas (por exemplo, `event.headers['content-type']`).
|
||||
</Note>
|
||||
|
||||
#### Payload do gatilho de evento do banco de dados
|
||||
|
||||
Quando um gatilho de evento do banco de dados invoca sua função lógica, ela recebe um `DatabaseEventPayload` por registro alterado. O payload combina metadados sobre o workspace e o objeto de origem com o evento em nível de registro.
|
||||
|
||||
```ts
|
||||
import type {
|
||||
DatabaseEventPayload,
|
||||
ObjectRecordCreateEvent,
|
||||
ObjectRecordDestroyEvent,
|
||||
ObjectRecordUpdateEvent,
|
||||
} from 'twenty-sdk/logic-function';
|
||||
|
||||
type Person = {
|
||||
id: string;
|
||||
emails?: { primaryEmail?: string };
|
||||
};
|
||||
```
|
||||
|
||||
O payload inclui:
|
||||
|
||||
| Propriedade | Descrição |
|
||||
| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
|
||||
| `name` | Nome do evento, como `person.updated`. |
|
||||
| `workspaceId` | Workspace onde o evento aconteceu. |
|
||||
| `objectMetadata` | Metadados do objeto que foi alterado. |
|
||||
| `recordId` | ID do registro alterado. |
|
||||
| `userId`, `userWorkspaceId`, `workspaceMemberId` | Campos do autor quando o evento foi causado por um usuário do workspace. |
|
||||
| `properties` | Dados do registro para o evento, com `before`, `after`, `diff` e `updatedFields`, dependendo da operação. |
|
||||
|
||||
| Evento | Dados do registro |
|
||||
| ------------------ | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `person.created` | `event.properties.after` |
|
||||
| `person.updated` | `event.properties.before`, `event.properties.after`, `event.properties.diff`, `event.properties.updatedFields` |
|
||||
| `person.destroyed` | `event.properties.before` |
|
||||
|
||||
Para exclusões lógicas, `.deleted` segue o formato de atualização porque o campo `deletedAt` do registro é alterado.
|
||||
Para exclusões permanentes, use `.destroyed`.
|
||||
|
||||
<Note>
|
||||
`databaseEventTriggerSettings.updatedFields` filtra quais eventos de atualização disparam a função.
|
||||
`event.properties.updatedFields` informa quais campos realmente foram alterados no evento atual.
|
||||
</Note>
|
||||
|
||||
Exemplo de evento de criação:
|
||||
|
||||
```ts
|
||||
type PersonCreatedEvent = DatabaseEventPayload<
|
||||
ObjectRecordCreateEvent<Person>
|
||||
>;
|
||||
|
||||
const handler = async (event: PersonCreatedEvent) => {
|
||||
const person = event.properties.after;
|
||||
|
||||
return {
|
||||
personId: event.recordId,
|
||||
email: person.emails?.primaryEmail,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Exemplo de evento de atualização:
|
||||
|
||||
```ts
|
||||
type PersonUpdatedEvent = DatabaseEventPayload<
|
||||
ObjectRecordUpdateEvent<Person>
|
||||
>;
|
||||
|
||||
const handler = async (event: PersonUpdatedEvent) => {
|
||||
const { before, after, diff, updatedFields } = event.properties;
|
||||
|
||||
return {
|
||||
personId: event.recordId,
|
||||
updatedFields,
|
||||
previousEmail: before.emails?.primaryEmail,
|
||||
currentEmail: after.emails?.primaryEmail,
|
||||
emailDiff: diff.emails,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Disparar somente em atualizações de email:
|
||||
|
||||
```ts
|
||||
export default defineLogicFunction({
|
||||
...,
|
||||
databaseEventTriggerSettings: {
|
||||
eventName: 'person.updated',
|
||||
updatedFields: ['emails'],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Exemplo de evento de destruição:
|
||||
|
||||
```ts
|
||||
type PersonDestroyedEvent = DatabaseEventPayload<
|
||||
ObjectRecordDestroyEvent<Person>
|
||||
>;
|
||||
|
||||
const handler = async (event: PersonDestroyedEvent) => {
|
||||
const personBeforeDestroy = event.properties.before;
|
||||
|
||||
return {
|
||||
personId: event.recordId,
|
||||
email: personBeforeDestroy.emails?.primaryEmail,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
#### Expor uma função como ferramenta de IA ou como ação de fluxo de trabalho
|
||||
|
||||
As funções de lógica podem ser expostas em duas superfícies, cada uma com seu próprio gatilho:
|
||||
|
||||
@@ -13,8 +13,8 @@ Fiecare fișier de funcție folosește `defineLogicFunction()` pentru a exporta
|
||||
|
||||
```ts src/logic-functions/createPostCard.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk/define';
|
||||
import { CoreApiClient, type Person } from 'twenty-client-sdk/core';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
const handler = async (params: RoutePayload) => {
|
||||
const client = new CoreApiClient();
|
||||
@@ -79,10 +79,10 @@ yarn twenty logs
|
||||
|
||||
Când un declanșator de rută invocă funcția logică, aceasta primește un obiect `RoutePayload` care urmează
|
||||
[AWS HTTP API v2 format](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html).
|
||||
Importați tipul `RoutePayload` din `twenty-sdk`:
|
||||
Importați tipul `RoutePayload` din `twenty-sdk/logic-function`:
|
||||
|
||||
```ts
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const { headers, queryStringParameters, pathParameters, body } = event;
|
||||
@@ -141,6 +141,115 @@ const handler = async (event: RoutePayload) => {
|
||||
Numele anteturilor sunt normalizate la litere mici. Accesați-le folosind chei cu litere mici (de exemplu, `event.headers['content-type']`).
|
||||
</Note>
|
||||
|
||||
#### Payload-ul declanșatorului de eveniment al bazei de date
|
||||
|
||||
Când un declanșator de eveniment al bazei de date apelează funcția dvs. logică, aceasta primește un `DatabaseEventPayload` pentru fiecare înregistrare modificată. Payload-ul combină metadatele despre spațiul de lucru și obiectul sursă cu evenimentul la nivel de înregistrare.
|
||||
|
||||
```ts
|
||||
import type {
|
||||
DatabaseEventPayload,
|
||||
ObjectRecordCreateEvent,
|
||||
ObjectRecordDestroyEvent,
|
||||
ObjectRecordUpdateEvent,
|
||||
} from 'twenty-sdk/logic-function';
|
||||
|
||||
type Person = {
|
||||
id: string;
|
||||
emails?: { primaryEmail?: string };
|
||||
};
|
||||
```
|
||||
|
||||
Payload-ul include:
|
||||
|
||||
| Proprietate | Descriere |
|
||||
| ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `name` | Numele evenimentului, cum ar fi `person.updated`. |
|
||||
| `workspaceId` | Spațiul de lucru în care a avut loc evenimentul. |
|
||||
| `objectMetadata` | Metadate pentru obiectul care s-a modificat. |
|
||||
| `recordId` | ID-ul înregistrării modificate. |
|
||||
| `userId`, `userWorkspaceId`, `workspaceMemberId` | Câmpurile actorului atunci când evenimentul a fost cauzat de un utilizator al spațiului de lucru. |
|
||||
| `properties` | Datele înregistrării pentru eveniment, cu `before`, `after`, `diff` și `updatedFields` în funcție de operație. |
|
||||
|
||||
| Eveniment | Datele înregistrării |
|
||||
| ------------------ | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `person.created` | `event.properties.after` |
|
||||
| `person.updated` | `event.properties.before`, `event.properties.after`, `event.properties.diff`, `event.properties.updatedFields` |
|
||||
| `person.destroyed` | `event.properties.before` |
|
||||
|
||||
Pentru ștergeri logice (soft delete), `.deleted` urmează structura de tip update deoarece câmpul `deletedAt` al înregistrării se modifică.
|
||||
Pentru ștergeri permanente, folosiți `.destroyed`.
|
||||
|
||||
<Note>
|
||||
`databaseEventTriggerSettings.updatedFields` filtrează ce evenimente de actualizare declanșează funcția.
|
||||
`event.properties.updatedFields` vă indică ce câmpuri s-au modificat efectiv în evenimentul curent.
|
||||
</Note>
|
||||
|
||||
Exemplu de eveniment de creare:
|
||||
|
||||
```ts
|
||||
type PersonCreatedEvent = DatabaseEventPayload<
|
||||
ObjectRecordCreateEvent<Person>
|
||||
>;
|
||||
|
||||
const handler = async (event: PersonCreatedEvent) => {
|
||||
const person = event.properties.after;
|
||||
|
||||
return {
|
||||
personId: event.recordId,
|
||||
email: person.emails?.primaryEmail,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Exemplu de eveniment de actualizare:
|
||||
|
||||
```ts
|
||||
type PersonUpdatedEvent = DatabaseEventPayload<
|
||||
ObjectRecordUpdateEvent<Person>
|
||||
>;
|
||||
|
||||
const handler = async (event: PersonUpdatedEvent) => {
|
||||
const { before, after, diff, updatedFields } = event.properties;
|
||||
|
||||
return {
|
||||
personId: event.recordId,
|
||||
updatedFields,
|
||||
previousEmail: before.emails?.primaryEmail,
|
||||
currentEmail: after.emails?.primaryEmail,
|
||||
emailDiff: diff.emails,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Declanșare doar la actualizări ale e-mailului:
|
||||
|
||||
```ts
|
||||
export default defineLogicFunction({
|
||||
...,
|
||||
databaseEventTriggerSettings: {
|
||||
eventName: 'person.updated',
|
||||
updatedFields: ['emails'],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Exemplu de eveniment de ștergere:
|
||||
|
||||
```ts
|
||||
type PersonDestroyedEvent = DatabaseEventPayload<
|
||||
ObjectRecordDestroyEvent<Person>
|
||||
>;
|
||||
|
||||
const handler = async (event: PersonDestroyedEvent) => {
|
||||
const personBeforeDestroy = event.properties.before;
|
||||
|
||||
return {
|
||||
personId: event.recordId,
|
||||
email: personBeforeDestroy.emails?.primaryEmail,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
#### Expunerea unei funcții ca instrument AI sau ca acțiune în fluxul de lucru
|
||||
|
||||
Funcțiile logice pot fi expuse în două locuri, fiecare cu propriul declanșator:
|
||||
|
||||
@@ -13,8 +13,8 @@ icon: bolt
|
||||
|
||||
```ts src/logic-functions/createPostCard.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk/define';
|
||||
import { CoreApiClient, type Person } from 'twenty-client-sdk/core';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
const handler = async (params: RoutePayload) => {
|
||||
const client = new CoreApiClient();
|
||||
@@ -78,10 +78,10 @@ yarn twenty logs
|
||||
#### Полезная нагрузка триггера маршрута
|
||||
|
||||
Когда триггер маршрута вызывает вашу логическую функцию, она получает объект `RoutePayload`, который соответствует [формату AWS HTTP API v2](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html).
|
||||
Импортируйте тип `RoutePayload` из `twenty-sdk`:
|
||||
Импортируйте тип `RoutePayload` из `twenty-sdk/logic-function`:
|
||||
|
||||
```ts
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const { headers, queryStringParameters, pathParameters, body } = event;
|
||||
@@ -140,6 +140,115 @@ const handler = async (event: RoutePayload) => {
|
||||
Имена заголовков приводятся к нижнему регистру. Обращайтесь к ним, используя ключи в нижнем регистре (например, `event.headers['content-type']`).
|
||||
</Note>
|
||||
|
||||
#### Полезная нагрузка триггера события базы данных
|
||||
|
||||
Когда триггер события базы данных вызывает вашу функцию логики, она получает по одному `DatabaseEventPayload` на каждую изменённую запись. Полезная нагрузка объединяет метаданные о рабочем пространстве-источнике и объекте с событием на уровне записи.
|
||||
|
||||
```ts
|
||||
import type {
|
||||
DatabaseEventPayload,
|
||||
ObjectRecordCreateEvent,
|
||||
ObjectRecordDestroyEvent,
|
||||
ObjectRecordUpdateEvent,
|
||||
} from 'twenty-sdk/logic-function';
|
||||
|
||||
type Person = {
|
||||
id: string;
|
||||
emails?: { primaryEmail?: string };
|
||||
};
|
||||
```
|
||||
|
||||
Полезная нагрузка включает:
|
||||
|
||||
| Свойство | Описание |
|
||||
| ------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
|
||||
| `name` | Имя события, например `person.updated`. |
|
||||
| `workspaceId` | Рабочее пространство, в котором произошло событие. |
|
||||
| `objectMetadata` | Метаданные для объекта, который изменился. |
|
||||
| `recordId` | Идентификатор измененной записи. |
|
||||
| `userId`, `userWorkspaceId`, `workspaceMemberId` | Поля инициатора, если событие было вызвано пользователем рабочего пространства. |
|
||||
| `properties` | Данные записи для события с `before`, `after`, `diff` и `updatedFields` в зависимости от операции. |
|
||||
|
||||
| Событие | Данные записи |
|
||||
| ------------------ | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `person.created` | `event.properties.after` |
|
||||
| `person.updated` | `event.properties.before`, `event.properties.after`, `event.properties.diff`, `event.properties.updatedFields` |
|
||||
| `person.destroyed` | `event.properties.before` |
|
||||
|
||||
При логическом удалении `.deleted` имеет формат обновления, поскольку изменяется поле `deletedAt` записи.
|
||||
Для окончательного удаления используйте `.destroyed`.
|
||||
|
||||
<Note>
|
||||
`databaseEventTriggerSettings.updatedFields` фильтрует, какие события обновления запускают функцию.
|
||||
`event.properties.updatedFields` указывает, какие поля фактически изменились в текущем событии.
|
||||
</Note>
|
||||
|
||||
Пример события создания:
|
||||
|
||||
```ts
|
||||
type PersonCreatedEvent = DatabaseEventPayload<
|
||||
ObjectRecordCreateEvent<Person>
|
||||
>;
|
||||
|
||||
const handler = async (event: PersonCreatedEvent) => {
|
||||
const person = event.properties.after;
|
||||
|
||||
return {
|
||||
personId: event.recordId,
|
||||
email: person.emails?.primaryEmail,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Пример события обновления:
|
||||
|
||||
```ts
|
||||
type PersonUpdatedEvent = DatabaseEventPayload<
|
||||
ObjectRecordUpdateEvent<Person>
|
||||
>;
|
||||
|
||||
const handler = async (event: PersonUpdatedEvent) => {
|
||||
const { before, after, diff, updatedFields } = event.properties;
|
||||
|
||||
return {
|
||||
personId: event.recordId,
|
||||
updatedFields,
|
||||
previousEmail: before.emails?.primaryEmail,
|
||||
currentEmail: after.emails?.primaryEmail,
|
||||
emailDiff: diff.emails,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Триггер только при обновлении email:
|
||||
|
||||
```ts
|
||||
export default defineLogicFunction({
|
||||
...,
|
||||
databaseEventTriggerSettings: {
|
||||
eventName: 'person.updated',
|
||||
updatedFields: ['emails'],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Пример события уничтожения:
|
||||
|
||||
```ts
|
||||
type PersonDestroyedEvent = DatabaseEventPayload<
|
||||
ObjectRecordDestroyEvent<Person>
|
||||
>;
|
||||
|
||||
const handler = async (event: PersonDestroyedEvent) => {
|
||||
const personBeforeDestroy = event.properties.before;
|
||||
|
||||
return {
|
||||
personId: event.recordId,
|
||||
email: personBeforeDestroy.emails?.primaryEmail,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
#### Предоставление функции в качестве инструмента ИИ или действия рабочего процесса
|
||||
|
||||
Функции логики могут быть представлены в двух интерфейсах, у каждого — свой триггер:
|
||||
|
||||
@@ -13,8 +13,8 @@ Her işlev dosyası, bir işleyici ve isteğe bağlı tetikleyiciler içeren bir
|
||||
|
||||
```ts src/logic-functions/createPostCard.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk/define';
|
||||
import { CoreApiClient, type Person } from 'twenty-client-sdk/core';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
const handler = async (params: RoutePayload) => {
|
||||
const client = new CoreApiClient();
|
||||
@@ -79,10 +79,10 @@ yarn twenty logs
|
||||
|
||||
Bir rota tetikleyicisi mantık fonksiyonunuzu çağırdığında,
|
||||
[AWS HTTP API v2 formatını](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html) izleyen bir `RoutePayload` nesnesi alır.
|
||||
`RoutePayload` türünü `twenty-sdk` içinden içe aktarın:
|
||||
`RoutePayload` türünü `twenty-sdk/logic-function` içinden içe aktarın:
|
||||
|
||||
```ts
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const { headers, queryStringParameters, pathParameters, body } = event;
|
||||
@@ -141,6 +141,115 @@ const handler = async (event: RoutePayload) => {
|
||||
Başlık adları küçük harfe normalize edilir. Onlara küçük harfli anahtarlarla erişin (örneğin, `event.headers['content-type']`).
|
||||
</Note>
|
||||
|
||||
#### Veritabanı olay tetikleyicisi yükü
|
||||
|
||||
Bir veritabanı olay tetikleyicisi mantık fonksiyonunuzu çağırdığında, değişen her kayıt için bir `DatabaseEventPayload` alır. Yük, kaynak çalışma alanı ve nesne hakkındaki üstveriyi, kayıt düzeyindeki olayla birleştirir.
|
||||
|
||||
```ts
|
||||
import type {
|
||||
DatabaseEventPayload,
|
||||
ObjectRecordCreateEvent,
|
||||
ObjectRecordDestroyEvent,
|
||||
ObjectRecordUpdateEvent,
|
||||
} from 'twenty-sdk/logic-function';
|
||||
|
||||
type Person = {
|
||||
id: string;
|
||||
emails?: { primaryEmail?: string };
|
||||
};
|
||||
```
|
||||
|
||||
Yük, şunları içerir:
|
||||
|
||||
| Özellik | Açıklama |
|
||||
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
|
||||
| `name` | `person.updated` gibi bir olay adı. |
|
||||
| `workspaceId` | Olayın gerçekleştiği çalışma alanı. |
|
||||
| `objectMetadata` | Değişen nesne için üstveri. |
|
||||
| `recordId` | Değişen kaydın kimliği. |
|
||||
| `userId`, `userWorkspaceId`, `workspaceMemberId` | Olay bir çalışma alanı kullanıcısı tarafından tetiklendiğinde aktör alanları. |
|
||||
| `properties` | İşleme bağlı olarak `before`, `after`, `diff` ve `updatedFields` içeren olay için kayıt verileri. |
|
||||
|
||||
| Etkinlik | Kayıt verileri |
|
||||
| ------------------ | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `person.created` | `event.properties.after` |
|
||||
| `person.updated` | `event.properties.before`, `event.properties.after`, `event.properties.diff`, `event.properties.updatedFields` |
|
||||
| `person.destroyed` | `event.properties.before` |
|
||||
|
||||
Yumuşak silmeler için, kayıt `deletedAt` alanı değiştiğinden `.deleted`, güncelleme tarzı yapıyı izler.
|
||||
Kalıcı silmeler için `.destroyed` kullanın.
|
||||
|
||||
<Note>
|
||||
`databaseEventTriggerSettings.updatedFields`, hangi güncelleme olaylarının fonksiyonu tetikleyeceğini filtreler.
|
||||
`event.properties.updatedFields`, mevcut olayda hangi alanların gerçekten değiştiğini size bildirir.
|
||||
</Note>
|
||||
|
||||
Oluşturma olayı örneği:
|
||||
|
||||
```ts
|
||||
type PersonCreatedEvent = DatabaseEventPayload<
|
||||
ObjectRecordCreateEvent<Person>
|
||||
>;
|
||||
|
||||
const handler = async (event: PersonCreatedEvent) => {
|
||||
const person = event.properties.after;
|
||||
|
||||
return {
|
||||
personId: event.recordId,
|
||||
email: person.emails?.primaryEmail,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Güncelleme olayı örneği:
|
||||
|
||||
```ts
|
||||
type PersonUpdatedEvent = DatabaseEventPayload<
|
||||
ObjectRecordUpdateEvent<Person>
|
||||
>;
|
||||
|
||||
const handler = async (event: PersonUpdatedEvent) => {
|
||||
const { before, after, diff, updatedFields } = event.properties;
|
||||
|
||||
return {
|
||||
personId: event.recordId,
|
||||
updatedFields,
|
||||
previousEmail: before.emails?.primaryEmail,
|
||||
currentEmail: after.emails?.primaryEmail,
|
||||
emailDiff: diff.emails,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Yalnızca e-posta güncellemelerinde tetikle:
|
||||
|
||||
```ts
|
||||
export default defineLogicFunction({
|
||||
...,
|
||||
databaseEventTriggerSettings: {
|
||||
eventName: 'person.updated',
|
||||
updatedFields: ['emails'],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Yok etme olayı örneği:
|
||||
|
||||
```ts
|
||||
type PersonDestroyedEvent = DatabaseEventPayload<
|
||||
ObjectRecordDestroyEvent<Person>
|
||||
>;
|
||||
|
||||
const handler = async (event: PersonDestroyedEvent) => {
|
||||
const personBeforeDestroy = event.properties.before;
|
||||
|
||||
return {
|
||||
personId: event.recordId,
|
||||
email: personBeforeDestroy.emails?.primaryEmail,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
#### Bir işlevi bir yapay zekâ aracı veya iş akışı eylemi olarak kullanıma sunma
|
||||
|
||||
Mantık işlevleri, her birinin kendi tetikleyicisi olacak şekilde iki yerde kullanılabilir hâle getirilebilir:
|
||||
|
||||
@@ -13,8 +13,8 @@ icon: bolt
|
||||
|
||||
```ts src/logic-functions/createPostCard.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk/define';
|
||||
import { CoreApiClient, type Person } from 'twenty-client-sdk/core';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
const handler = async (params: RoutePayload) => {
|
||||
const client = new CoreApiClient();
|
||||
@@ -79,10 +79,10 @@ yarn twenty logs
|
||||
|
||||
当路由触发器调用你的逻辑函数时,它会接收一个遵循
|
||||
[AWS HTTP API v2 格式](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html)的 `RoutePayload` 对象。
|
||||
从 `twenty-sdk` 导入 `RoutePayload` 类型:
|
||||
从 `twenty-sdk/logic-function` 导入 `RoutePayload` 类型:
|
||||
|
||||
```ts
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import type { RoutePayload } from 'twenty-sdk/logic-function';
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const { headers, queryStringParameters, pathParameters, body } = event;
|
||||
@@ -141,6 +141,115 @@ const handler = async (event: RoutePayload) => {
|
||||
请求头名称会被规范化为小写。 请使用小写键访问它们(例如,`event.headers['content-type']`)。
|
||||
</Note>
|
||||
|
||||
#### 数据库事件触发器有效负载
|
||||
|
||||
当数据库事件触发器调用你的逻辑函数时,每条被更改的记录都会对应一个 `DatabaseEventPayload`。 该负载将关于源工作区和对象的元数据与记录级事件组合在一起。
|
||||
|
||||
```ts
|
||||
import type {
|
||||
DatabaseEventPayload,
|
||||
ObjectRecordCreateEvent,
|
||||
ObjectRecordDestroyEvent,
|
||||
ObjectRecordUpdateEvent,
|
||||
} from 'twenty-sdk/logic-function';
|
||||
|
||||
type Person = {
|
||||
id: string;
|
||||
emails?: { primaryEmail?: string };
|
||||
};
|
||||
```
|
||||
|
||||
有效负载包括:
|
||||
|
||||
| 属性 | 描述 |
|
||||
| ------------------------------------------------ | ------------------------------------------------------------ |
|
||||
| `name` | 事件名称,例如 `person.updated`。 |
|
||||
| `workspaceId` | 事件发生的工作区。 |
|
||||
| `objectMetadata` | 已更改对象的元数据。 |
|
||||
| `recordId` | 已更改记录的 ID。 |
|
||||
| `userId`, `userWorkspaceId`, `workspaceMemberId` | 当事件由工作区用户触发时的操作者字段。 |
|
||||
| `properties` | 事件的记录数据,根据操作不同,包含 `before`、`after`、`diff` 和 `updatedFields`。 |
|
||||
|
||||
| 事件 | 记录数据 |
|
||||
| ------------------ | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `person.created` | `event.properties.after` |
|
||||
| `person.updated` | `event.properties.before`, `event.properties.after`, `event.properties.diff`, `event.properties.updatedFields` |
|
||||
| `person.destroyed` | `event.properties.before` |
|
||||
|
||||
对于软删除,`.deleted` 遵循更新样式的结构,因为记录的 `deletedAt` 字段发生了变化。
|
||||
对于永久删除,请使用 `.destroyed`。
|
||||
|
||||
<Note>
|
||||
`databaseEventTriggerSettings.updatedFields` 会筛选出哪些更新事件会触发该函数。
|
||||
`event.properties.updatedFields` 告诉你在当前事件中哪些字段实际发生了变化。
|
||||
</Note>
|
||||
|
||||
创建事件示例:
|
||||
|
||||
```ts
|
||||
type PersonCreatedEvent = DatabaseEventPayload<
|
||||
ObjectRecordCreateEvent<Person>
|
||||
>;
|
||||
|
||||
const handler = async (event: PersonCreatedEvent) => {
|
||||
const person = event.properties.after;
|
||||
|
||||
return {
|
||||
personId: event.recordId,
|
||||
email: person.emails?.primaryEmail,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
更新事件示例:
|
||||
|
||||
```ts
|
||||
type PersonUpdatedEvent = DatabaseEventPayload<
|
||||
ObjectRecordUpdateEvent<Person>
|
||||
>;
|
||||
|
||||
const handler = async (event: PersonUpdatedEvent) => {
|
||||
const { before, after, diff, updatedFields } = event.properties;
|
||||
|
||||
return {
|
||||
personId: event.recordId,
|
||||
updatedFields,
|
||||
previousEmail: before.emails?.primaryEmail,
|
||||
currentEmail: after.emails?.primaryEmail,
|
||||
emailDiff: diff.emails,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
仅在 email 更新时触发:
|
||||
|
||||
```ts
|
||||
export default defineLogicFunction({
|
||||
...,
|
||||
databaseEventTriggerSettings: {
|
||||
eventName: 'person.updated',
|
||||
updatedFields: ['emails'],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
销毁事件示例:
|
||||
|
||||
```ts
|
||||
type PersonDestroyedEvent = DatabaseEventPayload<
|
||||
ObjectRecordDestroyEvent<Person>
|
||||
>;
|
||||
|
||||
const handler = async (event: PersonDestroyedEvent) => {
|
||||
const personBeforeDestroy = event.properties.before;
|
||||
|
||||
return {
|
||||
personId: event.recordId,
|
||||
email: personBeforeDestroy.emails?.primaryEmail,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
#### 将函数公开为 AI 工具或工作流操作
|
||||
|
||||
逻辑函数可以在两个入口对外公开,每个入口都有各自的触发器:
|
||||
|
||||
@@ -4087,8 +4087,6 @@ export type Query = {
|
||||
checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid;
|
||||
commandMenuItem?: Maybe<CommandMenuItem>;
|
||||
commandMenuItems: Array<CommandMenuItem>;
|
||||
connectedAccountById?: Maybe<ConnectedAccountPublicDto>;
|
||||
connectedAccounts: Array<ConnectedAccountPublicDto>;
|
||||
currentUser: User;
|
||||
currentWorkspace: Workspace;
|
||||
enterpriseCheckoutSession?: Maybe<Scalars['String']>;
|
||||
@@ -4236,11 +4234,6 @@ export type QueryCommandMenuItemArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type QueryConnectedAccountByIdArgs = {
|
||||
id: Scalars['UUID'];
|
||||
};
|
||||
|
||||
|
||||
export type QueryEnterpriseCheckoutSessionArgs = {
|
||||
billingInterval?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
@@ -6899,15 +6892,6 @@ export type UpdateMessageFoldersMutationVariables = Exact<{
|
||||
|
||||
export type UpdateMessageFoldersMutation = { __typename?: 'Mutation', updateMessageFolders: Array<{ __typename?: 'MessageFolder', id: string, isSynced: boolean }> };
|
||||
|
||||
export type PublicConnectionParamsFragment = { __typename?: 'PublicConnectionParametersOutput', host: string, port: number, secure?: boolean | null, username?: string | null };
|
||||
|
||||
export type ConnectedAccountByIdQueryVariables = Exact<{
|
||||
id: Scalars['UUID'];
|
||||
}>;
|
||||
|
||||
|
||||
export type ConnectedAccountByIdQuery = { __typename?: 'Query', connectedAccountById?: { __typename?: 'ConnectedAccountPublicDTO', id: string, handle: string, provider: string, scopes?: Array<string> | null, userWorkspaceId: string, connectionParameters?: { __typename?: 'PublicImapSmtpCaldavConnectionParameters', IMAP?: { __typename?: 'PublicConnectionParametersOutput', host: string, port: number, secure?: boolean | null, username?: string | null } | null, SMTP?: { __typename?: 'PublicConnectionParametersOutput', host: string, port: number, secure?: boolean | null, username?: string | null } | null, CALDAV?: { __typename?: 'PublicConnectionParametersOutput', host: string, port: number, secure?: boolean | null, username?: string | null } | null } | null } | null };
|
||||
|
||||
export type GetConnectedImapSmtpCaldavAccountQueryVariables = Exact<{
|
||||
id: Scalars['UUID'];
|
||||
}>;
|
||||
@@ -7919,7 +7903,6 @@ export const MarketplaceAppDetailFieldsFragmentDoc = {"kind":"Document","definit
|
||||
export const MarketplaceAppFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"MarketplaceAppFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"MarketplaceApp"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"author"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"logo"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}}]}}]} as unknown as DocumentNode<MarketplaceAppFieldsFragment, unknown>;
|
||||
export const NavigationMenuItemFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NavigationMenuItemFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NavigationMenuItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"targetRecordId"}},{"kind":"Field","name":{"kind":"Name","value":"targetObjectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"folderId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"link"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"color"}},{"kind":"Field","name":{"kind":"Name","value":"pageLayoutId"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<NavigationMenuItemFieldsFragment, unknown>;
|
||||
export const NavigationMenuItemQueryFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NavigationMenuItemQueryFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NavigationMenuItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NavigationMenuItemFields"}},{"kind":"Field","name":{"kind":"Name","value":"targetRecordIdentifier"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"labelIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"imageIdentifier"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NavigationMenuItemFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"NavigationMenuItem"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"targetRecordId"}},{"kind":"Field","name":{"kind":"Name","value":"targetObjectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"folderId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"link"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"color"}},{"kind":"Field","name":{"kind":"Name","value":"pageLayoutId"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<NavigationMenuItemQueryFieldsFragment, unknown>;
|
||||
export const PublicConnectionParamsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PublicConnectionParams"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PublicConnectionParametersOutput"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}}]} as unknown as DocumentNode<PublicConnectionParamsFragment, unknown>;
|
||||
export const ApplicationRegistrationFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApplicationRegistrationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApplicationRegistration"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"universalIdentifier"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"logoUrl"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthClientId"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthRedirectUris"}},{"kind":"Field","name":{"kind":"Name","value":"oAuthScopes"}},{"kind":"Field","name":{"kind":"Name","value":"sourceType"}},{"kind":"Field","name":{"kind":"Name","value":"sourcePackage"}},{"kind":"Field","name":{"kind":"Name","value":"latestAvailableVersion"}},{"kind":"Field","name":{"kind":"Name","value":"isListed"}},{"kind":"Field","name":{"kind":"Name","value":"isFeatured"}},{"kind":"Field","name":{"kind":"Name","value":"isPreInstalled"}},{"kind":"Field","name":{"kind":"Name","value":"isConfigured"}},{"kind":"Field","name":{"kind":"Name","value":"ownerWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode<ApplicationRegistrationFragmentFragment, unknown>;
|
||||
export const BillingPriceLicensedFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingPriceLicensedFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingPriceLicensed"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"unitAmount"}},{"kind":"Field","name":{"kind":"Name","value":"recurringInterval"}},{"kind":"Field","name":{"kind":"Name","value":"priceUsageType"}},{"kind":"Field","name":{"kind":"Name","value":"creditAmount"}}]}}]} as unknown as DocumentNode<BillingPriceLicensedFragmentFragment, unknown>;
|
||||
export const BillingPriceMeteredFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BillingPriceMeteredFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"BillingPriceMetered"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"priceUsageType"}},{"kind":"Field","name":{"kind":"Name","value":"recurringInterval"}},{"kind":"Field","name":{"kind":"Name","value":"stripePriceId"}},{"kind":"Field","name":{"kind":"Name","value":"tiers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"flatAmount"}},{"kind":"Field","name":{"kind":"Name","value":"unitAmount"}},{"kind":"Field","name":{"kind":"Name","value":"upTo"}}]}}]}}]} as unknown as DocumentNode<BillingPriceMeteredFragmentFragment, unknown>;
|
||||
@@ -8070,7 +8053,6 @@ export const UpdateCalendarChannelDocument = {"kind":"Document","definitions":[{
|
||||
export const UpdateMessageChannelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateMessageChannel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateMessageChannelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateMessageChannel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"contactAutoCreationPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"excludeNonProfessionalEmails"}},{"kind":"Field","name":{"kind":"Name","value":"excludeGroupEmails"}},{"kind":"Field","name":{"kind":"Name","value":"messageFolderImportPolicy"}}]}}]}}]} as unknown as DocumentNode<UpdateMessageChannelMutation, UpdateMessageChannelMutationVariables>;
|
||||
export const UpdateMessageFolderDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateMessageFolder"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateMessageFolderInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateMessageFolder"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isSynced"}}]}}]}}]} as unknown as DocumentNode<UpdateMessageFolderMutation, UpdateMessageFolderMutationVariables>;
|
||||
export const UpdateMessageFoldersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateMessageFolders"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateMessageFoldersInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateMessageFolders"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isSynced"}}]}}]}}]} as unknown as DocumentNode<UpdateMessageFoldersMutation, UpdateMessageFoldersMutationVariables>;
|
||||
export const ConnectedAccountByIdDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ConnectedAccountById"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connectedAccountById"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}},{"kind":"Field","name":{"kind":"Name","value":"scopes"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"connectionParameters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"IMAP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PublicConnectionParams"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SMTP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PublicConnectionParams"}}]}},{"kind":"Field","name":{"kind":"Name","value":"CALDAV"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PublicConnectionParams"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PublicConnectionParams"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PublicConnectionParametersOutput"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}}]} as unknown as DocumentNode<ConnectedAccountByIdQuery, ConnectedAccountByIdQueryVariables>;
|
||||
export const GetConnectedImapSmtpCaldavAccountDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetConnectedImapSmtpCaldavAccount"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getConnectedImapSmtpCaldavAccount"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"connectionParameters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"IMAP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SMTP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}}]}},{"kind":"Field","name":{"kind":"Name","value":"CALDAV"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}}]}}]}}]}}]} as unknown as DocumentNode<GetConnectedImapSmtpCaldavAccountQuery, GetConnectedImapSmtpCaldavAccountQueryVariables>;
|
||||
export const MyCalendarChannelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyCalendarChannels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myCalendarChannels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"connectedAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"syncStage"}},{"kind":"Field","name":{"kind":"Name","value":"syncStageStartedAt"}},{"kind":"Field","name":{"kind":"Name","value":"isContactAutoCreationEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"contactAutoCreationPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"isSyncEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"connectedAccountId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyCalendarChannelsQuery, MyCalendarChannelsQueryVariables>;
|
||||
export const MyConnectedAccountsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyConnectedAccounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myConnectedAccounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}},{"kind":"Field","name":{"kind":"Name","value":"authFailedAt"}},{"kind":"Field","name":{"kind":"Name","value":"scopes"}},{"kind":"Field","name":{"kind":"Name","value":"handleAliases"}},{"kind":"Field","name":{"kind":"Name","value":"lastSignedInAt"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"connectionProviderId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"lastCredentialsRefreshedAt"}},{"kind":"Field","name":{"kind":"Name","value":"connectionParameters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"IMAP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SMTP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}},{"kind":"Field","name":{"kind":"Name","value":"CALDAV"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyConnectedAccountsQuery, MyConnectedAccountsQueryVariables>;
|
||||
|
||||
@@ -28,6 +28,13 @@ export type ComputeStepOutputSchemaInput = {
|
||||
workflowVersionId?: InputMaybe<Scalars['UUID']>;
|
||||
};
|
||||
|
||||
export type ConnectedAccountHandleDto = {
|
||||
__typename?: 'ConnectedAccountHandleDTO';
|
||||
handle: Scalars['String'];
|
||||
id: Scalars['UUID'];
|
||||
provider: Scalars['String'];
|
||||
};
|
||||
|
||||
export type CreateDraftFromWorkflowVersionInput = {
|
||||
/** Workflow ID */
|
||||
workflowId: Scalars['UUID'];
|
||||
@@ -252,6 +259,7 @@ export type Query = {
|
||||
getTimelineThreadsFromPersonId: TimelineThreadsWithTotal;
|
||||
isMaintenanceModeBannerDismissed: Scalars['Boolean'];
|
||||
search: SearchResultConnection;
|
||||
workflowStepConnectedAccountHandle?: Maybe<ConnectedAccountHandleDto>;
|
||||
};
|
||||
|
||||
|
||||
@@ -306,6 +314,11 @@ export type QuerySearchArgs = {
|
||||
searchInput: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
export type QueryWorkflowStepConnectedAccountHandleArgs = {
|
||||
connectedAccountId: Scalars['UUID'];
|
||||
};
|
||||
|
||||
export type RunWorkflowVersion = {
|
||||
__typename?: 'RunWorkflowVersion';
|
||||
workflowRunId: Scalars['UUID'];
|
||||
@@ -745,6 +758,13 @@ export type UpdateWorkflowVersionStepMutationVariables = Exact<{
|
||||
|
||||
export type UpdateWorkflowVersionStepMutation = { __typename?: 'Mutation', updateWorkflowVersionStep: { __typename?: 'WorkflowAction', id: any, name: string, type: WorkflowActionType, settings: any, valid: boolean, nextStepIds?: Array<any> | null, position?: { __typename?: 'WorkflowStepPosition', x: number, y: number } | null } };
|
||||
|
||||
export type WorkflowStepConnectedAccountHandleQueryVariables = Exact<{
|
||||
connectedAccountId: Scalars['UUID'];
|
||||
}>;
|
||||
|
||||
|
||||
export type WorkflowStepConnectedAccountHandleQuery = { __typename?: 'Query', workflowStepConnectedAccountHandle?: { __typename?: 'ConnectedAccountHandleDTO', id: any, handle: string, provider: string } | null };
|
||||
|
||||
export type SubmitFormStepMutationVariables = Exact<{
|
||||
input: SubmitFormStepInput;
|
||||
}>;
|
||||
@@ -794,6 +814,7 @@ export const RunWorkflowVersionDocument = {"kind":"Document","definitions":[{"ki
|
||||
export const StopWorkflowRunDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"StopWorkflowRun"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workflowRunId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stopWorkflowRun"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workflowRunId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workflowRunId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"__typename"}}]}}]}}]} as unknown as DocumentNode<StopWorkflowRunMutation, StopWorkflowRunMutationVariables>;
|
||||
export const UpdateWorkflowRunStepDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWorkflowRunStep"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateWorkflowRunStepInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateWorkflowRunStep"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"settings"}},{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"nextStepIds"}},{"kind":"Field","name":{"kind":"Name","value":"position"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"x"}},{"kind":"Field","name":{"kind":"Name","value":"y"}}]}}]}}]}}]} as unknown as DocumentNode<UpdateWorkflowRunStepMutation, UpdateWorkflowRunStepMutationVariables>;
|
||||
export const UpdateWorkflowVersionStepDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWorkflowVersionStep"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateWorkflowVersionStepInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateWorkflowVersionStep"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"settings"}},{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"nextStepIds"}},{"kind":"Field","name":{"kind":"Name","value":"position"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"x"}},{"kind":"Field","name":{"kind":"Name","value":"y"}}]}}]}}]}}]} as unknown as DocumentNode<UpdateWorkflowVersionStepMutation, UpdateWorkflowVersionStepMutationVariables>;
|
||||
export const WorkflowStepConnectedAccountHandleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"WorkflowStepConnectedAccountHandle"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workflowStepConnectedAccountHandle"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"connectedAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}}]}}]}}]} as unknown as DocumentNode<WorkflowStepConnectedAccountHandleQuery, WorkflowStepConnectedAccountHandleQueryVariables>;
|
||||
export const SubmitFormStepDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SubmitFormStep"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SubmitFormStepInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"submitFormStep"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode<SubmitFormStepMutation, SubmitFormStepMutationVariables>;
|
||||
export const TestHttpRequestDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"TestHttpRequest"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"TestHttpRequestInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"testHttpRequest"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"result"}},{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"statusText"}},{"kind":"Field","name":{"kind":"Name","value":"headers"}}]}}]}}]} as unknown as DocumentNode<TestHttpRequestMutation, TestHttpRequestMutationVariables>;
|
||||
export const UpdateWorkflowVersionPositionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWorkflowVersionPositions"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateWorkflowVersionPositionsInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateWorkflowVersionPositions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode<UpdateWorkflowVersionPositionsMutation, UpdateWorkflowVersionPositionsMutationVariables>;
|
||||
+4
-4
@@ -9,7 +9,7 @@ import { contextStoreFilterGroupsComponentState } from '@/context-store/states/c
|
||||
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
|
||||
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
|
||||
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
|
||||
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
|
||||
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
|
||||
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
@@ -90,8 +90,8 @@ export const buildHeadlessCommandContextApi = ({
|
||||
? (currentWorkspaceMember?.timeZone ?? systemTimeZone)
|
||||
: systemTimeZone;
|
||||
|
||||
const fieldMetadataItemByIdMap = store.get(
|
||||
fieldMetadataItemByIdMapSelector.atom,
|
||||
const flattenedFieldMetadataItems = store.get(
|
||||
flattenedFieldMetadataItemsSelector.atom,
|
||||
);
|
||||
|
||||
const graphqlFilter = isDefined(objectMetadataItem)
|
||||
@@ -100,7 +100,7 @@ export const buildHeadlessCommandContextApi = ({
|
||||
contextStoreFilters: filters,
|
||||
contextStoreFilterGroups: filterGroups,
|
||||
objectMetadataItem,
|
||||
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
|
||||
fieldMetadataItems: flattenedFieldMetadataItems,
|
||||
filterValueDependencies: {
|
||||
currentWorkspaceMemberId: currentWorkspaceMember?.id,
|
||||
timeZone: userTimezone,
|
||||
|
||||
+6
-1
@@ -6,6 +6,7 @@ import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/s
|
||||
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
|
||||
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
|
||||
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
|
||||
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
|
||||
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
||||
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
|
||||
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
|
||||
@@ -54,6 +55,10 @@ export const useFindManyRecordsSelectedInContextStore = ({
|
||||
fieldMetadataItemByIdMapSelector,
|
||||
);
|
||||
|
||||
const flattenedFieldMetadataItems = useAtomStateValue(
|
||||
flattenedFieldMetadataItemsSelector,
|
||||
);
|
||||
|
||||
const isSoftDeleteFilterActive = contextStoreFilters.some(
|
||||
(filter) =>
|
||||
fieldMetadataItemByIdMap.get(filter.fieldMetadataId)?.name ===
|
||||
@@ -65,7 +70,7 @@ export const useFindManyRecordsSelectedInContextStore = ({
|
||||
contextStoreFilters,
|
||||
contextStoreFilterGroups,
|
||||
objectMetadataItem,
|
||||
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
|
||||
fieldMetadataItems: flattenedFieldMetadataItems,
|
||||
filterValueDependencies,
|
||||
contextStoreAnyFieldFilterValue,
|
||||
});
|
||||
|
||||
+2
-4
@@ -29,8 +29,7 @@ describe('computeContextStoreFilters', () => {
|
||||
contextStoreFilters: [],
|
||||
contextStoreFilterGroups: [],
|
||||
objectMetadataItem: personObjectMetadataItem,
|
||||
findFieldMetadataItemById: (id) =>
|
||||
personObjectMetadataItem.fields.find((field) => field.id === id),
|
||||
fieldMetadataItems: personObjectMetadataItem.fields,
|
||||
filterValueDependencies: mockFilterValueDependencies,
|
||||
contextStoreAnyFieldFilterValue: '',
|
||||
});
|
||||
@@ -75,8 +74,7 @@ describe('computeContextStoreFilters', () => {
|
||||
contextStoreFilters,
|
||||
contextStoreFilterGroups: [],
|
||||
objectMetadataItem: personObjectMetadataItem,
|
||||
findFieldMetadataItemById: (id) =>
|
||||
personObjectMetadataItem.fields.find((field) => field.id === id),
|
||||
fieldMetadataItems: personObjectMetadataItem.fields,
|
||||
filterValueDependencies: mockFilterValueDependencies,
|
||||
contextStoreAnyFieldFilterValue: '',
|
||||
});
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
type RecordFilterValueDependencies,
|
||||
type RecordGqlOperationFilter,
|
||||
} from 'twenty-shared/types';
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import {
|
||||
computeRecordGqlOperationFilter,
|
||||
type FindFieldMetadataItemById,
|
||||
turnAnyFieldFilterIntoRecordGqlFilter,
|
||||
} from 'twenty-shared/utils';
|
||||
|
||||
@@ -18,7 +18,7 @@ type ComputeContextStoreFiltersProps = {
|
||||
contextStoreFilters: RecordFilter[];
|
||||
contextStoreFilterGroups: RecordFilterGroup[];
|
||||
objectMetadataItem: EnrichedObjectMetadataItem;
|
||||
findFieldMetadataItemById: FindFieldMetadataItemById;
|
||||
fieldMetadataItems: FieldMetadataItem[];
|
||||
filterValueDependencies: RecordFilterValueDependencies;
|
||||
contextStoreAnyFieldFilterValue: string;
|
||||
};
|
||||
@@ -28,7 +28,7 @@ export const computeContextStoreFilters = ({
|
||||
contextStoreFilters,
|
||||
contextStoreFilterGroups,
|
||||
objectMetadataItem,
|
||||
findFieldMetadataItemById,
|
||||
fieldMetadataItems,
|
||||
filterValueDependencies,
|
||||
contextStoreAnyFieldFilterValue,
|
||||
}: ComputeContextStoreFiltersProps) => {
|
||||
@@ -45,7 +45,7 @@ export const computeContextStoreFilters = ({
|
||||
recordGqlFilterForAnyFieldFilter,
|
||||
computeRecordGqlOperationFilter({
|
||||
filterValueDependencies,
|
||||
findFieldMetadataItemById,
|
||||
fieldMetadataItems,
|
||||
recordFilters: contextStoreFilters,
|
||||
recordFilterGroups: contextStoreFilterGroups,
|
||||
}),
|
||||
@@ -74,7 +74,7 @@ export const computeContextStoreFilters = ({
|
||||
},
|
||||
computeRecordGqlOperationFilter({
|
||||
filterValueDependencies,
|
||||
findFieldMetadataItemById,
|
||||
fieldMetadataItems,
|
||||
recordFilters: contextStoreFilters,
|
||||
recordFilterGroups: contextStoreFilterGroups,
|
||||
}),
|
||||
|
||||
+2
-9
@@ -58,15 +58,8 @@ export const PromiseRejectionEffect = () => {
|
||||
scope.setExtras({ mechanism: 'onUnhandle' });
|
||||
|
||||
const fingerprint = hasErrorCode(error) ? error.code : error.message;
|
||||
|
||||
if (isDefined(fingerprint)) {
|
||||
scope.setFingerprint([fingerprint]);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
error.name = error.message;
|
||||
}
|
||||
|
||||
scope.setFingerprint([fingerprint]);
|
||||
error.name = error.message;
|
||||
return scope;
|
||||
});
|
||||
} catch (sentryError) {
|
||||
|
||||
+15
-3
@@ -1,5 +1,9 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useIcons } from 'twenty-ui/display';
|
||||
|
||||
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
|
||||
import { NavigationMenuItemIcon } from '@/navigation-menu-item/display/components/NavigationMenuItemIcon';
|
||||
import { getNavigationMenuItemColor } from '@/navigation-menu-item/common/utils/getNavigationMenuItemColor';
|
||||
import { getPageLayoutNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/page-layout/utils/getPageLayoutNavigationMenuItemComputedLink';
|
||||
import type { NavigationMenuItemSectionContentProps } from '@/navigation-menu-item/display/sections/types/NavigationMenuItemSectionContentProps';
|
||||
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
|
||||
@@ -18,8 +22,15 @@ export const NavigationMenuItemPageLayoutDisplay = ({
|
||||
isLayoutCustomizationModeEnabledState,
|
||||
);
|
||||
|
||||
const { getIcon } = useIcons();
|
||||
const location = useLocation();
|
||||
|
||||
const label = item.name ?? '';
|
||||
const computedLink = getPageLayoutNavigationMenuItemComputedLink(item);
|
||||
const pageLayoutColor = getNavigationMenuItemColor(item);
|
||||
|
||||
const Icon = isDefined(item.icon) ? getIcon(item.icon) : undefined;
|
||||
const isActive = computedLink !== '' && location.pathname === computedLink;
|
||||
|
||||
return (
|
||||
<NavigationDrawerItem
|
||||
@@ -34,8 +45,9 @@ export const NavigationMenuItemPageLayoutDisplay = ({
|
||||
? editModeProps?.onEditModeClick
|
||||
: undefined
|
||||
}
|
||||
Icon={() => <NavigationMenuItemIcon navigationMenuItem={item} />}
|
||||
active={false}
|
||||
Icon={Icon}
|
||||
iconColor={pageLayoutColor}
|
||||
active={isActive}
|
||||
isSelectedInEditMode={editModeProps?.isSelectedInEditMode}
|
||||
isDragging={isDragging}
|
||||
triggerEvent="CLICK"
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { generateDepthRecordGqlFieldsFromFields } from '@/object-record/graphql/record-gql-fields/utils/generateDepthRecordGqlFieldsFromFields';
|
||||
import { reportMissingMorphRelationTargetMetadata } from '@/object-record/graphql/record-gql-fields/utils/reportMissingMorphRelationTargetMetadata';
|
||||
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
|
||||
|
||||
jest.mock(
|
||||
'@/object-record/graphql/record-gql-fields/utils/reportMissingMorphRelationTargetMetadata',
|
||||
() => ({
|
||||
reportMissingMorphRelationTargetMetadata: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
describe('generateDepthRecordGqlFieldsFromFields', () => {
|
||||
it('should skip morph relations whose target object metadata is missing', () => {
|
||||
const result = generateDepthRecordGqlFieldsFromFields({
|
||||
objectMetadataItems: [
|
||||
{
|
||||
id: 'company-id',
|
||||
fields: [],
|
||||
labelIdentifierFieldMetadataId: null,
|
||||
imageIdentifierFieldMetadataId: null,
|
||||
nameSingular: 'company',
|
||||
namePlural: 'companies',
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
id: 'field-id',
|
||||
name: 'company',
|
||||
type: FieldMetadataType.MORPH_RELATION,
|
||||
settings: { relationType: RelationType.MANY_TO_ONE },
|
||||
relation: null,
|
||||
morphRelations: [
|
||||
{
|
||||
type: RelationType.MANY_TO_ONE,
|
||||
targetObjectMetadata: {
|
||||
id: 'company-id',
|
||||
nameSingular: 'company',
|
||||
namePlural: 'companies',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: RelationType.MANY_TO_ONE,
|
||||
targetObjectMetadata: {
|
||||
id: 'oem-plant-id',
|
||||
nameSingular: 'oemPlant',
|
||||
namePlural: 'oemPlants',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
depth: 1,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
companyCompany: { id: true },
|
||||
companyCompanyId: true,
|
||||
});
|
||||
|
||||
expect(reportMissingMorphRelationTargetMetadata).toHaveBeenCalledWith({
|
||||
fieldMetadataName: 'company',
|
||||
missingMorphTargetNames: ['oemPlant'],
|
||||
});
|
||||
});
|
||||
});
|
||||
+31
-17
@@ -10,6 +10,7 @@ import { type RecordGqlFields } from '@/object-record/graphql/record-gql-fields/
|
||||
import { buildIdentifierGqlFields } from '@/object-record/graphql/record-gql-fields/utils/buildIdentifierGqlFields';
|
||||
import { generateActivityTargetGqlFields } from '@/object-record/graphql/record-gql-fields/utils/generateActivityTargetGqlFields';
|
||||
import { generateJunctionRelationGqlFields } from '@/object-record/graphql/record-gql-fields/utils/generateJunctionRelationGqlFields';
|
||||
import { reportMissingMorphRelationTargetMetadata } from '@/object-record/graphql/record-gql-fields/utils/reportMissingMorphRelationTargetMetadata';
|
||||
import { isJunctionRelationField } from '@/object-record/record-field/ui/utils/junction/isJunctionRelationField';
|
||||
import {
|
||||
computeMorphRelationGqlFieldName,
|
||||
@@ -127,7 +128,9 @@ export const generateDepthRecordGqlFieldsFromFields = ({
|
||||
);
|
||||
}
|
||||
|
||||
const morphGqlFields = fieldMetadata.morphRelations.map(
|
||||
const missingMorphTargetNames: string[] = [];
|
||||
|
||||
const morphGqlFields = fieldMetadata.morphRelations.flatMap(
|
||||
(morphRelation) => {
|
||||
const morphTargetObjectMetadataItem = objectMetadataItems.find(
|
||||
(objectMetadataItem) =>
|
||||
@@ -135,28 +138,39 @@ export const generateDepthRecordGqlFieldsFromFields = ({
|
||||
);
|
||||
|
||||
if (!morphTargetObjectMetadataItem) {
|
||||
throw new Error(
|
||||
`Target object metadata item not found for ${fieldMetadata.name} (morph target ${morphRelation.targetObjectMetadata.nameSingular})`,
|
||||
missingMorphTargetNames.push(
|
||||
morphRelation.targetObjectMetadata.nameSingular,
|
||||
);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return {
|
||||
gqlField: computeMorphRelationGqlFieldName({
|
||||
fieldName: fieldMetadata.name,
|
||||
relationType: morphRelation.type,
|
||||
targetObjectMetadataNameSingular:
|
||||
morphRelation.targetObjectMetadata.nameSingular,
|
||||
targetObjectMetadataNamePlural:
|
||||
morphRelation.targetObjectMetadata.namePlural,
|
||||
}),
|
||||
fieldMetadata,
|
||||
relationIdentifierSubGqlFields: buildIdentifierGqlFields(
|
||||
morphTargetObjectMetadataItem,
|
||||
),
|
||||
};
|
||||
return [
|
||||
{
|
||||
gqlField: computeMorphRelationGqlFieldName({
|
||||
fieldName: fieldMetadata.name,
|
||||
relationType: morphRelation.type,
|
||||
targetObjectMetadataNameSingular:
|
||||
morphRelation.targetObjectMetadata.nameSingular,
|
||||
targetObjectMetadataNamePlural:
|
||||
morphRelation.targetObjectMetadata.namePlural,
|
||||
}),
|
||||
fieldMetadata,
|
||||
relationIdentifierSubGqlFields: buildIdentifierGqlFields(
|
||||
morphTargetObjectMetadataItem,
|
||||
),
|
||||
},
|
||||
];
|
||||
},
|
||||
);
|
||||
|
||||
if (missingMorphTargetNames.length > 0) {
|
||||
reportMissingMorphRelationTargetMetadata({
|
||||
fieldMetadataName: fieldMetadata.name,
|
||||
missingMorphTargetNames,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...recordGqlFields,
|
||||
...morphGqlFields.reduce(
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
type ReportMissingMorphRelationTargetMetadataArgs = {
|
||||
fieldMetadataName: string;
|
||||
missingMorphTargetNames: string[];
|
||||
};
|
||||
|
||||
const MORPH_RELATION_TARGET_METADATA_MISMATCH_MESSAGE =
|
||||
'Missing morph target object metadata while generating record GraphQL fields';
|
||||
|
||||
export const reportMissingMorphRelationTargetMetadata = ({
|
||||
fieldMetadataName,
|
||||
missingMorphTargetNames,
|
||||
}: ReportMissingMorphRelationTargetMetadataArgs) => {
|
||||
const pathname =
|
||||
typeof window === 'undefined' ? undefined : window.location.pathname;
|
||||
const uniqueMissingMorphTargetNames = [...new Set(missingMorphTargetNames)];
|
||||
|
||||
// oxlint-disable-next-line no-console
|
||||
console.warn(MORPH_RELATION_TARGET_METADATA_MISMATCH_MESSAGE, {
|
||||
fieldMetadataName,
|
||||
missingMorphTargetNames: uniqueMissingMorphTargetNames,
|
||||
pathname,
|
||||
});
|
||||
|
||||
import('@sentry/react')
|
||||
.then(({ captureMessage, withScope }) => {
|
||||
withScope((scope) => {
|
||||
scope.setLevel('warning');
|
||||
scope.setTag('error_type', 'morph_target_metadata_mismatch');
|
||||
scope.setTag('field_name', fieldMetadataName);
|
||||
scope.setExtra(
|
||||
'missingMorphTargetNames',
|
||||
uniqueMissingMorphTargetNames,
|
||||
);
|
||||
scope.setExtra('pathname', pathname);
|
||||
scope.setFingerprint([
|
||||
'morph_target_metadata_mismatch',
|
||||
fieldMetadataName,
|
||||
...[...uniqueMissingMorphTargetNames].sort(),
|
||||
]);
|
||||
|
||||
captureMessage(MORPH_RELATION_TARGET_METADATA_MISMATCH_MESSAGE);
|
||||
});
|
||||
})
|
||||
.catch((sentryError) => {
|
||||
// oxlint-disable-next-line no-console
|
||||
console.warn(
|
||||
'Failed to capture morph target metadata mismatch warning with Sentry:',
|
||||
sentryError,
|
||||
);
|
||||
});
|
||||
};
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
|
||||
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
|
||||
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
|
||||
import { useRecordCalendarMonthDaysRange } from '@/object-record/record-calendar/month/hooks/useRecordCalendarMonthDaysRange';
|
||||
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
|
||||
@@ -48,8 +48,8 @@ export const useRecordCalendarQueryDateRangeFilter = (
|
||||
|
||||
const { filterValueDependencies } = useFilterValueDependencies();
|
||||
|
||||
const fieldMetadataItemByIdMap = useAtomStateValue(
|
||||
fieldMetadataItemByIdMapSelector,
|
||||
const flattenedFieldMetadataItems = useAtomStateValue(
|
||||
flattenedFieldMetadataItemsSelector,
|
||||
);
|
||||
|
||||
const anyFieldFilterValue = useAtomComponentStateValue(
|
||||
@@ -110,7 +110,7 @@ export const useRecordCalendarQueryDateRangeFilter = (
|
||||
filterValueDependencies,
|
||||
recordFilters: calendarRecordFilters,
|
||||
recordFilterGroups: currentRecordFilterGroups,
|
||||
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
|
||||
fieldMetadataItems: flattenedFieldMetadataItems,
|
||||
});
|
||||
|
||||
const { recordGqlOperationFilter: anyFieldFilter } =
|
||||
|
||||
+14
-17
@@ -22,14 +22,11 @@ const petMockObjectMetadataItem = getMockObjectMetadataItemOrThrow('pet');
|
||||
|
||||
const personMockObjectMetadataItem = getMockObjectMetadataItemOrThrow('person');
|
||||
|
||||
const findCompanyFieldById = (id: string) =>
|
||||
companyMockObjectMetadataItem.fields.find((field) => field.id === id);
|
||||
const companyFields = companyMockObjectMetadataItem.fields;
|
||||
|
||||
const findPersonFieldById = (id: string) =>
|
||||
personMockObjectMetadataItem.fields.find((field) => field.id === id);
|
||||
const personFields = personMockObjectMetadataItem.fields;
|
||||
|
||||
const findPetFieldById = (id: string) =>
|
||||
petMockObjectMetadataItem.fields.find((field) => field.id === id);
|
||||
const petFields = petMockObjectMetadataItem.fields;
|
||||
|
||||
const mockFilterValueDependencies: RecordFilterValueDependencies = {
|
||||
currentWorkspaceMemberId: '32219445-f587-4c40-b2b1-6d3205ed96da',
|
||||
@@ -63,7 +60,7 @@ describe('computeViewRecordGqlOperationFilter', () => {
|
||||
filterValueDependencies: mockFilterValueDependencies,
|
||||
recordFilters: [nameFilter],
|
||||
recordFilterGroups: [],
|
||||
findFieldMetadataItemById: findCompanyFieldById,
|
||||
fieldMetadataItems: companyFields,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -116,7 +113,7 @@ describe('computeViewRecordGqlOperationFilter', () => {
|
||||
filterValueDependencies: mockFilterValueDependencies,
|
||||
recordFilters: [nameFilter, employeesFilter],
|
||||
recordFilterGroups: [],
|
||||
findFieldMetadataItemById: findCompanyFieldById,
|
||||
fieldMetadataItems: companyFields,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -196,7 +193,7 @@ describe('should work as expected for the different field types', () => {
|
||||
addressFilterIsNotEmpty,
|
||||
],
|
||||
recordFilterGroups: [],
|
||||
findFieldMetadataItemById: findCompanyFieldById,
|
||||
fieldMetadataItems: companyFields,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -660,7 +657,7 @@ describe('should work as expected for the different field types', () => {
|
||||
phonesFilterIsNotEmpty,
|
||||
],
|
||||
recordFilterGroups: [],
|
||||
findFieldMetadataItemById: findPersonFieldById,
|
||||
fieldMetadataItems: personFields,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -857,7 +854,7 @@ describe('should work as expected for the different field types', () => {
|
||||
emailsFilterIsNotEmpty,
|
||||
],
|
||||
recordFilterGroups: [],
|
||||
findFieldMetadataItemById: findPersonFieldById,
|
||||
fieldMetadataItems: personFields,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -1069,7 +1066,7 @@ describe('should work as expected for the different field types', () => {
|
||||
dateFilterIsNotEmpty,
|
||||
],
|
||||
recordFilterGroups: [],
|
||||
findFieldMetadataItemById: findCompanyFieldById,
|
||||
fieldMetadataItems: companyFields,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -1171,7 +1168,7 @@ describe('should work as expected for the different field types', () => {
|
||||
employeesFilterIsNotEmpty,
|
||||
],
|
||||
recordFilterGroups: [],
|
||||
findFieldMetadataItemById: findCompanyFieldById,
|
||||
fieldMetadataItems: companyFields,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -1273,7 +1270,7 @@ describe('should work as expected for the different field types', () => {
|
||||
ARRFilterIsNot,
|
||||
],
|
||||
recordFilterGroups: [],
|
||||
findFieldMetadataItemById: findCompanyFieldById,
|
||||
fieldMetadataItems: companyFields,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -1350,7 +1347,7 @@ describe('should work as expected for the different field types', () => {
|
||||
filterValueDependencies: mockFilterValueDependencies,
|
||||
recordFilters: [ARRFilterIn, ARRFilterNotIn],
|
||||
recordFilterGroups: [],
|
||||
findFieldMetadataItemById: findCompanyFieldById,
|
||||
fieldMetadataItems: companyFields,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -1410,7 +1407,7 @@ describe('should work as expected for the different field types', () => {
|
||||
filterValueDependencies: mockFilterValueDependencies,
|
||||
recordFilters: [selectFilterIs, selectFilterIsNot],
|
||||
recordFilterGroups: [],
|
||||
findFieldMetadataItemById: findPetFieldById,
|
||||
fieldMetadataItems: petFields,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -1483,7 +1480,7 @@ describe('should work as expected for the different field types', () => {
|
||||
multiSelectFilterDoesNotContain,
|
||||
],
|
||||
recordFilterGroups: [],
|
||||
findFieldMetadataItemById: findCompanyFieldById,
|
||||
fieldMetadataItems: companyFields,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
|
||||
+4
-4
@@ -6,7 +6,7 @@ import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/s
|
||||
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
|
||||
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
|
||||
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
|
||||
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
||||
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
|
||||
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
|
||||
@@ -54,8 +54,8 @@ export const RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect =
|
||||
|
||||
const { filterValueDependencies } = useFilterValueDependencies();
|
||||
|
||||
const fieldMetadataItemByIdMap = useAtomStateValue(
|
||||
fieldMetadataItemByIdMapSelector,
|
||||
const flattenedFieldMetadataItems = useAtomStateValue(
|
||||
flattenedFieldMetadataItemsSelector,
|
||||
);
|
||||
|
||||
const computedFilter = computeContextStoreFilters({
|
||||
@@ -63,7 +63,7 @@ export const RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect =
|
||||
contextStoreFilters,
|
||||
contextStoreFilterGroups,
|
||||
objectMetadataItem,
|
||||
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
|
||||
fieldMetadataItems: flattenedFieldMetadataItems,
|
||||
filterValueDependencies,
|
||||
contextStoreAnyFieldFilterValue,
|
||||
});
|
||||
|
||||
+4
-4
@@ -7,7 +7,7 @@ import { contextStoreFilterGroupsComponentState } from '@/context-store/states/c
|
||||
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
|
||||
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
|
||||
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
|
||||
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
|
||||
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords';
|
||||
import { EXPORT_TABLE_DATA_DEFAULT_PAGE_SIZE } from '@/object-record/object-options-dropdown/constants/ExportTableDataDefaultPageSize';
|
||||
@@ -89,8 +89,8 @@ export const useRecordIndexLazyFetchRecords = ({
|
||||
|
||||
const { filterValueDependencies } = useFilterValueDependencies();
|
||||
|
||||
const fieldMetadataItemByIdMap = useAtomStateValue(
|
||||
fieldMetadataItemByIdMapSelector,
|
||||
const flattenedFieldMetadataItems = useAtomStateValue(
|
||||
flattenedFieldMetadataItemsSelector,
|
||||
);
|
||||
|
||||
const findManyRecordsParams = useFindManyRecordIndexTableParams(
|
||||
@@ -107,7 +107,7 @@ export const useRecordIndexLazyFetchRecords = ({
|
||||
contextStoreFilters,
|
||||
contextStoreFilterGroups,
|
||||
objectMetadataItem,
|
||||
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
|
||||
fieldMetadataItems: flattenedFieldMetadataItems,
|
||||
filterValueDependencies,
|
||||
contextStoreAnyFieldFilterValue,
|
||||
});
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
|
||||
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
|
||||
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
|
||||
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
|
||||
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
|
||||
@@ -49,12 +49,12 @@ export const useFindManyRecordIndexTableParams = (
|
||||
|
||||
const { filterValueDependencies } = useFilterValueDependencies();
|
||||
|
||||
const fieldMetadataItemByIdMap = useAtomStateValue(
|
||||
fieldMetadataItemByIdMapSelector,
|
||||
const flattenedFieldMetadataItems = useAtomStateValue(
|
||||
flattenedFieldMetadataItemsSelector,
|
||||
);
|
||||
|
||||
const currentFilters = computeRecordGqlOperationFilter({
|
||||
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
|
||||
fieldMetadataItems: flattenedFieldMetadataItems,
|
||||
recordFilterGroups: currentRecordFilterGroups,
|
||||
recordFilters: currentRecordFilters,
|
||||
filterValueDependencies,
|
||||
|
||||
+4
-4
@@ -1,5 +1,5 @@
|
||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
|
||||
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
|
||||
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
|
||||
import { useRelevantRecordsGqlFields } from '@/object-record/record-field/hooks/useRelevantRecordsGqlFields';
|
||||
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
|
||||
@@ -38,15 +38,15 @@ export const useRecordIndexGroupCommonQueryVariables = () => {
|
||||
|
||||
const { filterValueDependencies } = useFilterValueDependencies();
|
||||
|
||||
const fieldMetadataItemByIdMap = useAtomStateValue(
|
||||
fieldMetadataItemByIdMapSelector,
|
||||
const flattenedFieldMetadataItems = useAtomStateValue(
|
||||
flattenedFieldMetadataItemsSelector,
|
||||
);
|
||||
|
||||
const requestFilters = computeRecordGqlOperationFilter({
|
||||
filterValueDependencies,
|
||||
recordFilters: currentRecordFilters,
|
||||
recordFilterGroups: currentRecordFilterGroups,
|
||||
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
|
||||
fieldMetadataItems: flattenedFieldMetadataItems,
|
||||
});
|
||||
|
||||
const anyFieldFilterValue = useAtomComponentStateValue(
|
||||
|
||||
+4
-4
@@ -1,5 +1,5 @@
|
||||
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
|
||||
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
|
||||
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
|
||||
import { EMPTY_QUERY } from '@/object-record/constants/EmptyQuery';
|
||||
@@ -48,15 +48,15 @@ export const useRecordIndexGroupsAggregatesGroupBy = ({
|
||||
|
||||
const { filterValueDependencies } = useFilterValueDependencies();
|
||||
|
||||
const fieldMetadataItemByIdMap = useAtomStateValue(
|
||||
fieldMetadataItemByIdMapSelector,
|
||||
const flattenedFieldMetadataItems = useAtomStateValue(
|
||||
flattenedFieldMetadataItemsSelector,
|
||||
);
|
||||
|
||||
const requestFilters = computeRecordGqlOperationFilter({
|
||||
filterValueDependencies,
|
||||
recordFilters: currentRecordFilters,
|
||||
recordFilterGroups: currentRecordFilterGroups,
|
||||
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
|
||||
fieldMetadataItems: flattenedFieldMetadataItems,
|
||||
});
|
||||
|
||||
const { recordAggregateGqlField } =
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
import { useListenToObjectRecordOperationBrowserEvent } from '@/browser-event/hooks/useListenToObjectRecordOperationBrowserEvent';
|
||||
import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail';
|
||||
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
|
||||
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
|
||||
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
|
||||
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
|
||||
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
|
||||
@@ -40,8 +40,8 @@ export const RecordTableEmptyHasNewRecordEffect = () => {
|
||||
|
||||
const { filterValueDependencies } = useFilterValueDependencies();
|
||||
|
||||
const fieldMetadataItemByIdMap = useAtomStateValue(
|
||||
fieldMetadataItemByIdMapSelector,
|
||||
const flattenedFieldMetadataItems = useAtomStateValue(
|
||||
flattenedFieldMetadataItemsSelector,
|
||||
);
|
||||
|
||||
const currentRecordFilters = useAtomComponentStateValue(
|
||||
@@ -63,7 +63,7 @@ export const RecordTableEmptyHasNewRecordEffect = () => {
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
variables: {
|
||||
filter: computeRecordGqlOperationFilter({
|
||||
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
|
||||
fieldMetadataItems: flattenedFieldMetadataItems,
|
||||
recordFilters: currentRecordFilters,
|
||||
recordFilterGroups: currentRecordFilterGroups,
|
||||
filterValueDependencies,
|
||||
@@ -77,7 +77,7 @@ export const RecordTableEmptyHasNewRecordEffect = () => {
|
||||
currentRecordFilterGroups,
|
||||
filterValueDependencies,
|
||||
currentRecordSorts,
|
||||
fieldMetadataItemByIdMap,
|
||||
flattenedFieldMetadataItems,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
|
||||
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
|
||||
import { useAggregateRecords } from '@/object-record/hooks/useAggregateRecords';
|
||||
import { transformAggregateRawValueIntoAggregateDisplayValue } from '@/object-record/record-aggregate/utils/transformAggregateRawValueIntoAggregateDisplayValue';
|
||||
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
|
||||
@@ -46,14 +46,14 @@ export const useAggregateRecordsForRecordTableColumnFooter = (
|
||||
|
||||
const dateLocale = useAtomStateValue(dateLocaleState);
|
||||
|
||||
const fieldMetadataItemByIdMap = useAtomStateValue(
|
||||
fieldMetadataItemByIdMapSelector,
|
||||
const flattenedFieldMetadataItems = useAtomStateValue(
|
||||
flattenedFieldMetadataItemsSelector,
|
||||
);
|
||||
|
||||
const { filterValueDependencies } = useFilterValueDependencies();
|
||||
|
||||
const requestFilters = computeRecordGqlOperationFilter({
|
||||
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
|
||||
fieldMetadataItems: flattenedFieldMetadataItems,
|
||||
filterValueDependencies,
|
||||
recordFilterGroups: currentRecordFilterGroups,
|
||||
recordFilters: currentRecordFilters,
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
|
||||
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
|
||||
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
|
||||
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
|
||||
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
|
||||
@@ -16,8 +16,8 @@ export const RecordTableVirtualizedSSESubscribeEffect = () => {
|
||||
const { objectMetadataItem } = useRecordIndexContextOrThrow();
|
||||
const { filterValueDependencies } = useFilterValueDependencies();
|
||||
|
||||
const fieldMetadataItemByIdMap = useAtomStateValue(
|
||||
fieldMetadataItemByIdMapSelector,
|
||||
const flattenedFieldMetadataItems = useAtomStateValue(
|
||||
flattenedFieldMetadataItemsSelector,
|
||||
);
|
||||
|
||||
const currentRecordFilters = useAtomComponentStateValue(
|
||||
@@ -39,7 +39,7 @@ export const RecordTableVirtualizedSSESubscribeEffect = () => {
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
variables: {
|
||||
filter: computeRecordGqlOperationFilter({
|
||||
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
|
||||
fieldMetadataItems: flattenedFieldMetadataItems,
|
||||
recordFilters: currentRecordFilters,
|
||||
recordFilterGroups: currentRecordFilterGroups,
|
||||
filterValueDependencies,
|
||||
@@ -53,7 +53,7 @@ export const RecordTableVirtualizedSSESubscribeEffect = () => {
|
||||
currentRecordFilterGroups,
|
||||
filterValueDependencies,
|
||||
currentRecordSorts,
|
||||
fieldMetadataItemByIdMap,
|
||||
flattenedFieldMetadataItems,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
+4
-4
@@ -4,7 +4,7 @@ import { contextStoreFilterGroupsComponentState } from '@/context-store/states/c
|
||||
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
|
||||
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
|
||||
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
|
||||
import { fieldMetadataItemByIdMapSelector } from '@/object-metadata/states/fieldMetadataItemByIdMapSelector';
|
||||
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
|
||||
import { useIncrementalUpdateManyRecords } from '@/object-record/hooks/useIncrementalUpdateManyRecords';
|
||||
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
@@ -45,8 +45,8 @@ export const useUpdateMultipleRecordsActions = ({
|
||||
|
||||
const { filterValueDependencies } = useFilterValueDependencies();
|
||||
|
||||
const fieldMetadataItemByIdMap = useAtomStateValue(
|
||||
fieldMetadataItemByIdMapSelector,
|
||||
const flattenedFieldMetadataItems = useAtomStateValue(
|
||||
flattenedFieldMetadataItemsSelector,
|
||||
);
|
||||
|
||||
const graphqlFilter = computeContextStoreFilters({
|
||||
@@ -54,7 +54,7 @@ export const useUpdateMultipleRecordsActions = ({
|
||||
contextStoreFilters,
|
||||
contextStoreFilterGroups,
|
||||
objectMetadataItem,
|
||||
findFieldMetadataItemById: (id) => fieldMetadataItemByIdMap.get(id),
|
||||
fieldMetadataItems: flattenedFieldMetadataItems,
|
||||
filterValueDependencies,
|
||||
contextStoreAnyFieldFilterValue,
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user