Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c52c976aca | ||
|
|
d5c1f4e10a | ||
|
|
3b180e7cb5 | ||
|
|
3d60e6dbfc | ||
|
|
e89b12488c | ||
|
|
31674253a1 | ||
|
|
633553f729 | ||
|
|
4dd08097ce | ||
|
|
65ba36d475 | ||
|
|
2a4db16970 | ||
|
|
d040756fcf | ||
|
|
e50adaff2d | ||
|
|
f9a24072b7 | ||
|
|
e6125c0e0d | ||
|
|
88394c25ef | ||
|
|
90a1a9274f | ||
|
|
660f246076 | ||
|
|
53fdac1417 | ||
|
|
36452ecc8b | ||
|
|
c983ac9f82 | ||
|
|
820f97f53d | ||
|
|
41a7d6928b | ||
|
|
8d001eb33f | ||
|
|
a3f2fafce6 | ||
|
|
ff65b5001d | ||
|
|
dd3b6f2a2f | ||
|
|
e3be1f4971 | ||
|
|
59107b5b23 | ||
|
|
7c4302d02a | ||
|
|
fda2295beb | ||
|
|
df63dbff05 | ||
|
|
01b4754e62 | ||
|
|
e6399b180e | ||
|
|
8c2885f9ed | ||
|
|
a76047f28b | ||
|
|
281eaa3721 | ||
|
|
c804f27846 | ||
|
|
54e22423df | ||
|
|
a0dd7d9e22 | ||
|
|
97ec720d2c | ||
|
|
95a34d8517 | ||
|
|
4852ac401a | ||
|
|
1cd983a330 | ||
|
|
37ca09e8f9 | ||
|
|
91124a3cb8 | ||
|
|
41ad63a8ab | ||
|
|
9ddf9af4c4 | ||
|
|
3ffda0a29e | ||
|
|
0bb345b75f | ||
|
|
3c7c62c79f | ||
|
|
596ce32bd6 | ||
|
|
d2cfbf319b | ||
|
|
a3cf565ba3 | ||
|
|
fa8304d323 | ||
|
|
467ddaa27a | ||
|
|
fc4cf7fe09 | ||
|
|
9e94045fa5 | ||
|
|
ff22988caf | ||
|
|
74cc2b4c87 | ||
|
|
7aa2afc67e | ||
|
|
a025dc368b | ||
|
|
5bf7e8b101 | ||
|
|
276d4f6e84 | ||
|
|
4f439bbe43 | ||
|
|
4fbd8b207d | ||
|
|
f7d812fca6 | ||
|
|
c3a320c27b | ||
|
|
85752f8a61 | ||
|
|
8a0225e974 | ||
|
|
abc67efd40 | ||
|
|
636deffb93 | ||
|
|
e1828b6f41 | ||
|
|
4b76457217 | ||
|
|
d8bd717f1f | ||
|
|
b44fb1ad23 | ||
|
|
f32b03a3ec | ||
|
|
c8e405cb4e | ||
|
|
83db37d33f | ||
|
|
5bdbfe651e | ||
|
|
3fbb70c13f | ||
|
|
c6b6c824d4 | ||
|
|
b21bf66b38 | ||
|
|
22838ac6de | ||
|
|
bddd23fd9c | ||
|
|
842e679cc6 | ||
|
|
a713f8d87f | ||
|
|
fd6d5f895d | ||
|
|
11628d19a3 | ||
|
|
46ba5fd16c | ||
|
|
4649736d49 | ||
|
|
51384bc085 | ||
|
|
abfa6200dd | ||
|
|
480e5796ec | ||
|
|
d8e2de48e6 | ||
|
|
b49e58dfc6 | ||
|
|
a6118b7dc3 | ||
|
|
19ee9444ed | ||
|
|
b92617d46e | ||
|
|
c476c6c80b | ||
|
|
d2dda67596 | ||
|
|
6aec449a56 | ||
|
|
7ea1dfdd49 | ||
|
|
3290bf3ab1 | ||
|
|
fbaea0639a | ||
|
|
2a3b8adcfb | ||
|
|
8f362186ce | ||
|
|
8998009805 | ||
|
|
a710c105cf | ||
|
|
a5cd64daf5 | ||
|
|
df3e217d64 | ||
|
|
1c06506256 | ||
|
|
d271ba06f1 | ||
|
|
7d35979fcf | ||
|
|
8d3047c1fc | ||
|
|
e632b7dbb9 | ||
|
|
5b3ee3f1a7 | ||
|
|
875795cc30 | ||
|
|
350b835fbc | ||
|
|
ef49dad0bc | ||
|
|
e3a668a054 | ||
|
|
74536e6f98 | ||
|
|
b6ec2acb56 | ||
|
|
bb5f294c5a | ||
|
|
13aaea597e | ||
|
|
ec893c2767 | ||
|
|
f8c1ad5b3c | ||
|
|
a90895e167 | ||
|
|
9b7f7d059a | ||
|
|
95fee18126 | ||
|
|
5b8804ff06 | ||
|
|
6545ca274c | ||
|
|
15c52d3a39 | ||
|
|
3db1af9a17 | ||
|
|
1e7c1691f4 | ||
|
|
577312f121 | ||
|
|
fb8b9cb86c |
@@ -106,34 +106,35 @@ Replace `{VERSION}` with the actual version number (e.g., `1.9.0`)
|
||||
### 2. Create File Structure
|
||||
|
||||
**Create changelog file:**
|
||||
- Path: `packages/twenty-website/src/content/releases/{VERSION}.mdx`
|
||||
- Example: `packages/twenty-website/src/content/releases/1.9.0.mdx`
|
||||
- Path: `packages/twenty-website-new/src/content/releases/{VERSION}.mdx`
|
||||
- Example: `packages/twenty-website-new/src/content/releases/1.9.0.mdx`
|
||||
|
||||
**Create image folder:**
|
||||
- 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/`
|
||||
- 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/`
|
||||
|
||||
```bash
|
||||
# Create the image folder
|
||||
mkdir -p packages/twenty-website/public/images/releases/{MINOR_VERSION}
|
||||
mkdir -p packages/twenty-website-new/public/images/releases/{MINOR_VERSION}
|
||||
```
|
||||
|
||||
### 3. Move Illustration Files
|
||||
|
||||
**Source:** `/Users/thomascolasdesfrancs/Downloads/🆕`
|
||||
|
||||
**Destination:** `packages/twenty-website/public/images/releases/{MINOR_VERSION}/`
|
||||
**Destination:** `packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/`
|
||||
|
||||
**Naming Convention:** `{VERSION}-descriptive-name.png`
|
||||
**Naming Convention:** `{VERSION}-descriptive-name.webp`
|
||||
|
||||
Examples:
|
||||
- `1.9.0-feature-name.png`
|
||||
- `1.9.0-another-feature.png`
|
||||
- `1.9.0-feature-name.webp`
|
||||
- `1.9.0-another-feature.webp`
|
||||
|
||||
```bash
|
||||
# Move and rename files
|
||||
cp ~/Downloads/🆕/source-file.png packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-name.png
|
||||
# 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
|
||||
```
|
||||
|
||||
### 4. Research Features (if needed)
|
||||
@@ -158,19 +159,19 @@ Date: {YYYY-MM-DD}
|
||||
|
||||
Short description explaining what the feature does and why it's useful. Keep it user-focused and concise (1-2 sentences).
|
||||
|
||||

|
||||

|
||||
|
||||
# Feature 2 Name
|
||||
|
||||
Another short description of the second feature.
|
||||
|
||||

|
||||

|
||||
|
||||
# Feature 3 Name
|
||||
|
||||
Description of the third feature.
|
||||
|
||||

|
||||

|
||||
```
|
||||
|
||||
**Style Guidelines:**
|
||||
@@ -182,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/src/content/releases/` for examples
|
||||
- Check `packages/twenty-website-new/src/content/releases/` for examples
|
||||
- Recent releases: 1.7.0.mdx, 1.6.0.mdx, 1.5.0.mdx
|
||||
|
||||
### 6. Review
|
||||
@@ -190,10 +191,10 @@ Description of the third feature.
|
||||
Open the changelog file for review:
|
||||
```bash
|
||||
# Open in Cursor
|
||||
cursor packages/twenty-website/src/content/releases/{VERSION}.mdx
|
||||
cursor packages/twenty-website-new/src/content/releases/{VERSION}.mdx
|
||||
|
||||
# Open image folder to verify illustrations
|
||||
open packages/twenty-website/public/images/releases/{MINOR_VERSION}
|
||||
open packages/twenty-website-new/public/images/releases/{MINOR_VERSION}
|
||||
```
|
||||
|
||||
Review checklist:
|
||||
@@ -221,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/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-1.png
|
||||
- packages/twenty-website/public/images/releases/{MINOR_VERSION}/{VERSION}-feature-2.png
|
||||
- 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
|
||||
|
||||
Please review the content. Once you approve, I'll commit the changes and create the pull request.
|
||||
```
|
||||
@@ -241,8 +242,8 @@ Possible user responses:
|
||||
git status
|
||||
|
||||
# Add files
|
||||
git add packages/twenty-website/src/content/releases/{VERSION}.mdx
|
||||
git add packages/twenty-website/public/images/releases/{MINOR_VERSION}/
|
||||
git add packages/twenty-website-new/src/content/releases/{VERSION}.mdx
|
||||
git add packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/
|
||||
|
||||
# Commit
|
||||
git commit -m "Add {VERSION} release changelog"
|
||||
@@ -265,7 +266,7 @@ This release includes:
|
||||
- Feature 2
|
||||
- Feature 3
|
||||
|
||||
Changelog file: \`packages/twenty-website/src/content/releases/{VERSION}.mdx\`
|
||||
Changelog file: \`packages/twenty-website-new/src/content/releases/{VERSION}.mdx\`
|
||||
Release date: {DATE}" \
|
||||
--base main \
|
||||
--head {VERSION}
|
||||
@@ -279,21 +280,21 @@ 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/src/content/releases/`
|
||||
- **Location**: `packages/twenty-website-new/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/public/images/releases/`
|
||||
- **Location**: `packages/twenty-website-new/public/images/releases/`
|
||||
|
||||
### Image Files
|
||||
- **Format**: `{VERSION}-descriptive-name.png`
|
||||
- **Format**: `{VERSION}-descriptive-name.webp`
|
||||
- **Convention**: Kebab-case descriptive names
|
||||
- **Examples**:
|
||||
- `1.8.0-workflow-iterator.png`
|
||||
- `1.8.0-bulk-select.png`
|
||||
- `1.9.0-new-feature.png`
|
||||
- `1.8.0-workflow-iterator.webp`
|
||||
- `1.8.0-bulk-select.webp`
|
||||
- `1.9.0-new-feature.webp`
|
||||
|
||||
## Quick Reference Template
|
||||
|
||||
@@ -310,8 +311,8 @@ Features to document:
|
||||
3. ___________________________
|
||||
|
||||
Branch name: {VERSION}
|
||||
Changelog path: packages/twenty-website/src/content/releases/{VERSION}.mdx
|
||||
Images path: packages/twenty-website/public/images/releases/{MINOR_VERSION}/
|
||||
Changelog path: packages/twenty-website-new/src/content/releases/{VERSION}.mdx
|
||||
Images path: packages/twenty-website-new/public/images/releases/{MINOR_VERSION}/
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
@@ -7,6 +7,17 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Free disk space for install
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
# Default GitHub images ship large SDKs this repo does not use; removing
|
||||
# them avoids ENOSPC when restoring or linking a full Yarn node_modules.
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo rm -rf /usr/local/lib/android
|
||||
sudo rm -rf /opt/ghc
|
||||
sudo rm -rf /opt/hostedtoolcache/CodeQL
|
||||
df -h
|
||||
- name: Cache primary key builder
|
||||
id: globals
|
||||
shell: bash
|
||||
|
||||
@@ -4,20 +4,19 @@
|
||||
# See https://crowdin.github.io/crowdin-cli/configuration for more information
|
||||
#
|
||||
|
||||
"preserve_hierarchy": true
|
||||
"base_path": ".."
|
||||
|
||||
files: [
|
||||
{
|
||||
#
|
||||
# Source files filter - PO files for Lingui
|
||||
#
|
||||
"source": "**/en.po",
|
||||
preserve_hierarchy: true
|
||||
base_path: ..
|
||||
|
||||
files:
|
||||
#
|
||||
# Source files filter - PO files for Lingui
|
||||
#
|
||||
- source: packages/twenty-front/src/locales/en.po
|
||||
#
|
||||
# Translation files path
|
||||
#
|
||||
"translation": "%original_path%/%locale%.po",
|
||||
}
|
||||
]
|
||||
|
||||
translation: '%original_path%/%locale%.po'
|
||||
- source: packages/twenty-server/src/engine/core-modules/i18n/locales/en.po
|
||||
translation: '%original_path%/%locale%.po'
|
||||
- source: packages/twenty-emails/src/locales/en.po
|
||||
translation: '%original_path%/%locale%.po'
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
#
|
||||
# Crowdin CLI configuration for Website translations (twenty-website-new)
|
||||
# Project ID: 4
|
||||
# See https://crowdin.github.io/crowdin-cli/configuration for more information
|
||||
#
|
||||
|
||||
project_id: 4
|
||||
preserve_hierarchy: true
|
||||
base_url: 'https://twenty.api.crowdin.com'
|
||||
base_path: ..
|
||||
languages_mapping:
|
||||
locale:
|
||||
fr: fr-FR
|
||||
|
||||
files:
|
||||
#
|
||||
# Source file - PO file for Lingui
|
||||
#
|
||||
- source: packages/twenty-website-new/src/locales/en.po
|
||||
#
|
||||
# Translation files path
|
||||
#
|
||||
translation: '%original_path%/%locale%.po'
|
||||
@@ -78,6 +78,83 @@ jobs:
|
||||
tag: scope:backend
|
||||
tasks: lint,typecheck
|
||||
|
||||
server-previous-version-upgrade-mutation-guard:
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 10
|
||||
- name: Get changed upgrade-version-command files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@v45
|
||||
with:
|
||||
files: |
|
||||
packages/twenty-server/src/database/commands/upgrade-version-command/**
|
||||
- name: Check upgrade version commands are in current version only
|
||||
if: >
|
||||
steps.changed-files.outputs.any_changed == 'true' &&
|
||||
!contains(github.event.pull_request.labels.*.name, 'ci:allow-previous-version-upgrade-mutation')
|
||||
run: |
|
||||
VERSION_CONSTANT_FILE="packages/twenty-server/src/engine/core-modules/upgrade/constants/twenty-current-version.constant.ts"
|
||||
|
||||
CURRENT_VERSION=$(sed -n "s/.*TWENTY_CURRENT_VERSION = '\([0-9.]*\)'.*/\1/p" "$VERSION_CONSTANT_FILE")
|
||||
|
||||
if [ -z "$CURRENT_VERSION" ]; then
|
||||
echo "::error::Could not extract TWENTY_CURRENT_VERSION from $VERSION_CONSTANT_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CURRENT_DIR=$(echo "$CURRENT_VERSION" | sed -E 's/^([0-9]+)\.([0-9]+)\..*/\1-\2/')
|
||||
|
||||
echo "Current version: $CURRENT_VERSION (directory: $CURRENT_DIR)"
|
||||
|
||||
ADDED_OFFENDERS=""
|
||||
MODIFIED_OFFENDERS=""
|
||||
|
||||
check_files() {
|
||||
local category="$1"
|
||||
shift
|
||||
for file in "$@"; do
|
||||
VERSION_DIR=$(echo "$file" | sed -n 's|.*upgrade-version-command/\([0-9]*-[0-9]*\)/.*|\1|p')
|
||||
|
||||
if [ -n "$VERSION_DIR" ] && [ "$VERSION_DIR" != "$CURRENT_DIR" ]; then
|
||||
if [ "$category" = "added" ]; then
|
||||
ADDED_OFFENDERS="$ADDED_OFFENDERS\n - $file (version directory: $VERSION_DIR)"
|
||||
else
|
||||
MODIFIED_OFFENDERS="$MODIFIED_OFFENDERS\n - $file (version directory: $VERSION_DIR)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
check_files "added" ${{ steps.changed-files.outputs.added_files }}
|
||||
check_files "modified" ${{ steps.changed-files.outputs.modified_files }}
|
||||
|
||||
if [ -n "$ADDED_OFFENDERS" ] || [ -n "$MODIFIED_OFFENDERS" ]; then
|
||||
echo "This PR touches upgrade command files outside the current version directory ($CURRENT_DIR / $CURRENT_VERSION)."
|
||||
|
||||
if [ -n "$ADDED_OFFENDERS" ]; then
|
||||
echo ""
|
||||
echo "New files added to non-current version directories:"
|
||||
echo -e "$ADDED_OFFENDERS"
|
||||
fi
|
||||
|
||||
if [ -n "$MODIFIED_OFFENDERS" ]; then
|
||||
echo ""
|
||||
echo "Existing files modified in non-current version directories:"
|
||||
echo -e "$MODIFIED_OFFENDERS"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "If this is intentional, add the label 'ci:allow-previous-version-upgrade-mutation' to this PR and re-run CI."
|
||||
echo "Otherwise, please move your changes to the current version directory ($CURRENT_DIR)."
|
||||
|
||||
echo "::error::Upgrade commands were added or modified in non-current version directories."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
server-validation:
|
||||
needs: server-build
|
||||
timeout-minutes: 30
|
||||
@@ -311,6 +388,7 @@ jobs:
|
||||
changed-files-check,
|
||||
server-build,
|
||||
server-lint-typecheck,
|
||||
server-previous-version-upgrade-mutation-guard,
|
||||
server-validation,
|
||||
server-test,
|
||||
server-integration-test,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
name: CI Website
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
merge_group:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
@@ -18,53 +18,40 @@ jobs:
|
||||
with:
|
||||
files: |
|
||||
package.json
|
||||
packages/twenty-website/**
|
||||
website-build:
|
||||
yarn.lock
|
||||
packages/twenty-website-new/**
|
||||
packages/twenty-shared/**
|
||||
website-task:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=6144'
|
||||
strategy:
|
||||
matrix:
|
||||
task: [lint, typecheck, test]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Cancel Previous Runs
|
||||
uses: styfle/cancel-workflow-action@0.11.0
|
||||
with:
|
||||
access_token: ${{ github.token }}
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 10
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Server / Create DB
|
||||
run: PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
|
||||
|
||||
- name: Website / Run migrations
|
||||
run: npx nx database:migrate twenty-website
|
||||
env:
|
||||
DATABASE_PG_URL: postgres://postgres:postgres@localhost:5432/default
|
||||
- name: Website / Build Website
|
||||
run: npx nx build twenty-website
|
||||
env:
|
||||
DATABASE_PG_URL: postgres://postgres:postgres@localhost:5432/default
|
||||
KEYSTATIC_GITHUB_CLIENT_ID: xxx
|
||||
KEYSTATIC_GITHUB_CLIENT_SECRET: xxx
|
||||
KEYSTATIC_SECRET: xxx
|
||||
NEXT_PUBLIC_KEYSTATIC_GITHUB_APP_SLUG: xxx
|
||||
- name: Run ${{ matrix.task }} task
|
||||
uses: ./.github/actions/nx-affected
|
||||
with:
|
||||
tag: scope:website
|
||||
tasks: ${{ matrix.task }}
|
||||
ci-website-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, website-build]
|
||||
needs: [changed-files-check, website-task]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
|
||||
@@ -74,8 +74,6 @@ jobs:
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
source: '**/en.po'
|
||||
translation: '%original_path%/%locale%.po'
|
||||
export_only_approved: false
|
||||
localization_branch_name: i18n
|
||||
base_url: 'https://twenty.api.crowdin.com'
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# Pull down website translations from Crowdin every two hours or when triggered manually.
|
||||
# When force_pull input is true, translations will be pulled regardless of compilation status.
|
||||
|
||||
name: 'Pull website translations from Crowdin'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */2 * * *' # Every two hours.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force_pull:
|
||||
description: 'Force pull translations regardless of compilation status'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_call:
|
||||
inputs:
|
||||
force_pull:
|
||||
description: 'Force pull translations regardless of compilation status'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
pull_website_translations:
|
||||
name: Pull website translations
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
ref: ${{ github.head_ref || github.ref_name }}
|
||||
|
||||
- name: Setup website i18n branch
|
||||
run: |
|
||||
git fetch origin i18n-website || true
|
||||
git checkout -B i18n-website origin/i18n-website || git checkout -b i18n-website
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Build twenty-shared
|
||||
run: npx nx build twenty-shared
|
||||
|
||||
# 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
|
||||
continue-on-error: true
|
||||
|
||||
- name: Stash any changes before pulling translations
|
||||
run: |
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@twenty.com'
|
||||
git add .
|
||||
git stash
|
||||
|
||||
- name: Pull website translations from Crowdin
|
||||
if: inputs.force_pull || steps.compile_translations_strict.outcome == 'failure'
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
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'
|
||||
export_only_approved: false
|
||||
localization_branch_name: i18n-website
|
||||
base_url: 'https://twenty.api.crowdin.com'
|
||||
auto_approve_imported: false
|
||||
import_eq_suggestions: false
|
||||
download_sources: false
|
||||
push_sources: false
|
||||
skip_untranslated_strings: false
|
||||
skip_untranslated_files: false
|
||||
push_translations: false
|
||||
create_pull_request: false
|
||||
skip_ref_checkout: true
|
||||
dryrun_action: false
|
||||
config: '.github/crowdin-website.yml'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
# Website translations project
|
||||
CROWDIN_PROJECT_ID: '4'
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
# As the files are extracted from a Docker container, they belong to root:root.
|
||||
# We need to fix this before the next steps.
|
||||
- name: Fix file permissions
|
||||
run: sudo chown -R runner:docker .
|
||||
|
||||
- name: Compile website translations
|
||||
id: compile_translations
|
||||
run: |
|
||||
npx nx run twenty-website-new:lingui:compile
|
||||
git status
|
||||
git add packages/twenty-website-new/src/locales
|
||||
if ! git diff --staged --quiet --exit-code; then
|
||||
git commit -m "chore: compile website translations"
|
||||
echo "changes_detected=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "changes_detected=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Push changes
|
||||
if: steps.compile_translations.outputs.changes_detected == 'true'
|
||||
run: git push origin HEAD:i18n-website
|
||||
|
||||
- name: Create pull request
|
||||
if: steps.compile_translations.outputs.changes_detected == 'true'
|
||||
run: |
|
||||
if git diff --name-only origin/main..HEAD | grep -q .; then
|
||||
gh pr create -B main -H i18n-website --title 'i18n - website translations' --body 'Created by Github action' || true
|
||||
else
|
||||
echo "No file differences between branches, skipping PR creation"
|
||||
fi
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Trigger i18n automerge
|
||||
if: steps.compile_translations.outputs.changes_detected == 'true'
|
||||
uses: peter-evans/repository-dispatch@v2
|
||||
with:
|
||||
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
|
||||
repository: twentyhq/twenty-infra
|
||||
event-type: i18n-pr-ready
|
||||
@@ -0,0 +1,111 @@
|
||||
name: 'Push website translations to Crowdin'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: ['main']
|
||||
paths:
|
||||
- 'packages/twenty-website-new/**'
|
||||
- '.github/crowdin-website.yml'
|
||||
- '.github/workflows/website-i18n-push.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
extract_website_translations:
|
||||
name: Extract and upload website translations
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
ref: main
|
||||
|
||||
- name: Setup website i18n branch
|
||||
run: |
|
||||
git fetch origin i18n-website || true
|
||||
git checkout -B i18n-website origin/i18n-website || git checkout -b i18n-website
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Build dependencies
|
||||
run: npx nx build twenty-shared
|
||||
|
||||
- name: Extract website translations
|
||||
run: npx nx run twenty-website-new: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
|
||||
if ! git diff --staged --quiet --exit-code; then
|
||||
git commit -m "chore: extract website translations"
|
||||
echo "changes_detected=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "changes_detected=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Compile website translations
|
||||
run: npx nx run twenty-website-new: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
|
||||
if ! git diff --staged --quiet --exit-code; then
|
||||
git commit -m "chore: compile website translations"
|
||||
echo "changes_detected=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "changes_detected=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Push changes and create remote branch if needed
|
||||
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
|
||||
run: git push origin HEAD:i18n-website
|
||||
|
||||
- name: Upload missing website translations
|
||||
if: steps.check_extract_changes.outputs.changes_detected == 'true'
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
download_translations: false
|
||||
localization_branch_name: i18n-website
|
||||
base_url: 'https://twenty.api.crowdin.com'
|
||||
config: '.github/crowdin-website.yml'
|
||||
env:
|
||||
# Website translations project
|
||||
CROWDIN_PROJECT_ID: '4'
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
- name: Create a pull request
|
||||
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
|
||||
run: |
|
||||
if git diff --name-only origin/main..HEAD | grep -q .; then
|
||||
gh pr create -B main -H i18n-website --title 'i18n - website translations' --body 'Created by Github action' || true
|
||||
else
|
||||
echo "No file differences between branches, skipping PR creation"
|
||||
fi
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Trigger i18n automerge
|
||||
if: steps.check_extract_changes.outputs.changes_detected == 'true' || steps.check_compile_changes.outputs.changes_detected == 'true'
|
||||
uses: peter-evans/repository-dispatch@v2
|
||||
with:
|
||||
token: ${{ secrets.TWENTY_INFRA_TOKEN }}
|
||||
repository: twentyhq/twenty-infra
|
||||
event-type: i18n-pr-ready
|
||||
@@ -110,7 +110,8 @@ packages/
|
||||
├── twenty-ui/ # Shared UI components library
|
||||
├── twenty-shared/ # Common types and utilities
|
||||
├── twenty-emails/ # Email templates with React Email
|
||||
├── twenty-website/ # Next.js documentation website
|
||||
├── twenty-website-new/ # 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/public/images/core/logo.svg" width="100px" alt="Twenty logo" />
|
||||
<img src="./packages/twenty-website-new/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/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.png" width="12" height="12"/> Figma</a></p>
|
||||
<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.png" 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/public/images/readme/github-cover-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/github-cover-light.png" />
|
||||
<img src="./packages/twenty-website/public/images/readme/github-cover-light.png" alt="Twenty banner" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/github-cover-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/github-cover-light.png" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/github-cover-light.png" 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/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>
|
||||
<a href="https://twenty.com/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>
|
||||
|
||||
<br />
|
||||
|
||||
# Installation
|
||||
|
||||
### <img src="./packages/twenty-website/public/images/readme/globe-icon.svg" width="14" height="14"/> Cloud
|
||||
### <img src="./packages/twenty-website-new/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/public/images/readme/book-icon.svg" width="14" height="14"/> Build an app
|
||||
### <img src="./packages/twenty-website-new/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/public/images/readme/rocket-icon.svg" width="14" height="14"/> Self-hosting
|
||||
### <img src="./packages/twenty-website-new/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/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.
|
||||
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.
|
||||
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-build-apps-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-build-apps-light.png" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-build-apps-light.png" alt="Create your apps" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-build-apps-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-build-apps-light.png" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-build-apps-light.png" alt="Create your apps" />
|
||||
</picture>
|
||||
<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>
|
||||
<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>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-version-control-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-version-control-light.png" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-version-control-light.png" alt="Stay on top with version control" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-version-control-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-version-control-light.png" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-version-control-light.png" 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/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-new/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/public/images/readme/v2-all-tools-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-all-tools-light.png" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-all-tools-light.png" alt="All the tools you need to build anything" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-all-tools-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-all-tools-light.png" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-all-tools-light.png" 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/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-new/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/public/images/readme/v2-tools-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-tools-light.png" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-tools-light.png" alt="Customize your layouts" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-tools-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-tools-light.png" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-tools-light.png" alt="Customize your layouts" />
|
||||
</picture>
|
||||
<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>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-ai-agents-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-ai-agents-light.png" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-ai-agents-light.png" alt="AI agents and chats" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-ai-agents-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-ai-agents-light.png" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-ai-agents-light.png" alt="AI agents and chats" />
|
||||
</picture>
|
||||
<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>
|
||||
<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>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-crm-tools-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-crm-tools-light.png" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-crm-tools-light.png" alt="Plus all the tools of a good CRM" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website-new/public/images/readme/v2-crm-tools-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website-new/public/images/readme/v2-crm-tools-light.png" />
|
||||
<img src="./packages/twenty-website-new/public/images/readme/v2-crm-tools-light.png" 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/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-new/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/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>
|
||||
- <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>
|
||||
|
||||
|
||||
|
||||
# Thanks
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website/public/images/readme/chromatic.png" height="28" alt="Chromatic" /></a>
|
||||
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website-new/public/images/readme/chromatic.png" height="28" alt="Chromatic" /></a>
|
||||
|
||||
<a href="https://greptile.com"><img src="./packages/twenty-website/public/images/readme/greptile.png" height="28" alt="Greptile" /></a>
|
||||
<a href="https://greptile.com"><img src="./packages/twenty-website-new/public/images/readme/greptile.png" height="28" alt="Greptile" /></a>
|
||||
|
||||
<a href="https://sentry.io/"><img src="./packages/twenty-website/public/images/readme/sentry.png" height="28" alt="Sentry" /></a>
|
||||
<a href="https://sentry.io/"><img src="./packages/twenty-website-new/public/images/readme/sentry.png" height="28" alt="Sentry" /></a>
|
||||
|
||||
<a href="https://crowdin.com/"><img src="./packages/twenty-website/public/images/readme/crowdin.png" height="28" alt="Crowdin" /></a>
|
||||
<a href="https://crowdin.com/"><img src="./packages/twenty-website-new/public/images/readme/crowdin.png" 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/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>
|
||||
<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>
|
||||
|
||||
@@ -1,172 +1,20 @@
|
||||
{
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@apollo/client": "^4.0.0",
|
||||
"@floating-ui/react": "^0.24.3",
|
||||
"@linaria/core": "^6.2.0",
|
||||
"@linaria/react": "^6.2.1",
|
||||
"@radix-ui/colors": "^3.0.0",
|
||||
"@sniptt/guards": "^0.2.0",
|
||||
"@tabler/icons-react": "^3.31.0",
|
||||
"@wyw-in-js/babel-preset": "^1.0.6",
|
||||
"@wyw-in-js/vite": "^0.7.0",
|
||||
"archiver": "^7.0.1",
|
||||
"danger-plugin-todos": "^1.3.1",
|
||||
"date-fns": "^2.30.0",
|
||||
"date-fns-tz": "^2.0.0",
|
||||
"deep-equal": "^2.2.2",
|
||||
"file-type": "16.5.4",
|
||||
"framer-motion": "^11.18.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"googleapis": "105",
|
||||
"hex-rgb": "^5.0.0",
|
||||
"immer": "^10.1.1",
|
||||
"jotai": "^2.17.1",
|
||||
"libphonenumber-js": "^1.10.26",
|
||||
"lodash.camelcase": "^4.3.0",
|
||||
"lodash.chunk": "^4.2.0",
|
||||
"lodash.compact": "^3.0.1",
|
||||
"lodash.escaperegexp": "^4.1.2",
|
||||
"lodash.groupby": "^4.6.0",
|
||||
"lodash.identity": "^3.0.0",
|
||||
"lodash.isempty": "^4.4.0",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"lodash.isobject": "^3.0.2",
|
||||
"lodash.kebabcase": "^4.1.1",
|
||||
"lodash.mapvalues": "^4.6.0",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"lodash.omit": "^4.5.0",
|
||||
"lodash.pickby": "^4.6.0",
|
||||
"lodash.snakecase": "^4.1.1",
|
||||
"lodash.upperfirst": "^4.3.1",
|
||||
"microdiff": "^1.3.2",
|
||||
"next-with-linaria": "^1.3.0",
|
||||
"planer": "^1.2.0",
|
||||
"pluralize": "^8.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-responsive": "^9.0.2",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"react-tooltip": "^5.13.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"rxjs": "^7.2.0",
|
||||
"semver": "^7.5.4",
|
||||
"slash": "^5.1.0",
|
||||
"temporal-polyfill": "^0.3.0",
|
||||
"ts-key-enum": "^2.0.12",
|
||||
"tslib": "^2.8.1",
|
||||
"type-fest": "4.10.1",
|
||||
"typescript": "5.9.2",
|
||||
"uuid": "^9.0.0",
|
||||
"vite-tsconfig-paths": "^4.2.1",
|
||||
"xlsx-ugnis": "^0.19.3",
|
||||
"zod": "^4.1.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.14.5",
|
||||
"@babel/preset-react": "^7.14.5",
|
||||
"@babel/preset-typescript": "^7.24.6",
|
||||
"@chromatic-com/storybook": "^4.1.3",
|
||||
"@graphql-codegen/cli": "^3.3.1",
|
||||
"@graphql-codegen/client-preset": "^4.1.0",
|
||||
"@graphql-codegen/typescript": "^3.0.4",
|
||||
"@graphql-codegen/typescript-operations": "^3.0.4",
|
||||
"@graphql-codegen/typescript-react-apollo": "^3.3.7",
|
||||
"@nx/jest": "22.5.4",
|
||||
"@nx/js": "22.5.4",
|
||||
"@nx/react": "22.5.4",
|
||||
"@nx/storybook": "22.5.4",
|
||||
"@nx/vite": "22.5.4",
|
||||
"@nx/web": "22.5.4",
|
||||
"@oxlint/plugins": "^1.51.0",
|
||||
"@sentry/types": "^8",
|
||||
"@storybook-community/storybook-addon-cookie": "^5.0.0",
|
||||
"@storybook/addon-coverage": "^3.0.0",
|
||||
"@storybook/addon-docs": "^10.3.3",
|
||||
"@storybook/addon-links": "^10.3.3",
|
||||
"@storybook/addon-vitest": "^10.3.3",
|
||||
"@storybook/icons": "^2.0.1",
|
||||
"@storybook/react-vite": "^10.3.3",
|
||||
"@storybook/test-runner": "^0.24.2",
|
||||
"@swc-node/register": "^1.11.1",
|
||||
"@swc/cli": "^0.7.10",
|
||||
"@swc/core": "^1.15.11",
|
||||
"@swc/helpers": "~0.5.19",
|
||||
"@swc/jest": "^0.2.39",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/addressparser": "^1.0.3",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/bytes": "^3.1.1",
|
||||
"@types/chrome": "^0.0.267",
|
||||
"@types/deep-equal": "^1.0.1",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/graphql-fields": "^1.3.6",
|
||||
"@types/inquirer": "^9.0.9",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/lodash.camelcase": "^4.3.7",
|
||||
"@types/lodash.compact": "^3.0.9",
|
||||
"@types/lodash.escaperegexp": "^4.1.9",
|
||||
"@types/lodash.groupby": "^4.6.9",
|
||||
"@types/lodash.identity": "^3.0.9",
|
||||
"@types/lodash.isempty": "^4.4.7",
|
||||
"@types/lodash.isequal": "^4.5.7",
|
||||
"@types/lodash.isobject": "^3.0.7",
|
||||
"@types/lodash.kebabcase": "^4.1.7",
|
||||
"@types/lodash.mapvalues": "^4.6.9",
|
||||
"@types/lodash.omit": "^4.5.9",
|
||||
"@types/lodash.pickby": "^4.6.9",
|
||||
"@types/lodash.snakecase": "^4.1.7",
|
||||
"@types/lodash.upperfirst": "^4.3.7",
|
||||
"@types/ms": "^0.7.31",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/passport-google-oauth20": "^2.0.11",
|
||||
"@types/passport-jwt": "^3.0.8",
|
||||
"@types/passport-microsoft": "^2.1.0",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/react": "^18.2.39",
|
||||
"@types/react-datepicker": "^6.2.0",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@types/supertest": "^2.0.11",
|
||||
"@types/uuid": "^9.0.2",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
|
||||
"@vitejs/plugin-react-swc": "4.2.3",
|
||||
"@vitest/browser-playwright": "^4.0.18",
|
||||
"@vitest/coverage-istanbul": "^4.0.18",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@yarnpkg/types": "^4.0.0",
|
||||
"chromatic": "^6.18.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"danger": "^13.0.4",
|
||||
"dotenv-cli": "^7.4.4",
|
||||
"esbuild": "^0.25.10",
|
||||
"http-server": "^14.1.1",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "30.0.0-beta.3",
|
||||
"jest-environment-node": "^29.4.1",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"jsdom": "~22.1.0",
|
||||
"msw": "^2.12.7",
|
||||
"msw-storybook-addon": "^2.0.6",
|
||||
"nx": "22.5.4",
|
||||
"prettier": "^3.1.1",
|
||||
"raw-loader": "^4.0.2",
|
||||
"rimraf": "^5.0.5",
|
||||
"source-map-support": "^0.5.20",
|
||||
"storybook": "^10.3.3",
|
||||
"storybook-addon-mock-date": "2.0.0",
|
||||
"storybook-addon-pseudo-states": "^10.3.3",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-loader": "^9.2.3",
|
||||
"ts-node": "10.9.1",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.17.0",
|
||||
"verdaccio": "^6.3.1",
|
||||
"vite": "^7.0.0",
|
||||
"vitest": "^4.0.18"
|
||||
"verdaccio": "^6.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
@@ -185,7 +33,8 @@
|
||||
"@lingui/core": "5.1.2",
|
||||
"@types/qs": "6.9.16",
|
||||
"@wyw-in-js/transform@npm:0.6.0": "patch:@wyw-in-js/transform@npm%3A0.7.0#~/.yarn/patches/@wyw-in-js-transform-npm-0.7.0-ba641dc99f.patch",
|
||||
"@wyw-in-js/transform@npm:0.7.0": "patch:@wyw-in-js/transform@npm%3A0.7.0#~/.yarn/patches/@wyw-in-js-transform-npm-0.7.0-ba641dc99f.patch"
|
||||
"@wyw-in-js/transform@npm:0.7.0": "patch:@wyw-in-js/transform@npm%3A0.7.0#~/.yarn/patches/@wyw-in-js-transform-npm-0.7.0-ba641dc99f.patch",
|
||||
"@opentelemetry/api": "1.9.1"
|
||||
},
|
||||
"version": "0.2.1",
|
||||
"nx": {},
|
||||
@@ -203,7 +52,6 @@
|
||||
"packages/twenty-ui",
|
||||
"packages/twenty-utils",
|
||||
"packages/twenty-zapier",
|
||||
"packages/twenty-website",
|
||||
"packages/twenty-website-new",
|
||||
"packages/twenty-docs",
|
||||
"packages/twenty-e2e-testing",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div align="center">
|
||||
<a href="https://twenty.com">
|
||||
<picture>
|
||||
<img alt="Twenty logo" src="https://raw.githubusercontent.com/twentyhq/twenty/2f25922f4cd5bd61e1427c57c4f8ea224e1d552c/packages/twenty-website/public/images/core/logo.svg" height="128">
|
||||
<img alt="Twenty logo" src="https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-website-new/public/images/core/logo.svg" height="128">
|
||||
</picture>
|
||||
</a>
|
||||
<h1>Create Twenty App</h1>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "2.1.0",
|
||||
"version": "2.3.0",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
@@ -40,12 +40,17 @@
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@swc/core": "^1.15.11",
|
||||
"@swc/jest": "^0.2.39",
|
||||
"@types/fs-extra": "^11.0.0",
|
||||
"@types/inquirer": "^9.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/lodash.camelcase": "^4.3.7",
|
||||
"@types/lodash.kebabcase": "^4.1.7",
|
||||
"@types/lodash.startcase": "^4",
|
||||
"@types/node": "^20.0.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-node": "^29.4.1",
|
||||
"twenty-shared": "workspace:*",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.0.0",
|
||||
|
||||
@@ -26,6 +26,7 @@ const program = new Command(packageJson.name)
|
||||
'--skip-local-instance',
|
||||
'Skip the local Twenty instance setup prompt',
|
||||
)
|
||||
.option('-y, --yes', 'Auto-confirm prompts (e.g. start existing container)')
|
||||
.helpOption('-h, --help', 'Display this help message.')
|
||||
.action(
|
||||
async (
|
||||
@@ -36,6 +37,7 @@ const program = new Command(packageJson.name)
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
skipLocalInstance?: boolean;
|
||||
yes?: boolean;
|
||||
},
|
||||
) => {
|
||||
if (directory && !/^[a-z0-9-]+$/.test(directory)) {
|
||||
@@ -59,6 +61,7 @@ const program = new Command(packageJson.name)
|
||||
displayName: options?.displayName,
|
||||
description: options?.description,
|
||||
skipLocalInstance: options?.skipLocalInstance,
|
||||
yes: options?.yes,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -11,7 +11,9 @@ import * as path from 'path';
|
||||
import { basename } from 'path';
|
||||
import {
|
||||
authLoginOAuth,
|
||||
checkDockerRunning,
|
||||
ConfigService,
|
||||
containerExists,
|
||||
detectLocalServer,
|
||||
serverStart,
|
||||
type ServerStartResult,
|
||||
@@ -27,6 +29,7 @@ type CreateAppOptions = {
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
skipLocalInstance?: boolean;
|
||||
yes?: boolean;
|
||||
};
|
||||
|
||||
export class CreateAppCommand {
|
||||
@@ -71,7 +74,7 @@ export class CreateAppCommand {
|
||||
let serverResult: ServerStartResult | undefined;
|
||||
|
||||
if (!options.skipLocalInstance) {
|
||||
const shouldStartServer = await this.shouldStartServer();
|
||||
const shouldStartServer = await this.shouldStartServer(options.yes);
|
||||
|
||||
if (shouldStartServer) {
|
||||
const startResult = await serverStart({
|
||||
@@ -223,13 +226,35 @@ export class CreateAppCommand {
|
||||
);
|
||||
}
|
||||
|
||||
private async shouldStartServer(): Promise<boolean> {
|
||||
private async shouldStartServer(autoConfirm?: boolean): Promise<boolean> {
|
||||
const existingServerUrl = await detectLocalServer();
|
||||
|
||||
if (existingServerUrl) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (checkDockerRunning() && containerExists()) {
|
||||
if (autoConfirm) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { startExisting } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'startExisting',
|
||||
message:
|
||||
'An existing Twenty server container was found. Would you like to start it?',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
|
||||
return startExisting;
|
||||
}
|
||||
|
||||
if (autoConfirm) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { startDocker } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
|
||||
@@ -82,7 +82,17 @@ describe('verifyGitHubSignature', () => {
|
||||
});
|
||||
|
||||
describe('getRawBodyForSignature', () => {
|
||||
it('returns the string as-is for string body', () => {
|
||||
it('prefers event.rawBody when the runtime forwarded it', () => {
|
||||
const original = '{ "action": "opened", "number": 42 }';
|
||||
expect(
|
||||
getRawBodyForSignature({
|
||||
body: { action: 'opened', number: 42 },
|
||||
rawBody: original,
|
||||
}),
|
||||
).toBe(original);
|
||||
});
|
||||
|
||||
it('falls back to string body when rawBody is not provided', () => {
|
||||
expect(
|
||||
getRawBodyForSignature({ body: '{"a":1}', isBase64Encoded: false }),
|
||||
).toBe('{"a":1}');
|
||||
@@ -119,3 +129,22 @@ describe('verifyGitHubSignature with parsed body', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('end-to-end: server forwards rawBody, signature verifies', () => {
|
||||
it('verifies a signature when rawBody is present alongside parsed body', () => {
|
||||
const original = '{ "action": "opened", "number": 42 }';
|
||||
const event = {
|
||||
body: { action: 'opened', number: 42 },
|
||||
rawBody: original,
|
||||
isBase64Encoded: false,
|
||||
};
|
||||
|
||||
const rawBody = getRawBodyForSignature(event);
|
||||
const result = verifyGitHubSignature({
|
||||
rawBody,
|
||||
signatureHeader: sign(original),
|
||||
secret: SECRET,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,11 @@ export type SignatureVerificationResult =
|
||||
export function getRawBodyForSignature(event: {
|
||||
body: unknown;
|
||||
isBase64Encoded?: boolean;
|
||||
rawBody?: string;
|
||||
}): string | null {
|
||||
if (typeof event.rawBody === 'string') {
|
||||
return event.rawBody;
|
||||
}
|
||||
const raw = event.body;
|
||||
if (raw == null) return '';
|
||||
if (typeof raw === 'string') {
|
||||
|
||||
@@ -8,7 +8,6 @@ export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
displayName: 'Postcard App',
|
||||
description: 'Send postcards easily with Twenty',
|
||||
icon: 'IconWorld',
|
||||
applicationVariables: {
|
||||
DEFAULT_RECIPIENT_NAME: {
|
||||
universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { useRecordId } from 'twenty-sdk/front-component';
|
||||
import { useSelectedRecordIds } from 'twenty-sdk/front-component';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
@@ -72,7 +72,8 @@ const CardDisplay = ({
|
||||
};
|
||||
|
||||
const PostCardPreview = () => {
|
||||
const recordId = useRecordId();
|
||||
const selectedRecordIds = useSelectedRecordIds();
|
||||
const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
|
||||
const [postCard, setPostCard] = useState<PostCardRecord | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { useRecordId, updateProgress, enqueueSnackbar, unmountFrontComponent } from 'twenty-sdk/front-component';
|
||||
import { useSelectedRecordIds, updateProgress, enqueueSnackbar, unmountFrontComponent } from 'twenty-sdk/front-component';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
|
||||
|
||||
const SYSTEM_PROMPT =
|
||||
@@ -11,12 +10,13 @@ const SYSTEM_PROMPT =
|
||||
'text, nothing else — no greeting label, no sign-off label, just the message.';
|
||||
|
||||
const GeneratePostCardEffect = () => {
|
||||
const recordId = useRecordId();
|
||||
const selectedRecordIds = useSelectedRecordIds();
|
||||
const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDefined(recordId)) {
|
||||
if (recordId === null) {
|
||||
enqueueSnackbar({
|
||||
message: 'No record selected',
|
||||
message: 'Please select exactly one record',
|
||||
variant: 'error',
|
||||
});
|
||||
unmountFrontComponent();
|
||||
|
||||
@@ -1,62 +1,44 @@
|
||||
import { useEffect } from 'react';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { useRecordId, updateProgress, enqueueSnackbar, unmountFrontComponent } from 'twenty-sdk/front-component';
|
||||
import { useSelectedRecordIds, updateProgress, enqueueSnackbar, unmountFrontComponent } from 'twenty-sdk/front-component';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
|
||||
|
||||
const SendPostCardsEffect = () => {
|
||||
const recordId = useRecordId();
|
||||
const selectedRecordIds = useSelectedRecordIds();
|
||||
|
||||
useEffect(() => {
|
||||
const send = async () => {
|
||||
try {
|
||||
await updateProgress(0.1);
|
||||
const client = new CoreApiClient();
|
||||
|
||||
let idsToSend: string[] = [];
|
||||
|
||||
if (isDefined(recordId)) {
|
||||
idsToSend = [recordId];
|
||||
} else {
|
||||
const { postCards } = await client.query({
|
||||
postCards: {
|
||||
__args: {
|
||||
filter: { status: { eq: 'DRAFT' } },
|
||||
},
|
||||
edges: { node: { id: true } },
|
||||
},
|
||||
if (selectedRecordIds.length === 0) {
|
||||
await enqueueSnackbar({
|
||||
message: 'No postcards selected',
|
||||
variant: 'error',
|
||||
});
|
||||
|
||||
idsToSend =
|
||||
postCards?.edges?.map(
|
||||
(edge: { node: { id: string; status: true } }) => edge.node.id,
|
||||
) ?? [];
|
||||
}
|
||||
|
||||
if (idsToSend.length === 0) {
|
||||
await updateProgress(1);
|
||||
await unmountFrontComponent();
|
||||
return;
|
||||
}
|
||||
|
||||
await updateProgress(0.1);
|
||||
const client = new CoreApiClient();
|
||||
|
||||
await updateProgress(0.3);
|
||||
|
||||
for (let i = 0; i < idsToSend.length; i++) {
|
||||
for (let i = 0; i < selectedRecordIds.length; i++) {
|
||||
await client.mutation({
|
||||
updatePostCard: {
|
||||
__args: {
|
||||
id: idsToSend[i],
|
||||
id: selectedRecordIds[i],
|
||||
data: { status: 'SENT' },
|
||||
},
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
await updateProgress(0.3 + (0.7 * (i + 1)) / idsToSend.length);
|
||||
await updateProgress(0.3 + (0.7 * (i + 1)) / selectedRecordIds.length);
|
||||
}
|
||||
|
||||
const count = idsToSend.length;
|
||||
const count = selectedRecordIds.length;
|
||||
|
||||
await enqueueSnackbar({
|
||||
message: `${count} postcard${count > 1 ? 's' : ''} sent`,
|
||||
@@ -74,7 +56,7 @@ const SendPostCardsEffect = () => {
|
||||
};
|
||||
|
||||
send();
|
||||
}, [recordId]);
|
||||
}, [selectedRecordIds]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,5 @@ export default defineApplication({
|
||||
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000001',
|
||||
displayName: 'Root App',
|
||||
description: 'An app with all entities at root level',
|
||||
icon: 'IconFolder',
|
||||
defaultRoleUniversalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000002',
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ export default defineApplication({
|
||||
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
|
||||
displayName: 'Rich App',
|
||||
description: 'A simple rich app',
|
||||
icon: 'IconWorld',
|
||||
applicationVariables: {
|
||||
DEFAULT_RECIPIENT_NAME: {
|
||||
universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de',
|
||||
|
||||
@@ -10,5 +10,13 @@ export default defineLogicFunction({
|
||||
description: 'Look up a recipient by name to find their details',
|
||||
timeoutSeconds: 5,
|
||||
handler,
|
||||
isTool: true,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
recipientName: { type: 'string' },
|
||||
},
|
||||
required: ['recipientName'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -30,31 +30,31 @@ export default defineView({
|
||||
],
|
||||
groups: [
|
||||
{
|
||||
universalIdentifier: 'bg1a2b3c-0001-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
universalIdentifier: 'e9ed34f1-3c3d-41b1-869b-00aae0033d9c',
|
||||
fieldValue: 'DRAFT',
|
||||
position: 0,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'bg1a2b3c-0002-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
universalIdentifier: '19b1a3c1-53f0-4d32-b072-d645dac98e38',
|
||||
fieldValue: 'SENT',
|
||||
position: 1,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'bg1a2b3c-0003-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
universalIdentifier: 'f545cb5a-370d-423f-9b4e-278a9a465bdf',
|
||||
fieldValue: 'DELIVERED',
|
||||
position: 2,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'bg1a2b3c-0004-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
universalIdentifier: '5d4c6d5f-af53-4cd0-a843-df38915561b2',
|
||||
fieldValue: 'RETURNED',
|
||||
position: 3,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'bg1a2b3c-0005-4a7b-8c9d-0e1f2a3b4c5d',
|
||||
universalIdentifier: '5ebbd7dc-9939-4594-b2a0-519269b4531f',
|
||||
fieldValue: 'LOST',
|
||||
position: 4,
|
||||
isVisible: true,
|
||||
|
||||
@@ -111,7 +111,8 @@ export default defineLogicFunction({
|
||||
description:
|
||||
'Structured web search powered by Exa. Returns entity-aware results with category filtering (companies, people, research papers, news, and other content types). Prefer this when the query benefits from structured data or a specific category. For general real-time web browsing, prefer the native `web_search` tool when it is available.',
|
||||
timeoutSeconds: 30,
|
||||
isTool: true,
|
||||
toolInputSchema: exaWebSearchInputSchema,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: exaWebSearchInputSchema,
|
||||
},
|
||||
handler,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$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,79 @@
|
||||
# Linear for Twenty
|
||||
|
||||
Connect your Linear account to Twenty to create issues and look up teams
|
||||
straight from your workflows or the AI chat.
|
||||
|
||||
## What you can do
|
||||
|
||||
Once installed and connected, two tools become available:
|
||||
|
||||
- **Create Linear issue** — from the AI chat, ask something like
|
||||
*"create a Linear issue in the Engineering team titled 'Fix login bug'"*
|
||||
and the AI will file it for you. From a workflow, add it as a step
|
||||
with `teamId` + `title` (and optional `description`).
|
||||
- **List Linear teams** — discovers the teams in your Linear workspace,
|
||||
useful when you need to pick a `teamId` for the create-issue step.
|
||||
|
||||
## Installing
|
||||
|
||||
1. Open **Settings → Applications** in your Twenty workspace.
|
||||
2. Find **Linear** in the available apps and click **Install**.
|
||||
3. Open the app, go to the **Connections** tab, and click **Add connection**.
|
||||
4. Choose **Just for me** (your personal Linear account) or
|
||||
**Workspace shared** (a team-managed Linear account anyone in this
|
||||
workspace can act through), then complete the Linear sign-in.
|
||||
|
||||
That's it — you can now use the tools above.
|
||||
|
||||
> If you see a "Linear OAuth is not yet set up by your server administrator"
|
||||
> notice on the Connections tab, ask your Twenty admin to follow the
|
||||
> **Self-hosting setup** below — they need to provide the OAuth credentials
|
||||
> before connections can be added.
|
||||
|
||||
---
|
||||
|
||||
## Self-hosting setup
|
||||
|
||||
This section is for Twenty server admins. If you're on Twenty Cloud, skip
|
||||
this — the OAuth credentials are already configured.
|
||||
|
||||
### 1. Register an OAuth app in Linear
|
||||
|
||||
1. Visit https://linear.app/settings/api/applications/new.
|
||||
2. Set the **Redirect URI** to `<SERVER_URL>/apps/oauth/callback` (for
|
||||
local dev: `http://localhost:3000/apps/oauth/callback`).
|
||||
3. Copy the generated **Client ID** and **Client Secret**.
|
||||
|
||||
### 2. Wire the credentials into Twenty
|
||||
|
||||
1. In **Settings → Applications**, find **Linear**, click into it, and go
|
||||
to the **Application registration** tab (admin-only).
|
||||
2. Paste your Linear **Client ID** into `LINEAR_CLIENT_ID` and the
|
||||
**Client Secret** into `LINEAR_CLIENT_SECRET`.
|
||||
|
||||
Workspace users will now be able to add Linear connections from the
|
||||
**Connections** tab as described above.
|
||||
|
||||
### 3. (Developers only) Building the app from source
|
||||
|
||||
If you're working on this app rather than installing the published version:
|
||||
|
||||
```bash
|
||||
cd packages/twenty-apps/internal/twenty-linear
|
||||
|
||||
# For day-to-day development (publish + install + watch in one):
|
||||
yarn twenty dev
|
||||
|
||||
# Manual publish flow (deploy registers the app, install activates it):
|
||||
yarn twenty deploy
|
||||
yarn twenty install
|
||||
```
|
||||
|
||||
`twenty dev` is recommended for iteration — it publishes, installs, and
|
||||
watches for changes in one command. Use `twenty deploy` + `twenty install`
|
||||
when you want to control each step separately (e.g. deploying to a
|
||||
production server without auto-installing).
|
||||
|
||||
This serves as the reference implementation for Twenty's
|
||||
`defineConnectionProvider({ type: 'oauth' })` flow — useful as a template
|
||||
when adding OAuth integrations for other providers.
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "twenty-linear",
|
||||
"version": "0.1.5",
|
||||
"description": "Linear integration for Twenty. Connect a user's Linear account and create issues from logic functions.",
|
||||
"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 --config vitest.unit.config.ts",
|
||||
"test:watch": "vitest --config vitest.unit.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"twenty-sdk": "2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2",
|
||||
"oxlint": "^0.16.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite-tsconfig-paths": "^4.3.2",
|
||||
"vitest": "^3.1.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="#5E6AD2" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Linear</title><path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z"/></svg>
|
||||
|
After Width: | Height: | Size: 469 B |
@@ -0,0 +1,32 @@
|
||||
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: 'Linear',
|
||||
description:
|
||||
'Connect Linear to Twenty. Each workspace member connects their own Linear account; logic functions can then create issues and read team data on their behalf.',
|
||||
logoUrl: 'public/linear-logomark.svg',
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
// OAuth client_id/secret live at the registration level (one OAuth app per
|
||||
// Twenty server, configured by the server admin) — not per-workspace —
|
||||
// so they're declared as serverVariables, not applicationVariables.
|
||||
serverVariables: {
|
||||
LINEAR_CLIENT_ID: {
|
||||
description:
|
||||
'OAuth client ID from your Linear OAuth application (linear.app/settings/api/applications).',
|
||||
isSecret: false,
|
||||
isRequired: true,
|
||||
},
|
||||
LINEAR_CLIENT_SECRET: {
|
||||
description:
|
||||
'OAuth client secret from your Linear OAuth application. Stored encrypted; never exposed in API responses.',
|
||||
isSecret: true,
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConnectionProvider } from 'twenty-sdk/define';
|
||||
|
||||
import { LINEAR_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineConnectionProvider({
|
||||
universalIdentifier: LINEAR_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER,
|
||||
name: 'linear',
|
||||
displayName: 'Linear',
|
||||
type: 'oauth',
|
||||
oauth: {
|
||||
authorizationEndpoint: 'https://linear.app/oauth/authorize',
|
||||
tokenEndpoint: 'https://api.linear.app/oauth/token',
|
||||
revokeEndpoint: 'https://api.linear.app/oauth/revoke',
|
||||
scopes: ['read', 'write'],
|
||||
clientIdVariable: 'LINEAR_CLIENT_ID',
|
||||
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
|
||||
tokenRequestContentType: 'form-urlencoded',
|
||||
// Linear supports PKCE but doesn't require it for confidential clients.
|
||||
// Disabled to keep the test surface minimal.
|
||||
usePkce: false,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
// Group all universal identifiers in a single file. Per the codebase
|
||||
// convention (see twenty-for-twenty), closely-related constants live
|
||||
// together so the rest of the app's source files can stay at one
|
||||
// `export default` per file.
|
||||
|
||||
export const APPLICATION_UNIVERSAL_IDENTIFIER =
|
||||
'6f4e7c2a-3d8e-4a91-b2cf-9e0b8d5f4a2e';
|
||||
|
||||
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
|
||||
'b6c33347-e41c-4b90-8a37-7b3c49baa85a';
|
||||
|
||||
export const LINEAR_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER =
|
||||
'9c7d1f5e-6a0b-4d44-be0c-3f8b5a9d4e6f';
|
||||
|
||||
export const CREATE_LINEAR_ISSUE_UNIVERSAL_IDENTIFIER =
|
||||
'01f829c9-1661-41fa-9ee1-b67e64716c2e';
|
||||
|
||||
export const LIST_LINEAR_TEAMS_UNIVERSAL_IDENTIFIER =
|
||||
'15824bbc-9c64-4f97-b45f-d0a44b402bb8';
|
||||
@@ -0,0 +1,126 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createLinearIssueHandler } from '../handlers/create-linear-issue-handler';
|
||||
|
||||
import { buildConnection, stubConnectionsThenLinear } from './test-utils';
|
||||
|
||||
const SAVED_ENV = { ...process.env };
|
||||
|
||||
describe('createLinearIssueHandler', () => {
|
||||
beforeEach(() => {
|
||||
process.env.TWENTY_API_URL = 'http://api.test';
|
||||
process.env.TWENTY_APP_ACCESS_TOKEN = 'app-token';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...SAVED_ENV };
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns an error when required input fields are missing', async () => {
|
||||
const result = await createLinearIssueHandler({ title: 'no team' });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining('teamId'),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error when no Linear connection exists', async () => {
|
||||
stubConnectionsThenLinear([], {});
|
||||
|
||||
const result = await createLinearIssueHandler({
|
||||
teamId: 'team_1',
|
||||
title: 'hi',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
error: expect.stringContaining('not connected'),
|
||||
});
|
||||
});
|
||||
|
||||
it('calls Linear with the only available connection and returns the issue', async () => {
|
||||
const issue = {
|
||||
id: 'issue_1',
|
||||
identifier: 'TEAM-1',
|
||||
title: 'Hello from Twenty',
|
||||
url: 'https://linear.app/twenty/issue/TEAM-1',
|
||||
};
|
||||
|
||||
const fetchMock = stubConnectionsThenLinear([buildConnection()], {
|
||||
data: { issueCreate: { success: true, issue } },
|
||||
});
|
||||
|
||||
const result = await createLinearIssueHandler({
|
||||
teamId: 'team_1',
|
||||
title: 'Hello from Twenty',
|
||||
description: 'Body',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true, issue });
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[1];
|
||||
|
||||
expect(url).toBe('https://api.linear.app/graphql');
|
||||
expect(init.headers.Authorization).toBe('Bearer lin_test_access_token');
|
||||
expect(JSON.parse(init.body as string).variables.input).toEqual({
|
||||
teamId: 'team_1',
|
||||
title: 'Hello from Twenty',
|
||||
description: 'Body',
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers a workspace-shared connection over a user-visibility one', async () => {
|
||||
const userConnection = buildConnection({
|
||||
id: 'conn_user',
|
||||
accessToken: 'lin_user',
|
||||
});
|
||||
const sharedConnection = buildConnection({
|
||||
id: 'conn_shared',
|
||||
visibility: 'workspace',
|
||||
accessToken: 'lin_shared',
|
||||
});
|
||||
|
||||
const fetchMock = stubConnectionsThenLinear(
|
||||
[userConnection, sharedConnection],
|
||||
{
|
||||
data: {
|
||||
issueCreate: {
|
||||
success: true,
|
||||
issue: {
|
||||
id: 'issue_2',
|
||||
identifier: 'T-2',
|
||||
title: 'Hi',
|
||||
url: 'https://linear.app/x/T-2',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const result = await createLinearIssueHandler({
|
||||
teamId: 'team_1',
|
||||
title: 'Hi',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe(
|
||||
'Bearer lin_shared',
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces Linear GraphQL errors as the handler error', async () => {
|
||||
stubConnectionsThenLinear([buildConnection()], {
|
||||
errors: [{ message: 'Invalid teamId' }],
|
||||
});
|
||||
|
||||
const result = await createLinearIssueHandler({
|
||||
teamId: 'bogus',
|
||||
title: 'hi',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Invalid teamId' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { listLinearTeamsHandler } from '../handlers/list-linear-teams-handler';
|
||||
|
||||
import { buildConnection, stubConnectionsThenLinear } from './test-utils';
|
||||
|
||||
const SAVED_ENV = { ...process.env };
|
||||
|
||||
describe('listLinearTeamsHandler', () => {
|
||||
beforeEach(() => {
|
||||
process.env.TWENTY_API_URL = 'http://api.test';
|
||||
process.env.TWENTY_APP_ACCESS_TOKEN = 'app-token';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...SAVED_ENV };
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns the teams when the Linear query succeeds', async () => {
|
||||
const teams = [
|
||||
{ id: 'team_1', name: 'Engineering', key: 'ENG' },
|
||||
{ id: 'team_2', name: 'Design', key: 'DES' },
|
||||
];
|
||||
|
||||
const fetchMock = stubConnectionsThenLinear([buildConnection()], {
|
||||
data: { teams: { nodes: teams } },
|
||||
});
|
||||
|
||||
const result = await listLinearTeamsHandler();
|
||||
|
||||
expect(result).toEqual({ success: true, teams });
|
||||
expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe(
|
||||
'Bearer lin_test_access_token',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns success=false when no Linear connection exists', async () => {
|
||||
stubConnectionsThenLinear([], { data: { teams: { nodes: [] } } });
|
||||
|
||||
const result = await listLinearTeamsHandler();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('surfaces Linear errors', async () => {
|
||||
stubConnectionsThenLinear([buildConnection()], {
|
||||
errors: [{ message: 'rate limited' }],
|
||||
});
|
||||
|
||||
const result = await listLinearTeamsHandler();
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'rate limited' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { vi } from 'vitest';
|
||||
|
||||
export const USER_WORKSPACE_ID = '11111111-1111-1111-1111-111111111111';
|
||||
|
||||
export const buildConnection = (
|
||||
overrides: Partial<Record<string, unknown>> = {},
|
||||
) => ({
|
||||
id: 'conn_1',
|
||||
name: 'octocat@example.com',
|
||||
visibility: 'user' as const,
|
||||
providerName: 'linear',
|
||||
userWorkspaceId: USER_WORKSPACE_ID,
|
||||
accessToken: 'lin_test_access_token',
|
||||
scopes: ['read', 'write'],
|
||||
handle: 'octocat@example.com',
|
||||
lastRefreshedAt: '2024-01-01T00:00:00.000Z',
|
||||
authFailedAt: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Stubs `fetch` to first answer the SDK's `/apps/connections/list` call, then
|
||||
// the handler's downstream Linear GraphQL request.
|
||||
export const stubConnectionsThenLinear = (
|
||||
connections: ReturnType<typeof buildConnection>[],
|
||||
linearJson: unknown,
|
||||
) => {
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
if (url.endsWith('/apps/connections/list')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => connections,
|
||||
text: async () => JSON.stringify(connections),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => linearJson,
|
||||
text: async () => JSON.stringify(linearJson),
|
||||
};
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
return fetchMock;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export const ISSUE_CREATE_MUTATION = `
|
||||
mutation IssueCreate($input: IssueCreateInput!) {
|
||||
issueCreate(input: $input) {
|
||||
success
|
||||
issue { id identifier title url }
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
|
||||
import { CREATE_LINEAR_ISSUE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { createLinearIssueHandler } from 'src/logic-functions/handlers/create-linear-issue-handler';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: CREATE_LINEAR_ISSUE_UNIVERSAL_IDENTIFIER,
|
||||
name: 'create-linear-issue',
|
||||
description:
|
||||
'Create a Linear issue on behalf of the connected user. Requires a teamId (call list-linear-teams to discover one) and a title.',
|
||||
timeoutSeconds: 30,
|
||||
handler: createLinearIssueHandler,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
teamId: {
|
||||
type: 'string',
|
||||
description:
|
||||
'The Linear team ID to create the issue in. Use list-linear-teams to discover available teams.',
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
description: 'The issue title.',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'Optional issue description (Markdown supported).',
|
||||
},
|
||||
},
|
||||
required: ['teamId', 'title'],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { listConnections } from 'twenty-sdk/logic-function';
|
||||
|
||||
import { ISSUE_CREATE_MUTATION } from 'src/logic-functions/constants/issue-create-mutation.constant';
|
||||
import { type CreateIssueInput } from 'src/logic-functions/types/create-issue-input.type';
|
||||
import { type CreateIssueMutationResult } from 'src/logic-functions/types/create-issue-mutation-result.type';
|
||||
import { callLinearGraphQL } from 'src/logic-functions/utils/call-linear-graphql';
|
||||
|
||||
type HandlerResult =
|
||||
| {
|
||||
success: true;
|
||||
issue: {
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
| { success: false; error: string };
|
||||
|
||||
export const createLinearIssueHandler = async (
|
||||
input: CreateIssueInput,
|
||||
): Promise<HandlerResult> => {
|
||||
if (!input.teamId || !input.title) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Both `teamId` and `title` are required.',
|
||||
};
|
||||
}
|
||||
|
||||
const connections = await listConnections({ providerName: 'linear' });
|
||||
// Workspace-shared credentials win when present (a team-managed service
|
||||
// account); otherwise fall back to the first user-scoped connection.
|
||||
const connection =
|
||||
connections.find((c) => c.visibility === 'workspace') ?? connections[0];
|
||||
|
||||
if (!connection) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'Linear is not connected. Open the app settings and click "Add connection" first.',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await callLinearGraphQL<CreateIssueMutationResult>({
|
||||
accessToken: connection.accessToken,
|
||||
query: ISSUE_CREATE_MUTATION,
|
||||
variables: {
|
||||
input: {
|
||||
teamId: input.teamId,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (result.errors || !result.data) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.errors?.[0]?.message ?? 'Unknown Linear API error',
|
||||
};
|
||||
}
|
||||
|
||||
const { success, issue } = result.data.issueCreate;
|
||||
|
||||
if (!success || !issue) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Linear reported the mutation as unsuccessful.',
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, issue };
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { listConnections } from 'twenty-sdk/logic-function';
|
||||
|
||||
import { callLinearGraphQL } from 'src/logic-functions/utils/call-linear-graphql';
|
||||
|
||||
type LinearTeam = {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
};
|
||||
|
||||
type TeamsQueryResult = {
|
||||
teams: { nodes: LinearTeam[] };
|
||||
};
|
||||
|
||||
type HandlerResult =
|
||||
| { success: true; teams: LinearTeam[] }
|
||||
| { success: false; error: string };
|
||||
|
||||
export const listLinearTeamsHandler = async (): Promise<HandlerResult> => {
|
||||
const connections = await listConnections({ providerName: 'linear' });
|
||||
const connection =
|
||||
connections.find((c) => c.visibility === 'workspace') ?? connections[0];
|
||||
|
||||
if (!connection) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'Linear is not connected. Open the app settings and click "Add connection" first.',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await callLinearGraphQL<TeamsQueryResult>({
|
||||
accessToken: connection.accessToken,
|
||||
query: `
|
||||
query Teams {
|
||||
teams { nodes { id name key } }
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
if (result.errors || !result.data) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.errors?.[0]?.message ?? 'Unknown Linear API error',
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, teams: result.data.teams.nodes };
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
|
||||
import { LIST_LINEAR_TEAMS_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { listLinearTeamsHandler } from 'src/logic-functions/handlers/list-linear-teams-handler';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: LIST_LINEAR_TEAMS_UNIVERSAL_IDENTIFIER,
|
||||
name: 'list-linear-teams',
|
||||
description:
|
||||
"Returns the connected user's Linear teams. Useful for picking a teamId to pass to create-linear-issue.",
|
||||
timeoutSeconds: 15,
|
||||
handler: listLinearTeamsHandler,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export type CreateIssueInput = {
|
||||
teamId?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
export type CreateIssueMutationResult = {
|
||||
issueCreate: {
|
||||
success: boolean;
|
||||
issue: {
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
url: string;
|
||||
} | null;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { type LinearGraphQLResult } from 'src/logic-functions/utils/types/linear-graphql-result.type';
|
||||
|
||||
const LINEAR_GRAPHQL_ENDPOINT = 'https://api.linear.app/graphql';
|
||||
|
||||
export const callLinearGraphQL = async <TData>({
|
||||
accessToken,
|
||||
query,
|
||||
variables,
|
||||
}: {
|
||||
accessToken: string;
|
||||
query: string;
|
||||
variables?: Record<string, unknown>;
|
||||
}): Promise<LinearGraphQLResult<TData>> => {
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(LINEAR_GRAPHQL_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
message: `Linear API request failed: ${(error as Error).message}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
message: `Linear API responded with ${response.status}: ${text.slice(0, 500)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return (await response.json()) as LinearGraphQLResult<TData>;
|
||||
} catch (error) {
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
message: `Linear API returned a non-JSON response: ${(error as Error).message}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export type LinearGraphQLResult<TData> = {
|
||||
data?: TData;
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineRole } from 'twenty-sdk/define';
|
||||
|
||||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
// The Linear logic functions never read workspace data — they only call
|
||||
// Linear's GraphQL API on behalf of the connected user.
|
||||
export default defineRole({
|
||||
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Linear function role',
|
||||
description: 'No-op role for Linear logic functions',
|
||||
canReadAllObjectRecords: false,
|
||||
canUpdateAllObjectRecords: false,
|
||||
canSoftDeleteAllObjectRecords: false,
|
||||
canDestroyAllObjectRecords: false,
|
||||
canUpdateAllSettings: false,
|
||||
canBeAssignedToAgents: false,
|
||||
canBeAssignedToUsers: false,
|
||||
canBeAssignedToApiKeys: false,
|
||||
objectPermissions: [],
|
||||
fieldPermissions: [],
|
||||
permissionFlags: [],
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"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"],
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"src/*": ["./src/*"],
|
||||
"~/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": ["vitest/globals", "node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
const TWENTY_SDK_SRC = path.resolve(
|
||||
__dirname,
|
||||
'../../../twenty-sdk/src/sdk',
|
||||
);
|
||||
|
||||
// twenty-sdk's `exports` map points at compiled `./dist/*`. Aliasing the
|
||||
// subpaths to source keeps unit tests self-contained — no `yarn build` in
|
||||
// twenty-sdk required before running them.
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
projects: ['tsconfig.spec.json'],
|
||||
ignoreConfigErrors: true,
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: 'twenty-sdk/logic-function',
|
||||
replacement: path.join(TWENTY_SDK_SRC, 'logic-function/index.ts'),
|
||||
},
|
||||
{
|
||||
find: 'twenty-sdk/define',
|
||||
replacement: path.join(TWENTY_SDK_SRC, 'define/index.ts'),
|
||||
},
|
||||
// The SDK source uses `@/*` to refer to its own `src/`. Vitest
|
||||
// doesn't pick up the SDK's tsconfig path mapping when resolving
|
||||
// a different package, so map the alias here.
|
||||
{
|
||||
find: /^@\/(.*)$/,
|
||||
replacement: path.resolve(__dirname, '../../../twenty-sdk/src/$1'),
|
||||
},
|
||||
],
|
||||
},
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-client-sdk",
|
||||
"version": "2.1.0",
|
||||
"version": "2.3.0",
|
||||
"sideEffects": false,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@@ -46,6 +46,8 @@
|
||||
"graphql": "^16.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"twenty-shared": "workspace:*",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.0.0",
|
||||
|
||||
@@ -324,6 +324,115 @@ type FrontComponent {
|
||||
applicationTokenPair: ApplicationTokenPair
|
||||
}
|
||||
|
||||
type CommandMenuItem {
|
||||
id: UUID!
|
||||
workflowVersionId: UUID
|
||||
frontComponentId: UUID
|
||||
frontComponent: FrontComponent
|
||||
engineComponentKey: EngineComponentKey!
|
||||
label: String!
|
||||
icon: String
|
||||
shortLabel: String
|
||||
position: Float!
|
||||
isPinned: Boolean!
|
||||
availabilityType: CommandMenuItemAvailabilityType!
|
||||
payload: CommandMenuItemPayload
|
||||
hotKeys: [String!]
|
||||
conditionalAvailabilityExpression: String
|
||||
availabilityObjectMetadataId: UUID
|
||||
pageLayoutId: UUID
|
||||
universalIdentifier: UUID
|
||||
applicationId: UUID
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
enum EngineComponentKey {
|
||||
NAVIGATE_TO_NEXT_RECORD
|
||||
NAVIGATE_TO_PREVIOUS_RECORD
|
||||
CREATE_NEW_RECORD
|
||||
DELETE_RECORDS
|
||||
RESTORE_RECORDS
|
||||
DESTROY_RECORDS
|
||||
ADD_TO_FAVORITES
|
||||
REMOVE_FROM_FAVORITES
|
||||
EXPORT_NOTE_TO_PDF
|
||||
EXPORT_RECORDS
|
||||
UPDATE_MULTIPLE_RECORDS
|
||||
MERGE_MULTIPLE_RECORDS
|
||||
IMPORT_RECORDS
|
||||
EXPORT_VIEW
|
||||
SEE_DELETED_RECORDS
|
||||
CREATE_NEW_VIEW
|
||||
HIDE_DELETED_RECORDS
|
||||
EDIT_RECORD_PAGE_LAYOUT
|
||||
EDIT_DASHBOARD_LAYOUT
|
||||
SAVE_DASHBOARD_LAYOUT
|
||||
CANCEL_DASHBOARD_LAYOUT
|
||||
DUPLICATE_DASHBOARD
|
||||
ACTIVATE_WORKFLOW
|
||||
DEACTIVATE_WORKFLOW
|
||||
DISCARD_DRAFT_WORKFLOW
|
||||
TEST_WORKFLOW
|
||||
SEE_ACTIVE_VERSION_WORKFLOW
|
||||
SEE_RUNS_WORKFLOW
|
||||
SEE_VERSIONS_WORKFLOW
|
||||
ADD_NODE_WORKFLOW
|
||||
TIDY_UP_WORKFLOW
|
||||
DUPLICATE_WORKFLOW
|
||||
SEE_VERSION_WORKFLOW_RUN
|
||||
SEE_WORKFLOW_WORKFLOW_RUN
|
||||
STOP_WORKFLOW_RUN
|
||||
SEE_RUNS_WORKFLOW_VERSION
|
||||
SEE_WORKFLOW_WORKFLOW_VERSION
|
||||
USE_AS_DRAFT_WORKFLOW_VERSION
|
||||
SEE_VERSIONS_WORKFLOW_VERSION
|
||||
SEARCH_RECORDS
|
||||
SEARCH_RECORDS_FALLBACK
|
||||
ASK_AI
|
||||
VIEW_PREVIOUS_AI_CHATS
|
||||
NAVIGATION
|
||||
TRIGGER_WORKFLOW_VERSION
|
||||
FRONT_COMPONENT_RENDERER
|
||||
REPLY_TO_EMAIL_THREAD
|
||||
COMPOSE_EMAIL
|
||||
GO_TO_PEOPLE
|
||||
GO_TO_COMPANIES
|
||||
GO_TO_DASHBOARDS
|
||||
GO_TO_OPPORTUNITIES
|
||||
GO_TO_SETTINGS
|
||||
GO_TO_TASKS
|
||||
GO_TO_NOTES
|
||||
GO_TO_WORKFLOWS
|
||||
GO_TO_RUNS
|
||||
DELETE_SINGLE_RECORD
|
||||
DELETE_MULTIPLE_RECORDS
|
||||
RESTORE_SINGLE_RECORD
|
||||
RESTORE_MULTIPLE_RECORDS
|
||||
DESTROY_SINGLE_RECORD
|
||||
DESTROY_MULTIPLE_RECORDS
|
||||
EXPORT_FROM_RECORD_INDEX
|
||||
EXPORT_FROM_RECORD_SHOW
|
||||
EXPORT_MULTIPLE_RECORDS
|
||||
}
|
||||
|
||||
enum CommandMenuItemAvailabilityType {
|
||||
GLOBAL
|
||||
GLOBAL_OBJECT_CONTEXT
|
||||
RECORD_SELECTION
|
||||
FALLBACK
|
||||
}
|
||||
|
||||
union CommandMenuItemPayload = PathCommandMenuItemPayload | ObjectMetadataCommandMenuItemPayload
|
||||
|
||||
type PathCommandMenuItemPayload {
|
||||
path: String!
|
||||
}
|
||||
|
||||
type ObjectMetadataCommandMenuItemPayload {
|
||||
objectMetadataItemId: UUID!
|
||||
}
|
||||
|
||||
type LogicFunction {
|
||||
id: UUID!
|
||||
name: String!
|
||||
@@ -332,11 +441,11 @@ type LogicFunction {
|
||||
timeoutSeconds: Float!
|
||||
sourceHandlerPath: String!
|
||||
handlerName: String!
|
||||
toolInputSchema: JSON
|
||||
isTool: Boolean!
|
||||
cronTriggerSettings: JSON
|
||||
databaseEventTriggerSettings: JSON
|
||||
httpRouteTriggerSettings: JSON
|
||||
toolTriggerSettings: JSON
|
||||
workflowActionTriggerSettings: JSON
|
||||
applicationId: UUID
|
||||
universalIdentifier: UUID
|
||||
createdAt: DateTime!
|
||||
@@ -580,6 +689,7 @@ type Application {
|
||||
id: UUID!
|
||||
name: String!
|
||||
description: String
|
||||
logo: String
|
||||
version: String
|
||||
universalIdentifier: String!
|
||||
packageJsonChecksum: String
|
||||
@@ -594,6 +704,7 @@ type Application {
|
||||
defaultLogicFunctionRole: Role
|
||||
agents: [Agent!]!
|
||||
frontComponents: [FrontComponent!]!
|
||||
commandMenuItems: [CommandMenuItem!]!
|
||||
logicFunctions: [LogicFunction!]!
|
||||
objects: [Object!]!
|
||||
applicationVariables: [ApplicationVariable!]!
|
||||
@@ -862,7 +973,6 @@ type User {
|
||||
firstName: String!
|
||||
lastName: String!
|
||||
email: String!
|
||||
defaultAvatarUrl: String
|
||||
isEmailVerified: Boolean!
|
||||
disabled: Boolean
|
||||
canImpersonate: Boolean!
|
||||
@@ -1292,6 +1402,20 @@ enum PageLayoutType {
|
||||
STANDALONE_PAGE
|
||||
}
|
||||
|
||||
type ApplicationConnectionProviderOAuthConfig {
|
||||
scopes: [String!]!
|
||||
isClientCredentialsConfigured: Boolean!
|
||||
}
|
||||
|
||||
type ApplicationConnectionProvider {
|
||||
id: UUID!
|
||||
applicationId: String!
|
||||
type: String!
|
||||
name: String!
|
||||
displayName: String!
|
||||
oauth: ApplicationConnectionProviderOAuthConfig
|
||||
}
|
||||
|
||||
type Analytics {
|
||||
"""Boolean that confirms query was dispatched"""
|
||||
success: Boolean!
|
||||
@@ -2202,7 +2326,6 @@ type MarketplaceApp {
|
||||
id: String!
|
||||
name: String!
|
||||
description: String!
|
||||
icon: String!
|
||||
author: String!
|
||||
category: String!
|
||||
logo: String
|
||||
@@ -2311,114 +2434,6 @@ type PostgresCredentials {
|
||||
workspaceId: UUID!
|
||||
}
|
||||
|
||||
type CommandMenuItem {
|
||||
id: UUID!
|
||||
workflowVersionId: UUID
|
||||
frontComponentId: UUID
|
||||
frontComponent: FrontComponent
|
||||
engineComponentKey: EngineComponentKey!
|
||||
label: String!
|
||||
icon: String
|
||||
shortLabel: String
|
||||
position: Float!
|
||||
isPinned: Boolean!
|
||||
availabilityType: CommandMenuItemAvailabilityType!
|
||||
payload: CommandMenuItemPayload
|
||||
hotKeys: [String!]
|
||||
conditionalAvailabilityExpression: String
|
||||
availabilityObjectMetadataId: UUID
|
||||
pageLayoutId: UUID
|
||||
applicationId: UUID
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
enum EngineComponentKey {
|
||||
NAVIGATE_TO_NEXT_RECORD
|
||||
NAVIGATE_TO_PREVIOUS_RECORD
|
||||
CREATE_NEW_RECORD
|
||||
DELETE_RECORDS
|
||||
RESTORE_RECORDS
|
||||
DESTROY_RECORDS
|
||||
ADD_TO_FAVORITES
|
||||
REMOVE_FROM_FAVORITES
|
||||
EXPORT_NOTE_TO_PDF
|
||||
EXPORT_RECORDS
|
||||
UPDATE_MULTIPLE_RECORDS
|
||||
MERGE_MULTIPLE_RECORDS
|
||||
IMPORT_RECORDS
|
||||
EXPORT_VIEW
|
||||
SEE_DELETED_RECORDS
|
||||
CREATE_NEW_VIEW
|
||||
HIDE_DELETED_RECORDS
|
||||
EDIT_RECORD_PAGE_LAYOUT
|
||||
EDIT_DASHBOARD_LAYOUT
|
||||
SAVE_DASHBOARD_LAYOUT
|
||||
CANCEL_DASHBOARD_LAYOUT
|
||||
DUPLICATE_DASHBOARD
|
||||
ACTIVATE_WORKFLOW
|
||||
DEACTIVATE_WORKFLOW
|
||||
DISCARD_DRAFT_WORKFLOW
|
||||
TEST_WORKFLOW
|
||||
SEE_ACTIVE_VERSION_WORKFLOW
|
||||
SEE_RUNS_WORKFLOW
|
||||
SEE_VERSIONS_WORKFLOW
|
||||
ADD_NODE_WORKFLOW
|
||||
TIDY_UP_WORKFLOW
|
||||
DUPLICATE_WORKFLOW
|
||||
SEE_VERSION_WORKFLOW_RUN
|
||||
SEE_WORKFLOW_WORKFLOW_RUN
|
||||
STOP_WORKFLOW_RUN
|
||||
SEE_RUNS_WORKFLOW_VERSION
|
||||
SEE_WORKFLOW_WORKFLOW_VERSION
|
||||
USE_AS_DRAFT_WORKFLOW_VERSION
|
||||
SEE_VERSIONS_WORKFLOW_VERSION
|
||||
SEARCH_RECORDS
|
||||
SEARCH_RECORDS_FALLBACK
|
||||
ASK_AI
|
||||
VIEW_PREVIOUS_AI_CHATS
|
||||
NAVIGATION
|
||||
TRIGGER_WORKFLOW_VERSION
|
||||
FRONT_COMPONENT_RENDERER
|
||||
REPLY_TO_EMAIL_THREAD
|
||||
COMPOSE_EMAIL
|
||||
GO_TO_PEOPLE
|
||||
GO_TO_COMPANIES
|
||||
GO_TO_DASHBOARDS
|
||||
GO_TO_OPPORTUNITIES
|
||||
GO_TO_SETTINGS
|
||||
GO_TO_TASKS
|
||||
GO_TO_NOTES
|
||||
GO_TO_WORKFLOWS
|
||||
GO_TO_RUNS
|
||||
DELETE_SINGLE_RECORD
|
||||
DELETE_MULTIPLE_RECORDS
|
||||
RESTORE_SINGLE_RECORD
|
||||
RESTORE_MULTIPLE_RECORDS
|
||||
DESTROY_SINGLE_RECORD
|
||||
DESTROY_MULTIPLE_RECORDS
|
||||
EXPORT_FROM_RECORD_INDEX
|
||||
EXPORT_FROM_RECORD_SHOW
|
||||
EXPORT_MULTIPLE_RECORDS
|
||||
}
|
||||
|
||||
enum CommandMenuItemAvailabilityType {
|
||||
GLOBAL
|
||||
GLOBAL_OBJECT_CONTEXT
|
||||
RECORD_SELECTION
|
||||
FALLBACK
|
||||
}
|
||||
|
||||
union CommandMenuItemPayload = PathCommandMenuItemPayload | ObjectMetadataCommandMenuItemPayload
|
||||
|
||||
type PathCommandMenuItemPayload {
|
||||
path: String!
|
||||
}
|
||||
|
||||
type ObjectMetadataCommandMenuItemPayload {
|
||||
objectMetadataItemId: UUID!
|
||||
}
|
||||
|
||||
type ToolIndexEntry {
|
||||
name: String!
|
||||
description: String!
|
||||
@@ -2537,6 +2552,10 @@ type ConnectedAccountDTO {
|
||||
connectionParameters: ImapSmtpCaldavConnectionParameters
|
||||
lastSignedInAt: DateTime
|
||||
userWorkspaceId: UUID!
|
||||
connectionProviderId: UUID
|
||||
applicationId: UUID
|
||||
name: String
|
||||
visibility: String!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
@@ -2564,6 +2583,10 @@ type ConnectedAccountPublicDTO {
|
||||
scopes: [String!]
|
||||
lastSignedInAt: DateTime
|
||||
userWorkspaceId: UUID!
|
||||
connectionProviderId: UUID
|
||||
applicationId: UUID
|
||||
name: String
|
||||
visibility: String!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
connectionParameters: PublicImapSmtpCaldavConnectionParameters
|
||||
@@ -2609,19 +2632,6 @@ type Skill {
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
type AgentChatThread {
|
||||
id: UUID!
|
||||
title: String
|
||||
totalInputTokens: Int!
|
||||
totalOutputTokens: Int!
|
||||
contextWindowTokens: Int
|
||||
conversationSize: Int!
|
||||
totalInputCredits: Float!
|
||||
totalOutputCredits: Float!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
type AgentMessage {
|
||||
id: UUID!
|
||||
threadId: UUID!
|
||||
@@ -2634,6 +2644,21 @@ type AgentMessage {
|
||||
createdAt: DateTime!
|
||||
}
|
||||
|
||||
type AgentChatThread {
|
||||
id: ID!
|
||||
title: String
|
||||
totalInputTokens: Int!
|
||||
totalOutputTokens: Int!
|
||||
contextWindowTokens: Int
|
||||
conversationSize: Int!
|
||||
totalInputCredits: Float!
|
||||
totalOutputCredits: Float!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
deletedAt: DateTime
|
||||
lastMessageAt: DateTime
|
||||
}
|
||||
|
||||
type AiSystemPromptSection {
|
||||
title: String!
|
||||
content: String!
|
||||
@@ -2661,22 +2686,6 @@ type AgentChatEvent {
|
||||
event: JSON!
|
||||
}
|
||||
|
||||
type AgentChatThreadEdge {
|
||||
"""The node containing the AgentChatThread"""
|
||||
node: AgentChatThread!
|
||||
|
||||
"""Cursor for this node."""
|
||||
cursor: ConnectionCursor!
|
||||
}
|
||||
|
||||
type AgentChatThreadConnection {
|
||||
"""Paging information"""
|
||||
pageInfo: PageInfo!
|
||||
|
||||
"""Array of edges."""
|
||||
edges: [AgentChatThreadEdge!]!
|
||||
}
|
||||
|
||||
type AgentTurnEvaluation {
|
||||
id: UUID!
|
||||
turnId: UUID!
|
||||
@@ -2863,6 +2872,7 @@ enum AllMetadataName {
|
||||
fieldPermission
|
||||
frontComponent
|
||||
webhook
|
||||
connectionProvider
|
||||
}
|
||||
|
||||
type MinimalObjectMetadata {
|
||||
@@ -2933,6 +2943,7 @@ type Query {
|
||||
getPageLayoutTab(id: String!): PageLayoutTab!
|
||||
getPageLayouts(objectMetadataId: String, pageLayoutType: PageLayoutType): [PageLayout!]!
|
||||
getPageLayout(id: String!): PageLayout
|
||||
applicationConnectionProviders(applicationId: UUID!): [ApplicationConnectionProvider!]!
|
||||
getPageLayoutWidgets(pageLayoutTabId: String!): [PageLayoutWidget!]!
|
||||
getPageLayoutWidget(id: String!): PageLayoutWidget!
|
||||
findOneLogicFunction(input: LogicFunctionIdInput!): LogicFunction!
|
||||
@@ -2993,22 +3004,13 @@ type Query {
|
||||
webhooks: [Webhook!]!
|
||||
webhook(id: UUID!): Webhook
|
||||
minimalMetadata: MinimalMetadata!
|
||||
chatThreads: [AgentChatThread!]!
|
||||
chatThread(id: UUID!): AgentChatThread!
|
||||
chatMessages(threadId: UUID!): [AgentMessage!]!
|
||||
chatStreamCatchupChunks(threadId: UUID!): ChatStreamCatchupChunks!
|
||||
getAiSystemPromptPreview: AiSystemPromptPreview!
|
||||
skills: [Skill!]!
|
||||
skill(id: UUID!): Skill
|
||||
chatThreads(
|
||||
"""Limit or page results."""
|
||||
paging: CursorPaging! = {first: 10}
|
||||
|
||||
"""Specify to filter the records returned."""
|
||||
filter: AgentChatThreadFilter! = {}
|
||||
|
||||
"""Specify to sort results."""
|
||||
sorting: [AgentChatThreadSort!]! = [{field: updatedAt, direction: DESC}]
|
||||
): AgentChatThreadConnection!
|
||||
agentTurns(agentId: UUID!): [AgentTurn!]!
|
||||
checkUserExists(email: String!, captchaToken: String): CheckUserExist!
|
||||
checkWorkspaceInviteHashIsValid(inviteHash: String!): WorkspaceInviteHashValid!
|
||||
@@ -3057,56 +3059,6 @@ input AgentIdInput {
|
||||
id: UUID!
|
||||
}
|
||||
|
||||
input AgentChatThreadFilter {
|
||||
and: [AgentChatThreadFilter!]
|
||||
or: [AgentChatThreadFilter!]
|
||||
id: UUIDFilterComparison
|
||||
updatedAt: DateFieldComparison
|
||||
}
|
||||
|
||||
input DateFieldComparison {
|
||||
is: Boolean
|
||||
isNot: Boolean
|
||||
eq: DateTime
|
||||
neq: DateTime
|
||||
gt: DateTime
|
||||
gte: DateTime
|
||||
lt: DateTime
|
||||
lte: DateTime
|
||||
in: [DateTime!]
|
||||
notIn: [DateTime!]
|
||||
between: DateFieldComparisonBetween
|
||||
notBetween: DateFieldComparisonBetween
|
||||
}
|
||||
|
||||
input DateFieldComparisonBetween {
|
||||
lower: DateTime!
|
||||
upper: DateTime!
|
||||
}
|
||||
|
||||
input AgentChatThreadSort {
|
||||
field: AgentChatThreadSortFields!
|
||||
direction: SortDirection!
|
||||
nulls: SortNulls
|
||||
}
|
||||
|
||||
enum AgentChatThreadSortFields {
|
||||
id
|
||||
updatedAt
|
||||
}
|
||||
|
||||
"""Sort Directions"""
|
||||
enum SortDirection {
|
||||
ASC
|
||||
DESC
|
||||
}
|
||||
|
||||
"""Sort Nulls Options"""
|
||||
enum SortNulls {
|
||||
NULLS_FIRST
|
||||
NULLS_LAST
|
||||
}
|
||||
|
||||
input EventLogQueryInput {
|
||||
table: EventLogTable!
|
||||
filters: EventLogFiltersInput
|
||||
@@ -3193,6 +3145,7 @@ type Mutation {
|
||||
updateView(id: String!, input: UpdateViewInput!): View!
|
||||
deleteView(id: String!): Boolean!
|
||||
destroyView(id: String!): Boolean!
|
||||
upsertViewWidget(input: UpsertViewWidgetInput!): View!
|
||||
createViewSort(input: CreateViewSortInput!): ViewSort!
|
||||
updateViewSort(input: UpdateViewSortInput!): ViewSort!
|
||||
deleteViewSort(input: DeleteViewSortInput!): Boolean!
|
||||
@@ -3242,6 +3195,7 @@ type Mutation {
|
||||
resetPageLayoutToDefault(id: String!): PageLayout!
|
||||
resetPageLayoutWidgetToDefault(id: String!): PageLayoutWidget!
|
||||
resetPageLayoutTabToDefault(id: String!): PageLayoutTab!
|
||||
updateOneApplicationVariable(key: String!, value: String!, applicationId: UUID!): Boolean!
|
||||
createPageLayoutWidget(input: CreatePageLayoutWidgetInput!): PageLayoutWidget!
|
||||
updatePageLayoutWidget(id: String!, input: UpdatePageLayoutWidgetInput!): PageLayoutWidget!
|
||||
destroyPageLayoutWidget(id: String!): Boolean!
|
||||
@@ -3291,6 +3245,10 @@ type Mutation {
|
||||
createChatThread: AgentChatThread!
|
||||
sendChatMessage(threadId: UUID!, text: String!, messageId: UUID!, browsingContext: JSON, modelId: String, fileIds: [UUID!]): SendChatMessageResult!
|
||||
stopAgentChatStream(threadId: UUID!): Boolean!
|
||||
renameChatThread(id: UUID!, title: String!): AgentChatThread!
|
||||
archiveChatThread(id: UUID!): AgentChatThread!
|
||||
unarchiveChatThread(id: UUID!): AgentChatThread!
|
||||
deleteChatThread(id: UUID!): Boolean!
|
||||
deleteQueuedChatMessage(messageId: UUID!): Boolean!
|
||||
createSkill(input: CreateSkillInput!): Skill!
|
||||
updateSkill(input: UpdateSkillInput!): Skill!
|
||||
@@ -3340,7 +3298,6 @@ type Mutation {
|
||||
installApplication(appRegistrationId: String!, version: String): Boolean!
|
||||
runWorkspaceMigration(workspaceMigration: WorkspaceMigrationInput!): Boolean!
|
||||
uninstallApplication(universalIdentifier: String!): Boolean!
|
||||
updateOneApplicationVariable(key: String!, value: String!, applicationId: UUID!): Boolean!
|
||||
createOIDCIdentityProvider(input: SetupOIDCSsoInput!): SetupSso!
|
||||
createSAMLIdentityProvider(input: SetupSAMLSsoInput!): SetupSso!
|
||||
deleteSSOIdentityProvider(input: DeleteSsoInput!): DeleteSso!
|
||||
@@ -3510,6 +3467,59 @@ input UpdateViewInput {
|
||||
shouldHideEmptyGroups: Boolean
|
||||
}
|
||||
|
||||
input UpsertViewWidgetInput {
|
||||
"""The id of the view widget (page layout widget)."""
|
||||
widgetId: UUID!
|
||||
|
||||
"""The view fields to upsert."""
|
||||
viewFields: [UpsertViewWidgetViewFieldInput!]
|
||||
|
||||
"""The view filters to upsert."""
|
||||
viewFilters: [UpsertViewWidgetViewFilterInput!]
|
||||
|
||||
"""The view filter groups to upsert."""
|
||||
viewFilterGroups: [UpsertViewWidgetViewFilterGroupInput!]
|
||||
|
||||
"""The view sorts to upsert."""
|
||||
viewSorts: [UpsertViewWidgetViewSortInput!]
|
||||
}
|
||||
|
||||
input UpsertViewWidgetViewFieldInput {
|
||||
"""The id of an existing view field to update."""
|
||||
viewFieldId: UUID
|
||||
|
||||
"""
|
||||
The field metadata id. Used to create a new view field when viewFieldId is not provided.
|
||||
"""
|
||||
fieldMetadataId: UUID
|
||||
isVisible: Boolean!
|
||||
position: Float!
|
||||
size: Float
|
||||
}
|
||||
|
||||
input UpsertViewWidgetViewFilterInput {
|
||||
id: UUID
|
||||
fieldMetadataId: UUID!
|
||||
operand: ViewFilterOperand = CONTAINS
|
||||
value: JSON!
|
||||
viewFilterGroupId: UUID
|
||||
positionInViewFilterGroup: Float
|
||||
subFieldName: String
|
||||
}
|
||||
|
||||
input UpsertViewWidgetViewFilterGroupInput {
|
||||
id: UUID
|
||||
parentViewFilterGroupId: UUID
|
||||
logicalOperator: ViewFilterGroupLogicalOperator = AND
|
||||
positionInViewFilterGroup: Float
|
||||
}
|
||||
|
||||
input UpsertViewWidgetViewSortInput {
|
||||
id: UUID
|
||||
fieldMetadataId: UUID!
|
||||
direction: ViewSortDirection = ASC
|
||||
}
|
||||
|
||||
input CreateViewSortInput {
|
||||
id: UUID
|
||||
fieldMetadataId: UUID!
|
||||
@@ -3771,12 +3781,12 @@ input CreateLogicFunctionFromSourceInput {
|
||||
name: String!
|
||||
description: String
|
||||
timeoutSeconds: Float
|
||||
toolInputSchema: JSON
|
||||
isTool: Boolean
|
||||
source: JSON
|
||||
cronTriggerSettings: JSON
|
||||
databaseEventTriggerSettings: JSON
|
||||
httpRouteTriggerSettings: JSON
|
||||
toolTriggerSettings: JSON
|
||||
workflowActionTriggerSettings: JSON
|
||||
}
|
||||
|
||||
input ExecuteOneLogicFunctionInput {
|
||||
@@ -3800,13 +3810,13 @@ input UpdateLogicFunctionFromSourceInputUpdates {
|
||||
description: String
|
||||
timeoutSeconds: Float
|
||||
sourceHandlerCode: String
|
||||
toolInputSchema: JSON
|
||||
handlerName: String
|
||||
sourceHandlerPath: String
|
||||
isTool: Boolean
|
||||
cronTriggerSettings: JSON
|
||||
databaseEventTriggerSettings: JSON
|
||||
httpRouteTriggerSettings: JSON
|
||||
toolTriggerSettings: JSON
|
||||
workflowActionTriggerSettings: JSON
|
||||
}
|
||||
|
||||
input CreateCommandMenuItemInput {
|
||||
|
||||
@@ -26,11 +26,11 @@ 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-build:
|
||||
@cd ../.. && docker build -f ./packages/twenty-docker/twenty-website/Dockerfile --platform $(PLATFORM) --tag twenty-website:$(TAG) . && cd -
|
||||
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-run:
|
||||
@docker run -d -p 3000:3000 --name twenty-website twenty-website:$(TAG)
|
||||
prod-website-new-run:
|
||||
@docker run -d -p 3000:3000 --name twenty-website-new twenty-website-new:$(TAG)
|
||||
|
||||
# =============================================================================
|
||||
# Local Development Services
|
||||
|
||||
@@ -4,6 +4,6 @@ set -e
|
||||
echo "==> START Registering cron jobs"
|
||||
|
||||
cd /app/packages/twenty-server
|
||||
yarn command:prod cron:register:all --dev-mode
|
||||
yarn command:prod cron:register:all
|
||||
|
||||
echo "==> DONE"
|
||||
|
||||
@@ -11,10 +11,12 @@ 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
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
FROM node:24-alpine AS twenty-website-build
|
||||
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./package.json .
|
||||
COPY ./yarn.lock .
|
||||
COPY ./.yarnrc.yml .
|
||||
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-ui/package.json /app/packages/twenty-ui/
|
||||
COPY ./packages/twenty-shared/package.json /app/packages/twenty-shared/
|
||||
COPY ./packages/twenty-website/package.json /app/packages/twenty-website/package.json
|
||||
|
||||
RUN yarn
|
||||
|
||||
ENV KEYSTATIC_GITHUB_CLIENT_ID="<fake build value>"
|
||||
ENV KEYSTATIC_GITHUB_CLIENT_SECRET="<fake build value>"
|
||||
ENV KEYSTATIC_SECRET="<fake build value>"
|
||||
ENV NEXT_PUBLIC_KEYSTATIC_GITHUB_APP_SLUG="<fake build value>"
|
||||
|
||||
COPY ./packages/twenty-ui /app/packages/twenty-ui
|
||||
COPY ./packages/twenty-website /app/packages/twenty-website
|
||||
RUN npx nx build twenty-website
|
||||
|
||||
FROM node:24-alpine AS twenty-website
|
||||
|
||||
WORKDIR /app/packages/twenty-website
|
||||
|
||||
COPY --from=twenty-website-build /app /app
|
||||
|
||||
WORKDIR /app/packages/twenty-website
|
||||
|
||||
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 website."
|
||||
|
||||
RUN chown -R 1000 /app
|
||||
|
||||
# Use non root user with uid 1000
|
||||
USER 1000
|
||||
|
||||
CMD ["/bin/sh", "-c", "npx nx start"]
|
||||
@@ -1,8 +1,26 @@
|
||||
# ===========================================================================
|
||||
# Shared build stages (used by both targets)
|
||||
# Dependency stages
|
||||
# ===========================================================================
|
||||
|
||||
FROM node:24-alpine AS common-deps
|
||||
FROM node:24-alpine AS front-deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./package.json ./yarn.lock ./.yarnrc.yml ./tsconfig.base.json ./nx.json /app/
|
||||
COPY ./.yarn/releases /app/.yarn/releases
|
||||
COPY ./.yarn/patches /app/.yarn/patches
|
||||
|
||||
COPY ./packages/twenty-ui/package.json /app/packages/twenty-ui/
|
||||
COPY ./packages/twenty-shared/package.json /app/packages/twenty-shared/
|
||||
COPY ./packages/twenty-front/package.json /app/packages/twenty-front/
|
||||
COPY ./packages/twenty-front-component-renderer/package.json /app/packages/twenty-front-component-renderer/
|
||||
COPY ./packages/twenty-sdk/package.json /app/packages/twenty-sdk/
|
||||
COPY ./packages/twenty-client-sdk/package.json /app/packages/twenty-client-sdk/
|
||||
|
||||
RUN yarn workspaces focus twenty twenty-front twenty-front-component-renderer twenty-ui twenty-shared twenty-sdk twenty-client-sdk && yarn cache clean && npx nx reset
|
||||
|
||||
|
||||
FROM node:24-alpine AS server-deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -13,22 +31,16 @@ COPY ./.yarn/patches /app/.yarn/patches
|
||||
COPY ./packages/twenty-emails/package.json /app/packages/twenty-emails/
|
||||
COPY ./packages/twenty-server/package.json /app/packages/twenty-server/
|
||||
COPY ./packages/twenty-server/patches /app/packages/twenty-server/patches
|
||||
COPY ./packages/twenty-ui/package.json /app/packages/twenty-ui/
|
||||
COPY ./packages/twenty-shared/package.json /app/packages/twenty-shared/
|
||||
COPY ./packages/twenty-front/package.json /app/packages/twenty-front/
|
||||
COPY ./packages/twenty-front-component-renderer/package.json /app/packages/twenty-front-component-renderer/
|
||||
COPY ./packages/twenty-sdk/package.json /app/packages/twenty-sdk/
|
||||
COPY ./packages/twenty-client-sdk/package.json /app/packages/twenty-client-sdk/
|
||||
|
||||
RUN yarn && yarn cache clean && npx nx reset
|
||||
RUN yarn workspaces focus twenty twenty-server twenty-emails twenty-shared twenty-client-sdk && yarn cache clean && npx nx reset
|
||||
|
||||
|
||||
FROM common-deps AS twenty-server-build
|
||||
FROM server-deps AS twenty-server-build
|
||||
|
||||
COPY ./packages/twenty-emails /app/packages/twenty-emails
|
||||
COPY ./packages/twenty-shared /app/packages/twenty-shared
|
||||
COPY ./packages/twenty-ui /app/packages/twenty-ui
|
||||
COPY ./packages/twenty-sdk /app/packages/twenty-sdk
|
||||
COPY ./packages/twenty-client-sdk /app/packages/twenty-client-sdk
|
||||
COPY ./packages/twenty-server /app/packages/twenty-server
|
||||
|
||||
@@ -44,10 +56,10 @@ RUN npx nx run twenty-server:build
|
||||
RUN find /app/packages/twenty-server/dist -name '*.d.ts' -delete \
|
||||
&& rm -rf /app/packages/twenty-server/dist/packages/twenty-server/test
|
||||
|
||||
RUN yarn workspaces focus --production twenty-emails twenty-shared twenty-sdk twenty-client-sdk twenty-server
|
||||
RUN yarn workspaces focus --production twenty-emails twenty-shared twenty-client-sdk twenty-server
|
||||
|
||||
|
||||
FROM common-deps AS twenty-front-build
|
||||
FROM front-deps AS twenty-front-build
|
||||
|
||||
COPY ./packages/twenty-front /app/packages/twenty-front
|
||||
COPY ./packages/twenty-front-component-renderer /app/packages/twenty-front-component-renderer
|
||||
@@ -99,11 +111,8 @@ COPY --chown=1000 --from=twenty-server-build /app/packages/twenty-shared/package
|
||||
COPY --chown=1000 --from=twenty-server-build /app/packages/twenty-shared/dist /app/packages/twenty-shared/dist
|
||||
COPY --chown=1000 --from=twenty-server-build /app/packages/twenty-emails/package.json /app/packages/twenty-emails/
|
||||
COPY --chown=1000 --from=twenty-server-build /app/packages/twenty-emails/dist /app/packages/twenty-emails/dist
|
||||
COPY --chown=1000 --from=twenty-server-build /app/packages/twenty-sdk/package.json /app/packages/twenty-sdk/
|
||||
COPY --chown=1000 --from=twenty-server-build /app/packages/twenty-client-sdk/package.json /app/packages/twenty-client-sdk/
|
||||
COPY --chown=1000 --from=twenty-server-build /app/packages/twenty-client-sdk/dist /app/packages/twenty-client-sdk/dist
|
||||
COPY --chown=1000 --from=twenty-server-build /app/packages/twenty-ui/package.json /app/packages/twenty-ui/
|
||||
COPY --chown=1000 --from=twenty-server-build /app/packages/twenty-front/package.json /app/packages/twenty-front/
|
||||
|
||||
LABEL org.opencontainers.image.source=https://github.com/twentyhq/twenty
|
||||
LABEL org.opencontainers.image.description="Twenty server image (no frontend)."
|
||||
@@ -208,11 +217,8 @@ COPY --from=twenty-server-build /app/packages/twenty-shared/package.json /app/pa
|
||||
COPY --from=twenty-server-build /app/packages/twenty-shared/dist /app/packages/twenty-shared/dist
|
||||
COPY --from=twenty-server-build /app/packages/twenty-emails/package.json /app/packages/twenty-emails/
|
||||
COPY --from=twenty-server-build /app/packages/twenty-emails/dist /app/packages/twenty-emails/dist
|
||||
COPY --from=twenty-server-build /app/packages/twenty-sdk/package.json /app/packages/twenty-sdk/
|
||||
COPY --from=twenty-server-build /app/packages/twenty-client-sdk/package.json /app/packages/twenty-client-sdk/
|
||||
COPY --from=twenty-server-build /app/packages/twenty-client-sdk/dist /app/packages/twenty-client-sdk/dist
|
||||
COPY --from=twenty-server-build /app/packages/twenty-ui/package.json /app/packages/twenty-ui/
|
||||
COPY --from=twenty-server-build /app/packages/twenty-front/package.json /app/packages/twenty-front/
|
||||
|
||||
# Frontend static build
|
||||
COPY --from=twenty-front-build /app/packages/twenty-front/build /app/packages/twenty-server/dist/front
|
||||
|
||||
@@ -77,14 +77,10 @@ To deploy to Mintlify:
|
||||
3. Set subdirectory to `packages/twenty-docs`
|
||||
4. Mintlify will auto-deploy and generate search embeddings
|
||||
|
||||
## Next Steps
|
||||
## Status
|
||||
|
||||
1. **Manual Review** - Check for any component conversion issues
|
||||
2. **Fix Image Paths** - Verify all images render correctly
|
||||
3. **Test Navigation** - Ensure all internal links work
|
||||
4. **Deploy** - Push to production Mintlify
|
||||
5. **Update Helper Agent** - Verify searchArticles tool works with full content
|
||||
6. **Deprecate twenty-website docs** - Once migration is confirmed working
|
||||
This migration has been completed. The legacy `packages/twenty-website` package
|
||||
has been removed, and documentation now lives in `packages/twenty-docs`.
|
||||
|
||||
## Known Issues to Review
|
||||
|
||||
@@ -92,4 +88,3 @@ To deploy to Mintlify:
|
||||
- Some images may have incorrect paths
|
||||
- Custom styled components may need adjustment
|
||||
- Video embeds might need review
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
---
|
||||
title: Connections
|
||||
description: Let your app act on a user's behalf in third-party services via OAuth.
|
||||
icon: 'plug'
|
||||
---
|
||||
|
||||
Connections are credentials a user holds for an external service (Linear, GitHub, Slack, ...). Your app declares **how** those credentials are obtained — a **connection provider** — and consumes them at runtime to make authenticated calls to the third-party API.
|
||||
|
||||
Today only OAuth 2.0 is supported. Future credential types (personal access tokens, API keys, basic auth) will plug into the same surface — apps already using `defineConnectionProvider({ type: 'oauth', ... })` won't need to migrate.
|
||||
|
||||
<AccordionGroup>
|
||||
|
||||
<Accordion title="defineConnectionProvider" description="Declare how your app's connections are obtained">
|
||||
|
||||
A connection provider describes the OAuth handshake your app needs. The user clicks "Add connection" in your app's settings, completes the provider's consent screen, and a `ConnectedAccount` row is created in their workspace.
|
||||
|
||||
A working setup needs **two files** — the connection provider, and a matching `serverVariables` declaration on `defineApplication` that holds the OAuth client credentials.
|
||||
|
||||
```ts src/connection-providers/linear-connection.ts
|
||||
import { defineConnectionProvider } from 'twenty-sdk/define';
|
||||
|
||||
export default defineConnectionProvider({
|
||||
universalIdentifier: '9c7d1f5e-6a0b-4d44-be0c-3f8b5a9d4e6f',
|
||||
name: 'linear',
|
||||
displayName: 'Linear',
|
||||
icon: 'IconBrandLinear',
|
||||
type: 'oauth',
|
||||
oauth: {
|
||||
authorizationEndpoint: 'https://linear.app/oauth/authorize',
|
||||
tokenEndpoint: 'https://api.linear.app/oauth/token',
|
||||
scopes: ['read', 'write'],
|
||||
// These must match keys in `defineApplication.serverVariables` below.
|
||||
clientIdVariable: 'LINEAR_CLIENT_ID',
|
||||
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
|
||||
// Optional: defaults to 'json'. Some providers (Linear, Slack) want
|
||||
// 'form-urlencoded' for the token request.
|
||||
tokenRequestContentType: 'form-urlencoded',
|
||||
// Optional: defaults to true. Disable only if the provider rejects PKCE.
|
||||
usePkce: false,
|
||||
// Optional: extra query params on the authorize URL.
|
||||
// authorizationParams: { prompt: 'consent' },
|
||||
// Optional: provider's RFC 7009 token revocation endpoint, called on disconnect.
|
||||
// revokeEndpoint: 'https://example.com/oauth/revoke',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts src/application.config.ts
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: '...',
|
||||
displayName: 'Linear',
|
||||
description: 'Connect Linear to Twenty.',
|
||||
defaultRoleUniversalIdentifier: '...',
|
||||
// OAuth client credentials live on the app registration (one OAuth app per
|
||||
// Twenty server, configured by the admin) — not per-workspace. Declare them
|
||||
// as serverVariables so the admin can fill them in once for all installs.
|
||||
serverVariables: {
|
||||
LINEAR_CLIENT_ID: {
|
||||
description: 'OAuth client ID from your Linear OAuth application.',
|
||||
isSecret: false,
|
||||
isRequired: true,
|
||||
},
|
||||
LINEAR_CLIENT_SECRET: {
|
||||
description: 'OAuth client secret from your Linear OAuth application.',
|
||||
isSecret: true,
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Key points:
|
||||
|
||||
- `name` is the unique identifier string used in `listConnections({ providerName })` (kebab-case, must match `^[a-z][a-z0-9-]*$`).
|
||||
- `displayName` shows in the per-app settings tab and in the AI tool list.
|
||||
- `clientIdVariable` / `clientSecretVariable` are **names**, not values — they must match keys declared in `defineApplication.serverVariables`. The actual `client_id` and `client_secret` are entered by the server admin through the app registration UI, never committed to your repo.
|
||||
- Use `serverVariables` (not `applicationVariables`) — OAuth credentials are server-wide and one OAuth app per Twenty server.
|
||||
- Until both `serverVariables` are filled in, the per-app settings tab shows a "needs server admin" hint and the "Add connection" button is disabled.
|
||||
- `type: 'oauth'` is the only supported value today. The discriminator is forward-compatible: future types (`'pat'`, `'api-key'`, ...) will add new sub-config blocks alongside `oauth`.
|
||||
|
||||
The OAuth callback URL your provider needs to whitelist is:
|
||||
|
||||
```
|
||||
https://<your-twenty-server>/apps/oauth/callback
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="listConnections / getConnection" description="Use connections from a logic function">
|
||||
|
||||
Inside a logic function handler, `listConnections({ providerName })` returns this app's `ConnectedAccount` rows for the given provider, with refreshed access tokens.
|
||||
|
||||
```ts src/logic-functions/handlers/create-linear-issue-handler.ts
|
||||
import { listConnections } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const createLinearIssueHandler = async (input: {
|
||||
teamId?: string;
|
||||
title?: string;
|
||||
}) => {
|
||||
if (!input.teamId || !input.title) {
|
||||
return { success: false, error: 'teamId and title are required' };
|
||||
}
|
||||
|
||||
const connections = await listConnections({ providerName: 'linear' });
|
||||
|
||||
// Workspace-shared credentials win when present; fall back to the first
|
||||
// user-visibility one. For HTTP-route triggers you typically pick the
|
||||
// request user's connection via event.userWorkspaceId instead.
|
||||
const connection =
|
||||
connections.find((c) => c.visibility === 'workspace') ?? connections[0];
|
||||
|
||||
if (!connection) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'Linear is not connected. Open the app settings and click "Add connection".',
|
||||
};
|
||||
}
|
||||
|
||||
// Use connection.accessToken to call the third-party API.
|
||||
const response = await fetch('https://api.linear.app/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${connection.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: `mutation { issueCreate(input: { teamId: "${input.teamId}", title: "${input.title}" }) { success } }`,
|
||||
}),
|
||||
});
|
||||
|
||||
return { success: response.ok };
|
||||
};
|
||||
```
|
||||
|
||||
Each connection has:
|
||||
|
||||
| Field | Description |
|
||||
| ----------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | Unique row id; pass to `getConnection(id)` to refetch a single one |
|
||||
| `visibility` | `'user'` (private to one workspace member) or `'workspace'` (shared with all members) |
|
||||
| `scopes` | OAuth permissions granted by the upstream provider (distinct from `visibility` — those are unrelated) |
|
||||
| `userWorkspaceId` | The owner's userWorkspace id — useful for picking "the request user's connection" in HTTP-route triggers |
|
||||
| `accessToken` | Fresh OAuth access token (refreshed automatically if expired) |
|
||||
| `name` / `handle` | The connection's display name (auto-derived at OAuth callback, user-renameable) |
|
||||
| `authFailedAt` | Set when the most recent refresh failed; the user must reconnect |
|
||||
|
||||
Key points:
|
||||
|
||||
- Pass `{ providerName }` to filter by provider; omit it to get all connections this app owns across all providers.
|
||||
- The server transparently refreshes the access token before returning. Your handler always sees a usable token (or `authFailedAt` set).
|
||||
- `getConnection(id)` is the single-row equivalent.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Per-user vs workspace-shared visibility" description="How users choose between private and shared credentials">
|
||||
|
||||
When a user clicks "Add connection," they're prompted to pick a visibility:
|
||||
|
||||
- **Just for me** — the credential is private to the connecting user. Any logic function called on their behalf (HTTP-route trigger with `isAuthRequired: true`) sees it; cron triggers and database events do not.
|
||||
- **Workspace shared** — any workspace member can use the credential. Cron / database triggers also see it, since they have no request user.
|
||||
|
||||
Use the right one for each handler:
|
||||
|
||||
```ts
|
||||
// HTTP-route trigger — prefer the request user's own connection.
|
||||
const conn =
|
||||
connections.find((c) => c.userWorkspaceId === event.userWorkspaceId) ??
|
||||
connections.find((c) => c.visibility === 'workspace');
|
||||
|
||||
// Cron trigger — no request user; only shared credentials are sensible.
|
||||
const conn = connections.find((c) => c.visibility === 'workspace');
|
||||
```
|
||||
|
||||
Multiple connections per (user, provider) are allowed, so the same user can hold "Personal Linear" and "Work Linear" side by side.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="One-time provider setup" description="Register your OAuth app with the third-party service">
|
||||
|
||||
For each connection provider, the server admin needs to register an OAuth app at the third party first.
|
||||
|
||||
1. Go to the provider's developer settings (e.g. https://linear.app/settings/api/applications/new).
|
||||
2. Set the **Redirect URI** to `<SERVER_URL>/apps/oauth/callback`.
|
||||
3. Copy the generated **Client ID** and **Client Secret**.
|
||||
4. Open the installed app in Twenty as a server admin → set the values on the corresponding `serverVariables`.
|
||||
5. Workspace members can then add connections from the per-app **Connections** section.
|
||||
|
||||
</Accordion>
|
||||
|
||||
</AccordionGroup>
|
||||
@@ -77,7 +77,6 @@ export default defineApplication({
|
||||
universalIdentifier: '39783023-bcac-41e3-b0d2-ff1944d8465d',
|
||||
displayName: 'My Twenty App',
|
||||
description: 'My first Twenty app',
|
||||
icon: 'IconWorld',
|
||||
applicationVariables: {
|
||||
DEFAULT_RECIPIENT_NAME: {
|
||||
universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de',
|
||||
|
||||
@@ -15,7 +15,7 @@ Front components can render in two locations within Twenty:
|
||||
|
||||
## Basic example
|
||||
|
||||
The quickest way to see a front component in action is to register it as a **command**. Adding a `command` field with `isPinned: true` makes it appear as a quick-action button in the top-right corner of the page — no page layout needed:
|
||||
The quickest way to see a front component in action is to register it with a **command menu item**. Use `defineCommandMenuItem` in a separate file to make the component appear as a quick-action button in the top-right corner of the page:
|
||||
|
||||
```tsx src/front-components/hello-world.tsx
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
@@ -34,14 +34,20 @@ export default defineFrontComponent({
|
||||
name: 'hello-world',
|
||||
description: 'A simple front component',
|
||||
component: HelloWorld,
|
||||
command: {
|
||||
universalIdentifier: 'd4e5f6a7-b8c9-0123-defa-456789012345',
|
||||
shortLabel: 'Hello',
|
||||
label: 'Hello World',
|
||||
icon: 'IconBolt',
|
||||
isPinned: true,
|
||||
availabilityType: 'GLOBAL',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts src/command-menu-items/hello-world.command-menu-item.ts
|
||||
import { defineCommandMenuItem } from 'twenty-sdk/define';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: 'd4e5f6a7-b8c9-0123-defa-456789012345',
|
||||
shortLabel: 'Hello',
|
||||
label: 'Hello World',
|
||||
icon: 'IconBolt',
|
||||
isPinned: true,
|
||||
availabilityType: 'GLOBAL',
|
||||
frontComponentUniversalIdentifier: '74c526eb-cb68-4cf7-b05c-0dd8c288d948',
|
||||
});
|
||||
```
|
||||
|
||||
@@ -62,7 +68,6 @@ Click it to render the component inline.
|
||||
| `name` | No | Display name |
|
||||
| `description` | No | Description of what the component does |
|
||||
| `isHeadless` | No | Set to `true` if the component has no visible UI (see below) |
|
||||
| `command` | No | Register the component as a command (see [command options](#command-options) below) |
|
||||
|
||||
## Placing a front component on a page
|
||||
|
||||
@@ -141,11 +146,17 @@ export default defineFrontComponent({
|
||||
description: 'Creates a task from the command menu',
|
||||
component: RunAction,
|
||||
isHeadless: true,
|
||||
command: {
|
||||
universalIdentifier: 'f6a7b8c9-d0e1-2345-fabc-456789012345',
|
||||
label: 'Run my action',
|
||||
icon: 'IconPlayerPlay',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts src/command-menu-items/run-action.command-menu-item.ts
|
||||
import { defineCommandMenuItem } from 'twenty-sdk/define';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: 'f6a7b8c9-d0e1-2345-fabc-456789012345',
|
||||
label: 'Run my action',
|
||||
icon: 'IconPlayerPlay',
|
||||
frontComponentUniversalIdentifier: 'e5f6a7b8-c9d0-1234-efab-345678901234',
|
||||
});
|
||||
```
|
||||
|
||||
@@ -177,11 +188,6 @@ export default defineFrontComponent({
|
||||
description: 'Deletes a draft with confirmation',
|
||||
component: DeleteDraft,
|
||||
isHeadless: true,
|
||||
command: {
|
||||
universalIdentifier: 'b8c9d0e1-f2a3-4567-bcde-678901234567',
|
||||
label: 'Delete draft',
|
||||
icon: 'IconTrash',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@@ -223,7 +229,8 @@ Available hooks:
|
||||
| Hook | Returns | Description |
|
||||
|------|---------|-------------|
|
||||
| `useUserId()` | `string` or `null` | The current user's ID |
|
||||
| `useRecordId()` | `string` or `null` | The current record's ID (when placed on a record page) |
|
||||
| `useSelectedRecordIds()` | `string[]` | All selected record IDs (empty array if none selected) |
|
||||
| `useRecordId()` | `string` or `null` | **Deprecated.** Use `useSelectedRecordIds()` instead |
|
||||
| `useFrontComponentId()` | `string` | This component instance's ID |
|
||||
| `useFrontComponentExecutionContext(selector)` | varies | Access the full execution context with a selector function |
|
||||
|
||||
@@ -286,14 +293,84 @@ export default defineFrontComponent({
|
||||
});
|
||||
```
|
||||
|
||||
## Command options
|
||||
### Working with multiple records
|
||||
|
||||
Adding a `command` field to `defineFrontComponent` registers the component in the command menu (Cmd+K). If `isPinned` is `true`, it also appears as a quick-action button in the top-right corner of the page.
|
||||
Use `useSelectedRecordIds()` to handle multiple selected records. This is useful for bulk operations:
|
||||
|
||||
```tsx src/front-components/bulk-export.tsx
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { useSelectedRecordIds, numberOfSelectedRecords } from 'twenty-sdk/front-component';
|
||||
import { enqueueSnackbar, closeSidePanel } from 'twenty-sdk/front-component';
|
||||
import { CoreApiClient } from 'twenty-sdk/clients';
|
||||
|
||||
const BulkExport = () => {
|
||||
const selectedRecordIds = useSelectedRecordIds();
|
||||
|
||||
const handleExport = async () => {
|
||||
const client = new CoreApiClient();
|
||||
|
||||
for (const recordId of selectedRecordIds) {
|
||||
await client.mutation({
|
||||
updateTask: {
|
||||
__args: { id: recordId, data: { exported: true } },
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await enqueueSnackbar({
|
||||
message: `Exported ${selectedRecordIds.length} records`,
|
||||
variant: 'success',
|
||||
});
|
||||
|
||||
await closeSidePanel();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<p>Export {selectedRecordIds.length} selected record(s)?</p>
|
||||
<button onClick={handleExport}>Export</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'd0e1f2a3-b4c5-6789-defa-012345678901',
|
||||
name: 'bulk-export',
|
||||
description: 'Export selected records',
|
||||
component: BulkExport,
|
||||
command: {
|
||||
universalIdentifier: 'd0e1f2a3-b4c5-6789-defa-012345678902',
|
||||
label: 'Bulk Export',
|
||||
availabilityType: 'RECORD_SELECTION',
|
||||
conditionalAvailabilityExpression: numberOfSelectedRecords > 0,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## defineCommandMenuItem
|
||||
|
||||
Use `defineCommandMenuItem` to register a front component in the command menu (Cmd+K). If `isPinned` is `true`, it also appears as a quick-action button in the top-right corner of the page.
|
||||
|
||||
```ts src/command-menu-items/open-dashboard.command-menu-item.ts
|
||||
import { defineCommandMenuItem } from 'twenty-sdk/define';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
label: 'Open Dashboard',
|
||||
shortLabel: 'Dashboard',
|
||||
icon: 'IconLayoutDashboard',
|
||||
isPinned: true,
|
||||
availabilityType: 'GLOBAL',
|
||||
frontComponentUniversalIdentifier: '74c526eb-cb68-4cf7-b05c-0dd8c288d948',
|
||||
});
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `universalIdentifier` | Yes | Stable unique ID for the command |
|
||||
| `label` | Yes | Full label shown in the command menu (Cmd+K) |
|
||||
| `frontComponentUniversalIdentifier` | Yes | The `universalIdentifier` of the front component this command opens |
|
||||
| `shortLabel` | No | Shorter label displayed on the pinned quick-action button |
|
||||
| `icon` | No | Icon name displayed next to the label (e.g. `'IconBolt'`, `'IconSend'`) |
|
||||
| `isPinned` | No | When `true`, shows the command as a quick-action button in the top-right corner of the page |
|
||||
@@ -305,30 +382,23 @@ Adding a `command` field to `defineFrontComponent` registers the component in th
|
||||
|
||||
The `conditionalAvailabilityExpression` field lets you control when a command is visible based on the current page context. Import typed variables and operators from `twenty-sdk` to build expressions:
|
||||
|
||||
```tsx
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
```ts src/command-menu-items/bulk-update.command-menu-item.ts
|
||||
import { defineCommandMenuItem } from 'twenty-sdk/define';
|
||||
import {
|
||||
pageType,
|
||||
numberOfSelectedRecords,
|
||||
objectPermissions,
|
||||
everyEquals,
|
||||
isDefined,
|
||||
} from 'twenty-sdk/front-component';
|
||||
|
||||
export default defineFrontComponent({
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: '...',
|
||||
name: 'bulk-action',
|
||||
component: BulkAction,
|
||||
command: {
|
||||
universalIdentifier: '...',
|
||||
label: 'Bulk Update',
|
||||
availabilityType: 'RECORD_SELECTION',
|
||||
conditionalAvailabilityExpression: everyEquals(
|
||||
objectPermissions,
|
||||
'canUpdateObjectRecords',
|
||||
true,
|
||||
),
|
||||
},
|
||||
label: 'Bulk Update',
|
||||
availabilityType: 'RECORD_SELECTION',
|
||||
frontComponentUniversalIdentifier: '...',
|
||||
conditionalAvailabilityExpression: everyEquals(
|
||||
objectPermissions,
|
||||
'canUpdateObjectRecords',
|
||||
true,
|
||||
),
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
@@ -4,48 +4,52 @@ icon: "rocket"
|
||||
description: Create your first Twenty app in minutes.
|
||||
---
|
||||
|
||||
## What are apps?
|
||||
|
||||
Apps let you extend Twenty with custom objects, fields, logic functions, front components, AI skills, and more — all managed as code. Instead of configuring everything through the UI, you define your data model and logic in TypeScript and deploy it to one or more workspaces.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, make sure the following is installed on your machine:
|
||||
- **Node.js 24+** — [Download](https://nodejs.org/)
|
||||
- **Yarn 4** — bundled with Node via Corepack. Enable it: `corepack enable`
|
||||
- **Docker** — [Download](https://www.docker.com/products/docker-desktop/). Needed to run a local Twenty server. Skip if you already have Twenty running elsewhere.
|
||||
|
||||
- **Node.js 24+** — [Download here](https://nodejs.org/)
|
||||
- **Yarn 4** — Comes with Node.js via Corepack. Enable it by running `corepack enable`
|
||||
- **Docker** — [Download here](https://www.docker.com/products/docker-desktop/). Required to run a local Twenty instance. Not needed if you already have a Twenty server running.
|
||||
Building a Twenty app has three phases. The scaffolder collapses them into one happy-path command, but each phase is a separate concept — when something fails, knowing which phase you're in tells you what to fix.
|
||||
|
||||
## Create your first app
|
||||
| Phase | What you do | Tool | Result |
|
||||
|---|---|---|---|
|
||||
| **1. Scaffold** | Generate the app's source code | `npx create-twenty-app` | A TypeScript project on disk |
|
||||
| **2. Run a server** | Start a Twenty server to sync into | Docker + `yarn twenty server` | A running Twenty instance |
|
||||
| **3. Sync** | Live-sync your code to the server | `yarn twenty dev` | Your changes appear in the UI |
|
||||
|
||||
### Scaffold your app
|
||||
---
|
||||
|
||||
Open a terminal and run:
|
||||
## Phase 1 — Scaffold your project
|
||||
|
||||
Create a new app from the template:
|
||||
|
||||
```bash filename="Terminal"
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
```
|
||||
|
||||
You will be prompted to enter a name and a description for your app. Press **Enter** to accept the defaults.
|
||||
You'll be prompted for a name and description — press **Enter** for the defaults. This generates a TypeScript project in `my-twenty-app/` with a starter `application-config.ts`, a default role, a CI workflow, and an integration test.
|
||||
|
||||
This creates a new folder called `my-twenty-app` with everything you need.
|
||||
**After this phase:** you have an app's source code on your machine. It isn't running yet — that's Phase 2.
|
||||
|
||||
### Set up a local Twenty instance
|
||||
---
|
||||
|
||||
The scaffolder will ask:
|
||||
## Phase 2 — Run a local Twenty server
|
||||
|
||||
Your app needs a Twenty server to sync into. The server is a full Twenty instance — UI, GraphQL API, PostgreSQL — running locally in Docker. Your local code uploads its definitions to that server, which makes them appear in the UI.
|
||||
|
||||
The scaffolder offers to start one for you:
|
||||
|
||||
> **Would you like to set up a local Twenty instance?**
|
||||
|
||||
- **Type `yes`** (recommended) — This pulls the `twenty-app-dev` Docker image and starts a local Twenty server on port `2020`. Make sure Docker is running before you continue.
|
||||
- **Type `no`** — Choose this if you already have a Twenty server running locally.
|
||||
- **Yes (recommended)** — pulls the `twentycrm/twenty-app-dev` Docker image and starts it on port `2020`. Make sure Docker is running first.
|
||||
- **No** — choose this if you already have a Twenty server you want to connect to. You can wire it up later with `yarn twenty remote add`.
|
||||
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<img src="/images/docs/developers/extends/apps/start-instance.png" alt="Should start local instance?" />
|
||||
</div>
|
||||
|
||||
### Sign in to your workspace
|
||||
|
||||
Next, a browser window will open with the Twenty login page. Sign in with the pre-seeded demo account:
|
||||
Once the server is up, a browser opens for sign-in. Use the pre-seeded demo account:
|
||||
|
||||
- **Email:** `tim@apple.dev`
|
||||
- **Password:** `tim@apple.dev`
|
||||
@@ -54,50 +58,66 @@ Next, a browser window will open with the Twenty login page. Sign in with the pr
|
||||
<img src="/images/docs/developers/extends/apps/login.png" alt="Twenty login screen" />
|
||||
</div>
|
||||
|
||||
### Authorize the app
|
||||
|
||||
After you sign in, you will see an authorization screen. This lets your app interact with your workspace.
|
||||
|
||||
Click **Authorize** to continue.
|
||||
Click **Authorize** on the next screen — this gives the CLI access to your workspace.
|
||||
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<img src="/images/docs/developers/extends/apps/authorize.png" alt="Twenty CLI authorization screen" />
|
||||
</div>
|
||||
|
||||
Once authorized, your terminal will confirm that everything is set up.
|
||||
Your terminal will confirm everything is set up.
|
||||
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<img src="/images/docs/developers/extends/apps/scaffolded.png" alt="App scaffolded successfully" />
|
||||
</div>
|
||||
|
||||
### Start developing
|
||||
**After this phase:** you have a running Twenty server at [http://localhost:2020](http://localhost:2020) with your CLI authorized to sync to it.
|
||||
|
||||
Go into your new app folder and start the development server:
|
||||
<Note>
|
||||
If Docker isn't installed or running, the scaffolder will tell you the right start command for your OS. Once Docker is up, you can resume with `yarn twenty server start` — no need to re-scaffold.
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Sync your changes
|
||||
|
||||
This is the inner loop you'll spend most of your time in.
|
||||
|
||||
```bash filename="Terminal"
|
||||
cd my-twenty-app
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
This watches your source files, rebuilds on every change, and syncs your app to the local Twenty server automatically. You should see a live status panel in your terminal.
|
||||
This watches `src/`, rebuilds on every change, and syncs the result to the server. Edit a file, save, and within a second the server reflects the change. You'll see a live status panel in your terminal.
|
||||
|
||||
For more detailed output (build logs, sync requests, error traces), use the `--verbose` flag:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev --verbose
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Dev mode is only available on Twenty instances running in development (`NODE_ENV=development`). Production instances reject dev sync requests. Use `yarn twenty deploy` to deploy to production servers — see [Publishing Apps](/developers/extend/apps/publishing) for details.
|
||||
</Warning>
|
||||
For more detailed output (build logs, sync requests, error traces), add `--verbose`.
|
||||
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<img src="/images/docs/developers/extends/apps/dev.jpg" alt="Dev mode terminal output" />
|
||||
<img src="/images/docs/developers/extends/apps/dev.png" alt="Dev mode terminal output" />
|
||||
</div>
|
||||
|
||||
#### One-shot sync with `yarn twenty dev --once`
|
||||
Open [http://localhost:2020/settings/applications#developer](http://localhost:2020/settings/applications#developer). You should see your app under **Your Apps**.
|
||||
|
||||
If you do not want a watcher running in the background (for example in a CI pipeline, a git hook, or a scripted workflow), pass the `--once` flag. It runs the same pipeline as `yarn twenty dev` — build manifest, bundle files, upload, sync, regenerate the typed API client — but **exits as soon as the sync completes**:
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<img src="/images/docs/developers/extends/apps/app-in-ui-1.png" alt="Your Apps list showing My twenty app" />
|
||||
</div>
|
||||
|
||||
Click **My twenty app** to see its **application registration** — a server-level record describing your app (name, identifier, OAuth credentials, source). One registration can be installed across multiple workspaces on the same server.
|
||||
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<img src="/images/docs/developers/extends/apps/app-in-ui-2.png" alt="Application registration details" />
|
||||
</div>
|
||||
|
||||
Click **View installed app** to see the workspace install. The **About** tab shows version and management options.
|
||||
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<img src="/images/docs/developers/extends/apps/app-in-ui-3.png" alt="Installed app" />
|
||||
</div>
|
||||
|
||||
**After this phase:** you have a live development loop. Edit any file in `src/` and it appears in the UI.
|
||||
|
||||
### One-shot sync for CI and scripts
|
||||
|
||||
Pass `--once` to run a single build + sync and exit — same pipeline, no watcher:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev --once
|
||||
@@ -105,38 +125,14 @@ yarn twenty dev --once
|
||||
|
||||
| Command | Behavior | When to use |
|
||||
|---------|----------|-------------|
|
||||
| `yarn twenty dev` | Watches your source files and re-syncs on every change. Keeps running until you stop it. | Interactive local development — you want the live status panel and instant feedback loop. |
|
||||
| `yarn twenty dev --once` | Performs a single build + sync, then exits with code `0` on success or `1` on failure. | Scripts, CI, pre-commit hooks, AI agents, and any non-interactive workflow. |
|
||||
| `yarn twenty dev` | Watches and re-syncs on every change. Runs until you stop it. | Interactive local development. |
|
||||
| `yarn twenty dev --once` | Single build + sync, exits `0` on success, `1` on failure. | CI, pre-commit hooks, AI agents, scripted workflows. |
|
||||
|
||||
Both modes require a Twenty server running in development mode and an authenticated remote — the same prerequisites apply.
|
||||
Both modes need a server in development mode and an authenticated remote.
|
||||
|
||||
### See your app in Twenty
|
||||
|
||||
Open [http://localhost:2020/settings/applications#developer](http://localhost:2020/settings/applications#developer) in your browser. Navigate to **Settings > Apps** and select the **Developer** tab. You should see your app listed under **Your Apps**:
|
||||
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<img src="/images/docs/developers/extends/apps/app-in-ui-1.png" alt="Your Apps list showing My twenty app" />
|
||||
</div>
|
||||
|
||||
Click on **My twenty app** to open its **application registration**. A registration is a server-level record that describes your app — its name, unique identifier, OAuth credentials, and source (local, npm, or tarball). It lives on the server, not inside any specific workspace. When you install an app into a workspace, Twenty creates a workspace-scoped **application** that points back to this registration. One registration can be installed across multiple workspaces on the same server.
|
||||
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<img src="/images/docs/developers/extends/apps/app-in-ui-2.png" alt="Application registration details" />
|
||||
</div>
|
||||
|
||||
Click **View installed app** to see the installed app. The **About** tab shows the current version and management options:
|
||||
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<img src="/images/docs/developers/extends/apps/app-in-ui-3.png" alt="Installed app — About tab" />
|
||||
</div>
|
||||
|
||||
Switch to the **Content** tab to see everything your app provides — objects, fields, logic functions, and agents:
|
||||
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<img src="/images/docs/developers/extends/apps/app-in-ui-4.png" alt="Installed app — Content tab" />
|
||||
</div>
|
||||
|
||||
You are all set! Edit any file in `src/` and the changes will be picked up automatically.
|
||||
<Warning>
|
||||
Dev mode is only available on Twenty instances running in development (`NODE_ENV=development`). Production instances reject dev sync requests — use `yarn twenty deploy` to deploy to production servers. See [Publishing Apps](/developers/extend/apps/publishing).
|
||||
</Warning>
|
||||
|
||||
---
|
||||
|
||||
@@ -146,115 +142,110 @@ Apps are composed of **entities** — each defined as a TypeScript file with a s
|
||||
|
||||
| Entity | What it does |
|
||||
|--------|-------------|
|
||||
| **Objects & Fields** | Define custom data models (like Post Card, Invoice) with typed fields |
|
||||
| **Logic functions** | Server-side TypeScript functions triggered by HTTP routes, cron schedules, or database events |
|
||||
| **Objects & Fields** | Custom data models (Post Card, Invoice, etc.) with typed fields |
|
||||
| **Logic functions** | Server-side TypeScript triggered by HTTP routes, cron schedules, or database events |
|
||||
| **Front components** | React components that render inside Twenty's UI (side panel, widgets, command menu) |
|
||||
| **Skills & Agents** | AI capabilities — reusable instructions and autonomous assistants |
|
||||
| **Views & Navigation** | Pre-configured list views and sidebar menu items for your objects |
|
||||
| **Views & Navigation** | Pre-configured list views and sidebar menu items |
|
||||
| **Page layouts** | Custom record detail pages with tabs and widgets |
|
||||
|
||||
Head over to [Building Apps](/developers/extend/apps/building) for a detailed guide on each entity type.
|
||||
|
||||
---
|
||||
Full reference: [Building Apps](/developers/extend/apps/building).
|
||||
|
||||
## Project structure
|
||||
|
||||
The scaffolder generates the following file structure:
|
||||
|
||||
```text filename="my-twenty-app/"
|
||||
my-twenty-app/
|
||||
package.json
|
||||
yarn.lock
|
||||
.gitignore
|
||||
.nvmrc
|
||||
.yarnrc.yml
|
||||
.oxlintrc.json
|
||||
tsconfig.json
|
||||
tsconfig.spec.json # TypeScript config for tests
|
||||
vitest.config.ts # Vitest test runner configuration
|
||||
LLMS.md
|
||||
README.md
|
||||
.github/
|
||||
└── workflows/
|
||||
└── ci.yml # GitHub Actions CI workflow
|
||||
public/ # Public assets (images, fonts, etc.)
|
||||
src/
|
||||
├── application-config.ts # Required — main application configuration
|
||||
├── default-role.ts # Default role for logic functions
|
||||
├── constants/
|
||||
│ └── universal-identifiers.ts # Auto-generated UUIDs and app metadata
|
||||
└── __tests__/
|
||||
├── setup-test.ts # Test setup (server health check, config)
|
||||
└── app-install.integration-test.ts # Integration test
|
||||
application-config.ts # Required — your app's entry point
|
||||
default-role.ts # Permissions for logic functions
|
||||
constants/
|
||||
universal-identifiers.ts # Auto-generated UUIDs and metadata
|
||||
__tests__/
|
||||
setup-test.ts
|
||||
app-install.integration-test.ts
|
||||
.github/workflows/ci.yml # GitHub Actions
|
||||
public/ # Static assets
|
||||
vitest.config.ts # Test runner config
|
||||
tsconfig.json, tsconfig.spec.json
|
||||
.nvmrc, .yarnrc.yml, .oxlintrc.json
|
||||
README.md, LLMS.md
|
||||
```
|
||||
|
||||
| File / Folder | Purpose |
|
||||
|---|---|
|
||||
| `src/application-config.ts` | **Required.** The main configuration file for your app. |
|
||||
| `src/default-role.ts` | Default role controlling what your logic functions can access. |
|
||||
| `src/constants/universal-identifiers.ts` | Auto-generated UUIDs and metadata (display name, description). |
|
||||
| `src/__tests__/` | Integration tests (setup + example test). |
|
||||
| `public/` | Static assets (images, fonts) served with your app. |
|
||||
|
||||
### Starting from an example
|
||||
|
||||
To start from a more complete example with custom objects, fields, logic functions, front components, and more, use the `--example` flag:
|
||||
Use `--example` to start with a more complete project (custom objects, fields, logic functions, front components):
|
||||
|
||||
```bash filename="Terminal"
|
||||
npx create-twenty-app@latest my-twenty-app --example postcard
|
||||
```
|
||||
|
||||
Examples are sourced from the [twenty-apps/examples](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples) directory on GitHub. You can also scaffold individual entities into an existing project with `yarn twenty add` (see [Building Apps](/developers/extend/apps/building#scaffolding-entities-with-yarn-twenty-add)).
|
||||
Examples live in [twenty-apps/examples](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples). You can also scaffold individual entities into an existing project with `yarn twenty add` — see [Building Apps](/developers/extend/apps/building#scaffolding-entities-with-yarn-twenty-add).
|
||||
|
||||
### Key files
|
||||
---
|
||||
|
||||
| File / Folder | Purpose |
|
||||
|---|---|
|
||||
| `package.json` | Declares your app name, version, and dependencies. Includes a `twenty` script so you can run `yarn twenty help` to see all commands. |
|
||||
| `src/application-config.ts` | **Required.** The main configuration file for your app. |
|
||||
| `src/default-role.ts` | Default role that controls what your logic functions can access. |
|
||||
| `src/constants/universal-identifiers.ts` | Auto-generated UUIDs and app metadata (display name, description). |
|
||||
| `src/__tests__/` | Integration tests (setup + example test). |
|
||||
| `public/` | Static assets (images, fonts) served with your app. |
|
||||
## Managing the local server
|
||||
|
||||
## Local development server
|
||||
Use `yarn twenty server` to control the local Twenty container:
|
||||
|
||||
The scaffolder already started a local Twenty server for you. To manage it later, use `yarn twenty server`:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `yarn twenty server start` | Start the local server (pulls image if needed) |
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `yarn twenty server start` | Start the server (pulls the image if needed) |
|
||||
| `yarn twenty server start --port 3030` | Start on a custom port |
|
||||
| `yarn twenty server start --test` | Start a separate test instance on port 2021 |
|
||||
| `yarn twenty server stop` | Stop the server (preserves data) |
|
||||
| `yarn twenty server status` | Show server status, URL, and credentials |
|
||||
| `yarn twenty server status` | Show URL, version, and login credentials |
|
||||
| `yarn twenty server logs` | Stream server logs |
|
||||
| `yarn twenty server logs --lines 100` | Show the last 100 log lines |
|
||||
| `yarn twenty server reset` | Delete all data and start fresh |
|
||||
| `yarn twenty server reset` | Wipe data and start fresh |
|
||||
| `yarn twenty server upgrade` | Pull the latest `twenty-app-dev` image |
|
||||
| `yarn twenty server upgrade 2.2.0` | Upgrade to a specific version |
|
||||
|
||||
Data is persisted across restarts in two Docker volumes (`twenty-app-dev-data` for PostgreSQL, `twenty-app-dev-storage` for files). Use `reset` to wipe everything and start fresh.
|
||||
Data persists across restarts in two Docker volumes (`twenty-app-dev-data` for PostgreSQL, `twenty-app-dev-storage` for files). Use `reset` to wipe everything.
|
||||
|
||||
### Running a test instance
|
||||
### Upgrading the server image
|
||||
|
||||
Pass `--test` to any `server` command to manage a second, fully isolated instance — useful for running integration tests or experimenting without touching your main dev data.
|
||||
`yarn twenty server upgrade` pulls the latest image, compares digests, and only recreates the container if anything actually changed. Volumes are preserved — only the container is replaced. If a new image was pulled and the container was running, the upgrade automatically starts a new container; run `yarn twenty server start` afterward to wait for it to become healthy.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
```bash filename="Terminal"
|
||||
yarn twenty server upgrade # Latest
|
||||
yarn twenty server upgrade 2.2.0 # Specific version
|
||||
```
|
||||
|
||||
Verify the running version with `yarn twenty server status` (it shows the `APP_VERSION` baked into the container).
|
||||
|
||||
### Running a parallel test instance
|
||||
|
||||
Pass `--test` to any `server` command to manage a second, fully isolated instance — useful for integration tests or experiments without touching your main dev data:
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `yarn twenty server start --test` | Start the test instance (defaults to port 2021) |
|
||||
| `yarn twenty server stop --test` | Stop the test instance |
|
||||
| `yarn twenty server status --test` | Show test instance status, URL, and credentials |
|
||||
| `yarn twenty server logs --test` | Stream test instance logs |
|
||||
| `yarn twenty server reset --test` | Wipe test data and start fresh |
|
||||
| `yarn twenty server stop --test` | Stop it |
|
||||
| `yarn twenty server status --test` | Show its status |
|
||||
| `yarn twenty server logs --test` | Stream its logs |
|
||||
| `yarn twenty server reset --test` | Wipe its data |
|
||||
| `yarn twenty server upgrade --test` | Upgrade its image |
|
||||
|
||||
The test instance runs in its own Docker container (`twenty-app-dev-test`) with dedicated volumes (`twenty-app-dev-test-data`, `twenty-app-dev-test-storage`) and config, so it can run in parallel with your main instance without conflicts. Combine `--test` with `--port` to override the default 2021.
|
||||
The test instance has its own container (`twenty-app-dev-test`), volumes (`twenty-app-dev-test-data`, `twenty-app-dev-test-storage`), and config — it runs alongside your main instance without conflicts. Combine `--test` with `--port` to override 2021.
|
||||
|
||||
<Note>
|
||||
The server requires **Docker** to be running. If you see a "Docker not running" error, make sure Docker Desktop (or the Docker daemon) is started.
|
||||
</Note>
|
||||
---
|
||||
|
||||
## Manual setup (without the scaffolder)
|
||||
|
||||
If you prefer to set things up yourself instead of using `create-twenty-app`, you can do it in two steps.
|
||||
|
||||
**1. Add `twenty-sdk` and `twenty-client-sdk` as dependencies:**
|
||||
Skip the scaffolder if you're adding the SDK to an existing project:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn add twenty-sdk twenty-client-sdk
|
||||
```
|
||||
|
||||
**2. Add a `twenty` script to your `package.json`:**
|
||||
Add the script to `package.json`:
|
||||
|
||||
```json filename="package.json"
|
||||
{
|
||||
@@ -264,19 +255,19 @@ yarn add twenty-sdk twenty-client-sdk
|
||||
}
|
||||
```
|
||||
|
||||
You can now run `yarn twenty dev`, `yarn twenty help`, and all other commands.
|
||||
You can now run `yarn twenty dev`, `yarn twenty server start`, and the rest.
|
||||
|
||||
<Note>
|
||||
Do not install `twenty-sdk` globally. Always use it as a local project dependency so that each project can pin its own version.
|
||||
Don't install `twenty-sdk` globally — pin it per project so each app uses its own version.
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you run into issues:
|
||||
- **Docker errors** — Make sure Docker Desktop (or the daemon) is running before `yarn twenty server start`. The error message will show the right start command for your OS.
|
||||
- **Wrong Node version** — Need 24+. Check with `node -v`.
|
||||
- **Yarn 4 missing** — Run `corepack enable`.
|
||||
- **Dependencies broken** — `rm -rf node_modules && yarn install`.
|
||||
|
||||
- Make sure **Docker is running** before starting the scaffolder with a local instance.
|
||||
- Make sure you are using **Node.js 24+** (`node -v` to check).
|
||||
- Make sure **Corepack is enabled** (`corepack enable`) so Yarn 4 is available.
|
||||
- Try deleting `node_modules` and running `yarn install` again if dependencies seem broken.
|
||||
|
||||
Still stuck? Ask for help on the [Twenty Discord](https://discord.com/channels/1130383047699738754/1130386664812982322).
|
||||
Stuck? Ask on the [Twenty Discord](https://discord.com/channels/1130383047699738754/1130386664812982322).
|
||||
|
||||
@@ -101,6 +101,7 @@ The `RoutePayload` type has the following structure:
|
||||
| `queryStringParameters` | `Record<string, string \| undefined>` | Query string parameters (multiple values joined with commas) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }`|
|
||||
| `pathParameters` | `Record<string, string \| undefined>` | Path parameters extracted from the route pattern | `/users/:id`, `/users/123` -> `{ id: '123' }` |
|
||||
| `body` | `object \| null` | Parsed request body (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
|
||||
| `rawBody` | `string \| undefined` | Original UTF-8 request body, before JSON parsing. Useful for verifying HMAC-style webhook signatures (e.g. GitHub's `X-Hub-Signature-256`, Stripe). `undefined` when the runtime did not preserve it. | |
|
||||
| `isBase64Encoded` | `boolean` | Whether the body is base64 encoded | |
|
||||
| `requestContext.http.method` | `string` | HTTP method (GET, POST, PUT, PATCH, DELETE) | |
|
||||
| `requestContext.http.path` | `string` | Raw request path | |
|
||||
@@ -141,11 +142,14 @@ const handler = async (event: RoutePayload) => {
|
||||
Header names are normalized to lowercase. Access them using lowercase keys (e.g., `event.headers['content-type']`).
|
||||
</Note>
|
||||
|
||||
#### Exposing a function as a tool
|
||||
#### Exposing a function as an AI tool or workflow action
|
||||
|
||||
Logic functions can be exposed as **tools** for AI agents and workflows. When marked as a tool, a function becomes discoverable by Twenty's AI features and can be used in workflow automations.
|
||||
Logic functions can be exposed on two surfaces, each with its own trigger:
|
||||
|
||||
To mark a logic function as a tool, set `isTool: true`:
|
||||
- **`toolTriggerSettings`** — makes the function discoverable by Twenty's AI features (chat, MCP, function calling). Uses standard JSON Schema, the format LLMs natively understand.
|
||||
- **`workflowActionTriggerSettings`** — makes the function appear as a step in the visual workflow builder. Uses Twenty's rich `InputSchema` so the builder can render proper field editors, variable pickers, and labels.
|
||||
|
||||
A function can opt into one, the other, or both. They sit alongside `cronTriggerSettings`, `databaseEventTriggerSettings`, and `httpRouteTriggerSettings` — same pattern, same shape.
|
||||
|
||||
```ts src/logic-functions/enrich-company.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
@@ -175,31 +179,33 @@ export default defineLogicFunction({
|
||||
description: 'Enrich a company record with external data',
|
||||
timeoutSeconds: 10,
|
||||
handler,
|
||||
isTool: true,
|
||||
toolTriggerSettings: {},
|
||||
});
|
||||
```
|
||||
|
||||
Key points:
|
||||
|
||||
- You can combine `isTool` with triggers — a function can be both a tool (callable by AI agents) and triggered by events at the same time.
|
||||
- **`toolInputSchema`** (optional): A JSON Schema object describing the parameters your function accepts. The schema is computed automatically from source code static analysis, but you can set it explicitly:
|
||||
- A function can mix surfaces — declare both `toolTriggerSettings` and `workflowActionTriggerSettings` to expose it in chat AND in the workflow builder.
|
||||
- `toolTriggerSettings.inputSchema` and `workflowActionTriggerSettings.inputSchema` are both optional. When omitted, the manifest builder infers them from the handler source code (JSON Schema for the AI tool, Twenty's `InputSchema` for the workflow action). Provide one explicitly when you want richer typing — for example, with `FieldMetadataType`-aware fields like `CURRENCY` or `RELATION` for the workflow builder, or with `description` fields the AI agent can read:
|
||||
|
||||
```ts
|
||||
export default defineLogicFunction({
|
||||
...,
|
||||
toolInputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
companyName: {
|
||||
type: 'string',
|
||||
description: 'The name of the company to enrich',
|
||||
},
|
||||
domain: {
|
||||
type: 'string',
|
||||
description: 'The company website domain (optional)',
|
||||
toolTriggerSettings: {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
companyName: {
|
||||
type: 'string',
|
||||
description: 'The name of the company to enrich',
|
||||
},
|
||||
domain: {
|
||||
type: 'string',
|
||||
description: 'The company website domain (optional)',
|
||||
},
|
||||
},
|
||||
required: ['companyName'],
|
||||
},
|
||||
required: ['companyName'],
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -238,7 +244,7 @@ yarn twenty exec --postInstall
|
||||
```
|
||||
|
||||
Key points:
|
||||
- Post-install functions use `definePostInstallLogicFunction()` — a specialized variant that omits trigger settings (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `isTool`).
|
||||
- Post-install functions use `definePostInstallLogicFunction()` — a specialized variant that omits trigger settings (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `toolTriggerSettings`, `workflowActionTriggerSettings`).
|
||||
- The handler receives an `InstallPayload` with `{ previousVersion?: string; newVersion: string }` — `newVersion` is the version being installed, and `previousVersion` is the version that was previously installed (or `undefined` on a fresh install). Use these values to distinguish fresh installs from upgrades and to run version-specific migration logic.
|
||||
- **When the hook runs**: on fresh installs only, by default. Pass `shouldRunOnVersionUpgrade: true` if you also want it to run when the app is upgraded from a previous version. When omitted, the flag defaults to `false` and upgrades skip the hook.
|
||||
- **Execution model — async by default, sync opt-in**: the `shouldRunSynchronously` flag controls *how* post-install is executed.
|
||||
|
||||
@@ -77,6 +77,39 @@ Pre-release tags work as expected: bumping `1.0.0-rc.1` → `1.0.0-rc.2` is allo
|
||||
|
||||
{/* TODO: add screenshot of the Upgrade button */}
|
||||
|
||||
### Server version compatibility
|
||||
|
||||
If your app uses a feature introduced in a specific Twenty server version (for example, OAuth providers added in v2.3.0), you should declare the minimum server version your app requires using the `engines.twenty` field in `package.json`:
|
||||
|
||||
```json filename="package.json"
|
||||
{
|
||||
"name": "twenty-my-app",
|
||||
"version": "1.0.0",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
"twenty": ">=2.3.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The value is a standard [semver range](https://github.com/npm/node-semver#ranges). Common patterns:
|
||||
|
||||
| Range | Meaning |
|
||||
|-------|---------|
|
||||
| `>=2.3.0` | Any server from 2.3.0 onward |
|
||||
| `>=2.3.0 <3.0.0` | 2.3.0 or later, but below the next major |
|
||||
| `^2.3.0` | Same as `>=2.3.0 <3.0.0` |
|
||||
|
||||
**What happens at deploy and install time:**
|
||||
|
||||
- If `engines.twenty` is set and the target server's version does not satisfy the range, the deploy (tarball upload) or install is rejected with a `SERVER_VERSION_INCOMPATIBLE` error and a message indicating both the required range and the actual server version.
|
||||
- If `engines.twenty` is **not set**, the app is accepted on any server version (backward-compatible with existing apps).
|
||||
- If the server has no `APP_VERSION` configured, the check is skipped.
|
||||
|
||||
<Note>
|
||||
The server is the authoritative check — it validates `engines.twenty` on both tarball upload and workspace install. If you deploy a tarball out-of-band or install from the marketplace, the server still enforces compatibility.
|
||||
</Note>
|
||||
|
||||
## Automated CI/CD (scaffolded workflows)
|
||||
|
||||
Apps generated with `create-twenty-app` ship with two GitHub Actions workflows out of the box, under `.github/workflows/`. They are ready to run as soon as you push the repo to GitHub — no extra setup is needed for CI, and CD only requires a single secret.
|
||||
@@ -184,9 +217,9 @@ The Twenty server syncs its marketplace catalog from the npm registry **every ho
|
||||
You can trigger the sync immediately instead of waiting:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty catalog-sync
|
||||
yarn twenty server catalog-sync
|
||||
# To target a specific remote:
|
||||
# yarn twenty catalog-sync --remote production
|
||||
# yarn twenty server catalog-sync --remote production
|
||||
```
|
||||
|
||||
The metadata shown in the marketplace comes from your `defineApplication()` config — fields like `displayName`, `description`, `author`, `category`, `logoUrl`, `screenshots`, `aboutDescription`, `websiteUrl`, and `termsUrl`.
|
||||
|
||||
@@ -3866,10 +3866,10 @@
|
||||
"language": "ro",
|
||||
"tabs": [
|
||||
{
|
||||
"tab": "Getting Started",
|
||||
"tab": "Începeți",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Welcome",
|
||||
"group": "Bun venit",
|
||||
"pages": [
|
||||
"l/ro/getting-started/introduction",
|
||||
"l/ro/getting-started/key-features",
|
||||
@@ -3877,7 +3877,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Core Concepts",
|
||||
"group": "Concepte cheie",
|
||||
"pages": [
|
||||
"l/ro/getting-started/core-concepts/data-model",
|
||||
"l/ro/getting-started/core-concepts/layout",
|
||||
@@ -3895,7 +3895,7 @@
|
||||
"tab": "User Guide",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Overview",
|
||||
"group": "Prezentare generală",
|
||||
"pages": [
|
||||
"l/ro/user-guide/introduction"
|
||||
]
|
||||
@@ -3906,7 +3906,7 @@
|
||||
"pages": [
|
||||
"l/ro/user-guide/data-model/overview",
|
||||
{
|
||||
"group": "Reference",
|
||||
"group": "Referință",
|
||||
"pages": [
|
||||
"l/ro/user-guide/data-model/capabilities/objects",
|
||||
"l/ro/user-guide/data-model/capabilities/fields",
|
||||
@@ -3932,7 +3932,7 @@
|
||||
"pages": [
|
||||
"l/ro/user-guide/data-migration/overview",
|
||||
{
|
||||
"group": "Reference",
|
||||
"group": "Referință",
|
||||
"pages": [
|
||||
"l/ro/user-guide/data-migration/capabilities/file-formats",
|
||||
"l/ro/user-guide/data-migration/capabilities/field-mapping",
|
||||
@@ -3964,7 +3964,7 @@
|
||||
"pages": [
|
||||
"l/ro/user-guide/calendar-emails/overview",
|
||||
{
|
||||
"group": "Reference",
|
||||
"group": "Referință",
|
||||
"pages": [
|
||||
"l/ro/user-guide/calendar-emails/capabilities/mailbox",
|
||||
"l/ro/user-guide/calendar-emails/capabilities/calendar"
|
||||
@@ -3989,7 +3989,7 @@
|
||||
"pages": [
|
||||
"l/ro/user-guide/workflows/overview",
|
||||
{
|
||||
"group": "Reference",
|
||||
"group": "Referință",
|
||||
"pages": [
|
||||
"l/ro/user-guide/workflows/capabilities/workflow-triggers",
|
||||
"l/ro/user-guide/workflows/capabilities/workflow-actions",
|
||||
@@ -4052,7 +4052,7 @@
|
||||
"pages": [
|
||||
"l/ro/user-guide/ai/overview",
|
||||
{
|
||||
"group": "Reference",
|
||||
"group": "Referință",
|
||||
"pages": [
|
||||
"l/ro/user-guide/ai/capabilities/ai-chatbot",
|
||||
"l/ro/user-guide/ai/capabilities/ai-agents",
|
||||
@@ -4068,16 +4068,16 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Layout",
|
||||
"group": "Aspect",
|
||||
"icon": "table-columns",
|
||||
"pages": [
|
||||
"l/ro/user-guide/layout/overview",
|
||||
{
|
||||
"group": "Reference",
|
||||
"group": "Referință",
|
||||
"pages": [
|
||||
"l/ro/user-guide/layout/capabilities/navigation",
|
||||
{
|
||||
"group": "Views",
|
||||
"group": "Vizualizări",
|
||||
"pages": [
|
||||
"l/ro/user-guide/views-pipelines/capabilities/table-views",
|
||||
"l/ro/user-guide/views-pipelines/capabilities/kanban-views",
|
||||
@@ -4091,7 +4091,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "How-Tos",
|
||||
"group": "Ghiduri practice",
|
||||
"pages": [
|
||||
"l/ro/user-guide/views-pipelines/how-tos/create-a-table-view-with-grouping",
|
||||
"l/ro/user-guide/views-pipelines/how-tos/create-a-kanban-view-for-projects",
|
||||
@@ -4110,7 +4110,7 @@
|
||||
"pages": [
|
||||
"l/ro/user-guide/dashboards/overview",
|
||||
{
|
||||
"group": "Reference",
|
||||
"group": "Referință",
|
||||
"pages": [
|
||||
"l/ro/user-guide/dashboards/capabilities/dashboards",
|
||||
"l/ro/user-guide/dashboards/capabilities/widgets",
|
||||
@@ -4132,7 +4132,7 @@
|
||||
"pages": [
|
||||
"l/ro/user-guide/permissions-access/overview",
|
||||
{
|
||||
"group": "Reference",
|
||||
"group": "Referință",
|
||||
"pages": [
|
||||
"l/ro/user-guide/permissions-access/capabilities/permissions",
|
||||
"l/ro/user-guide/permissions-access/capabilities/sso-configuration"
|
||||
@@ -4152,7 +4152,7 @@
|
||||
"pages": [
|
||||
"l/ro/user-guide/billing/overview",
|
||||
{
|
||||
"group": "Reference",
|
||||
"group": "Referință",
|
||||
"pages": [
|
||||
"l/ro/user-guide/billing/capabilities/pricing-plans",
|
||||
"l/ro/user-guide/billing/capabilities/credits"
|
||||
@@ -4172,7 +4172,7 @@
|
||||
"pages": [
|
||||
"l/ro/user-guide/settings/overview",
|
||||
{
|
||||
"group": "Reference",
|
||||
"group": "Referință",
|
||||
"pages": [
|
||||
"l/ro/user-guide/settings/capabilities/workspace-settings",
|
||||
"l/ro/user-guide/settings/capabilities/member-management",
|
||||
@@ -4196,7 +4196,7 @@
|
||||
"tab": "Dezvoltatori",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Overview",
|
||||
"group": "Prezentare generală",
|
||||
"pages": [
|
||||
"l/ro/developers/introduction"
|
||||
]
|
||||
|
||||
@@ -35,14 +35,8 @@ Everything is detected via AST analysis at build time — no config files, no re
|
||||
|
||||
## The developer experience
|
||||
|
||||
```bash
|
||||
npx create-twenty-app@latest my-app
|
||||
cd my-app
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
`yarn twenty dev` watches your source files, rebuilds on change, and live-syncs to a local Twenty instance. The typed API client regenerates automatically when the schema changes. When you're ready, `yarn twenty deploy` pushes to production. Apps can also be published to npm and listed in the Twenty marketplace.
|
||||
You write your app as a TypeScript project on your machine. The CLI watches your source files and live-syncs them to a running Twenty server — edit a file, see the change in the UI within a second. The typed API client regenerates automatically when the schema changes. When you're ready, `yarn twenty deploy` pushes to a production server, or `yarn twenty publish` lists your app on npm and the Twenty marketplace.
|
||||
|
||||
<Card title="Build your first app" icon="arrow-right" href="/developers/extend/apps/getting-started">
|
||||
Full walkthrough — scaffold, develop, deploy.
|
||||
Three-phase walkthrough — scaffold, run a local server, sync your changes.
|
||||
</Card>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 773 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 724 KiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 662 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 245 KiB |
@@ -94,15 +94,16 @@ const handler = async (event: RoutePayload) => {
|
||||
|
||||
يحتوي نوع `RoutePayload` على البنية التالية:
|
||||
|
||||
| الخاصية | النوع | الوصف | مثال |
|
||||
| ---------------------------- | ------------------------------------------------------- | ------------------------------------------------------------ | -------------------------------------------------------------------------- |
|
||||
| `headers` | `Record\<string, string \| undefined>` | رؤوس HTTP (فقط تلك المدرجة في `forwardedRequestHeaders`) | انظر القسم أدناه |
|
||||
| `queryStringParameters` | `Record\<string, string \| undefined>` | معلمات سلسلة الاستعلام (تُضمّ القيم المتعددة باستخدام فواصل) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
|
||||
| `pathParameters` | `Record\<string, string \| undefined>` | معلمات المسار المستخرجة من نمط المسار | `/users/:id`, `/users/123` -> `{ id: '123' }` |
|
||||
| `body` | `object \| null` | جسم الطلب المُحلَّل (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
|
||||
| `isBase64Encoded` | `boolean` | ما إذا كان جسم الطلب مُرمَّزًا بترميز base64 | |
|
||||
| `requestContext.http.method` | `string` | طريقة HTTP (GET, POST, PUT, PATCH, DELETE) | |
|
||||
| `requestContext.http.path` | `string` | المسار الخام للطلب | |
|
||||
| الخاصية | النوع | الوصف | مثال |
|
||||
| ---------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `headers` | `Record\<string, string \| undefined>` | رؤوس HTTP (فقط تلك المدرجة في `forwardedRequestHeaders`) | انظر القسم أدناه |
|
||||
| `queryStringParameters` | `Record\<string, string \| undefined>` | معلمات سلسلة الاستعلام (تُضمّ القيم المتعددة باستخدام فواصل) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
|
||||
| `pathParameters` | `Record\<string, string \| undefined>` | معلمات المسار المستخرجة من نمط المسار | `/users/:id`, `/users/123` -> `{ id: '123' }` |
|
||||
| `body` | `object \| null` | جسم الطلب المُحلَّل (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
|
||||
| `rawBody` | `string \| undefined` | Original UTF-8 request body, before JSON parsing. Useful for verifying HMAC-style webhook signatures (e.g. GitHub's `X-Hub-Signature-256`, Stripe). `undefined` when the runtime did not preserve it. | |
|
||||
| `isBase64Encoded` | `boolean` | ما إذا كان جسم الطلب مُرمَّزًا بترميز base64 | |
|
||||
| `requestContext.http.method` | `string` | طريقة HTTP (GET, POST, PUT, PATCH, DELETE) | |
|
||||
| `requestContext.http.path` | `string` | المسار الخام للطلب | |
|
||||
|
||||
|
||||
#### forwardedRequestHeaders
|
||||
|
||||
@@ -19,7 +19,7 @@ description: تعرّف على خطط تسعير Twenty وكيفية التبد
|
||||
* دعم قياسي
|
||||
|
||||
<Note>
|
||||
الميزات المتميزة (SSO وأذونات على مستوى الصف) غير مشمولة في خطة Pro.
|
||||
Premium features (SSO, row-level permissions and AI usage data) are not included in the Pro plan.
|
||||
</Note>
|
||||
|
||||
### المؤسسة (سحابي)
|
||||
@@ -27,7 +27,7 @@ description: تعرّف على خطط تسعير Twenty وكيفية التبد
|
||||
للفرق الأكبر ذات الاحتياجات المتقدّمة:
|
||||
|
||||
* كل ما في Pro
|
||||
* **ميزات متميزة**: تكامل SSO وأذونات على مستوى الصف
|
||||
* **Premium features**: SSO integration, row-level permissions and AI usage data
|
||||
* دعم متميز
|
||||
|
||||
## خطط الاستضافة الذاتية
|
||||
@@ -45,7 +45,7 @@ description: تعرّف على خطط تسعير Twenty وكيفية التبد
|
||||
للفرق التي تحتاج إلى ميزات متميزة أثناء الاستضافة الذاتية:
|
||||
|
||||
* جميع ميزات Pro
|
||||
* **ميزات متميزة**: تكامل SSO وأذونات على مستوى الصف
|
||||
* **Premium features**: SSO integration, row-level permissions and AI usage data
|
||||
* دعم فريق Twenty
|
||||
* لا يُشترط نشر الشيفرة المخصّصة كمفتوح المصدر قبل التوزيع
|
||||
|
||||
@@ -55,6 +55,7 @@ description: تعرّف على خطط تسعير Twenty وكيفية التبد
|
||||
|
||||
* **تكامل SSO**: تسجيل دخول أحادي مع موفّر الهوية لديك
|
||||
* **أذونات على مستوى الصف**: تحكّم دقيق في الوصول على مستوى السجل
|
||||
* **AI usage data**: Track AI consumption across the workspace
|
||||
|
||||
## التبديل بين الخطط
|
||||
|
||||
@@ -77,3 +78,15 @@ description: تعرّف على خطط تسعير Twenty وكيفية التبد
|
||||
### التبديل إلى الفوترة الشهرية
|
||||
|
||||
تواصل مع الدعم للعودة إلى الفوترة الشهرية.
|
||||
|
||||
## Obtain an Enterprise Key for Organization (Self-Hosted)
|
||||
|
||||
To use the Organization (Self-Hosted) plan, you need to obtain an Enterprise key:
|
||||
|
||||
1. Go to **Settings → Admin Panel → Enterprise**
|
||||
|
||||
<img src="/images/user-guide/billing/enterprise-key.png" alt="Enterprise key" />
|
||||
|
||||
2. Click **Get Enterprise Key**
|
||||
3. When you are redirected to Stripe, enter your payment details and confirm
|
||||
4. When your Enterprise key is displayed, paste it into the Enterprise settings page and activate the Organization license
|
||||
|
||||
@@ -16,6 +16,11 @@ description: الأسئلة الشائعة حول تسعير Twenty والفوت
|
||||
لا تتوفر الميزات المتميزة إلا في خطط Organization (السحابة أو الاستضافة الذاتية):
|
||||
* **تكامل SSO**: تسجيل الدخول الأحادي مع موفر الهوية لديك
|
||||
* **أذونات على مستوى السجل**: تحكم دقيق في الوصول على مستوى السجل
|
||||
* **بيانات استخدام الذكاء الاصطناعي**: تتبع استهلاك الذكاء الاصطناعي عبر مساحة العمل
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="هل خطة Organization على السحابة وخطة Organization ذاتية الاستضافة متماثلتان؟">
|
||||
كلتاهما تقدمان الميزات المتميزة نفسها. ومع ذلك، لا يمكن استخدام اشتراك السحابة لعمليات النشر ذاتية الاستضافة. يتطلب النشر ذاتي الاستضافة مفتاح Enterprise.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="هل تقدمون مقاعد مجانية للمستخدمين العارضين فقط؟">
|
||||
|
||||
@@ -53,38 +53,45 @@ People ←→ Project Assignments ←→ Projects
|
||||
1. اذهب إلى **الإعدادات → نموذج البيانات**
|
||||
2. انقر **+ كائن جديد**
|
||||
3. سمِّه تسمية وصفية (مثلًا: "تعيين مشروع"، "عضو فريق"، "طلب منتج")
|
||||
4. انقر على **حفظ**
|
||||
4. فعِّل خيار "تخطي إنشاء حقل الاسم"
|
||||
|
||||
<img src="/images/user-guide/fields/new-pivot-object.png" alt="كائن ربط جديد" />
|
||||
|
||||
5. انقر على **حفظ**
|
||||
|
||||
<Tip>
|
||||
**اتفاقية التسمية**: استخدم اسمًا يصف العلاقة، مثل "تعيين مشروع" أو "عضوية الفريق". هذا يجعل نموذج البيانات أسهل في الفهم.
|
||||
</Tip>
|
||||
|
||||
## الخطوة 2: إنشاء علاقات من كائن الربط
|
||||
## الخطوة 2: إنشاء علاقات بين الكائنات وكائن الربط
|
||||
|
||||
أضِف حقول علاقة من كائن الربط إلى كلا الكائنين اللذين تريد ربطهما.
|
||||
أضف حقول العلاقة من كلٍ من الكائنين لديك إلى كائن الربط.
|
||||
|
||||
### العلاقة الأولى (كائن الربط → الكائن A)
|
||||
### العلاقة الأولى (الكائن A → كائن الربط)
|
||||
|
||||
1. حدِّد كائن الربط في **الإعدادات → نموذج البيانات**
|
||||
2. انقر **+ إضافة حقل**
|
||||
3. اختر **العلاقة** كنوع الحقل
|
||||
4. اختر الكائن الأول (مثلًا، "الأشخاص")
|
||||
5. عيِّن نوع العلاقة إلى **متعدد-إلى-واحد** (يمكن لعديد من التعيينات الارتباط بشخص واحد)
|
||||
6. قم بتسمية الحقول:
|
||||
* الحقل على كائن الربط: مثلًا، "شخص"
|
||||
* الحقل على الأشخاص: مثلًا، "تعيينات المشروع"
|
||||
7. انقر على **حفظ**
|
||||
|
||||
### العلاقة الثانية (كائن الربط → الكائن B)
|
||||
|
||||
1. وأنت ما زلت في كائن الربط، انقر **+ إضافة حقل**
|
||||
2. اختر **العلاقة** كنوع الحقل
|
||||
3. اختر الكائن الثاني (مثلًا، "المشاريع")
|
||||
4. عيِّن نوع العلاقة إلى **متعدد-إلى-واحد**
|
||||
1. حدِّد الكائن الأول لديك في **الإعدادات → نموذج البيانات**
|
||||
2. انقر **+ إضافة علاقة**
|
||||
3. اختر كائن الربط (مثلًا، "تعيينات المشروع")
|
||||
4. عيِّن نوع العلاقة إلى **واحد-إلى-متعدد** (يمكن لشخص واحد الارتباط بالعديد من التعيينات)
|
||||
5. قم بتسمية الحقول:
|
||||
* الحقل على الأشخاص: مثلًا، "تعيينات المشروع"
|
||||
* الحقل على كائن الربط: مثلًا، "شخص"
|
||||
6. انقر على **حفظ**
|
||||
|
||||
### العلاقة الثانية (الكائن B → كائن الربط)
|
||||
|
||||
1. حدِّد الكائن الثاني لديك في **الإعدادات → نموذج البيانات**
|
||||
2. انقر **+ إضافة علاقة**
|
||||
3. اختر كائن الربط (مثلًا، "تعيينات المشروع")
|
||||
4. عيِّن نوع العلاقة إلى **واحد-إلى-متعدد** (يمكن لمشروع واحد الارتباط بالعديد من التعيينات)
|
||||
5. فعّل **"هذه علاقة بكائن ربط"**
|
||||
|
||||
<img src="/images/user-guide/fields/junction-relation-toggle.png" style={{width:'100%'}} />
|
||||
|
||||
6. قم بتسمية الحقول:
|
||||
* الحقل على كائن الربط: مثلًا، "مشروع"
|
||||
* الحقل على المشاريع: مثلًا، "أعضاء الفريق"
|
||||
6. انقر على **حفظ**
|
||||
7. انقر على **حفظ**
|
||||
|
||||
## الخطوة 3: ضبط عرض علاقة الربط
|
||||
|
||||
@@ -98,18 +105,6 @@ People ←→ Project Assignments ←→ Projects
|
||||
6. حدِّد **العلاقة الهدف** (مثلًا، "مشروع" — الحقل على كائن الربط الذي يشير إلى الجانب الآخر)
|
||||
7. انقر على **حفظ**
|
||||
|
||||
{/* TODO: Add image
|
||||
<img src="/images/user-guide/fields/junction-relation-toggle.png" style={{width:'100%'}}/>
|
||||
*/}
|
||||
|
||||
كرِّر على الكائن الآخر:
|
||||
|
||||
1. اختر "المشاريع" في نموذج البيانات
|
||||
2. حرِّر حقل العلاقة "أعضاء الفريق"
|
||||
3. فعّل مفتاح الربط
|
||||
4. حدِّد "شخص" كالعلاقة الهدف
|
||||
5. حفظ
|
||||
|
||||
## النتيجة
|
||||
|
||||
بعد التكوين:
|
||||
@@ -130,15 +125,15 @@ People ←→ Project Assignments ←→ Projects
|
||||
|
||||
### إضافة علاقات
|
||||
|
||||
1. **تعيين مشروع → الأشخاص**
|
||||
* النوع: متعدد-إلى-واحد
|
||||
* الحقل على التعيين: "شخص"
|
||||
1. **الأشخاص → تعيين مشروع**
|
||||
* النوع: واحد-إلى-متعدد
|
||||
* الحقل على الأشخاص: "تعيينات المشروع"
|
||||
* الحقل على التعيين: "شخص"
|
||||
|
||||
2. **تعيين مشروع → المشاريع**
|
||||
* النوع: متعدد-إلى-واحد
|
||||
* الحقل على التعيين: "مشروع"
|
||||
2. **المشاريع → تعيين مشروع**
|
||||
* النوع: واحد-إلى-متعدد
|
||||
* الحقل على المشاريع: "أعضاء الفريق"
|
||||
* الحقل على التعيين: "مشروع"
|
||||
|
||||
### ضبط عرض علاقة الربط
|
||||
|
||||
|
||||
@@ -30,3 +30,9 @@ description: خصص الشريط الجانبي الأيسر ليتوافق مع
|
||||
## قائمة الأوامر
|
||||
|
||||
اضغط `Cmd+K` (أو `Ctrl+K`) لفتح قائمة الأوامر — شريط بحث للوصول السريع يتيح لك الانتقال إلى أي سجل أو طريقة عرض أو إجراء دون التنقل عبر الشريط الجانبي.
|
||||
|
||||
## تخصيص الشريط الجانبي
|
||||
|
||||
لتخصيص الشريط الجانبي، مرّر المؤشر فوق قسم "مساحة العمل" في الشريط الجانبي وانقر على أيقونة مفتاح الربط.
|
||||
|
||||
<img src="/images/user-guide/layout/navigation-edit-icon.png" alt="أيقونة تحرير التنقّل" />
|
||||
|
||||
@@ -3,6 +3,8 @@ title: صفحات السجل
|
||||
description: خصص تخطيط صفحات تفاصيل السجلات باستخدام علامات تبويب وأدوات.
|
||||
---
|
||||
|
||||
## نظرة عامة
|
||||
|
||||
عند فتح سجل في Twenty، تتكون صفحة التفاصيل من **علامات تبويب** و**أدوات**. كلاهما قابل للتخصيص بالكامل حسب نوع الكائن.
|
||||
|
||||
## علامات التبويب
|
||||
@@ -38,12 +40,20 @@ description: خصص تخطيط صفحات تفاصيل السجلات باستخ
|
||||
|
||||
1. افتح أي سجل
|
||||
2. اضغط على `Cmd+K` وابحث عن "تحرير تخطيط صفحة السجل"
|
||||
|
||||
أو
|
||||
|
||||
1. انتقل إلى الإعدادات > نموذج البيانات > الكائن الذي تختاره > التخطيط
|
||||
|
||||
2. انقر على الزر "تخصيص صفحة السجل" لذلك الكائن
|
||||
|
||||
3. أنت الآن في وضع التخصيص:
|
||||
* **أضف أدوات** من منتقي الأدوات
|
||||
* **اسحب الأدوات** لإعادة وضعها على الشبكة
|
||||
* **غيّر حجم الأدوات** بسحب حوافها
|
||||
* **اضبط الحقول** المعروضة داخل كل أداة
|
||||
* **أدِر علامات التبويب** — أضف، أزل، أعد التسمية، وأعد الترتيب
|
||||
|
||||
4. احفظ تغييراتك — سيتم تطبيقها على جميع السجلات لذلك النوع من الكائنات
|
||||
|
||||
## ظهور الحقول
|
||||
|
||||
@@ -95,15 +95,16 @@ const handler = async (event: RoutePayload) => {
|
||||
|
||||
Typ `RoutePayload` má následující strukturu:
|
||||
|
||||
| Vlastnost | Typ | Popis | Příklad |
|
||||
| ---------------------------- | ------------------------------------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `headers` | `Record\<string, string \| undefined>` | Záhlaví HTTP (pouze ta uvedená v `forwardedRequestHeaders`) | viz sekci níže |
|
||||
| `queryStringParameters` | `Record\<string, string \| undefined>` | Parametry query stringu (více hodnot spojených čárkami) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
|
||||
| `pathParameters` | `Record\<string, string \| undefined>` | Parametry cesty extrahované ze vzoru trasy | `/users/:id`, `/users/123` -> `{ id: '123' }` |
|
||||
| `body` | `object \| null` | Parsované tělo požadavku (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
|
||||
| `isBase64Encoded` | `boolean` | Zda je tělo kódováno base64 | |
|
||||
| `requestContext.http.method` | `string` | Metoda HTTP (GET, POST, PUT, PATCH, DELETE) | |
|
||||
| `requestContext.http.path` | `string` | Nezpracovaná cesta požadavku | |
|
||||
| Vlastnost | Typ | Popis | Příklad |
|
||||
| ---------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `headers` | `Record\<string, string \| undefined>` | Záhlaví HTTP (pouze ta uvedená v `forwardedRequestHeaders`) | viz sekci níže |
|
||||
| `queryStringParameters` | `Record\<string, string \| undefined>` | Parametry query stringu (více hodnot spojených čárkami) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
|
||||
| `pathParameters` | `Record\<string, string \| undefined>` | Parametry cesty extrahované ze vzoru trasy | `/users/:id`, `/users/123` -> `{ id: '123' }` |
|
||||
| `body` | `object \| null` | Parsované tělo požadavku (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
|
||||
| `rawBody` | `string \| undefined` | Original UTF-8 request body, before JSON parsing. Useful for verifying HMAC-style webhook signatures (e.g. GitHub's `X-Hub-Signature-256`, Stripe). `undefined` when the runtime did not preserve it. | |
|
||||
| `isBase64Encoded` | `boolean` | Zda je tělo kódováno base64 | |
|
||||
| `requestContext.http.method` | `string` | Metoda HTTP (GET, POST, PUT, PATCH, DELETE) | |
|
||||
| `requestContext.http.path` | `string` | Nezpracovaná cesta požadavku | |
|
||||
|
||||
|
||||
#### forwardedRequestHeaders
|
||||
|
||||
@@ -19,7 +19,7 @@ Pro týmy připravené škálovat:
|
||||
* Standardní podpora
|
||||
|
||||
<Note>
|
||||
Prémiové funkce (SSO a oprávnění na úrovni řádků) nejsou součástí plánu Pro.
|
||||
Premium features (SSO, row-level permissions and AI usage data) are not included in the Pro plan.
|
||||
</Note>
|
||||
|
||||
### Organizace (Cloud)
|
||||
@@ -27,7 +27,7 @@ Prémiové funkce (SSO a oprávnění na úrovni řádků) nejsou součástí pl
|
||||
Pro větší týmy s pokročilými potřebami:
|
||||
|
||||
* Vše z plánu Pro
|
||||
* **Prémiové funkce**: integrace SSO a oprávnění na úrovni řádků
|
||||
* **Premium features**: SSO integration, row-level permissions and AI usage data
|
||||
* Prioritní podpora
|
||||
|
||||
## Plány pro self‑hosting
|
||||
@@ -45,7 +45,7 @@ Hostujte Twenty na vlastní infrastruktuře bez poplatků:
|
||||
Pro týmy, které při self‑hostingu potřebují prémiové funkce:
|
||||
|
||||
* Všechny funkce plánu Pro
|
||||
* **Prémiové funkce**: integrace SSO a oprávnění na úrovni řádků
|
||||
* **Premium features**: SSO integration, row-level permissions and AI usage data
|
||||
* Podpora týmu Twenty
|
||||
* Není vyžadováno zveřejnit vlastní kód jako open‑source před distribucí
|
||||
|
||||
@@ -55,6 +55,7 @@ Prémiové funkce jsou dostupné pouze v plánech Organizace (Cloud nebo self‑
|
||||
|
||||
* **Integrace SSO**: jednotné přihlášení s vaším poskytovatelem identity
|
||||
* **Oprávnění na úrovni řádků**: jemně odstupňované řízení přístupu na úrovni záznamu
|
||||
* **AI usage data**: Track AI consumption across the workspace
|
||||
|
||||
## Přepínání plánů
|
||||
|
||||
@@ -77,3 +78,15 @@ Chcete-li přejít na nižší plán, kontaktujte podporu.
|
||||
### Přepnout na měsíční fakturaci
|
||||
|
||||
Chcete-li přepnout zpět na měsíční fakturaci, kontaktujte podporu.
|
||||
|
||||
## Obtain an Enterprise Key for Organization (Self-Hosted)
|
||||
|
||||
To use the Organization (Self-Hosted) plan, you need to obtain an Enterprise key:
|
||||
|
||||
1. Go to **Settings → Admin Panel → Enterprise**
|
||||
|
||||
<img src="/images/user-guide/billing/enterprise-key.png" alt="Enterprise key" />
|
||||
|
||||
2. Click **Get Enterprise Key**
|
||||
3. When you are redirected to Stripe, enter your payment details and confirm
|
||||
4. When your Enterprise key is displayed, paste it into the Enterprise settings page and activate the Organization license
|
||||
|
||||
@@ -16,6 +16,11 @@ Pokud chcete hostovat sami a potřebujete prémiové funkce (SSO a oprávnění
|
||||
Prémiové funkce jsou dostupné pouze v plánech Organization (Cloud nebo Self-Hosted):
|
||||
* **Integrace SSO**: Jednotné přihlášení s vaším poskytovatelem identity
|
||||
* **Oprávnění na úrovni řádků**: Jemně granulované řízení přístupu na úrovni záznamu
|
||||
* **AI usage data**: Track AI consumption across the workspace
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Is Organization plan on cloud and Organization plan on self-hosted the same?">
|
||||
They offer the same premium features. However, a cloud subscription cannot be used for self-hosted deployments. Self-hosted requires an Enterprise key.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Nabízíte volná místa pro uživatele pouze s přístupem ke čtení?">
|
||||
|
||||
@@ -53,38 +53,45 @@ Nejprve vytvořte mezilehlý objekt, který bude uchovávat propojení.
|
||||
1. Přejděte do **Nastavení → Datový model**
|
||||
2. Klikněte na **+ New object**
|
||||
3. Pojmenujte jej výstižně (např. "Project Assignment", "Team Member", "Product Order")
|
||||
4. Klikněte na **Uložit**
|
||||
4. Přepněte "Přeskočit vytvoření pole Název" na zapnuto
|
||||
|
||||
<img src="/images/user-guide/fields/new-pivot-object.png" alt="Nový spojovací objekt" />
|
||||
|
||||
5. Klikněte na **Uložit**
|
||||
|
||||
<Tip>
|
||||
**Konvence pojmenování**: Použijte název, který popisuje vztah, například "Project Assignment" nebo "Team Membership". Tím bude datový model srozumitelnější.
|
||||
</Tip>
|
||||
|
||||
## Krok 2: Vytvořte vztahy ze spojovacího objektu
|
||||
## Krok 2: Vytvořte vztahy mezi objekty a spojovacím objektem
|
||||
|
||||
Přidejte relační pole ze spojovacího objektu do obou objektů, které chcete propojit.
|
||||
Přidejte vztahová pole z obou objektů do spojovacího objektu.
|
||||
|
||||
### První vztah (Junction → Objekt A)
|
||||
### První vztah (Objekt A → Junction)
|
||||
|
||||
1. Vyberte svůj spojovací objekt v **Settings → Data Model**
|
||||
2. Klikněte na **+ Add Field**
|
||||
3. Zvolte **Relation** jako typ pole
|
||||
4. Vyberte první objekt (např. "People")
|
||||
5. Nastavte typ vztahu na **Many-to-One** (mnoho přiřazení může odkazovat na jednu osobu)
|
||||
6. Pojmenujte pole:
|
||||
* Pole na spojovacím objektu: např. "Person"
|
||||
* Pole na People: např. "Project Assignments"
|
||||
7. Klikněte na **Uložit**
|
||||
|
||||
### Druhý vztah (Junction → Objekt B)
|
||||
|
||||
1. Stále na spojovacím objektu klikněte na **+ Add Field**
|
||||
2. Zvolte **Relation** jako typ pole
|
||||
3. Vyberte druhý objekt (např. "Projects")
|
||||
4. Nastavte typ vztahu na **Many-to-One**
|
||||
1. Vyberte svůj první objekt v **Settings → Data Model**
|
||||
2. Klikněte na **+ Přidat vztah**
|
||||
3. Vyberte spojovací objekt (např. "Project Assignments")
|
||||
4. Nastavte typ vztahu na **One-To-Many** (jedna osoba může být propojena s mnoha přiřazeními)
|
||||
5. Pojmenujte pole:
|
||||
* Pole na People: např. "Project Assignments"
|
||||
* Pole na spojovacím objektu: např. "Person"
|
||||
6. Klikněte na **Uložit**
|
||||
|
||||
### Druhý vztah (Objekt B → Junction)
|
||||
|
||||
1. Vyberte svůj druhý objekt v **Settings → Data Model**
|
||||
2. Klikněte na **+ Přidat vztah**
|
||||
3. Vyberte spojovací objekt (např. "Project Assignments")
|
||||
4. Nastavte typ vztahu na **One-To-Many** (jeden projekt může být propojen s mnoha přiřazeními)
|
||||
5. Povolte **"This is a relation to a Junction Object"**
|
||||
|
||||
<img src="/images/user-guide/fields/junction-relation-toggle.png" style={{width:'100%'}} />
|
||||
|
||||
6. Pojmenujte pole:
|
||||
* Pole na spojovacím objektu: např. "Project"
|
||||
* Pole na Projects: např. "Team Members"
|
||||
6. Klikněte na **Uložit**
|
||||
7. Klikněte na **Uložit**
|
||||
|
||||
## Krok 3: Nakonfigurujte zobrazení vztahu přes spojovací objekt
|
||||
|
||||
@@ -98,18 +105,6 @@ Nyní nakonfigurujte zdrojové objekty tak, aby zobrazovaly propojené záznamy
|
||||
6. Vyberte **Target relation** (např. "Project" — pole na spojovacím objektu, které ukazuje na druhou stranu)
|
||||
7. Klikněte na **Uložit**
|
||||
|
||||
{/* TODO: Add image
|
||||
<img src="/images/user-guide/fields/junction-relation-toggle.png" style={{width:'100%'}}/>
|
||||
*/}
|
||||
|
||||
Opakujte pro druhý objekt:
|
||||
|
||||
1. Vyberte "Projects" v Data Model
|
||||
2. Upravte relační pole "Team Members"
|
||||
3. Povolte přepínač pro vztah přes spojovací objekt
|
||||
4. Vyberte "Person" jako cílový vztah
|
||||
5. Uložit
|
||||
|
||||
## Výsledek
|
||||
|
||||
Po konfiguraci:
|
||||
@@ -130,15 +125,15 @@ Zde je kompletní postup:
|
||||
|
||||
### Přidejte vztahy
|
||||
|
||||
1. **Project Assignment → People**
|
||||
* Typ: Many-to-One
|
||||
* Pole na Assignment: "Person"
|
||||
1. **People → Project Assignment**
|
||||
* Typ: One-to-Many
|
||||
* Pole na People: "Project Assignments"
|
||||
* Pole na Assignment: "Person"
|
||||
|
||||
2. **Project Assignment → Projects**
|
||||
* Typ: Many-to-One
|
||||
* Pole na Assignment: "Project"
|
||||
2. **Projects → Project Assignment**
|
||||
* Typ: One-to-Many
|
||||
* Pole na Projects: "Team Members"
|
||||
* Pole na Assignment: "Project"
|
||||
|
||||
### Nakonfigurujte zobrazení spojovacího objektu
|
||||
|
||||
|
||||
@@ -30,3 +30,9 @@ Přidejte odkazy na externí nástroje přímo do postranního panelu. Užitečn
|
||||
## Nabídka příkazů
|
||||
|
||||
Stiskněte `Cmd+K` (nebo `Ctrl+K`) pro otevření nabídky příkazů — rychlá vyhledávací lišta pro přechod na libovolný záznam, zobrazení nebo akci bez procházení postranního panelu.
|
||||
|
||||
## Přizpůsobení postranního panelu
|
||||
|
||||
Chcete-li přizpůsobit postranní panel, najeďte myší na sekci "Pracovní prostor" v postranním panelu a klikněte na ikonu klíče.
|
||||
|
||||
<img src="/images/user-guide/layout/navigation-edit-icon.png" alt="Ikona pro úpravu navigace" />
|
||||
|
||||
@@ -3,6 +3,8 @@ title: Stránky záznamů
|
||||
description: Přizpůsobte rozvržení jednotlivých stránek detailu záznamu pomocí karet a widgetů.
|
||||
---
|
||||
|
||||
## Přehled
|
||||
|
||||
Když v Twenty otevřete záznam, stránka detailu se skládá z **karet** a **widgetů**. Obojí lze pro každý typ objektu plně přizpůsobit.
|
||||
|
||||
## Karty
|
||||
@@ -38,12 +40,20 @@ Widgety jsou stavební prvky uvnitř každé karty. Mezi dostupné typy widgetů
|
||||
|
||||
1. Otevřete libovolný záznam
|
||||
2. Stiskněte `Cmd+K` a vyhledejte "Upravit rozložení stránky záznamu"
|
||||
|
||||
nebo
|
||||
|
||||
1. Přejděte do Nastavení > Datový model > objekt dle vašeho výběru > Rozložení
|
||||
|
||||
2. Klikněte na tlačítko "Přizpůsobit stránku záznamu" pro daný objekt
|
||||
|
||||
3. Nyní jste v režimu přizpůsobení:
|
||||
* **Přidávejte widgety** z výběru widgetů
|
||||
* **Přetahujte widgety** pro jejich přemístění v mřížce
|
||||
* **Změňte velikost widgetů** tažením jejich okrajů
|
||||
* **Nakonfigurujte pole** zobrazovaná v jednotlivých widgetech
|
||||
* **Spravujte karty** — přidávejte, odebírejte, přejmenovávejte, měňte pořadí
|
||||
|
||||
4. Uložte změny — budou platit pro všechny záznamy daného typu objektu
|
||||
|
||||
## Viditelnost polí
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
---
|
||||
title: Verbindungen
|
||||
description: Ermöglichen Sie Ihrer App, im Namen eines Benutzers über OAuth in Diensten von Drittanbietern zu handeln.
|
||||
icon: plug
|
||||
---
|
||||
|
||||
Verbindungen sind Anmeldedaten, die ein Benutzer für einen externen Dienst besitzt (Linear, GitHub, Slack, ...). Ihre App legt fest, **wie** diese Anmeldedaten bezogen werden — ein **Verbindungsanbieter** — und verwendet sie zur Laufzeit, um authentifizierte Aufrufe an die Drittanbieter-API zu tätigen.
|
||||
|
||||
Derzeit wird nur OAuth 2.0 unterstützt. Zukünftige Anmeldedatentypen (Personal Access Tokens, API-Schlüssel, Basic Auth) werden in dieselbe Oberfläche integriert — Apps, die bereits `defineConnectionProvider({ type: 'oauth', ... })` müssen nicht migriert werden.
|
||||
|
||||
<AccordionGroup>
|
||||
|
||||
<Accordion title="defineConnectionProvider" description="Legen Sie fest, wie die Verbindungen Ihrer App bezogen werden">
|
||||
|
||||
Ein Verbindungsanbieter beschreibt den OAuth-Handshake, den Ihre App benötigt. Der Benutzer klickt in den Einstellungen Ihrer App auf "Verbindung hinzufügen", schließt den Zustimmungsbildschirm des Anbieters ab, und in seinem Arbeitsbereich wird eine `ConnectedAccount`-Zeile erstellt.
|
||||
|
||||
Eine funktionierende Einrichtung benötigt **zwei Dateien** — den Verbindungsanbieter und eine passende `serverVariables`-Deklaration in `defineApplication`, die die OAuth-Client-Anmeldedaten enthält.
|
||||
|
||||
```ts src/connection-providers/linear-connection.ts
|
||||
import { defineConnectionProvider } from 'twenty-sdk/define';
|
||||
|
||||
export default defineConnectionProvider({
|
||||
universalIdentifier: '9c7d1f5e-6a0b-4d44-be0c-3f8b5a9d4e6f',
|
||||
name: 'linear',
|
||||
displayName: 'Linear',
|
||||
icon: 'IconBrandLinear',
|
||||
type: 'oauth',
|
||||
oauth: {
|
||||
authorizationEndpoint: 'https://linear.app/oauth/authorize',
|
||||
tokenEndpoint: 'https://api.linear.app/oauth/token',
|
||||
scopes: ['read', 'write'],
|
||||
// These must match keys in `defineApplication.serverVariables` below.
|
||||
clientIdVariable: 'LINEAR_CLIENT_ID',
|
||||
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
|
||||
// Optional: defaults to 'json'. Some providers (Linear, Slack) want
|
||||
// 'form-urlencoded' for the token request.
|
||||
tokenRequestContentType: 'form-urlencoded',
|
||||
// Optional: defaults to true. Disable only if the provider rejects PKCE.
|
||||
usePkce: false,
|
||||
// Optional: extra query params on the authorize URL.
|
||||
// authorizationParams: { prompt: 'consent' },
|
||||
// Optional: provider's RFC 7009 token revocation endpoint, called on disconnect.
|
||||
// revokeEndpoint: 'https://example.com/oauth/revoke',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts src/application.config.ts
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: '...',
|
||||
displayName: 'Linear',
|
||||
description: 'Connect Linear to Twenty.',
|
||||
defaultRoleUniversalIdentifier: '...',
|
||||
// OAuth client credentials live on the app registration (one OAuth app per
|
||||
// Twenty server, configured by the admin) — not per-workspace. Declare them
|
||||
// as serverVariables so the admin can fill them in once for all installs.
|
||||
serverVariables: {
|
||||
LINEAR_CLIENT_ID: {
|
||||
description: 'OAuth client ID from your Linear OAuth application.',
|
||||
isSecret: false,
|
||||
isRequired: true,
|
||||
},
|
||||
LINEAR_CLIENT_SECRET: {
|
||||
description: 'OAuth client secret from your Linear OAuth application.',
|
||||
isSecret: true,
|
||||
isRequired: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Hauptpunkte:
|
||||
|
||||
* `name` ist die eindeutige Bezeichner-Zeichenfolge, die in `listConnections({ providerName })` verwendet wird (kebab-case, muss `^[a-z][a-z0-9-]*$` entsprechen).
|
||||
* `displayName` wird im Einstellungs-Tab der jeweiligen App und in der KI-Toolliste angezeigt.
|
||||
* `clientIdVariable` / `clientSecretVariable` sind **Namen**, keine Werte — sie müssen den in `defineApplication.serverVariables` deklarierten Schlüsseln entsprechen. Die tatsächlichen `client_id` und `client_secret` werden vom Serveradministrator über die App-Registrierungsoberfläche eingegeben und niemals in Ihr Repository eingecheckt.
|
||||
* Verwenden Sie `serverVariables` (nicht `applicationVariables`) — OAuth-Anmeldedaten gelten serverweit und es gibt eine OAuth-App pro Twenty-Server.
|
||||
* Solange beide `serverVariables` nicht ausgefüllt sind, zeigt der Einstellungs-Tab pro App den Hinweis "Benötigt Server-Admin" an und der Button "Verbindung hinzufügen" ist deaktiviert.
|
||||
* `type: 'oauth'` ist derzeit der einzige unterstützte Wert. Der Diskriminator ist vorwärtskompatibel: zukünftige Typen (`'pat'`, `'api-key'`, ...) werden neue Unterkonfigurationsblöcke neben `oauth` hinzufügen.
|
||||
|
||||
Die OAuth-Callback-URL, die Ihr Anbieter auf die Whitelist setzen muss, lautet:
|
||||
|
||||
```
|
||||
https://<your-twenty-server>/apps/oauth/callback
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="listConnections / getConnection" description="Verbindungen aus einer Logikfunktion verwenden">
|
||||
|
||||
Innerhalb eines Logikfunktions-Handlers gibt `listConnections({ providerName })` die `ConnectedAccount`-Zeilen dieser App für den angegebenen Anbieter zurück, mit aktualisierten Zugriffstoken.
|
||||
|
||||
```ts src/logic-functions/handlers/create-linear-issue-handler.ts
|
||||
import { listConnections } from 'twenty-sdk/logic-function';
|
||||
|
||||
export const createLinearIssueHandler = async (input: {
|
||||
teamId?: string;
|
||||
title?: string;
|
||||
}) => {
|
||||
if (!input.teamId || !input.title) {
|
||||
return { success: false, error: 'teamId and title are required' };
|
||||
}
|
||||
|
||||
const connections = await listConnections({ providerName: 'linear' });
|
||||
|
||||
// Workspace-shared credentials win when present; fall back to the first
|
||||
// user-visibility one. For HTTP-route triggers you typically pick the
|
||||
// request user's connection via event.userWorkspaceId instead.
|
||||
const connection =
|
||||
connections.find((c) => c.visibility === 'workspace') ?? connections[0];
|
||||
|
||||
if (!connection) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'Linear is not connected. Open the app settings and click "Add connection".',
|
||||
};
|
||||
}
|
||||
|
||||
// Use connection.accessToken to call the third-party API.
|
||||
const response = await fetch('https://api.linear.app/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${connection.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: `mutation { issueCreate(input: { teamId: "${input.teamId}", title: "${input.title}" }) { success } }`,
|
||||
}),
|
||||
});
|
||||
|
||||
return { success: response.ok };
|
||||
};
|
||||
```
|
||||
|
||||
Jede Verbindung hat:
|
||||
|
||||
| Feld | Beschreibung |
|
||||
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | Eindeutige Zeilen-ID; an `getConnection(id)` übergeben, um eine einzelne Verbindung erneut abzurufen |
|
||||
| `sichtbarkeit` | `'user'` (privat für ein Mitglied des Arbeitsbereichs) oder `'workspace'` (mit allen Mitgliedern geteilt) |
|
||||
| `geltungsbereiche` | Vom Upstream-Anbieter gewährte OAuth-Berechtigungen (unabhängig von `visibility` — diese sind nicht miteinander verknüpft) |
|
||||
| `userWorkspaceId` | Die userWorkspace-ID des Eigentümers — nützlich, um "die Verbindung des anfragenden Benutzers" in HTTP-Routen-Triggern auszuwählen |
|
||||
| `accessToken` | Frisches OAuth-Zugriffstoken (wird bei Ablauf automatisch erneuert) |
|
||||
| `name` / `handle` | Anzeigename der Verbindung (automatisch beim OAuth-Callback abgeleitet, vom Benutzer umbenennbar) |
|
||||
| `authFailedAt` | Gesetzt, wenn die jüngste Aktualisierung fehlgeschlagen ist; der Benutzer muss die Verbindung erneut herstellen |
|
||||
|
||||
Hauptpunkte:
|
||||
|
||||
* Übergeben Sie `{ providerName }`, um nach Anbieter zu filtern; lassen Sie es weg, um alle Verbindungen dieser App über alle Anbieter hinweg zu erhalten.
|
||||
* Der Server aktualisiert das Zugriffstoken vor der Rückgabe transparent. Ihr Handler sieht stets ein verwendbares Token (oder `authFailedAt` ist gesetzt).
|
||||
* `getConnection(id)` ist das Pendant für eine einzelne Zeile.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Sichtbarkeit: pro Benutzer vs. im Arbeitsbereich geteilt" description="Wie Benutzer zwischen privaten und geteilten Anmeldedaten wählen">
|
||||
|
||||
Wenn ein Benutzer auf "Verbindung hinzufügen" klickt, wird er aufgefordert, eine Sichtbarkeit auszuwählen:
|
||||
|
||||
* **Nur für mich** — die Anmeldedaten sind für den sich verbindenden Benutzer privat. Jede Logikfunktion, die in seinem/ihrem Auftrag aufgerufen wird (HTTP-Routen-Trigger mit `isAuthRequired: true`), sieht sie; Cron-Trigger und Datenbankereignisse nicht.
|
||||
* **Im Arbeitsbereich geteilt** — jedes Arbeitsbereichsmitglied kann die Anmeldedaten verwenden. Cron-/Datenbank-Trigger sehen sie ebenfalls, da sie keinen anfragenden Benutzer haben.
|
||||
|
||||
Verwenden Sie für jeden Handler die richtige Option:
|
||||
|
||||
```ts
|
||||
// HTTP-route trigger — prefer the request user's own connection.
|
||||
const conn =
|
||||
connections.find((c) => c.userWorkspaceId === event.userWorkspaceId) ??
|
||||
connections.find((c) => c.visibility === 'workspace');
|
||||
|
||||
// Cron trigger — no request user; only shared credentials are sensible.
|
||||
const conn = connections.find((c) => c.visibility === 'workspace');
|
||||
```
|
||||
|
||||
Mehrere Verbindungen pro (Benutzer, Anbieter) sind erlaubt, sodass derselbe Benutzer "Persönliches Linear" und "Arbeits-Linear" nebeneinander haben kann.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Einmalige Anbietereinrichtung" description="Registrieren Sie Ihre OAuth-App beim Drittanbieterdienst">
|
||||
|
||||
Für jeden Verbindungsanbieter muss der Serveradministrator zunächst eine OAuth-App beim Drittanbieter registrieren.
|
||||
|
||||
1. Gehen Sie zu den Entwickler-Einstellungen des Anbieters (z. B. https://linear.app/settings/api/applications/new).
|
||||
2. Setzen Sie die **Redirect-URI** auf `\<SERVER_URL>/apps/oauth/callback`.
|
||||
3. Kopieren Sie die generierte **Client ID** und das **Client Secret**.
|
||||
4. Öffnen Sie die installierte App in Twenty als Serveradministrator → setzen Sie die Werte in den entsprechenden `serverVariables`.
|
||||
5. Mitglieder des Arbeitsbereichs können dann Verbindungen im **Verbindungen**-Abschnitt der jeweiligen App hinzufügen.
|
||||
|
||||
</Accordion>
|
||||
|
||||
</AccordionGroup>
|
||||
@@ -77,7 +77,6 @@ export default defineApplication({
|
||||
universalIdentifier: '39783023-bcac-41e3-b0d2-ff1944d8465d',
|
||||
displayName: 'My Twenty App',
|
||||
description: 'My first Twenty app',
|
||||
icon: 'IconWorld',
|
||||
applicationVariables: {
|
||||
DEFAULT_RECIPIENT_NAME: {
|
||||
universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de',
|
||||
|
||||
@@ -15,7 +15,7 @@ Front-Komponenten können an zwei Stellen innerhalb von Twenty gerendert werden:
|
||||
|
||||
## Einfaches Beispiel
|
||||
|
||||
Der schnellste Weg, eine Front-Komponente in Aktion zu sehen, ist, sie als Befehl zu registrieren. Das Hinzufügen eines `command`-Felds mit `isPinned: true` lässt sie als Schnellaktionsschaltfläche oben rechts auf der Seite erscheinen — kein Seitenlayout erforderlich:
|
||||
The quickest way to see a front component in action is to register it with a **command menu item**. Use `defineCommandMenuItem` in a separate file to make the component appear as a quick-action button in the top-right corner of the page:
|
||||
|
||||
```tsx src/front-components/hello-world.tsx
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
@@ -34,14 +34,20 @@ export default defineFrontComponent({
|
||||
name: 'hello-world',
|
||||
description: 'A simple front component',
|
||||
component: HelloWorld,
|
||||
command: {
|
||||
universalIdentifier: 'd4e5f6a7-b8c9-0123-defa-456789012345',
|
||||
shortLabel: 'Hello',
|
||||
label: 'Hello World',
|
||||
icon: 'IconBolt',
|
||||
isPinned: true,
|
||||
availabilityType: 'GLOBAL',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts src/command-menu-items/hello-world.command-menu-item.ts
|
||||
import { defineCommandMenuItem } from 'twenty-sdk/define';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: 'd4e5f6a7-b8c9-0123-defa-456789012345',
|
||||
shortLabel: 'Hello',
|
||||
label: 'Hello World',
|
||||
icon: 'IconBolt',
|
||||
isPinned: true,
|
||||
availabilityType: 'GLOBAL',
|
||||
frontComponentUniversalIdentifier: '74c526eb-cb68-4cf7-b05c-0dd8c288d948',
|
||||
});
|
||||
```
|
||||
|
||||
@@ -55,14 +61,13 @@ Klicken Sie darauf, um die Komponente inline zu rendern.
|
||||
|
||||
## Konfigurationsfelder
|
||||
|
||||
| Feld | Erforderlich | Beschreibung |
|
||||
| --------------------- | ------------ | ---------------------------------------------------------------------------------------- |
|
||||
| `universalIdentifier` | Ja | Stabile eindeutige ID für diese Komponente |
|
||||
| `component` | Ja | Eine React-Komponentenfunktion |
|
||||
| `name` | Nein | Anzeigename |
|
||||
| `description` | Nein | Beschreibung dessen, was die Komponente macht |
|
||||
| `isHeadless` | Nein | Auf `true` setzen, wenn die Komponente keine sichtbare UI hat (siehe unten) |
|
||||
| `command` | Nein | Die Komponente als Befehl registrieren (siehe unten [Befehlsoptionen](#command-options)) |
|
||||
| Feld | Erforderlich | Beschreibung |
|
||||
| --------------------- | ------------ | --------------------------------------------------------------------------- |
|
||||
| `universalIdentifier` | Ja | Stabile eindeutige ID für diese Komponente |
|
||||
| `component` | Ja | Eine React-Komponentenfunktion |
|
||||
| `name` | Nein | Anzeigename |
|
||||
| `description` | Nein | Beschreibung dessen, was die Komponente macht |
|
||||
| `isHeadless` | Nein | Auf `true` setzen, wenn die Komponente keine sichtbare UI hat (siehe unten) |
|
||||
|
||||
## Eine Front-Komponente auf einer Seite platzieren
|
||||
|
||||
@@ -141,11 +146,17 @@ export default defineFrontComponent({
|
||||
description: 'Creates a task from the command menu',
|
||||
component: RunAction,
|
||||
isHeadless: true,
|
||||
command: {
|
||||
universalIdentifier: 'f6a7b8c9-d0e1-2345-fabc-456789012345',
|
||||
label: 'Run my action',
|
||||
icon: 'IconPlayerPlay',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts src/command-menu-items/run-action.command-menu-item.ts
|
||||
import { defineCommandMenuItem } from 'twenty-sdk/define';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: 'f6a7b8c9-d0e1-2345-fabc-456789012345',
|
||||
label: 'Run my action',
|
||||
icon: 'IconPlayerPlay',
|
||||
frontComponentUniversalIdentifier: 'e5f6a7b8-c9d0-1234-efab-345678901234',
|
||||
});
|
||||
```
|
||||
|
||||
@@ -177,11 +188,6 @@ export default defineFrontComponent({
|
||||
description: 'Deletes a draft with confirmation',
|
||||
component: DeleteDraft,
|
||||
isHeadless: true,
|
||||
command: {
|
||||
universalIdentifier: 'b8c9d0e1-f2a3-4567-bcde-678901234567',
|
||||
label: 'Delete draft',
|
||||
icon: 'IconTrash',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@@ -223,7 +229,8 @@ Verfügbare Hooks:
|
||||
| Hook | Gibt zurück | Beschreibung |
|
||||
| --------------------------------------------- | -------------------- | --------------------------------------------------------------------------- |
|
||||
| `useUserId()` | `string` oder `null` | Die ID des aktuellen Benutzers |
|
||||
| `useRecordId()` | `string` oder `null` | Die ID des aktuellen Datensatzes (wenn auf einer Datensatzseite platziert) |
|
||||
| `useSelectedRecordIds()` | `Zeichenkette[]` | Alle ausgewählten Datensatz-IDs (leeres Array, wenn keine ausgewählt sind) |
|
||||
| `useRecordId()` | `string` oder `null` | **Veraltet.** Verwenden Sie stattdessen `useSelectedRecordIds()` |
|
||||
| `useFrontComponentId()` | `string` | Die ID dieser Komponenteninstanz |
|
||||
| `useFrontComponentExecutionContext(selector)` | variiert | Zugriff auf den vollständigen Ausführungskontext mit einer Selektorfunktion |
|
||||
|
||||
@@ -286,14 +293,84 @@ export default defineFrontComponent({
|
||||
});
|
||||
```
|
||||
|
||||
## Befehlsoptionen
|
||||
### Mit mehreren Datensätzen arbeiten
|
||||
|
||||
Das Hinzufügen eines `command`-Felds zu `defineFrontComponent` registriert die Komponente im Befehlsmenü (Cmd+K). Wenn `isPinned` `true` ist, erscheint sie außerdem als Schnellaktionsschaltfläche oben rechts auf der Seite.
|
||||
Verwenden Sie `useSelectedRecordIds()`, um mehrere ausgewählte Datensätze zu verwalten. Dies ist nützlich für Stapelvorgänge:
|
||||
|
||||
```tsx src/front-components/bulk-export.tsx
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { useSelectedRecordIds, numberOfSelectedRecords } from 'twenty-sdk/front-component';
|
||||
import { enqueueSnackbar, closeSidePanel } from 'twenty-sdk/front-component';
|
||||
import { CoreApiClient } from 'twenty-sdk/clients';
|
||||
|
||||
const BulkExport = () => {
|
||||
const selectedRecordIds = useSelectedRecordIds();
|
||||
|
||||
const handleExport = async () => {
|
||||
const client = new CoreApiClient();
|
||||
|
||||
for (const recordId of selectedRecordIds) {
|
||||
await client.mutation({
|
||||
updateTask: {
|
||||
__args: { id: recordId, data: { exported: true } },
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await enqueueSnackbar({
|
||||
message: `Exported ${selectedRecordIds.length} records`,
|
||||
variant: 'success',
|
||||
});
|
||||
|
||||
await closeSidePanel();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<p>Export {selectedRecordIds.length} selected record(s)?</p>
|
||||
<button onClick={handleExport}>Export</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'd0e1f2a3-b4c5-6789-defa-012345678901',
|
||||
name: 'bulk-export',
|
||||
description: 'Export selected records',
|
||||
component: BulkExport,
|
||||
command: {
|
||||
universalIdentifier: 'd0e1f2a3-b4c5-6789-defa-012345678902',
|
||||
label: 'Bulk Export',
|
||||
availabilityType: 'RECORD_SELECTION',
|
||||
conditionalAvailabilityExpression: numberOfSelectedRecords > 0,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## defineCommandMenuItem
|
||||
|
||||
Use `defineCommandMenuItem` to register a front component in the command menu (Cmd+K). Wenn `isPinned` `true` ist, erscheint sie außerdem als Schnellaktionsschaltfläche oben rechts auf der Seite.
|
||||
|
||||
```ts src/command-menu-items/open-dashboard.command-menu-item.ts
|
||||
import { defineCommandMenuItem } from 'twenty-sdk/define';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
||||
label: 'Open Dashboard',
|
||||
shortLabel: 'Dashboard',
|
||||
icon: 'IconLayoutDashboard',
|
||||
isPinned: true,
|
||||
availabilityType: 'GLOBAL',
|
||||
frontComponentUniversalIdentifier: '74c526eb-cb68-4cf7-b05c-0dd8c288d948',
|
||||
});
|
||||
```
|
||||
|
||||
| Feld | Erforderlich | Beschreibung |
|
||||
| --------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `universalIdentifier` | Ja | Stabile eindeutige ID für den Befehl |
|
||||
| `label` | Ja | Vollständiges Label, das im Befehlsmenü (Cmd+K) angezeigt wird |
|
||||
| `frontComponentUniversalIdentifier` | Ja | The `universalIdentifier` of the front component this command opens |
|
||||
| `shortLabel` | Nein | Kürzeres Label, das auf der angehefteten Schnellaktionsschaltfläche angezeigt wird |
|
||||
| `icon` | Nein | Neben dem Label angezeigter Icon-Name (z. B. 'IconBolt', 'IconSend') |
|
||||
| `isPinned` | Nein | Bei `true` wird der Befehl als Schnellaktionsschaltfläche oben rechts auf der Seite angezeigt |
|
||||
@@ -305,30 +382,23 @@ Das Hinzufügen eines `command`-Felds zu `defineFrontComponent` registriert die
|
||||
|
||||
Mit dem Feld `conditionalAvailabilityExpression` können Sie basierend auf dem aktuellen Seitenkontext steuern, wann ein Befehl sichtbar ist. Importieren Sie typisierte Variablen und Operatoren aus `twenty-sdk`, um Ausdrücke zu erstellen:
|
||||
|
||||
```tsx
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
```ts src/command-menu-items/bulk-update.command-menu-item.ts
|
||||
import { defineCommandMenuItem } from 'twenty-sdk/define';
|
||||
import {
|
||||
pageType,
|
||||
numberOfSelectedRecords,
|
||||
objectPermissions,
|
||||
everyEquals,
|
||||
isDefined,
|
||||
} from 'twenty-sdk/front-component';
|
||||
|
||||
export default defineFrontComponent({
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: '...',
|
||||
name: 'bulk-action',
|
||||
component: BulkAction,
|
||||
command: {
|
||||
universalIdentifier: '...',
|
||||
label: 'Bulk Update',
|
||||
availabilityType: 'RECORD_SELECTION',
|
||||
conditionalAvailabilityExpression: everyEquals(
|
||||
objectPermissions,
|
||||
'canUpdateObjectRecords',
|
||||
true,
|
||||
),
|
||||
},
|
||||
label: 'Bulk Update',
|
||||
availabilityType: 'RECORD_SELECTION',
|
||||
frontComponentUniversalIdentifier: '...',
|
||||
conditionalAvailabilityExpression: everyEquals(
|
||||
objectPermissions,
|
||||
'canUpdateObjectRecords',
|
||||
true,
|
||||
),
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
@@ -4,48 +4,52 @@ icon: rocket
|
||||
description: Erstellen Sie in wenigen Minuten Ihre erste Twenty-App.
|
||||
---
|
||||
|
||||
## Was sind Apps?
|
||||
|
||||
Apps ermöglichen es Ihnen, Twenty mit benutzerdefinierten Objekten, Feldern, Logikfunktionen, Frontend-Komponenten, KI-Fähigkeiten und mehr zu erweitern — alles als Code verwaltet. Anstatt alles über die UI zu konfigurieren, definieren Sie Ihr Datenmodell und Ihre Logik in TypeScript und stellen es in einem oder mehreren Workspaces bereit.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
Bevor Sie beginnen, stellen Sie sicher, dass Folgendes auf Ihrem Rechner installiert ist:
|
||||
|
||||
* **Node.js 24+** — [Hier herunterladen](https://nodejs.org/)
|
||||
* **Yarn 4** — Wird mit Node.js über Corepack mitgeliefert. Aktivieren Sie es, indem Sie `corepack enable` ausführen
|
||||
* **Docker** — [Hier herunterladen](https://www.docker.com/products/docker-desktop/). Erforderlich, um eine lokale Twenty-Instanz auszuführen. Nicht erforderlich, wenn bereits ein Twenty-Server läuft.
|
||||
* **Yarn 4** — Wird mit Node.js über Corepack mitgeliefert. Aktivieren Sie es: `corepack enable`
|
||||
* **Docker** — [Hier herunterladen](https://www.docker.com/products/docker-desktop/). Erforderlich, um einen lokalen Twenty-Server auszuführen. Überspringen Sie dies, wenn Twenty bereits anderswo läuft.
|
||||
|
||||
## Erstellen Sie Ihre erste App
|
||||
Das Erstellen einer Twenty-App umfasst drei Phasen. Das Scaffolding-Tool fasst sie zu einem einzigen Happy-Path-Befehl zusammen, aber jede Phase ist ein eigenes Konzept — wenn etwas fehlschlägt, hilft Ihnen das Wissen, in welcher Phase Sie sich befinden, zu erkennen, was zu beheben ist.
|
||||
|
||||
### App-Gerüst erstellen
|
||||
| Phase | Was Sie tun | Tool | Ergebnis |
|
||||
| ----------------------- | ------------------------------------------------------- | ----------------------------- | ---------------------------------------------------- |
|
||||
| **1. Gerüst erstellen** | Den Quellcode der App erzeugen | `npx create-twenty-app` | Ein TypeScript-Projekt auf der Festplatte |
|
||||
| **2. Server starten** | Einen Twenty-Server starten, in den synchronisiert wird | Docker + `yarn twenty server` | Eine laufende Twenty-Instanz |
|
||||
| **3. Synchronisieren** | Ihren Code live mit dem Server synchronisieren | `yarn twenty dev` | Ihre Änderungen erscheinen in der Benutzeroberfläche |
|
||||
|
||||
Öffnen Sie ein Terminal und führen Sie Folgendes aus:
|
||||
---
|
||||
|
||||
## Phase 1 — Projektgerüst erstellen
|
||||
|
||||
Erstellen Sie eine neue App aus der Vorlage:
|
||||
|
||||
```bash filename="Terminal"
|
||||
npx create-twenty-app@latest my-twenty-app
|
||||
```
|
||||
|
||||
Sie werden aufgefordert, einen Namen und eine Beschreibung für Ihre App einzugeben. Drücken Sie **Enter**, um die Standardwerte zu übernehmen.
|
||||
Sie werden nach einem Namen und einer Beschreibung gefragt — drücken Sie **Enter** für die Standardwerte. Dadurch wird ein TypeScript-Projekt in `my-twenty-app/` erzeugt, mit einer Startdatei `application-config.ts`, einer Standardrolle, einem CI-Workflow und einem Integrationstest.
|
||||
|
||||
Dadurch wird ein neuer Ordner namens `my-twenty-app` mit allem erstellt, was Sie benötigen.
|
||||
**Nach dieser Phase:** Sie haben den Quellcode einer App auf Ihrem Rechner. Es läuft noch nicht — das ist Phase 2.
|
||||
|
||||
### Lokale Twenty-Instanz einrichten
|
||||
---
|
||||
|
||||
Das Scaffolding-Tool fragt:
|
||||
## Phase 2 — Einen lokalen Twenty-Server starten
|
||||
|
||||
Ihre App benötigt einen Twenty-Server, in den sie synchronisieren kann. Der Server ist eine vollständige Twenty-Instanz — UI, GraphQL-API, PostgreSQL — die lokal in Docker läuft. Ihr lokaler Code lädt seine Definitionen auf diesen Server hoch, wodurch sie in der Benutzeroberfläche erscheinen.
|
||||
|
||||
Das Scaffolding-Tool bietet an, einen für Sie zu starten:
|
||||
|
||||
> **Möchten Sie eine lokale Twenty-Instanz einrichten?**
|
||||
|
||||
* **Geben Sie `yes` ein** (empfohlen) — Dadurch wird das Docker-Image `twenty-app-dev` heruntergeladen und ein lokaler Twenty-Server auf Port `2020` gestartet. Stellen Sie sicher, dass Docker läuft, bevor Sie fortfahren.
|
||||
* **Geben Sie `no` ein** — Wählen Sie dies, wenn bereits ein Twenty-Server lokal läuft.
|
||||
* **Ja (empfohlen)** — lädt das Docker-Image `twentycrm/twenty-app-dev` herunter und startet es auf Port `2020`. Stellen Sie sicher, dass Docker läuft.
|
||||
* **Nein** — wählen Sie dies, wenn Sie bereits einen Twenty-Server haben, mit dem Sie sich verbinden möchten. Sie können die Verbindung später mit `yarn twenty remote add` herstellen.
|
||||
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<img src="/images/docs/developers/extends/apps/start-instance.png" alt="Soll die lokale Instanz gestartet werden?" />
|
||||
</div>
|
||||
|
||||
### Melden Sie sich bei Ihrem Arbeitsbereich an
|
||||
|
||||
Anschließend öffnet sich ein Browserfenster mit der Twenty-Anmeldeseite. Melden Sie sich mit dem vorab eingerichteten Demo-Konto an:
|
||||
Sobald der Server läuft, öffnet sich ein Browser zur Anmeldung. Verwenden Sie das vorab eingerichtete Demo-Konto:
|
||||
|
||||
* **E-Mail:** `tim@apple.dev`
|
||||
* **Passwort:** `tim@apple.dev`
|
||||
@@ -54,89 +58,81 @@ Anschließend öffnet sich ein Browserfenster mit der Twenty-Anmeldeseite. Melde
|
||||
<img src="/images/docs/developers/extends/apps/login.png" alt="Twenty-Anmeldebildschirm" />
|
||||
</div>
|
||||
|
||||
### Autorisieren Sie die App
|
||||
|
||||
Nach der Anmeldung sehen Sie einen Autorisierungsbildschirm. Dadurch kann Ihre App mit Ihrem Arbeitsbereich interagieren.
|
||||
|
||||
Klicken Sie auf **Authorize**, um fortzufahren.
|
||||
Klicken Sie auf dem nächsten Bildschirm auf **Authorize** — dadurch erhält die CLI Zugriff auf Ihren Arbeitsbereich.
|
||||
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<img src="/images/docs/developers/extends/apps/authorize.png" alt="Twenty-CLI-Autorisierungsbildschirm" />
|
||||
</div>
|
||||
|
||||
Nach der Autorisierung bestätigt Ihr Terminal, dass alles eingerichtet ist.
|
||||
Ihr Terminal bestätigt, dass alles eingerichtet ist.
|
||||
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<img src="/images/docs/developers/extends/apps/scaffolded.png" alt="App-Gerüst erfolgreich erstellt" />
|
||||
</div>
|
||||
|
||||
### Beginnen Sie mit der Entwicklung
|
||||
**Nach dieser Phase:** Sie haben einen laufenden Twenty-Server unter [http://localhost:2020](http://localhost:2020), und Ihre CLI ist autorisiert, mit ihm zu synchronisieren.
|
||||
|
||||
Wechseln Sie in Ihren neuen App-Ordner und starten Sie den Entwicklungsserver:
|
||||
<Note>
|
||||
Wenn Docker nicht installiert ist oder nicht läuft, zeigt das Scaffolding-Tool den richtigen Startbefehl für Ihr Betriebssystem an. Sobald Docker läuft, können Sie mit `yarn twenty server start` fortfahren — ein erneutes Scaffolding ist nicht nötig.
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Ihre Änderungen synchronisieren
|
||||
|
||||
Das ist die innere Schleife, in der Sie die meiste Zeit verbringen werden.
|
||||
|
||||
```bash filename="Terminal"
|
||||
cd my-twenty-app
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
Dadurch werden Ihre Quelldateien überwacht, bei jeder Änderung neu gebaut und Ihre App automatisch mit dem lokalen Twenty-Server synchronisiert. In Ihrem Terminal sollte eine Live-Statusanzeige angezeigt werden.
|
||||
Dies überwacht `src/`, baut bei jeder Änderung neu und synchronisiert das Ergebnis mit dem Server. Bearbeiten Sie eine Datei, speichern Sie, und innerhalb einer Sekunde spiegelt der Server die Änderung wider. Sie sehen eine Live-Statusanzeige in Ihrem Terminal.
|
||||
|
||||
Für ausführlichere Ausgaben (Build-Protokolle, Sync-Anfragen, Fehlerspuren) verwenden Sie das Flag `--verbose`:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev --verbose
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Der Dev-Modus ist nur auf Twenty-Instanzen verfügbar, die im Entwicklungsmodus laufen (`NODE_ENV=development`). Produktionsinstanzen lehnen Dev-Synchronisierungsanfragen ab. Verwenden Sie `yarn twenty deploy`, um auf Produktionsservern bereitzustellen — Details finden Sie unter [Apps veröffentlichen](/l/de/developers/extend/apps/publishing).
|
||||
</Warning>
|
||||
Für ausführlichere Ausgaben (Build-Protokolle, Sync-Anfragen, Fehlerspuren) fügen Sie `--verbose` hinzu.
|
||||
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<img src="/images/docs/developers/extends/apps/dev.jpg" alt="Terminalausgabe im Dev-Modus" />
|
||||
<img src="/images/docs/developers/extends/apps/dev.png" alt="Terminalausgabe im Dev-Modus" />
|
||||
</div>
|
||||
|
||||
#### Einmalige Synchronisierung mit `yarn twenty dev --once`
|
||||
|
||||
Wenn Sie keinen Watcher im Hintergrund ausführen lassen möchten (zum Beispiel in einer CI-Pipeline, einem Git-Hook oder einem skriptgesteuerten Workflow), geben Sie das Flag `--once` an. Es führt dieselbe Pipeline aus wie `yarn twenty dev` — Build-Manifest erstellen, Dateien bündeln, hochladen, synchronisieren, den typisierten API-Client neu generieren — beendet sich jedoch, **sobald die Synchronisierung abgeschlossen ist**:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev --once
|
||||
```
|
||||
|
||||
| Befehl | Verhalten | Wann verwenden |
|
||||
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| `yarn twenty dev` | Überwacht Ihre Quelldateien und synchronisiert bei jeder Änderung erneut. Läuft weiter, bis Sie es stoppen. | Interaktive lokale Entwicklung — Sie möchten das Live-Status-Panel und eine sofortige Feedback-Schleife. |
|
||||
| `yarn twenty dev --once` | Führt einen einzelnen Build + Sync aus und beendet sich anschließend mit Code `0` bei Erfolg oder `1` bei einem Fehler. | Skripte, CI, Pre-Commit-Hooks, KI-Agenten und jeder nicht-interaktive Workflow. |
|
||||
|
||||
Beide Modi erfordern einen Twenty-Server, der im Entwicklungsmodus läuft, und ein authentifiziertes Remote — es gelten dieselben Voraussetzungen.
|
||||
|
||||
### Sehen Sie sich Ihre App in Twenty an
|
||||
|
||||
Öffnen Sie [http://localhost:2020/settings/applications#developer](http://localhost:2020/settings/applications#developer) in Ihrem Browser. Navigieren Sie zu **Settings > Apps** und wählen Sie die Registerkarte **Developer**. Unter **Your Apps** sollte Ihre App aufgeführt sein:
|
||||
Öffnen Sie [http://localhost:2020/settings/applications#developer](http://localhost:2020/settings/applications#developer). Unter **Your Apps** sollte Ihre App angezeigt werden.
|
||||
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<img src="/images/docs/developers/extends/apps/app-in-ui-1.png" alt="Liste "Your Apps", die "My twenty app" anzeigt" />
|
||||
</div>
|
||||
|
||||
Klicken Sie auf **My twenty app**, um die **Anwendungsregistrierung** zu öffnen. Eine Registrierung ist ein Servereintrag, der Ihre App beschreibt — ihren Namen, den eindeutigen Bezeichner, OAuth-Zugangsdaten und die Quelle (lokal, npm oder Tarball). Sie befindet sich auf dem Server, nicht in einem bestimmten Arbeitsbereich. Wenn Sie eine App in einen Arbeitsbereich installieren, erstellt Twenty eine arbeitsbereichsbezogene Anwendung, die auf diese Registrierung verweist. Eine Registrierung kann in mehreren Arbeitsbereichen auf demselben Server installiert werden.
|
||||
Klicken Sie auf **My twenty app**, um die **Anwendungsregistrierung** anzuzeigen — ein serverseitiger Datensatz, der Ihre App beschreibt (Name, Bezeichner, OAuth-Anmeldedaten, Quelle). Eine Registrierung kann in mehreren Arbeitsbereichen auf demselben Server installiert werden.
|
||||
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<img src="/images/docs/developers/extends/apps/app-in-ui-2.png" alt="Details der Anwendungsregistrierung" />
|
||||
</div>
|
||||
|
||||
Klicken Sie auf **View installed app**, um die installierte App anzuzeigen. Die Registerkarte **About** zeigt die aktuelle Version und Verwaltungsoptionen:
|
||||
Klicken Sie auf **View installed app**, um die Installation im Arbeitsbereich anzuzeigen. Die Registerkarte **About** zeigt die Version und Verwaltungsoptionen.
|
||||
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<img src="/images/docs/developers/extends/apps/app-in-ui-3.png" alt="Installierte App — Registerkarte About" />
|
||||
<img src="/images/docs/developers/extends/apps/app-in-ui-3.png" alt="Installierte App" />
|
||||
</div>
|
||||
|
||||
Wechseln Sie zur Registerkarte **Content**, um alles zu sehen, was Ihre App bereitstellt — Objekte, Felder, Logikfunktionen und Agenten:
|
||||
**Nach dieser Phase:** Sie haben eine Live-Entwicklungsschleife. Bearbeiten Sie eine beliebige Datei in `src/`, und sie erscheint in der Benutzeroberfläche.
|
||||
|
||||
<div style={{textAlign: 'center'}}>
|
||||
<img src="/images/docs/developers/extends/apps/app-in-ui-4.png" alt="Installierte App — Registerkarte Content" />
|
||||
</div>
|
||||
### Einmalige Synchronisierung für CI und Skripte
|
||||
|
||||
Alles erledigt! Bearbeiten Sie eine beliebige Datei in `src/`, und die Änderungen werden automatisch übernommen.
|
||||
Verwenden Sie `--once`, um einen einzelnen Build + Sync auszuführen und zu beenden — gleiche Pipeline, kein Watcher:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev --once
|
||||
```
|
||||
|
||||
| Befehl | Verhalten | Wann verwenden |
|
||||
| ------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------- |
|
||||
| `yarn twenty dev` | Überwacht und synchronisiert bei jeder Änderung erneut. Läuft, bis Sie es stoppen. | Interaktive lokale Entwicklung. |
|
||||
| `yarn twenty dev --once` | Einmaliger Build + Sync, beendet sich mit `0` bei Erfolg, mit `1` bei Fehler. | CI, Pre-Commit-Hooks, KI-Agenten, skriptgesteuerte Workflows. |
|
||||
|
||||
Beide Modi benötigen einen Server im Entwicklungsmodus und eine authentifizierte Remote-Verbindung.
|
||||
|
||||
<Warning>
|
||||
Der Dev-Modus ist nur auf Twenty-Instanzen verfügbar, die im Entwicklungsmodus laufen (`NODE_ENV=development`). Produktionsinstanzen lehnen Dev-Sync-Anfragen ab — verwenden Sie `yarn twenty deploy`, um auf Produktionsserver bereitzustellen. Siehe [Apps veröffentlichen](/l/de/developers/extend/apps/publishing).
|
||||
</Warning>
|
||||
|
||||
---
|
||||
|
||||
@@ -144,117 +140,112 @@ Alles erledigt! Bearbeiten Sie eine beliebige Datei in `src/`, und die Änderung
|
||||
|
||||
Apps bestehen aus **Entitäten** — jede ist als TypeScript-Datei mit einem einzigen `export default` definiert:
|
||||
|
||||
| Entität | Was sie macht |
|
||||
| -------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Objekte & Felder** | Definieren Sie benutzerdefinierte Datenmodelle (wie Post Card, Invoice) mit typisierten Feldern |
|
||||
| **Logikfunktionen** | Serverseitige TypeScript-Funktionen, die durch HTTP-Routen, Cron-Zeitpläne oder Datenbankereignisse ausgelöst werden |
|
||||
| **Frontend-Komponenten** | React-Komponenten, die in der UI von Twenty gerendert werden (Seitenleiste, Widgets, Befehlsmenü) |
|
||||
| **Fähigkeiten & Agenten** | KI-Funktionen — wiederverwendbare Anweisungen und autonome Assistenten |
|
||||
| **Ansichten & Navigation** | Vorkonfigurierte Listenansichten und Seitenleisteneinträge für Ihre Objekte |
|
||||
| **Seitenlayouts** | Benutzerdefinierte Datensatz-Detailseiten mit Tabs und Widgets |
|
||||
| Entität | Was sie macht |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| **Objekte & Felder** | Benutzerdefinierte Datenmodelle (Postkarte, Rechnung usw.) mit typisierten Feldern |
|
||||
| **Logikfunktionen** | Serverseitiges TypeScript, ausgelöst durch HTTP-Routen, Cron-Zeitpläne oder Datenbankereignisse |
|
||||
| **Frontend-Komponenten** | React-Komponenten, die in der UI von Twenty gerendert werden (Seitenleiste, Widgets, Befehlsmenü) |
|
||||
| **Fähigkeiten & Agenten** | KI-Funktionen — wiederverwendbare Anweisungen und autonome Assistenten |
|
||||
| **Ansichten & Navigation** | Vorkonfigurierte Listenansichten und Seitenleisteneinträge |
|
||||
| **Seitenlayouts** | Benutzerdefinierte Datensatz-Detailseiten mit Tabs und Widgets |
|
||||
|
||||
Wechseln Sie zu [Apps erstellen](/l/de/developers/extend/apps/building) für eine ausführliche Anleitung zu jedem Entitätstyp.
|
||||
|
||||
---
|
||||
Vollständige Referenz: [Apps entwickeln](/l/de/developers/extend/apps/building).
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
Der Scaffolder erzeugt die folgende Verzeichnisstruktur:
|
||||
|
||||
```text filename="my-twenty-app/"
|
||||
my-twenty-app/
|
||||
package.json
|
||||
yarn.lock
|
||||
.gitignore
|
||||
.nvmrc
|
||||
.yarnrc.yml
|
||||
.oxlintrc.json
|
||||
tsconfig.json
|
||||
tsconfig.spec.json # TypeScript config for tests
|
||||
vitest.config.ts # Vitest test runner configuration
|
||||
LLMS.md
|
||||
README.md
|
||||
.github/
|
||||
└── workflows/
|
||||
└── ci.yml # GitHub Actions CI workflow
|
||||
public/ # Public assets (images, fonts, etc.)
|
||||
src/
|
||||
├── application-config.ts # Required — main application configuration
|
||||
├── default-role.ts # Default role for logic functions
|
||||
├── constants/
|
||||
│ └── universal-identifiers.ts # Auto-generated UUIDs and app metadata
|
||||
└── __tests__/
|
||||
├── setup-test.ts # Test setup (server health check, config)
|
||||
└── app-install.integration-test.ts # Integration test
|
||||
application-config.ts # Required — your app's entry point
|
||||
default-role.ts # Permissions for logic functions
|
||||
constants/
|
||||
universal-identifiers.ts # Auto-generated UUIDs and metadata
|
||||
__tests__/
|
||||
setup-test.ts
|
||||
app-install.integration-test.ts
|
||||
.github/workflows/ci.yml # GitHub Actions
|
||||
public/ # Static assets
|
||||
vitest.config.ts # Test runner config
|
||||
tsconfig.json, tsconfig.spec.json
|
||||
.nvmrc, .yarnrc.yml, .oxlintrc.json
|
||||
README.md, LLMS.md
|
||||
```
|
||||
|
||||
| Datei / Ordner | Zweck |
|
||||
| ---------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| `src/application-config.ts` | **Erforderlich.** Die Hauptkonfigurationsdatei für Ihre App. |
|
||||
| `src/default-role.ts` | Standardrolle, die steuert, worauf Ihre Logikfunktionen zugreifen können. |
|
||||
| `src/constants/universal-identifiers.ts` | Automatisch erzeugte UUIDs und Metadaten (Anzeigename, Beschreibung). |
|
||||
| `src/__tests__/` | Integrationstests (Setup + Beispieltest). |
|
||||
| `public/` | Statische Assets (Bilder, Schriftarten), die mit Ihrer App ausgeliefert werden. |
|
||||
|
||||
### Mit einem Beispiel beginnen
|
||||
|
||||
Um mit einem umfassenderen Beispiel mit benutzerdefinierten Objekten, Feldern, Logikfunktionen, Frontend-Komponenten und mehr zu starten, verwenden Sie die Option `--example`:
|
||||
Verwenden Sie `--example`, um mit einem vollständigeren Projekt zu starten (benutzerdefinierte Objekte, Felder, Logikfunktionen, Front-End-Komponenten):
|
||||
|
||||
```bash filename="Terminal"
|
||||
npx create-twenty-app@latest my-twenty-app --example postcard
|
||||
```
|
||||
|
||||
Die Beispiele stammen aus dem Verzeichnis [twenty-apps/examples](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples) auf GitHub. Sie können auch einzelne Entitäten in einem bestehenden Projekt mit `yarn twenty add` erzeugen (siehe [Apps erstellen](/l/de/developers/extend/apps/building#scaffolding-entities-with-yarn-twenty-add)).
|
||||
Die Beispiele befinden sich unter [twenty-apps/examples](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples). Sie können auch einzelne Entitäten in einem bestehenden Projekt mit `yarn twenty add` erzeugen — siehe [Apps entwickeln](/l/de/developers/extend/apps/building#scaffolding-entities-with-yarn-twenty-add).
|
||||
|
||||
### Wichtige Dateien
|
||||
---
|
||||
|
||||
| Datei / Ordner | Zweck |
|
||||
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `package.json` | Deklariert den App-Namen, die Version und Abhängigkeiten. Enthält ein `twenty`-Skript, sodass Sie `yarn twenty help` ausführen können, um alle Befehle anzuzeigen. |
|
||||
| `src/application-config.ts` | **Erforderlich.** Die Hauptkonfigurationsdatei für Ihre App. |
|
||||
| `src/default-role.ts` | Standardrolle, die steuert, worauf Ihre Logikfunktionen zugreifen können. |
|
||||
| `src/constants/universal-identifiers.ts` | Automatisch erzeugte UUIDs und App-Metadaten (Anzeigename, Beschreibung). |
|
||||
| `src/__tests__/` | Integrationstests (Setup + Beispieltest). |
|
||||
| `public/` | Statische Assets (Bilder, Schriftarten), die mit Ihrer App ausgeliefert werden. |
|
||||
## Lokalen Server verwalten
|
||||
|
||||
## Lokaler Entwicklungsserver
|
||||
Verwenden Sie `yarn twenty server`, um den lokalen Twenty-Container zu steuern:
|
||||
|
||||
Der Scaffolder hat bereits einen lokalen Twenty-Server für Sie gestartet. Um ihn später zu verwalten, verwenden Sie `yarn twenty server`:
|
||||
| Befehl | Was es tut |
|
||||
| -------------------------------------- | --------------------------------------------------- |
|
||||
| `yarn twenty server start` | Server starten (lädt das Image bei Bedarf herunter) |
|
||||
| `yarn twenty server start --port 3030` | Auf einem benutzerdefinierten Port starten |
|
||||
| `yarn twenty server stop` | Server stoppen (Daten bleiben erhalten) |
|
||||
| `yarn twenty server status` | URL, Version und Anmeldedaten anzeigen |
|
||||
| `yarn twenty server logs` | Serverprotokolle streamen |
|
||||
| `yarn twenty server reset` | Alle Daten löschen und neu starten |
|
||||
| `yarn twenty server upgrade` | Das neueste `twenty-app-dev`-Image herunterladen |
|
||||
| `yarn twenty server upgrade 2.2.0` | Auf eine bestimmte Version aktualisieren |
|
||||
|
||||
| Befehl | Beschreibung |
|
||||
| -------------------------------------- | ----------------------------------------------------------- |
|
||||
| `yarn twenty server start` | Lokalen Server starten (lädt das Image bei Bedarf herunter) |
|
||||
| `yarn twenty server start --port 3030` | Auf einem benutzerdefinierten Port starten |
|
||||
| `yarn twenty server start --test` | Starten Sie eine separate Testinstanz auf Port 2021 |
|
||||
| `yarn twenty server stop` | Server stoppen (Daten bleiben erhalten) |
|
||||
| `yarn twenty server status` | Serverstatus, URL und Anmeldedaten anzeigen |
|
||||
| `yarn twenty server logs` | Serverprotokolle streamen |
|
||||
| `yarn twenty server logs --lines 100` | Die letzten 100 Protokollzeilen anzeigen |
|
||||
| `yarn twenty server reset` | Alle Daten löschen und neu starten |
|
||||
Daten bleiben über Neustarts hinweg in zwei Docker-Volumes bestehen (`twenty-app-dev-data` für PostgreSQL, `twenty-app-dev-storage` für Dateien). Verwenden Sie `reset`, um alles zu löschen.
|
||||
|
||||
Daten bleiben über Neustarts hinweg in zwei Docker-Volumes bestehen (`twenty-app-dev-data` für PostgreSQL, `twenty-app-dev-storage` für Dateien). Verwenden Sie `reset`, um alles zu löschen und neu zu beginnen.
|
||||
### Aktualisieren des Server-Images
|
||||
|
||||
### Eine Testinstanz ausführen
|
||||
`yarn twenty server upgrade` lädt das neueste Image herunter, vergleicht die Digests und erstellt den Container nur neu, wenn sich tatsächlich etwas geändert hat. Die Volumes bleiben erhalten — nur der Container wird ersetzt. Wenn ein neues Image heruntergeladen wurde und der Container lief, startet das Upgrade automatisch einen neuen Container; führen Sie anschließend `yarn twenty server start` aus, um zu warten, bis er betriebsbereit ist.
|
||||
|
||||
Übergeben Sie `--test` an jeden `server`-Befehl, um eine zweite, vollständig isolierte Instanz zu verwalten — nützlich, um Integrationstests auszuführen oder zu experimentieren, ohne Ihre Hauptentwicklungsdaten anzutasten.
|
||||
```bash filename="Terminal"
|
||||
yarn twenty server upgrade # Latest
|
||||
yarn twenty server upgrade 2.2.0 # Specific version
|
||||
```
|
||||
|
||||
| Befehl | Beschreibung |
|
||||
| ---------------------------------- | ----------------------------------------------------- |
|
||||
| `yarn twenty server start --test` | Die Testinstanz starten (standardmäßig Port 2021) |
|
||||
| `yarn twenty server stop --test` | Die Testinstanz stoppen |
|
||||
| `yarn twenty server status --test` | Status, URL und Anmeldedaten der Testinstanz anzeigen |
|
||||
| `yarn twenty server logs --test` | Protokolle der Testinstanz streamen |
|
||||
| `yarn twenty server reset --test` | Alle Testdaten löschen und neu starten |
|
||||
Überprüfen Sie die laufende Version mit `yarn twenty server status` (dies zeigt die im Container enthaltene `APP_VERSION` an).
|
||||
|
||||
Die Testinstanz läuft in einem eigenen Docker-Container (`twenty-app-dev-test`) mit dedizierten Volumes (`twenty-app-dev-test-data`, `twenty-app-dev-test-storage`) und eigener Konfiguration, sodass sie parallel zu Ihrer Hauptinstanz ohne Konflikte ausgeführt werden kann. Kombinieren Sie `--test` mit `--port`, um den Standardport 2021 zu überschreiben.
|
||||
### Eine parallele Testinstanz ausführen
|
||||
|
||||
<Note>
|
||||
Der Server erfordert, dass **Docker** läuft. Wenn der Fehler "Docker not running" angezeigt wird, stellen Sie sicher, dass Docker Desktop (oder der Docker-Daemon) gestartet ist.
|
||||
</Note>
|
||||
Übergeben Sie `--test` an jeden `server`-Befehl, um eine zweite, vollständig isolierte Instanz zu verwalten — nützlich für Integrationstests oder Experimente, ohne Ihre Hauptentwicklungsdaten anzutasten:
|
||||
|
||||
| Befehl | Was es tut |
|
||||
| ----------------------------------- | ------------------------------------------------- |
|
||||
| `yarn twenty server start --test` | Die Testinstanz starten (standardmäßig Port 2021) |
|
||||
| `yarn twenty server stop --test` | Anhalten |
|
||||
| `yarn twenty server status --test` | Status anzeigen |
|
||||
| `yarn twenty server logs --test` | Protokolle streamen |
|
||||
| `yarn twenty server reset --test` | Daten löschen |
|
||||
| `yarn twenty server upgrade --test` | Image aktualisieren |
|
||||
|
||||
Die Testinstanz hat ihren eigenen Container (`twenty-app-dev-test`), eigene Volumes (`twenty-app-dev-test-data`, `twenty-app-dev-test-storage`) und eine eigene Konfiguration — sie läuft parallel zu Ihrer Hauptinstanz ohne Konflikte. Kombinieren Sie `--test` mit `--port`, um den Port 2021 zu überschreiben.
|
||||
|
||||
---
|
||||
|
||||
## Manuelle Einrichtung (ohne Scaffolder)
|
||||
|
||||
Wenn Sie die Einrichtung lieber selbst vornehmen möchten, anstatt `create-twenty-app` zu verwenden, können Sie dies in zwei Schritten tun.
|
||||
|
||||
**1. Fügen Sie `twenty-sdk` und `twenty-client-sdk` als Abhängigkeiten hinzu:**
|
||||
Überspringen Sie das Scaffolding-Tool, wenn Sie das SDK zu einem bestehenden Projekt hinzufügen:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn add twenty-sdk twenty-client-sdk
|
||||
```
|
||||
|
||||
**2. Fügen Sie Ihrer `package.json` ein `twenty`-Skript hinzu:**
|
||||
Fügen Sie der `package.json` das Skript hinzu:
|
||||
|
||||
```json filename="package.json"
|
||||
{
|
||||
@@ -264,19 +255,19 @@ yarn add twenty-sdk twenty-client-sdk
|
||||
}
|
||||
```
|
||||
|
||||
Sie können jetzt `yarn twenty dev`, `yarn twenty help` und alle anderen Befehle ausführen.
|
||||
Sie können jetzt `yarn twenty dev`, `yarn twenty server start` und den Rest ausführen.
|
||||
|
||||
<Note>
|
||||
Installieren Sie `twenty-sdk` nicht global. Verwenden Sie es immer als lokale Projektabhängigkeit, damit jedes Projekt seine eigene Version festlegen kann.
|
||||
Installieren Sie `twenty-sdk` nicht global — fixieren Sie es pro Projekt, damit jede App ihre eigene Version verwendet.
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
## Fehlerbehebung
|
||||
|
||||
Wenn Probleme auftreten:
|
||||
* **Docker-Fehler** — Stellen Sie sicher, dass Docker Desktop (oder der Daemon) läuft, bevor Sie `yarn twenty server start` ausführen. Die Fehlermeldung zeigt den richtigen Startbefehl für Ihr Betriebssystem an.
|
||||
* **Falsche Node-Version** — 24+ erforderlich. Prüfen Sie mit `node -v`.
|
||||
* **Yarn 4 fehlt** — Führen Sie `corepack enable` aus.
|
||||
* **Abhängigkeiten defekt** — `rm -rf node_modules && yarn install`.
|
||||
|
||||
* Stellen Sie sicher, dass **Docker läuft**, bevor Sie das Scaffolding-Tool mit einer lokalen Instanz starten.
|
||||
* Stellen Sie sicher, dass Sie **Node.js 24+** verwenden (`node -v` zur Überprüfung).
|
||||
* Stellen Sie sicher, dass **Corepack aktiviert ist** (`corepack enable`), damit Yarn 4 verfügbar ist.
|
||||
* Versuchen Sie, `node_modules` zu löschen und `yarn install` erneut auszuführen, wenn Abhängigkeiten fehlerhaft erscheinen.
|
||||
|
||||
Hängen Sie immer noch fest? Bitten Sie im [Twenty-Discord](https://discord.com/channels/1130383047699738754/1130386664812982322) um Hilfe.
|
||||
Hängen Sie fest? Bitten Sie im [Twenty-Discord](https://discord.com/channels/1130383047699738754/1130386664812982322) um Hilfe.
|
||||
|
||||
@@ -94,15 +94,16 @@ const handler = async (event: RoutePayload) => {
|
||||
|
||||
Der Typ `RoutePayload` hat die folgende Struktur:
|
||||
|
||||
| Eigenschaft | Typ | Beschreibung | Beispiel |
|
||||
| ---------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `headers` | `Record\<string, string \| undefined>` | HTTP-Header (nur die in `forwardedRequestHeaders` aufgelisteten) | siehe Abschnitt unten |
|
||||
| `queryStringParameters` | `Record\<string, string \| undefined>` | Query-String-Parameter (mehrere Werte mit Kommas verbunden) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
|
||||
| `pathParameters` | `Record\<string, string \| undefined>` | Aus dem Routenmuster extrahierte Pfadparameter | `/users/:id`, `/users/123` -> `{ id: '123' }` |
|
||||
| `body` | `object \| null` | Geparster Request-Body (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
|
||||
| `isBase64Encoded` | `boolean` | Gibt an, ob der Body Base64-codiert ist | |
|
||||
| `requestContext.http.method` | `Zeichenkette` | HTTP-Methode (GET, POST, PUT, PATCH, DELETE) | |
|
||||
| `requestContext.http.path` | `Zeichenkette` | Rohpfad der Anfrage | |
|
||||
| Eigenschaft | Typ | Beschreibung | Beispiel |
|
||||
| ---------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `headers` | `Record\<string, string \| undefined>` | HTTP-Header (nur die in `forwardedRequestHeaders` aufgelisteten) | siehe Abschnitt unten |
|
||||
| `queryStringParameters` | `Record\<string, string \| undefined>` | Query-String-Parameter (mehrere Werte mit Kommas verbunden) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
|
||||
| `pathParameters` | `Record\<string, string \| undefined>` | Aus dem Routenmuster extrahierte Pfadparameter | `/users/:id`, `/users/123` -> `{ id: '123' }` |
|
||||
| `body` | `object \| null` | Geparster Request-Body (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
|
||||
| `rawBody` | `string \| undefined` | Ursprünglicher UTF-8-Request-Body vor dem JSON-Parsing. Nützlich zur Verifizierung von Webhook-Signaturen im HMAC-Stil (z. B. GitHubs `X-Hub-Signature-256`, Stripe). `undefined`, wenn die Laufzeitumgebung es nicht beibehalten hat. | |
|
||||
| `isBase64Encoded` | `boolean` | Gibt an, ob der Body Base64-codiert ist | |
|
||||
| `requestContext.http.method` | `Zeichenkette` | HTTP-Methode (GET, POST, PUT, PATCH, DELETE) | |
|
||||
| `requestContext.http.path` | `Zeichenkette` | Rohpfad der Anfrage | |
|
||||
|
||||
|
||||
#### forwardedRequestHeaders
|
||||
@@ -140,11 +141,14 @@ 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>
|
||||
|
||||
#### Eine Funktion als Tool bereitstellen
|
||||
#### Eine Funktion als KI-Tool oder Workflow-Aktion verfügbar machen
|
||||
|
||||
Logikfunktionen können als **Tools** für KI-Agenten und Workflows verfügbar gemacht werden. Wenn eine Funktion als Tool markiert ist, wird sie von den KI-Funktionen von Twenty auffindbar und kann in Workflow-Automatisierungen verwendet werden.
|
||||
Logikfunktionen können auf zwei Oberflächen verfügbar gemacht werden, jeweils mit eigenem Trigger:
|
||||
|
||||
Um eine Logikfunktion als Tool zu markieren, setzen Sie `isTool: true`:
|
||||
* **`toolTriggerSettings`** — macht die Funktion über die KI-Funktionen von Twenty (Chat, MCP, Funktionsaufrufe) auffindbar. Verwendet das standardmäßige JSON Schema, das Format, das LLMs nativ verstehen.
|
||||
* **`workflowActionTriggerSettings`** — lässt die Funktion als Schritt im visuellen Workflow-Builder erscheinen. Verwendet das umfangreiche `InputSchema` von Twenty, sodass der Builder geeignete Feldeditoren, Variablenauswahlen und Beschriftungen rendern kann.
|
||||
|
||||
Eine Funktion kann sich für eine, die andere oder beide entscheiden. Sie stehen neben `cronTriggerSettings`, `databaseEventTriggerSettings` und `httpRouteTriggerSettings` — gleiches Muster, gleiche Struktur.
|
||||
|
||||
```ts src/logic-functions/enrich-company.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
@@ -174,31 +178,33 @@ export default defineLogicFunction({
|
||||
description: 'Enrich a company record with external data',
|
||||
timeoutSeconds: 10,
|
||||
handler,
|
||||
isTool: true,
|
||||
toolTriggerSettings: {},
|
||||
});
|
||||
```
|
||||
|
||||
Hauptpunkte:
|
||||
|
||||
* Sie können `isTool` mit Triggern kombinieren — eine Funktion kann gleichzeitig sowohl ein Tool (von KI-Agenten aufrufbar) als auch durch Ereignisse ausgelöst werden.
|
||||
* **`toolInputSchema`** (optional): Ein JSON-Schema-Objekt, das die Parameter beschreibt, die Ihre Funktion akzeptiert. Das Schema wird automatisch durch statische Analyse des Quellcodes ermittelt, Sie können es jedoch auch explizit festlegen:
|
||||
* Eine Funktion kann Oberflächen mischen — deklarieren Sie sowohl `toolTriggerSettings` als auch `workflowActionTriggerSettings`, um sie im Chat UND im Workflow-Builder bereitzustellen.
|
||||
* `toolTriggerSettings.inputSchema` und `workflowActionTriggerSettings.inputSchema` sind beide optional. Wenn sie weggelassen werden, leitet der Manifest-Builder sie aus dem Handler-Quellcode ab (JSON Schema für das KI-Tool, das `InputSchema` von Twenty für die Workflow-Aktion). Geben Sie eines explizit an, wenn Sie eine reichere Typisierung wünschen — zum Beispiel mit `FieldMetadataType`-fähigen Feldern wie `CURRENCY` oder `RELATION` für den Workflow-Builder oder mit `description`-Feldern, die der KI-Agent lesen kann:
|
||||
|
||||
```ts
|
||||
export default defineLogicFunction({
|
||||
...,
|
||||
toolInputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
companyName: {
|
||||
type: 'string',
|
||||
description: 'The name of the company to enrich',
|
||||
},
|
||||
domain: {
|
||||
type: 'string',
|
||||
description: 'The company website domain (optional)',
|
||||
toolTriggerSettings: {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
companyName: {
|
||||
type: 'string',
|
||||
description: 'The name of the company to enrich',
|
||||
},
|
||||
domain: {
|
||||
type: 'string',
|
||||
description: 'The company website domain (optional)',
|
||||
},
|
||||
},
|
||||
required: ['companyName'],
|
||||
},
|
||||
required: ['companyName'],
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -237,7 +243,7 @@ yarn twenty exec --postInstall
|
||||
```
|
||||
|
||||
Hauptpunkte:
|
||||
* Post-Installationsfunktionen verwenden `definePostInstallLogicFunction()` — eine spezialisierte Variante, die Trigger-Einstellungen (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `isTool`) weglässt.
|
||||
* Post-Installationsfunktionen verwenden `definePostInstallLogicFunction()` — eine spezialisierte Variante, die Trigger-Einstellungen (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `toolTriggerSettings`, `workflowActionTriggerSettings`) weglässt.
|
||||
* Der Handler erhält ein `InstallPayload` mit `{ previousVersion?: string; newVersion: string }` — `newVersion` ist die zu installierende Version, und `previousVersion` ist die zuvor installierte Version (oder `undefined` bei einer Neuinstallation). Verwenden Sie diese Werte, um Neuinstallationen von Upgrades zu unterscheiden und versionsspezifische Migrationslogik auszuführen.
|
||||
* **Wann der Hook ausgeführt wird**: standardmäßig nur bei Neuinstallationen. Übergeben Sie `shouldRunOnVersionUpgrade: true`, wenn er auch beim Upgrade der App von einer vorherigen Version ausgeführt werden soll. Wenn weggelassen, ist das Flag standardmäßig `false` und Upgrades überspringen den Hook.
|
||||
* **Ausführungsmodell — standardmäßig asynchron, synchron optional**: Das Flag `shouldRunSynchronously` steuert, *wie* Post-Install ausgeführt wird.
|
||||
|
||||
@@ -77,6 +77,39 @@ Pre-Release-Tags funktionieren wie erwartet: Das Erhöhen von `1.0.0-rc.1` → `
|
||||
|
||||
{/* TODO: add screenshot of the Upgrade button */}
|
||||
|
||||
### Kompatibilität der Serverversionen
|
||||
|
||||
Wenn Ihre App eine Funktion verwendet, die in einer bestimmten Twenty-Serverversion eingeführt wurde (z. B. OAuth-Anbieter, die in v2.3.0 hinzugefügt wurden), sollten Sie die minimale Serverversion, die Ihre App benötigt, mithilfe des Felds `engines.twenty` in `package.json` angeben:
|
||||
|
||||
```json filename="package.json"
|
||||
{
|
||||
"name": "twenty-my-app",
|
||||
"version": "1.0.0",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
"twenty": ">=2.3.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Der Wert ist ein standardmäßiger [semver-Bereich](https://github.com/npm/node-semver#ranges). Häufige Muster:
|
||||
|
||||
| Bereich | Bedeutung |
|
||||
| ---------------------------------- | ------------------------------------------------------ |
|
||||
| `>=2.3.0` | Jeder Server ab 2.3.0 |
|
||||
| `>=2.3.0 \<3.0.0` | 2.3.0 oder höher, aber unter der nächsten Hauptversion |
|
||||
| `^2.3.0` | Entspricht `>=2.3.0 \<3.0.0` |
|
||||
|
||||
**Was bei Bereitstellung und Installation passiert:**
|
||||
|
||||
* Wenn `engines.twenty` gesetzt ist und die Version des Zielservers den Bereich nicht erfüllt, wird die Bereitstellung (Tarball-Upload) oder Installation mit dem Fehler `SERVER_VERSION_INCOMPATIBLE` abgelehnt, zusammen mit einer Meldung, die sowohl den erforderlichen Bereich als auch die tatsächliche Serverversion angibt.
|
||||
* Wenn `engines.twenty` nicht gesetzt ist, wird die App auf jeder Serverversion akzeptiert (abwärtskompatibel mit bestehenden Apps).
|
||||
* Wenn auf dem Server keine `APP_VERSION` konfiguriert ist, wird die Prüfung übersprungen.
|
||||
|
||||
<Note>
|
||||
Der Server ist die maßgebliche Prüfinstanz — er validiert `engines.twenty` sowohl beim Tarball-Upload als auch bei der Workspace-Installation. Auch wenn Sie ein Tarball außerhalb des regulären Prozesses bereitstellen oder aus dem Marketplace installieren, erzwingt der Server weiterhin die Kompatibilität.
|
||||
</Note>
|
||||
|
||||
## Automatisiertes CI/CD (vorgefertigte Workflows)
|
||||
|
||||
Apps, die mit `create-twenty-app` erzeugt wurden, enthalten von Haus aus zwei GitHub-Actions-Workflows unter `.github/workflows/`. Sie sind einsatzbereit, sobald Sie das Repository zu GitHub pushen — für CI ist keine zusätzliche Einrichtung erforderlich, und für CD ist nur ein einziges Secret nötig.
|
||||
@@ -184,9 +217,9 @@ Der Twenty-Server synchronisiert seinen Marktplatzkatalog **stündlich** aus der
|
||||
Sie können die Synchronisierung sofort auslösen, anstatt zu warten:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty catalog-sync
|
||||
yarn twenty server catalog-sync
|
||||
# To target a specific remote:
|
||||
# yarn twenty catalog-sync --remote production
|
||||
# yarn twenty server catalog-sync --remote production
|
||||
```
|
||||
|
||||
Die im Marktplatz angezeigten Metadaten stammen aus Ihrer `defineApplication()`-Konfiguration — Felder wie `displayName`, `description`, `author`, `category`, `logoUrl`, `screenshots`, `aboutDescription`, `websiteUrl` und `termsUrl`.
|
||||
|
||||
@@ -19,7 +19,7 @@ Für Teams, die bereit sind zu skalieren:
|
||||
* Standard-Support
|
||||
|
||||
<Note>
|
||||
Premiumfunktionen (SSO und Berechtigungen auf Zeilenebene) sind im Pro-Plan nicht enthalten.
|
||||
Premiumfunktionen (SSO, Berechtigungen auf Zeilenebene und KI-Nutzungsdaten) sind im Pro-Tarif nicht enthalten.
|
||||
</Note>
|
||||
|
||||
### Organisation (Cloud)
|
||||
@@ -27,7 +27,7 @@ Premiumfunktionen (SSO und Berechtigungen auf Zeilenebene) sind im Pro-Plan nich
|
||||
Für größere Teams mit erweiterten Anforderungen:
|
||||
|
||||
* Alles aus Pro
|
||||
* **Premiumfunktionen**: SSO-Integration und Berechtigungen auf Zeilenebene
|
||||
* **Premiumfunktionen**: SSO-Integration, Berechtigungen auf Zeilenebene und KI-Nutzungsdaten
|
||||
* Vorrangiger Support
|
||||
|
||||
## Selbstgehostete Pläne
|
||||
@@ -45,7 +45,7 @@ Hosten Sie Twenty auf Ihrer eigenen Infrastruktur kostenlos:
|
||||
Für Teams, die beim Selbsthosting Premiumfunktionen benötigen:
|
||||
|
||||
* Alle Pro-Funktionen
|
||||
* **Premiumfunktionen**: SSO-Integration und Berechtigungen auf Zeilenebene
|
||||
* **Premiumfunktionen**: SSO-Integration, Berechtigungen auf Zeilenebene und KI-Nutzungsdaten
|
||||
* Support durch das Twenty-Team
|
||||
* Keine Verpflichtung, benutzerdefinierten Code vor der Weitergabe als Open Source zu veröffentlichen
|
||||
|
||||
@@ -55,6 +55,7 @@ Premiumfunktionen sind nur in den Organisation-Plänen (Cloud oder Selbstgehoste
|
||||
|
||||
* **SSO-Integration**: Single Sign-On mit Ihrem Identitätsanbieter
|
||||
* **Berechtigungen auf Zeilenebene**: Feingranulare Zugriffskontrolle auf Datensatzebene
|
||||
* **KI-Nutzungsdaten**: Verfolgen Sie den KI-Verbrauch in Ihrem Arbeitsbereich
|
||||
|
||||
## Pläne wechseln
|
||||
|
||||
@@ -77,3 +78,15 @@ Wenden Sie sich an den Support, um Ihren Plan herabzustufen.
|
||||
### Zur monatlichen Abrechnung wechseln
|
||||
|
||||
Wenden Sie sich an den Support, um wieder zur monatlichen Abrechnung zu wechseln.
|
||||
|
||||
## Enterprise-Schlüssel für Organization (Self-Hosted) abrufen
|
||||
|
||||
Um den Tarif Organization (Self-Hosted) zu verwenden, müssen Sie einen Enterprise-Schlüssel abrufen:
|
||||
|
||||
1. Gehen Sie zu **Einstellungen → Admin-Panel → Enterprise**
|
||||
|
||||
<img src="/images/user-guide/billing/enterprise-key.png" alt="Enterprise-Schlüssel" />
|
||||
|
||||
2. Klicken Sie auf **Enterprise-Schlüssel abrufen**
|
||||
3. Wenn Sie zu Stripe weitergeleitet werden, geben Sie Ihre Zahlungsdaten ein und bestätigen Sie
|
||||
4. Wenn Ihr Enterprise-Schlüssel angezeigt wird, fügen Sie ihn auf der Seite Enterprise-Einstellungen ein und aktivieren Sie die Organization-Lizenz
|
||||
|
||||
@@ -16,6 +16,11 @@ Wenn Sie selbst hosten möchten und die Premium-Funktionen (SSO und Berechtigung
|
||||
Premium-Funktionen sind nur in den Organization-Tarifen (Cloud oder Self-Hosted) verfügbar:
|
||||
* **SSO-Integration**: Single Sign-On mit Ihrem Identitätsanbieter
|
||||
* **Berechtigungen auf Zeilenebene**: feingranulare Zugriffskontrolle auf Datensatzebene
|
||||
* **KI-Nutzungsdaten**: Verfolgen Sie den KI-Verbrauch in Ihrem Arbeitsbereich
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Sind der Organisationsplan in der Cloud und der Organisationsplan bei selbst gehosteter Bereitstellung gleich?">
|
||||
Sie bieten dieselben Premium-Funktionen. Ein Cloud-Abonnement kann jedoch nicht für selbst gehostete Bereitstellungen verwendet werden. Für selbst gehostete Bereitstellungen ist ein Enterprise-Schlüssel erforderlich.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Bieten Sie kostenlose Plätze für Nur-Ansicht-Benutzer an?">
|
||||
|
||||
@@ -53,38 +53,45 @@ Erstellen Sie zunächst das Zwischenobjekt, das die Verknüpfungen speichert.
|
||||
1. Gehen Sie zu **Einstellungen → Datenmodell**
|
||||
2. Klicken Sie auf **+ Neues Objekt**
|
||||
3. Benennen Sie es aussagekräftig (z. B. "Projektzuweisung", "Teammitglied", "Produktbestellung")
|
||||
4. Klicken Sie auf **Speichern**
|
||||
4. Schalten Sie "Erstellen eines Namensfelds überspringen" ein
|
||||
|
||||
<img src="/images/user-guide/fields/new-pivot-object.png" alt="Neues Verknüpfungsobjekt" />
|
||||
|
||||
5. Klicken Sie auf **Speichern**
|
||||
|
||||
<Tip>
|
||||
**Namenskonvention**: Verwenden Sie einen Namen, der die Beziehung beschreibt, z. B. "Projektzuweisung" oder "Teammitgliedschaft". Das macht das Datenmodell leichter verständlich.
|
||||
</Tip>
|
||||
|
||||
## Schritt 2: Beziehungen vom Verknüpfungsobjekt erstellen
|
||||
## Schritt 2: Beziehungen zwischen Objekten und dem Verknüpfungsobjekt erstellen
|
||||
|
||||
Fügen Sie vom Verknüpfungsobjekt aus Beziehungsfelder zu beiden Objekten hinzu, die Sie verbinden möchten.
|
||||
Fügen Sie für jedes Ihrer beiden Objekte Beziehungsfelder zum Verknüpfungsobjekt hinzu.
|
||||
|
||||
### Erste Beziehung (Verknüpfung → Objekt A)
|
||||
### Erste Beziehung (Objekt A → Verknüpfung)
|
||||
|
||||
1. Wählen Sie Ihr Verknüpfungsobjekt unter **Einstellungen → Datenmodell** aus
|
||||
2. Klicken Sie auf **+ Feld hinzufügen**
|
||||
3. Wählen Sie **Relation** als Feldtyp
|
||||
4. Wählen Sie das erste Objekt aus (z. B. "Personen")
|
||||
5. Legen Sie den Beziehungstyp auf **Viele-zu-Eins** fest (viele Zuweisungen können mit einer Person verknüpft werden)
|
||||
6. Benennen Sie die Felder:
|
||||
* Feld auf der Verknüpfung: z. B. "Person"
|
||||
* Feld bei Personen: z. B. "Projektzuweisungen"
|
||||
7. Klicken Sie auf **Speichern**
|
||||
|
||||
### Zweite Beziehung (Verknüpfung → Objekt B)
|
||||
|
||||
1. Bleiben Sie im Verknüpfungsobjekt und klicken Sie auf **+ Feld hinzufügen**
|
||||
2. Wählen Sie **Relation** als Feldtyp
|
||||
3. Wählen Sie das zweite Objekt aus (z. B. "Projekte")
|
||||
4. Legen Sie den Beziehungstyp auf **Viele-zu-Eins** fest
|
||||
1. Wählen Sie Ihr erstes Objekt unter **Einstellungen → Datenmodell** aus
|
||||
2. Klicken Sie auf **+ Beziehung hinzufügen**
|
||||
3. Wählen Sie das Verknüpfungsobjekt aus (z. B. "Projektzuweisungen")
|
||||
4. Legen Sie den Beziehungstyp auf **Eins-zu-Viele** fest (eine Person kann mit vielen Zuweisungen verknüpft werden)
|
||||
5. Benennen Sie die Felder:
|
||||
* Feld bei Personen: z. B. "Projektzuweisungen"
|
||||
* Feld auf der Verknüpfung: z. B. "Person"
|
||||
6. Klicken Sie auf **Speichern**
|
||||
|
||||
### Zweite Beziehung (Objekt B → Verknüpfung)
|
||||
|
||||
1. Wählen Sie Ihr zweites Objekt unter **Einstellungen → Datenmodell** aus
|
||||
2. Klicken Sie auf **+ Beziehung hinzufügen**
|
||||
3. Wählen Sie das Verknüpfungsobjekt aus (z. B. "Projektzuweisungen")
|
||||
4. Legen Sie den Beziehungstyp auf **Eins-zu-Viele** fest (ein Projekt kann mit vielen Zuweisungen verknüpft werden)
|
||||
5. Aktivieren Sie **"Dies ist eine Beziehung zu einem Verknüpfungsobjekt"**
|
||||
|
||||
<img src="/images/user-guide/fields/junction-relation-toggle.png" style={{width:'100%'}} />
|
||||
|
||||
6. Benennen Sie die Felder:
|
||||
* Feld auf der Verknüpfung: z. B. "Projekt"
|
||||
* Feld bei Projekten: z. B. "Teammitglieder"
|
||||
6. Klicken Sie auf **Speichern**
|
||||
7. Klicken Sie auf **Speichern**
|
||||
|
||||
## Schritt 3: Anzeige der Verknüpfungsbeziehung konfigurieren
|
||||
|
||||
@@ -98,18 +105,6 @@ Konfigurieren Sie nun die Quellobjekte so, dass verknüpfte Datensätze direkt a
|
||||
6. Wählen Sie die **Zielbeziehung** aus (z. B. "Projekt" — das Feld an der Verknüpfung, das auf die andere Seite zeigt)
|
||||
7. Klicken Sie auf **Speichern**
|
||||
|
||||
{/* TODO: Add image
|
||||
<img src="/images/user-guide/fields/junction-relation-toggle.png" style={{width:'100%'}}/>
|
||||
*/}
|
||||
|
||||
Wiederholen Sie dies für das andere Objekt:
|
||||
|
||||
1. Wählen Sie "Projekte" im Datenmodell aus
|
||||
2. Bearbeiten Sie das Beziehungsfeld "Teammitglieder"
|
||||
3. Aktivieren Sie den Verknüpfungsschalter
|
||||
4. Wählen Sie "Person" als Zielbeziehung aus
|
||||
5. Speichern
|
||||
|
||||
## Ergebnis
|
||||
|
||||
Nach der Konfiguration:
|
||||
@@ -130,15 +125,15 @@ Hier ist eine vollständige Schritt-für-Schritt-Anleitung:
|
||||
|
||||
### Beziehungen hinzufügen
|
||||
|
||||
1. **Projektzuweisung → Personen**
|
||||
* Typ: Viele-zu-Eins
|
||||
* Feld bei Zuweisung: "Person"
|
||||
1. **Personen → Projektzuweisung**
|
||||
* Typ: Eins-zu-Viele
|
||||
* Feld bei Personen: "Projektzuweisungen"
|
||||
* Feld bei Zuweisung: "Person"
|
||||
|
||||
2. **Projektzuweisung → Projekte**
|
||||
* Typ: Viele-zu-Eins
|
||||
* Feld bei Zuweisung: "Projekt"
|
||||
2. **Projekte → Projektzuweisung**
|
||||
* Typ: Eins-zu-Viele
|
||||
* Feld bei Projekten: "Teammitglieder"
|
||||
* Feld bei Zuweisung: "Projekt"
|
||||
|
||||
### Verknüpfungsanzeige konfigurieren
|
||||
|
||||
|
||||
@@ -30,3 +30,9 @@ Fügen Sie Links zu externen Tools direkt in der Seitenleiste hinzu. Nützlich,
|
||||
## Befehlsmenü
|
||||
|
||||
Drücken Sie `Cmd+K` (oder `Ctrl+K`), um das Befehlsmenü zu öffnen — eine Suchleiste für den Schnellzugriff, mit der Sie zu jedem Datensatz, jeder Ansicht oder Aktion springen können, ohne durch die Seitenleiste zu navigieren.
|
||||
|
||||
## Anpassung der Seitenleiste
|
||||
|
||||
Um die Seitenleiste anzupassen, bewegen Sie den Mauszeiger über den Abschnitt "Arbeitsbereich" in der Seitenleiste und klicken Sie auf das Schraubenschlüssel-Symbol.
|
||||
|
||||
<img src="/images/user-guide/layout/navigation-edit-icon.png" alt="Symbol zum Bearbeiten der Navigation" />
|
||||
|
||||