Compare commits

..
Author SHA1 Message Date
neo773andCharles Bochet 0b13c39e02 Fix participant matching failing due to updating all columns instead of only changed fields (#18105)
The updateMany in matchParticipants was spreading the entire participant
object into the SET clause, which included generated columns
(searchVector) and composite field columns (createdBySource) that can't
be written to. Narrowed the update to only set personId and
workspaceMemberId.
```
[1] query failed: UPDATE "workspace_3ixj3i1a5avy16ptijtb3lae3"."calendarEventParticipant" SET "id" = $1, "createdAt" = $2, "updatedAt" = $3, "deletedAt" = $4, "createdBySource" = $5, "createdByWorkspaceMemberId" = $6, "createdByName" = $7, "createdByContext" = $8, "updatedBySource" = $9, "updatedByWorkspaceMemberId" = $10, "updatedByName" = $11, "updatedByContext" = $12, "position" = $13, "searchVector" = $14, "handle" = $15, "displayName" = $16, "isOrganizer" = $17, "responseStatus" = $18, "calendarEventId" = $19, "personId" = $20, "workspaceMemberId" = $21 WHERE "id" = $22 RETURNING * -- PARAMETERS: ["f1526103-451a-429f-9743-3a81c3a3e3aa","2026-02-19T22:23:32.354Z","2026-02-19T22:23:32.354Z",null,"MANUAL",null,"System",null,"MANUAL",null,"System",null,0,"'test@test.com':1","test@test.com","",false,"NEEDS_ACTION","fb1892a8-6daf-435b-a43b-e8fe8693eb99","60bf9e82-f1b7-4bed-a1af-7039fb9c32f5",null,"f1526103-451a-429f-9743-3a81c3a3e3aa"]
[1] error: error: column "searchVector" can only be updated to DEFAULT
[1] Exception Captured
[1]   undefined
[1]   [
[1]     PostgresException [Error]: Data validation error.
[1]         at computeTwentyORMException (/Users/huzef/Documents/bounties/twenty/packages/twenty-server/dist/engine/twenty-orm/error-handling/compute-twenty-orm-exception.js:35:19)
[1]         at WorkspaceUpdateQueryBuilder.executeMany (/Users/huzef/Documents/bounties/twenty/packages/twenty-server/dist/engine/twenty-orm/repository/workspace-update-query-builder.js:269:82)
[1]         at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
[1]         at async WorkspaceRepository.updateMany (/Users/huzef/Documents/bounties/twenty/packages/twenty-server/dist/engine/twenty-orm/repository/workspace.repository.js:234:25)
[1]         at async MatchParticipantService.matchParticipants (/Users/huzef/Documents/bounties/twenty/packages/twenty-server/dist/modules/match-participant/match-participant.service.js:93:13)
[1]         at async /Users/huzef/Documents/bounties/twenty/packages/twenty-server/dist/modules/match-participant/match-participant.service.js:160:13
[1]         at async MatchParticipantService.matchParticipantsForPeople (/Users/huzef/Documents/bounties/twenty/packages/twenty-server/dist/modules/match-participant/match-participant.service.js:130:9)
[1]         at async CalendarEventParticipantMatchParticipantJob.handle (/Users/huzef/Documents/bounties/twenty/packages/twenty-server/dist/modules/calendar/calendar-event-participant-manager/jobs/calendar-event-participant-match-participant.job.js:45:13)
[1]         at async MessageQueueExplorer.invokeProcessMethods (/Users/huzef/Documents/bounties/twenty/packages/twenty-server/dist/engine/core-modules/message-queue/message-queue.explorer.js:113:17)
[1]         at async MessageQueueExplorer.handleProcessor (/Users/huzef/Documents/bounties/twenty/packages/twenty-server/dist/engine/core-modules/message-queue/message-queue.explorer.js:104:13) {
[1]       code: '428C9'
[1]     }
[1]   ]
```
2026-02-20 10:27:36 +01:00
Etienne 38a49650a3 FILES field - Attachment name display fix (#18073)
with new 'file' FILES field on attachment, UI should display attachment
name from file field value.
Issue with API users updating only 'file' FILES field (and not name
field anymore)
2026-02-19 16:07:18 +01:00
Etienne 35c4ec0d44 Remove non positive integer constraint on Nav Menu Item 2/2 (#18090)
Follow up https://github.com/twentyhq/twenty/pull/18081
2026-02-19 15:20:29 +01:00
WeikoandEtienne 791462bab0 Fix google compose scope not gated by feature flag (#18093)
## Context
New google api scope has been introduced in
https://github.com/twentyhq/twenty/pull/17793 without being gated behind
a feature flag

Microsoft is using pre-existing Mail.ReadWrite scope there is nothing to
gate

## Before
<img width="553" height="445" alt="Screenshot 2026-02-19 at 14 57 58"
src="https://github.com/user-attachments/assets/59a4f76b-d38d-492f-b013-b6cad4091a7f"
/>


## After
<img width="535" height="392" alt="Screenshot 2026-02-19 at 14 58 44"
src="https://github.com/user-attachments/assets/0337bf15-ec30-4549-bb9d-571a982dffd8"
/>
2026-02-19 15:20:19 +01:00
Etienne 78aad6733f Remove non positive integer constraint on Nav Menu Item (#18081)
To fit with position typed field logic + Ease favorite to nav menu item
migration (which have negative and float position)
2026-02-19 14:40:00 +01:00
5149 changed files with 104852 additions and 215154 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ This directory contains Twenty's development guidelines and best practices in th
### React Development
- **react-general-guidelines.mdc** - Core React development principles (Auto-attached to React files)
- **react-state-management.mdc** - State management approaches with Jotai (Auto-attached to state files)
- **react-state-management.mdc** - State management approaches with Recoil (Auto-attached to state files)
### Testing & Quality
- **testing-guidelines.mdc** - Testing strategies and best practices (Auto-attached to test files)
+1 -1
View File
@@ -7,7 +7,7 @@ alwaysApply: true
# Twenty Architecture
## Tech Stack
- **Frontend**: React 18, TypeScript, Jotai, Styled Components, Vite
- **Frontend**: React 18, TypeScript, Recoil, Styled Components, Vite
- **Backend**: NestJS, TypeORM, PostgreSQL, Redis, GraphQL
- **Monorepo**: Nx workspace with yarn
+12 -33
View File
@@ -4,20 +4,16 @@ alwaysApply: false
---
# React State Management
## Jotai Patterns
## Recoil Patterns
```typescript
// ✅ Atoms for primitive state (use createAtomState for keyed state with optional persistence)
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const currentUserState = createAtomState<User | null>({
// ✅ Atoms for primitive state
export const currentUserState = atom<User | null>({
key: 'currentUserState',
defaultValue: null,
default: null,
});
// ✅ Derived atoms for computed state (use createAtomSelector)
import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector';
export const userDisplayNameSelector = createAtomSelector({
// ✅ Selectors for derived state
export const userDisplayNameSelector = selector({
key: 'userDisplayNameSelector',
get: ({ get }) => {
const user = get(currentUserState);
@@ -25,30 +21,13 @@ export const userDisplayNameSelector = createAtomSelector({
},
});
// ✅ Atom factory pattern for dynamic atoms (use createAtomFamilyState)
import { createAtomFamilyState } from '@/ui/utilities/state/jotai/utils/createAtomFamilyState';
export const userByIdState = createAtomFamilyState<User | null, string>({
// ✅ Atom families for dynamic atoms
export const userByIdState = atomFamily<User | null, string>({
key: 'userByIdState',
defaultValue: null,
default: null,
});
```
## Jotai Hooks
```typescript
// useAtomState - read and write
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
// useAtomStateValue - read only
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
// useSetAtomState - write only
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
```
## Provider
Jotai works without a Provider by default. For scoped stores or testing, use `Provider` from `jotai`.
## Local State Guidelines
```typescript
// ✅ Multiple useState for unrelated state
@@ -95,7 +74,7 @@ const increment = useCallback(() => {
```
## Performance Tips
- Use atom factory pattern (createAtomFamilyState) for dynamic data collections
- Derived atoms (createAtomSelector) are automatically memoized by Jotai
- Avoid heavy computations in derived atoms
- Use atom families for dynamic data collections
- Implement proper selector caching
- Avoid heavy computations in selectors
- Batch state updates when possible
+1 -7
View File
@@ -19,10 +19,4 @@ runs:
uses: nrwl/nx-set-shas@v4
- name: Run affected command
shell: bash
env:
NX_CONFIGURATION: ${{ inputs.configuration }}
NX_TASKS: ${{ inputs.tasks }}
NX_PARALLEL: ${{ inputs.parallel }}
NX_TAG: ${{ inputs.tag }}
NX_ARGS: ${{ inputs.args }}
run: npx nx affected --nxBail --configuration="$NX_CONFIGURATION" -t="$NX_TASKS" --parallel="$NX_PARALLEL" --exclude="*,!tag:$NX_TAG" $NX_ARGS
run: npx nx affected --nxBail --configuration=${{ inputs.configuration }} -t=${{ inputs.tasks }} --parallel=${{ inputs.parallel }} --exclude='*,!tag:${{ inputs.tag }}' ${{ inputs.args }}
+1 -4
View File
@@ -19,11 +19,8 @@ runs:
- name: Cache primary key builder
id: cache-primary-key-builder
shell: bash
env:
CACHE_KEY: ${{ inputs.key }}
REF_NAME: ${{ github.ref_name }}
run: |
echo "CACHE_PRIMARY_KEY_PREFIX=v4-${CACHE_KEY}-${REF_NAME}" >> "${GITHUB_OUTPUT}"
echo "CACHE_PRIMARY_KEY_PREFIX=v4-${{ inputs.key }}-${{ github.ref_name }}" >> "${GITHUB_OUTPUT}"
- name: Restore cache
uses: actions/cache/restore@v4
id: restore-cache
@@ -1,80 +0,0 @@
name: Spawn Twenty Docker Image
description: >
Starts a full Twenty instance (server, worker, database, redis) using Docker
Compose. The server is available at http://localhost:3000 for subsequent steps
in the caller's job.
Pulls the specified semver image tag from Docker Hub.
Designed to be consumed from external repositories (e.g., twenty-app).
inputs:
twenty-version:
description: 'Twenty Docker Hub image tag as semver (e.g., v0.40.0, v1.0.0).'
required: true
twenty-repository:
description: 'Twenty repository to checkout docker compose files from.'
required: false
default: 'twentyhq/twenty'
github-token:
description: 'GitHub token for cross-repo checkout. Required when calling from an external repository.'
required: false
default: ${{ github.token }}
outputs:
server-url:
description: 'URL where the Twenty server can be reached'
value: http://localhost:3000
access-token:
description: 'Admin access token for the Twenty instance'
value: ${{ steps.admin-token.outputs.access-token }}
runs:
using: 'composite'
steps:
- name: Validate version
shell: bash
run: |
VERSION="${{ inputs.twenty-version }}"
if ! echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::twenty-version must be a semver tag (e.g., v0.40.0). Got: '$VERSION'"
exit 1
fi
- name: Checkout docker compose files
uses: actions/checkout@v4
with:
repository: ${{ inputs.twenty-repository }}
ref: ${{ inputs.twenty-version }}
token: ${{ inputs.github-token }}
sparse-checkout: |
packages/twenty-docker
sparse-checkout-cone-mode: false
path: .twenty-spawn
- name: Prepare environment
shell: bash
working-directory: ./.twenty-spawn/packages/twenty-docker
run: |
cp .env.example .env
echo "" >> .env
echo "TAG=${{ inputs.twenty-version }}" >> .env
echo "APP_SECRET=replace_me_with_a_random_string" >> .env
echo "SERVER_URL=http://localhost:3000" >> .env
- name: Start Twenty instance
shell: bash
working-directory: ./.twenty-spawn/packages/twenty-docker
run: |
docker compose up -d --wait || {
echo "::error::Docker compose failed to start or health checks timed out"
docker compose logs
exit 1
}
echo "Twenty instance is ready at http://localhost:3000"
- name: Set admin access token
id: admin-token
shell: bash
run: |
ACCESS_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik"
echo "::add-mask::$ACCESS_TOKEN"
echo "access-token=$ACCESS_TOKEN" >> "$GITHUB_OUTPUT"
+1 -1
View File
@@ -11,7 +11,7 @@ on:
jobs:
deploy-main:
timeout-minutes: 3
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Repository Dispatch
uses: peter-evans/repository-dispatch@v2
+1 -1
View File
@@ -11,7 +11,7 @@ on:
jobs:
deploy-tag:
timeout-minutes: 3
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Repository Dispatch
uses: peter-evans/repository-dispatch@v2
+1 -1
View File
@@ -16,7 +16,7 @@ permissions:
jobs:
changed-files:
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
outputs:
any_changed: ${{ steps.changed-files.outputs.any_changed }}
steps:
+189 -9
View File
@@ -16,6 +16,8 @@ env:
permissions:
contents: read
pull-requests: write
checks: write
jobs:
changed-files-check:
@@ -32,7 +34,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 45
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
services:
postgres:
image: twentycrm/twenty-postgres-spilo
@@ -582,16 +584,182 @@ jobs:
echo "::warning::REST Metadata API analysis tool error - continuing workflow"
fi
- name: Upload breaking changes report
- name: Comment API Changes on PR
if: always()
uses: actions/upload-artifact@v4
uses: actions/github-script@v7
with:
name: breaking-changes-report
path: |
*-diff.md
*-diff.json
if-no-files-found: ignore
retention-days: 3
script: |
const fs = require('fs');
let hasChanges = false;
let comment = '';
try {
if (fs.existsSync('graphql-schema-diff.md')) {
const graphqlDiff = fs.readFileSync('graphql-schema-diff.md', 'utf8');
if (graphqlDiff.trim()) {
if (!hasChanges) {
comment = '## 📊 API Changes Report\n\n';
hasChanges = true;
}
comment += '### GraphQL Schema Changes\n' + graphqlDiff + '\n\n';
}
}
if (fs.existsSync('graphql-metadata-diff.md')) {
const graphqlMetadataDiff = fs.readFileSync('graphql-metadata-diff.md', 'utf8');
if (graphqlMetadataDiff.trim()) {
if (!hasChanges) {
comment = '## 📊 API Changes Report\n\n';
hasChanges = true;
}
comment += '### GraphQL Metadata Schema Changes\n' + graphqlMetadataDiff + '\n\n';
}
}
if (fs.existsSync('rest-api-diff.md')) {
const restDiff = fs.readFileSync('rest-api-diff.md', 'utf8');
if (restDiff.trim()) {
if (!hasChanges) {
comment = '## 📊 API Changes Report\n\n';
hasChanges = true;
}
comment += restDiff + '\n\n';
}
}
if (fs.existsSync('rest-metadata-api-diff.md')) {
const metadataDiff = fs.readFileSync('rest-metadata-api-diff.md', 'utf8');
if (metadataDiff.trim()) {
if (!hasChanges) {
comment = '## 📊 API Changes Report\n\n';
hasChanges = true;
}
comment += metadataDiff + '\n\n';
}
}
// Only post comment if there are changes
if (hasChanges) {
// Add branch state information only if there were conflicts
const branchState = process.env.BRANCH_STATE || 'unknown';
let branchStateNote = '';
if (branchState === 'conflicts') {
branchStateNote = '\n\n⚠️ **Note**: Could not merge with `main` due to conflicts. This comparison shows changes between the current branch and `main` as separate states.\n';
}
// Check if there are any breaking changes detected
let hasBreakingChanges = false;
let breakingChangeNote = '';
// Check for breaking changes in any of the diff files
if (fs.existsSync('rest-api-diff.md')) {
const restDiff = fs.readFileSync('rest-api-diff.md', 'utf8');
if (restDiff.includes('Breaking Changes') || restDiff.includes('🚨') ||
restDiff.includes('Removed Endpoints') || restDiff.includes('Changed Operations')) {
hasBreakingChanges = true;
}
}
if (fs.existsSync('rest-metadata-api-diff.md')) {
const metadataDiff = fs.readFileSync('rest-metadata-api-diff.md', 'utf8');
if (metadataDiff.includes('Breaking Changes') || metadataDiff.includes('🚨') ||
metadataDiff.includes('Removed Endpoints') || metadataDiff.includes('Changed Operations')) {
hasBreakingChanges = true;
}
}
// Also check GraphQL changes for breaking changes indicators
if (fs.existsSync('graphql-schema-diff.md')) {
const graphqlDiff = fs.readFileSync('graphql-schema-diff.md', 'utf8');
if (graphqlDiff.includes('Breaking changes') || graphqlDiff.includes('BREAKING')) {
hasBreakingChanges = true;
}
}
if (fs.existsSync('graphql-metadata-diff.md')) {
const graphqlMetadataDiff = fs.readFileSync('graphql-metadata-diff.md', 'utf8');
if (graphqlMetadataDiff.includes('Breaking changes') || graphqlMetadataDiff.includes('BREAKING')) {
hasBreakingChanges = true;
}
}
// Check PR title for "breaking"
const prTitle = ${{ toJSON(github.event.pull_request.title) }};
const titleContainsBreaking = prTitle.toLowerCase().includes('breaking');
if (hasBreakingChanges) {
if (titleContainsBreaking) {
breakingChangeNote = '\n\n## ✅ Breaking Change Protocol\n\n' +
'**This PR title contains "breaking" and breaking changes were detected - the CI will fail as expected.**\n\n' +
'📝 **Action Required**: Please add `BREAKING CHANGE:` to your commit message to trigger a major version bump.\n\n' +
'Example:\n```\nfeat: add new API endpoint\n\nBREAKING CHANGE: removed deprecated field from User schema\n```';
} else {
breakingChangeNote = '\n\n## ⚠️ Breaking Change Protocol\n\n' +
'**Breaking changes detected but PR title does not contain "breaking" - CI will pass but action needed.**\n\n' +
'🔄 **Options**:\n' +
'1. **If this IS a breaking change**: Add "breaking" to your PR title and add `BREAKING CHANGE:` to your commit message\n' +
'2. **If this is NOT a breaking change**: The API diff tool may have false positives - please review carefully\n\n' +
'For breaking changes, add to commit message:\n```\nfeat: add new API endpoint\n\nBREAKING CHANGE: removed deprecated field from User schema\n```';
}
}
const COMMENT_MARKER = '<!-- API_CHANGES_REPORT -->';
const commentBody = COMMENT_MARKER + '\n' + comment + branchStateNote + '\n⚠️ **Please review these API changes carefully before merging.**' + breakingChangeNote;
// Get all comments to find existing API changes comment
const {data: comments} = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
// Find our existing comment
const botComment = comments.find(comment => comment.body.includes(COMMENT_MARKER));
if (botComment) {
// Update existing comment
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: commentBody
});
console.log('Updated existing API changes comment');
} else {
// Create new comment
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: commentBody
});
console.log('Created new API changes comment');
}
} else {
console.log('No API changes detected - skipping PR comment');
// Check if there's an existing comment to remove
const {data: comments} = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const COMMENT_MARKER = '<!-- API_CHANGES_REPORT -->';
const botComment = comments.find(comment => comment.body.includes(COMMENT_MARKER));
if (botComment) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
});
console.log('Deleted existing API changes comment (no changes detected)');
}
}
} catch (error) {
console.log('Could not post comment:', error);
}
- name: Cleanup servers
if: always()
@@ -603,4 +771,16 @@ jobs:
kill $(cat /tmp/main-server.pid) || true
fi
- name: Upload API specifications and diffs
if: always()
uses: actions/upload-artifact@v4
with:
name: api-specifications-and-diffs
path: |
/tmp/main-server.log
/tmp/current-server.log
*-api.json
*-schema-introspection.json
*-diff.md
*-diff.json
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test]
@@ -50,7 +50,7 @@ jobs:
ci-create-app-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs: [changed-files-check, create-app-test]
steps:
- name: Fail job if any needs failed
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 10
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 10
runs-on: depot-ubuntu-24.04-8
runs-on: ubuntu-latest-8-cores
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
@@ -56,7 +56,7 @@ jobs:
ci-emails-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs: [changed-files-check, emails-test]
steps:
- name: Fail job if any needs failed
+80 -76
View File
@@ -14,8 +14,8 @@ concurrency:
env:
# restore-cache action adds 'v4-' prefix and '-<branch>-<sha>' suffix to the key
STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION: storybook-build-depot-ubuntu-24.04-8-runner
STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION: v4-storybook-build-depot-ubuntu-24.04-8-runner-${{ github.ref_name }}-${{ github.sha }}
STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION: storybook-build-ubuntu-latest-8-cores-runner
STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION: v4-storybook-build-ubuntu-latest-8-cores-runner-${{ github.ref_name }}-${{ github.sha }}
jobs:
changed-files-check:
@@ -42,7 +42,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
runs-on: ubuntu-latest-8-cores
env:
REACT_APP_SERVER_BASE_URL: http://localhost:3000
steps:
@@ -68,7 +68,7 @@ jobs:
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION }}
front-sb-test:
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
runs-on: ubuntu-latest-8-cores
needs: front-sb-build
strategy:
fail-fast: false
@@ -98,52 +98,52 @@ jobs:
run: npx nx reset:env twenty-front
- name: Run storybook tests
run: npx nx storybook:test twenty-front --configuration=${{ matrix.storybook_scope }} --shard=${{ matrix.shard }}/${{ env.SHARD_COUNTER }}
# - name: Rename coverage file
# run: |
# if [ -f "packages/twenty-front/coverage/storybook/coverage-final.json" ]; then
# mv packages/twenty-front/coverage/storybook/coverage-final.json packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
# else
# echo "Error: coverage-final.json not found"
# ls -la packages/twenty-front/coverage/storybook/ || echo "Coverage directory does not exist"
# exit 1
# fi
# - name: Upload coverage artifact
# uses: actions/upload-artifact@v4
# with:
# retention-days: 1
# name: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-${{ matrix.shard }}
# path: packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
# merge-reports-and-check-coverage:
# timeout-minutes: 30
# runs-on: depot-ubuntu-24.04
# needs: front-sb-test
# env:
# PATH_TO_COVERAGE: packages/twenty-front/coverage/storybook
# strategy:
# matrix:
# storybook_scope: [modules, pages, performance]
# steps:
# - uses: actions/checkout@v4
# with:
# fetch-depth: 0
# - name: Install dependencies
# uses: ./.github/actions/yarn-install
# - uses: actions/download-artifact@v4
# with:
# pattern: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-*
# merge-multiple: true
# path: coverage-artifacts
# - name: Merge coverage reports
# run: |
# mkdir -p ${{ env.PATH_TO_COVERAGE }}
# npx nyc merge coverage-artifacts ${{ env.PATH_TO_COVERAGE }}/coverage-storybook.json
# - name: Checking coverage
# run: npx nx storybook:coverage twenty-front --checkCoverage=true --configuration=${{ matrix.storybook_scope }}
- name: Rename coverage file
run: |
if [ -f "packages/twenty-front/coverage/storybook/coverage-final.json" ]; then
mv packages/twenty-front/coverage/storybook/coverage-final.json packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
else
echo "Error: coverage-final.json not found"
ls -la packages/twenty-front/coverage/storybook/ || echo "Coverage directory does not exist"
exit 1
fi
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
retention-days: 1
name: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-${{ matrix.shard }}
path: packages/twenty-front/coverage/storybook/coverage-shard-${{matrix.shard}}.json
merge-reports-and-check-coverage:
timeout-minutes: 30
runs-on: ubuntu-latest
needs: front-sb-test
env:
PATH_TO_COVERAGE: packages/twenty-front/coverage/storybook
strategy:
matrix:
storybook_scope: [modules, pages, performance]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- uses: actions/download-artifact@v4
with:
pattern: coverage-artifacts-${{ matrix.storybook_scope }}-${{ github.run_id }}-*
merge-multiple: true
path: coverage-artifacts
- name: Merge coverage reports
run: |
mkdir -p ${{ env.PATH_TO_COVERAGE }}
npx nyc merge coverage-artifacts ${{ env.PATH_TO_COVERAGE }}/coverage-storybook.json
- name: Checking coverage
run: npx nx storybook:coverage twenty-front --checkCoverage=true --configuration=${{ matrix.storybook_scope }}
front-chromatic-deployment:
timeout-minutes: 30
if: false
needs: front-sb-build
runs-on: depot-ubuntu-24.04-8
runs-on: ubuntu-latest-8-cores
env:
REACT_APP_SERVER_BASE_URL: http://127.0.0.1:3000
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
@@ -169,9 +169,8 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
env:
NODE_OPTIONS: '--max-old-space-size=4096'
TASK_CACHE_KEY: front-task-${{ matrix.task }}
strategy:
matrix:
@@ -203,6 +202,14 @@ jobs:
with:
tag: scope:frontend
tasks: ${{ matrix.task }}
- name: Check for coverage threshold failure
if: always() && steps.run-task.outcome == 'failure' && matrix.task == 'test'
shell: bash
run: |
echo "::error::The test task failed. If no individual test is failing, this is likely a coverage threshold not being met."
echo ""
echo "To debug locally, run: npx nx run twenty-front:test:ci"
exit 1
- name: Save ${{ matrix.task }} cache
uses: ./.github/actions/save-cache
with:
@@ -211,7 +218,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
runs-on: ubuntu-latest-8-cores
env:
NODE_OPTIONS: "--max-old-space-size=10240"
steps:
@@ -229,14 +236,14 @@ jobs:
run: npx nx reset:env twenty-front
- name: Build frontend
run: npx nx build twenty-front
# - name: Upload frontend build artifact
# uses: actions/upload-artifact@v4
# with:
# name: frontend-build
# path: packages/twenty-front/build
# retention-days: 1
- name: Upload frontend build artifact
uses: actions/upload-artifact@v4
with:
name: frontend-build
path: packages/twenty-front/build
retention-days: 1
e2e-test:
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs: [changed-files-check-e2e, front-build]
if: |
always() &&
@@ -296,18 +303,15 @@ jobs:
cp packages/twenty-front/.env.example packages/twenty-front/.env
npx nx reset:env:e2e-testing-server twenty-server
# - name: Download frontend build artifact
# if: needs.front-build.result == 'success'
# uses: actions/download-artifact@v4
# with:
# name: frontend-build
# path: packages/twenty-front/build
- name: Download frontend build artifact
if: needs.front-build.result == 'success'
uses: actions/download-artifact@v4
with:
name: frontend-build
path: packages/twenty-front/build
# - name: Build frontend (if not available from front-build)
# if: needs.front-build.result == 'skipped'
# run: NODE_ENV=production NODE_OPTIONS="--max-old-space-size=10240" npx nx build twenty-front
- name: Build frontend
- name: Build frontend (if not available from front-build)
if: needs.front-build.result == 'skipped'
run: NODE_ENV=production NODE_OPTIONS="--max-old-space-size=10240" npx nx build twenty-front
- name: Build server
@@ -339,23 +343,23 @@ jobs:
- name: Run Playwright tests
run: npx nx test twenty-e2e-testing
# - uses: actions/upload-artifact@v4
# if: always()
# with:
# name: playwright-report
# path: packages/twenty-e2e-testing/run_results/
# retention-days: 30
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: packages/twenty-e2e-testing/run_results/
retention-days: 30
ci-front-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs:
[
changed-files-check,
front-task,
front-build,
# merge-reports-and-check-coverage,
merge-reports-and-check-coverage,
front-sb-test,
front-sb-build,
]
@@ -366,7 +370,7 @@ jobs:
ci-e2e-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs: [changed-files-check-e2e, e2e-test]
steps:
- name: Fail job if any needs failed
+1 -1
View File
@@ -25,7 +25,7 @@ defaults:
jobs:
create_pr:
timeout-minutes: 10
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
+1 -1
View File
@@ -15,7 +15,7 @@ defaults:
jobs:
tag_and_release:
timeout-minutes: 10
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release')
steps:
- name: Check PR Author
+3 -3
View File
@@ -23,7 +23,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test:unit, storybook:build, storybook:test, test:integration]
@@ -50,7 +50,7 @@ jobs:
tasks: ${{ matrix.task }}
sdk-e2e-test:
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
runs-on: ubuntu-latest-8-cores
needs: [changed-files-check, sdk-test]
if: needs.changed-files-check.outputs.any_changed == 'true'
services:
@@ -94,7 +94,7 @@ jobs:
ci-sdk-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs: [changed-files-check, sdk-test, sdk-e2e-test]
steps:
- name: Fail job if any needs failed
+4 -4
View File
@@ -31,7 +31,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
runs-on: ubuntu-latest-8-cores
services:
postgres:
image: twentycrm/twenty-postgres-spilo
@@ -143,7 +143,7 @@ jobs:
key: ${{ steps.restore-server-setup-cache.outputs.cache-primary-key }}
server-test:
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
runs-on: ubuntu-latest-8-cores
needs: server-setup
steps:
- name: Fetch custom Github Actions and base branch history
@@ -164,7 +164,7 @@ jobs:
server-integration-test:
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
runs-on: ubuntu-latest-8-cores
needs: server-setup
strategy:
fail-fast: false
@@ -250,7 +250,7 @@ jobs:
ci-server-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs: [changed-files-check, server-setup, server-test, server-integration-test]
steps:
- name: Fail job if any needs failed
+2 -4
View File
@@ -22,9 +22,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04
env:
NODE_OPTIONS: '--max-old-space-size=4096'
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test]
@@ -47,7 +45,7 @@ jobs:
ci-shared-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs: [changed-files-check, shared-test]
steps:
- name: Fail job if any needs failed
@@ -23,7 +23,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -92,7 +92,7 @@ jobs:
ci-test-docker-compose-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs: [changed-files-check, test]
steps:
- name: Fail job if any needs failed
+5 -2
View File
@@ -9,7 +9,10 @@ on:
types: [opened, synchronize, reopened, closed]
permissions:
actions: write
checks: write
contents: write
issues: write
pull-requests: write
statuses: write
@@ -22,7 +25,7 @@ concurrency:
jobs:
danger-js:
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
if: github.event.action != 'closed'
steps:
- uses: actions/checkout@v4
@@ -35,7 +38,7 @@ jobs:
congratulate:
timeout-minutes: 3
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
if: github.event.action == 'closed' && github.event.pull_request.merged == true
steps:
- uses: actions/checkout@v4
+2 -2
View File
@@ -23,7 +23,7 @@ jobs:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 10
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
services:
postgres:
image: twentycrm/twenty-postgres-spilo
@@ -65,7 +65,7 @@ jobs:
ci-website-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
needs: [changed-files-check, website-build]
steps:
- name: Fail job if any needs failed
-128
View File
@@ -1,128 +0,0 @@
name: CI Zapier
on:
pull_request:
merge_group:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
SERVER_SETUP_CACHE_KEY: server-setup
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/twenty-zapier/**
packages/twenty-server/**
!packages/twenty-zapier/package.json
!packages/twenty-zapier/CHANGELOG.md
server-setup:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
services:
postgres:
image: twentycrm/twenty-postgres-spilo
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: 'true'
SPILO_PROVIDER: 'local'
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build twenty-shared
run: npx nx build twenty-shared
- name: Server / Write .env
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Server / Build
run: npx nx build twenty-server
- name: Create and setup database
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
npx nx run twenty-server:database:reset
- name: Server / Start
run: |
npx nx start twenty-server &
echo "Waiting for server to be ready..."
timeout 60 bash -c 'until curl -s http://localhost:3000/health; do sleep 2; done'
- name: Start worker
run: |
npx nx run twenty-server:worker &
echo "Worker started"
- name: Zapier / Build
run: npx nx build twenty-zapier
- name: Zapier / Run Tests
uses: ./.github/actions/nx-affected
with:
tag: scope:zapier
tasks: test
zapier-test:
needs: server-setup
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04
strategy:
matrix:
task: [lint, typecheck, validate]
steps:
- 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: 0
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build
run: npx nx build twenty-zapier
- name: Run ${{ matrix.task }} task
uses: ./.github/actions/nx-affected
with:
tag: scope:zapier
tasks: ${{ matrix.task }}
ci-zapier-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
needs: [changed-files-check, zapier-test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
+16 -9
View File
@@ -23,7 +23,7 @@ jobs:
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude') && github.event.comment.user.type != 'Bot') ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude') && github.event.review.user.type != 'Bot') ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: write
@@ -87,7 +87,7 @@ jobs:
exit 0
fi
ISSUE_NUMBER="${{ github.event.issue.number || github.event.pull_request.number }}"
ENCODED_BRANCH=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "$BRANCH")
ENCODED_BRANCH=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$BRANCH', safe=''))")
PR_URL="https://github.com/${{ github.repository }}/compare/main...${ENCODED_BRANCH}?quick_pull=1"
BODY="⚠️ Claude ran out of turns before creating a PR. Work has been pushed to [\`$BRANCH\`](https://github.com/${{ github.repository }}/tree/$ENCODED_BRANCH).\n\n[**Create PR →**]($PR_URL)"
if [ -n "$ISSUE_NUMBER" ]; then
@@ -96,7 +96,7 @@ jobs:
claude-cross-repo:
if: github.event_name == 'repository_dispatch'
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: write
@@ -157,11 +157,18 @@ jobs:
"PG_DATABASE_URL": "postgres://postgres:postgres@localhost:5432/default"
}
}
- name: Dispatch response to ci-privileged
- name: Post response to source issue
if: always()
uses: peter-evans/repository-dispatch@v2
uses: actions/github-script@v7
with:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: claude-cross-repo-response
client-payload: '{"repo": ${{ toJSON(steps.prompt.outputs.repo) }}, "issue_number": ${{ toJSON(steps.prompt.outputs.issue_number) }}, "run_id": ${{ toJSON(github.run_id) }}, "run_url": ${{ toJSON(format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id)) }}}'
github-token: ${{ secrets.TWENTY_DISPATCH_TOKEN }}
script: |
const [owner, repo] = '${{ steps.prompt.outputs.repo }}'.split('/');
const issueNumber = parseInt('${{ steps.prompt.outputs.issue_number }}', 10);
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `Claude finished processing this request. [See workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId})`
});
+1 -1
View File
@@ -34,7 +34,7 @@ concurrency:
jobs:
pull_docs_translations:
name: Pull docs translations
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
+1 -1
View File
@@ -21,7 +21,7 @@ concurrency:
jobs:
push_docs:
name: Push documentation to Crowdin
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
+1 -1
View File
@@ -32,7 +32,7 @@ concurrency:
jobs:
pull_translations:
name: Pull translations
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
+1 -1
View File
@@ -17,7 +17,7 @@ concurrency:
jobs:
extract_translations:
name: Extract and upload translations
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
+118
View File
@@ -0,0 +1,118 @@
# Weekly translation QA report using Crowdin's native QA checks
name: 'Weekly Translation QA Report'
permissions:
contents: write
pull-requests: write
on:
schedule:
- cron: '0 9 * * 1' # Every Monday at 9am UTC
workflow_dispatch: # Allow manual trigger
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
qa_report:
name: Generate QA Report
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build twenty-shared
run: npx nx build twenty-shared
- name: Generate QA report from Crowdin
id: generate_report
run: |
npx ts-node packages/twenty-utils/translation-qa-report.ts || true
if [ -f TRANSLATION_QA_REPORT.md ]; then
echo "report_generated=true" >> $GITHUB_OUTPUT
# Count critical issues (exclude spellcheck)
CRITICAL=$(grep -oP '⚠️\s+\K\d+' TRANSLATION_QA_REPORT.md 2>/dev/null || echo "0")
echo "critical_issues=$CRITICAL" >> $GITHUB_OUTPUT
else
echo "report_generated=false" >> $GITHUB_OUTPUT
echo "critical_issues=0" >> $GITHUB_OUTPUT
fi
env:
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Create QA branch and commit report
if: steps.generate_report.outputs.report_generated == 'true'
run: |
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@twenty.com'
BRANCH_NAME="i18n-qa-report-$(date +%Y-%m-%d)"
git checkout -B $BRANCH_NAME
git add TRANSLATION_QA_REPORT.md
if ! git diff --staged --quiet --exit-code; then
git commit -m "docs: weekly translation QA report"
git push origin HEAD:$BRANCH_NAME --force
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
else
echo "No changes to commit"
echo "BRANCH_NAME=" >> $GITHUB_ENV
fi
- name: Create pull request
if: steps.generate_report.outputs.report_generated == 'true' && env.BRANCH_NAME != ''
run: |
CRITICAL="${{ steps.generate_report.outputs.critical_issues }}"
BODY=$(cat <<EOF
## Weekly Translation QA Report
**Critical issues (excluding spellcheck): $CRITICAL**
📊 **View in Crowdin**: https://twenty.crowdin.com/u/projects/1/all?filter=qa-issue
### For AI-Assisted Fixing
Open this PR in Cursor and say:
> "Fix the translation QA issues using the Crowdin API"
The AI can help fix:
- ✅ Variables mismatch (missing/wrong placeholders)
- ✅ Escaped Unicode sequences
- ⚠️ Tags mismatch
- ⚠️ Empty translations
### Available Scripts
\`\`\`bash
# View QA report
CROWDIN_PERSONAL_TOKEN=xxx npx ts-node packages/twenty-utils/translation-qa-report.ts
# Fix encoding issues automatically
CROWDIN_PERSONAL_TOKEN=xxx npx ts-node packages/twenty-utils/fix-crowdin-translations.ts
\`\`\`
---
*Close without merging after issues are addressed*
EOF
)
EXISTING_PR=$(gh pr list --head $BRANCH_NAME --json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING_PR" ]; then
gh pr edit $EXISTING_PR --body "$BODY"
else
gh pr create \
--base main \
--head $BRANCH_NAME \
--title "i18n: Translation QA Report ($CRITICAL critical issues)" \
--body "$BODY" || true
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-71
View File
@@ -1,71 +0,0 @@
name: Post CI Comments
on:
workflow_run:
workflows: ['GraphQL and OpenAPI Breaking Changes Detection']
types: [completed]
permissions:
actions: read
jobs:
dispatch-breaking-changes:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Get PR number from workflow run
id: pr-info
uses: actions/github-script@v7
with:
script: |
const runId = context.payload.workflow_run.id;
const headSha = context.payload.workflow_run.head_sha;
const headBranch = context.payload.workflow_run.head_branch;
const headRepo = context.payload.workflow_run.head_repository;
// workflow_run.pull_requests is empty for fork PRs,
// so fall back to searching by head SHA
let pullRequests = context.payload.workflow_run.pull_requests;
let prNumber;
if (pullRequests && pullRequests.length > 0) {
prNumber = pullRequests[0].number;
} else {
core.info(`pull_requests is empty (likely a fork PR), searching by SHA ${headSha}`);
const owner = context.repo.owner;
const repo = context.repo.repo;
const headLabel = `${headRepo.owner.login}:${headBranch}`;
const { data: prs } = await github.rest.pulls.list({
owner,
repo,
state: 'open',
head: headLabel,
per_page: 1,
});
if (prs.length > 0) {
prNumber = prs[0].number;
}
}
if (!prNumber) {
core.info('No pull request found for this workflow run');
core.setOutput('has_pr', 'false');
return;
}
core.setOutput('pr_number', prNumber);
core.setOutput('run_id', runId);
core.setOutput('has_pr', 'true');
core.info(`PR #${prNumber}, Run ID: ${runId}`);
- name: Dispatch to ci-privileged
if: steps.pr-info.outputs.has_pr == 'true'
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: breaking-changes-report
client-payload: '{"pr_number": ${{ toJSON(steps.pr-info.outputs.pr_number) }}, "run_id": ${{ toJSON(steps.pr-info.outputs.run_id) }}, "repo": ${{ toJSON(github.repository) }}, "branch_state": ${{ toJSON(github.event.workflow_run.head_branch) }}}'
+7 -22
View File
@@ -2,8 +2,13 @@ name: 'Preview Environment Dispatch'
permissions:
contents: write
actions: write
pull-requests: read
on:
# Using pull_request_target instead of pull_request to have access to secrets for external contributors
# Security note: This is safe because we're only using the repository-dispatch action with limited scope
# and not checking out or running any code from the external contributor's PR
pull_request_target:
types: [opened, synchronize, reopened, labeled]
paths:
@@ -19,21 +24,9 @@ concurrency:
jobs:
trigger-preview:
if: |
(github.event.action == 'labeled' && github.event.label.name == 'preview-app') ||
(
(
github.event.pull_request.author_association == 'MEMBER' ||
github.event.pull_request.author_association == 'OWNER' ||
github.event.pull_request.author_association == 'COLLABORATOR'
) && (
github.event.action == 'opened' ||
github.event.action == 'synchronize' ||
github.event.action == 'reopened'
)
)
if: github.event.action == 'opened' || github.event.action == 'synchronize' || github.event.action == 'reopened' || (github.event.action == 'labeled' && github.event.label.name == 'preview-app')
timeout-minutes: 5
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Trigger preview environment workflow
uses: peter-evans/repository-dispatch@v2
@@ -42,11 +35,3 @@ jobs:
repository: ${{ github.repository }}
event-type: preview-environment
client-payload: '{"pr_number": "${{ github.event.pull_request.number }}", "pr_head_sha": "${{ github.event.pull_request.head.sha }}", "repo_full_name": "${{ github.repository }}"}'
- name: Dispatch to ci-privileged for PR comment
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
repository: twentyhq/ci-privileged
event-type: preview-env-url
client-payload: '{"pr_number": ${{ toJSON(github.event.pull_request.number) }}, "keepalive_dispatch_time": ${{ toJSON(github.event.pull_request.updated_at) }}, "repo": ${{ toJSON(github.repository) }}}'
+62 -34
View File
@@ -2,6 +2,7 @@ name: 'Preview Environment Keep Alive'
permissions:
contents: read
pull-requests: write
on:
repository_dispatch:
@@ -16,7 +17,7 @@ jobs:
uses: actions/checkout@v4
with:
ref: ${{ github.event.client_payload.pr_head_sha }}
- name: Run compose setup
run: |
echo "Patching docker-compose.yml..."
@@ -24,17 +25,17 @@ jobs:
yq eval 'del(.services.server.image)' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
yq eval 'del(.services.worker.image)' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
echo "Adding SIGN_IN_PREFILLED environment variable to server service..."
yq eval '.services.server.environment.SIGN_IN_PREFILLED = "${SIGN_IN_PREFILLED}"' -i packages/twenty-docker/docker-compose.yml
echo "Setting up .env file..."
cp packages/twenty-docker/.env.example packages/twenty-docker/.env
echo "Generating secrets..."
echo "" >> packages/twenty-docker/.env
echo "# === Randomly generated secrets ===" >> packages/twenty-docker/.env
@@ -45,25 +46,24 @@ jobs:
cd packages/twenty-docker/
docker compose build
working-directory: ./
- name: Create Tunnel
id: expose-tunnel
uses: codetalkio/expose-tunnel@v1.5.0
with:
service: bore.pub
port: 3000
- name: Start services with correct SERVER_URL
env:
TUNNEL_URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}
run: |
cd packages/twenty-docker/
echo "Setting SERVER_URL to $TUNNEL_URL"
# Update the SERVER_URL with the tunnel URL
echo "Setting SERVER_URL to ${{ steps.expose-tunnel.outputs.tunnel-url }}"
sed -i '/SERVER_URL=/d' .env
echo "" >> .env
echo "SERVER_URL=$TUNNEL_URL" >> .env
echo "SERVER_URL=${{ steps.expose-tunnel.outputs.tunnel-url }}" >> .env
# Start the services
echo "Docker compose up..."
docker compose up -d || {
@@ -71,7 +71,7 @@ jobs:
docker compose logs
exit 1
}
echo "Waiting for services to be ready..."
count=0
while [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-db-1) = "healthy" ] || [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-server-1) = "healthy" ]; do
@@ -84,7 +84,7 @@ jobs:
fi
echo "Still waiting for services... ($count/60)"
done
echo "All services are up and running!"
working-directory: ./
@@ -99,33 +99,61 @@ jobs:
fi
working-directory: ./
- name: Output tunnel URL
env:
TUNNEL_URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}
- name: Output tunnel URL to logs
run: |
echo "✅ Preview Environment Ready!"
echo "🔗 Preview URL: $TUNNEL_URL"
echo "🔗 Preview URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}"
echo "⏱️ This environment will be available for 5 hours"
echo "## 🚀 Preview Environment Ready!" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Preview URL: $TUNNEL_URL" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "This environment will automatically shut down after 5 hours." >> "$GITHUB_STEP_SUMMARY"
echo "$TUNNEL_URL" > tunnel-url.txt
- name: Upload tunnel URL artifact
uses: actions/upload-artifact@v4
- name: Post comment on PR
uses: actions/github-script@v6
with:
name: tunnel-url
path: tunnel-url.txt
retention-days: 1
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const COMMENT_MARKER = '<!-- PR_PREVIEW_ENV -->';
const commentBody = `${COMMENT_MARKER}
🚀 **Preview Environment Ready!**
Your preview environment is available at: ${{ steps.expose-tunnel.outputs.tunnel-url }}
This environment will automatically shut down when the PR is closed or after 5 hours.`;
// Get all comments
const {data: comments} = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ github.event.client_payload.pr_number }},
});
// Find our comment
const botComment = comments.find(comment => comment.body.includes(COMMENT_MARKER));
if (botComment) {
// Update existing comment
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: commentBody
});
console.log('Updated existing comment');
} else {
// Create new comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ github.event.client_payload.pr_number }},
body: commentBody
});
console.log('Created new comment');
}
- name: Keep tunnel alive for 5 hours
run: timeout 300m sleep 18000 # Stop on whichever we reach first (300m or 5hour sleep)
- name: Cleanup
if: always()
run: |
cd packages/twenty-docker/
docker compose down -v
working-directory: ./
working-directory: ./
+2 -4
View File
@@ -28,8 +28,6 @@ npx jest path/to/test.test.ts --config=packages/PROJECT/jest.config.mjs
npx nx test twenty-front # Frontend unit tests
npx nx test twenty-server # Backend unit tests
npx nx run twenty-server:test:integration:with-db-reset # Integration tests with DB reset
# To run an indivual test or a pattern of tests, use the following command:
cd packages/{workspace} && npx jest "pattern or filename"
# Storybook
npx nx storybook:build twenty-front
@@ -90,7 +88,7 @@ npx nx run twenty-front:graphql:generate --configuration=metadata
## Architecture Overview
### Tech Stack
- **Frontend**: React 18, TypeScript, Jotai (state management), Emotion (styling), Vite
- **Frontend**: React 18, TypeScript, Recoil (state management), Emotion (styling), Vite
- **Backend**: NestJS, TypeORM, PostgreSQL, Redis, GraphQL (with GraphQL Yoga)
- **Monorepo**: Nx workspace managed with Yarn 4
@@ -138,7 +136,7 @@ packages/
- Multi-line comments use multiple `//` lines, not `/** */`
### State Management
- **Jotai** for global state: atoms for primitive state, selectors for derived state, atom families for dynamic collections
- **Recoil** for global state: atoms for primitive state, selectors for derived state, atom families for dynamic collections
- Component-specific state with React hooks (`useState`, `useReducer` for complex logic)
- GraphQL cache managed by Apollo Client
- Use functional state updates: `setState(prev => prev + 1)`
+2 -3
View File
@@ -28,7 +28,7 @@ See:
🚀 [Self-hosting](https://docs.twenty.com/developers/self-hosting/docker-compose)
🖥️ [Local Setup](https://docs.twenty.com/developers/local-setup)
# Why Twenty
# Does the world need another CRM?
We built Twenty for three reasons:
@@ -109,7 +109,7 @@ Below are a few features we have implemented to date:
- [TypeScript](https://www.typescriptlang.org/)
- [Nx](https://nx.dev/)
- [NestJS](https://nestjs.com/), with [BullMQ](https://bullmq.io/), [PostgreSQL](https://www.postgresql.org/), [Redis](https://redis.io/)
- [React](https://reactjs.org/), with [Jotai](https://jotai.org/), [Emotion](https://emotion.sh/) and [Lingui](https://lingui.dev/)
- [React](https://reactjs.org/), with [Recoil](https://recoiljs.org/), [Emotion](https://emotion.sh/) and [Lingui](https://lingui.dev/)
@@ -120,7 +120,6 @@ Below are a few features we have implemented to date:
<a href="https://greptile.com"><img src="./packages/twenty-website/public/images/readme/greptile.png" height="30" alt="Greptile" /></a>
<a href="https://sentry.io/"><img src="./packages/twenty-website/public/images/readme/sentry.png" height="30" alt="Sentry" /></a>
<a href="https://crowdin.com/"><img src="./packages/twenty-website/public/images/readme/crowdin.png" height="30" alt="Crowdin" /></a>
<a href="https://e2b.dev/"><img src="./packages/twenty-website/public/images/readme/e2b.svg" height="30" alt="E2B" /></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).
-381
View File
@@ -1,381 +0,0 @@
# Emotion → Linaria Migration Plan: twenty-front
## Overview
Migrate all Emotion (`@emotion/styled`, `@emotion/react`) usages in
`packages/twenty-front/src` to Linaria (`@linaria/react`, `@linaria/core`),
following the same patterns already established in the `twenty-ui` package.
Linaria is a **zero-runtime** CSS-in-JS library. Styles are extracted at
build time by [wyw-in-js](https://wyw-in-js.dev/) (the Vite plugin is
`@wyw-in-js/vite`, already configured in `twenty-front/vite.config.ts`).
This means every expression inside a `styled` or `css` template literal
must be statically evaluable at build time — no runtime theme objects,
no closures over component state, no side-effects.
**Total files to migrate: ~998**
| Category | Files | Description |
|---|---|---|
| styled-only | 694 | Import `@emotion/styled` but not `useTheme` |
| styled + useTheme | 224 | Import both `@emotion/styled` and `useTheme` |
| useTheme-only | 79 | Import `useTheme` but not `@emotion/styled` |
| css / Global only | 1 | Import `css` or `Global` from `@emotion/react` only |
## Theme Architecture
Two build-time utilities produce the theme system:
- **`buildThemeReferencingRootCssVariables`** — walks the theme object and
builds a nested mirror where every leaf is a `var(--t-xxx)` string
(evaluated at build time by wyw-in-js)
- **`prepareThemeForRootCssVariableInjection`** — walks the runtime theme
and collects flat `[--css-variable-name, value]` pairs, injected onto
`document.documentElement` by `ThemeCssVariableInjectorEffect`
`themeCssVariables` is the build-time object; every leaf resolves to a CSS
`var()` reference. It is safe to use inside `styled` and `css` templates
because wyw-in-js can evaluate it statically.
## Migration Patterns
### 1. `styled` import
```diff
- import styled from '@emotion/styled';
+ import { styled } from '@linaria/react';
```
### 2. Theme access in styled components
Replace Emotion's `({ theme }) =>` prop-function pattern with static
`themeCssVariables` references:
```diff
+ import { themeCssVariables } from 'twenty-ui/theme';
const StyledTitle = styled.span`
- color: ${({ theme }) => theme.font.color.primary};
- font-size: ${({ theme }) => theme.font.size.lg};
+ color: ${themeCssVariables.font.color.primary};
+ font-size: ${themeCssVariables.font.size.lg};
`;
```
### 3. Spacing
`theme.spacing(N)` is a function; in Linaria it becomes an indexed lookup:
```diff
- margin-top: ${({ theme }) => theme.spacing(3)};
+ margin-top: ${themeCssVariables.spacing[3]};
```
For multi-arg spacing like `theme.spacing(2, 4)``"8px 16px"`:
```diff
- padding: ${({ theme }) => theme.spacing(2, 4)};
+ padding: ${themeCssVariables.spacing[2]} ${themeCssVariables.spacing[4]};
```
The spacing scale covers integers 032 plus `0.5` and `1.5`. Any other
fractional values (`0.25`, `0.75`, `1.25`, `2.5`, `3.5`) must be replaced
with literal pixel values (e.g. `theme.spacing(2.5)``10px`).
### 4. `useTheme` → `useContext(ThemeContext)`
For runtime theme access (icon sizes, animation durations, conditional logic
outside of styled components):
```diff
- import { useTheme } from '@emotion/react';
+ import { useContext } from 'react';
+ import { ThemeContext } from 'twenty-ui/theme';
const MyComponent = () => {
- const theme = useTheme();
+ const { theme } = useContext(ThemeContext);
return <Icon size={theme.icon.size.sm} />;
};
```
### 5. `css` template literal
Linaria's `css` (from `@linaria/core`) returns a **class name string**, not
a serialized style object like Emotion's `css`. This has two consequences:
**Standalone usage** — apply via `className`, not the `css` prop:
```diff
- import { css } from '@emotion/react';
+ import { css } from '@linaria/core';
const myClass = css`
text-decoration: none;
`;
- <Link css={myClass} />
+ <Link className={myClass} />
```
**Inside `styled` templates** — do NOT nest `css` tags. Linaria's `css`
returns a class name, not raw CSS text, so interpolating it inside `styled`
produces broken output. Use plain strings instead:
```diff
// WRONG — css`` returns a class name, not CSS text
${({ handle }) =>
handle === 'left'
- ? css`left: ${themeCssVariables.spacing[1]};`
- : css`right: ${themeCssVariables.spacing[1]};`}
+ ? `left: ${themeCssVariables.spacing[1]};`
+ : `right: ${themeCssVariables.spacing[1]};`}
```
### 6. Interpolation return types
wyw-in-js requires prop interpolation functions to return `string | number`.
They must **never** return `false`, `undefined`, or `null`. Replace
short-circuit `&&` with ternary expressions:
```diff
// WRONG — returns false when condition is false
- ${({ isActive }) => isActive && `background: ${themeCssVariables.color.blue};`}
// CORRECT
+ ${({ isActive }) => isActive ? `background: ${themeCssVariables.color.blue};` : ''}
```
### 7. Block interpolations (multi-declaration returns)
Linaria wraps each interpolation result in a single CSS custom property
(`var(--xxx)`). An interpolation that returns **multiple CSS declarations**
produces invalid CSS. Split into one interpolation per property:
```diff
// WRONG — single interpolation returning multiple declarations
- ${({ divider, theme }) => {
- const border = `1px solid ${theme.border.color.light}`;
- return divider === 'left' ? `border-left: ${border}` : `border-right: ${border}`;
- }}
// CORRECT — one interpolation per property
+ border-left: ${({ divider }) =>
+ divider === 'left' ? `1px solid ${themeCssVariables.border.color.light}` : 'none'};
+ border-right: ${({ divider }) =>
+ divider === 'right' ? `1px solid ${themeCssVariables.border.color.light}` : 'none'};
```
### 8. CSS var + unit concatenation
CSS custom properties can't be concatenated with unit suffixes directly
(`var(--x)px` is invalid). Use `calc()` to attach units:
```diff
- transition: background ${themeCssVariables.animation.duration.instant}s ease;
+ transition: background calc(${themeCssVariables.animation.duration.instant} * 1s) ease;
```
### 9. `styled(Component)` requires `className`
Linaria's `styled(Component)` works by passing a generated `className` to
the wrapped component. The component **must** accept and forward a
`className` prop — otherwise the styles are silently lost. If the component
doesn't support it, either add `className` support or use a wrapper div.
Linaria also does **not** support Emotion's `shouldForwardProp` option.
Custom props on HTML elements are automatically filtered by Linaria's
runtime (via `@emotion/is-prop-valid`). For custom components, all props are
forwarded — ensure the wrapped component ignores unknown props gracefully.
### 10. `type Theme` → `type ThemeType`
```diff
- import { type Theme } from '@emotion/react';
+ import { type ThemeType } from 'twenty-ui/theme';
```
### 11. Framer Motion integration
Linaria doesn't support `styled(motion.div)` — wrapping a motion element
with `styled()` causes the component body to be stripped at build time by
wyw-in-js. Define the styled component first, then wrap with
`motion.create()`:
```tsx
const StyledBarBase = styled.div`
background-color: ${themeCssVariables.font.color.primary};
height: 100%;
`;
const StyledBar = motion.create(StyledBarBase);
```
### 12. Dynamic styles via CSS variables
When a component needs to compute styles from multiple props with complex
branching logic (e.g. combining `variant`, `accent`, `disabled`, `focus`),
Linaria's prop interpolations become unwieldy. Use a `computeDynamicStyles`
helper that returns a `CSSProperties` object injected via `style={}`,
referenced from the static CSS with `var()`:
```tsx
const StyledButton = styled.button`
background: var(--btn-bg);
border-color: var(--btn-border-color);
&:hover { background: var(--btn-hover-bg); }
`;
const dynamicStyles = useMemo(() => {
const s = computeButtonDynamicStyles(variant, accent, ...);
return {
'--btn-bg': s.background,
'--btn-hover-bg': s.hoverBackground,
} as CSSProperties;
}, [variant, accent, ...]);
return <StyledButton style={dynamicStyles} />;
```
### 13. `Global` component
Replace Emotion's `<Global styles={...} />` with standard CSS or the
`ThemeCssVariableInjectorEffect` pattern from twenty-ui.
### 14. `ThemeProvider`
The `BaseThemeProvider` already wraps children with both Emotion's
`ThemeProvider` and Linaria's `ThemeContextProvider`. Once all Emotion usages
are gone, the Emotion `ThemeProvider` wrapper can be removed.
---
## PR Breakdown
Files are grouped to keep each PR around ~100 files with consistent review
surface. We start with the simplest, lowest-risk modules.
### PR 1 (~97 files) — Small standalone modules
Low-risk modules with mostly simple `styled`-only patterns.
| Module | Files |
|---|---|
| spreadsheet-import | 28 |
| billing | 10 |
| views | 14 |
| navigation-menu-item | 14 |
| blocknote-editor | 7 |
| advanced-text-editor | 7 |
| favorites | 7 |
| navigation | 4 |
| information-banner | 3 |
| sign-in-background-mock | 3 |
### PR 2 (~100 files) — Auth, tiny modules, loading, testing, pages (part 1)
| Module | Files |
|---|---|
| auth | 19 |
| action-menu | 3 |
| object-metadata | 3 |
| onboarding | 2 |
| workspace | 2 |
| file | 2 |
| error-handler | 2 |
| front-components | 1 |
| geo-map | 1 |
| hooks | 1 |
| loading | 5 |
| testing | 5 |
| pages (first ~55 files) | ~55 |
### PR 3 (~97 files) — Pages (remaining) + activities + AI
| Module | Files |
|---|---|
| pages (remaining ~16 files) | ~16 |
| activities | 53 |
| ai | 28 |
### PR 4 (~100 files) — Command-menu + workflow (part 1)
| Module | Files |
|---|---|
| command-menu | 53 |
| workflow (first ~47 files) | ~47 |
### PR 5 (~115 files) — Workflow (remaining) + page-layout
| Module | Files |
|---|---|
| workflow (remaining ~32 files) | ~32 |
| page-layout | 83 |
### PR 6 (~100 files) — UI module (part 1)
| Module | Files |
|---|---|
| ui (first ~100 files) | ~100 |
### PR 7 (~85 files) — UI module (remaining) + object-record (start)
| Module | Files |
|---|---|
| ui (remaining ~23 files) | ~23 |
| object-record (first ~62 files) | ~62 |
### PR 8 (~100 files) — Object-record (continued)
| Module | Files |
|---|---|
| object-record (next ~100 files) | ~100 |
### PR 9 (~100 files) — Settings (part 1)
| Module | Files |
|---|---|
| settings (first ~100 files) | ~100 |
### PR 10 (~102 files) — Settings (part 2) + final cleanup
| Module | Files |
|---|---|
| settings (remaining ~102 files) | ~102 |
| css/Global-only file | 1 |
### Post-migration PR — Remove Emotion
Once all PRs are merged:
- Remove `ThemeProvider` from `@emotion/react` in `BaseThemeProvider`
- Remove `@emotion/styled` and `@emotion/react` dependencies
- Remove `@styled/typescript-styled-plugin` from tsconfig
- Clean up any remaining Emotion-related configuration
---
## Risk Assessment
| Risk | Mitigation |
|---|---|
| wyw-in-js evaluates at build time; dynamic expressions may fail | Use `themeCssVariables` for static theme values; pass dynamic values as component props or via `style={}` CSS variables |
| `theme.spacing(N)` function → `themeCssVariables.spacing[N]` index | Pre-computed for integers 032 plus 0.5 and 1.5; other fractional values → literal pixel values |
| `useTheme` used for runtime logic (not just styles) | Replace with `useContext(ThemeContext)`, destructure `{ theme }` |
| Multi-arg `theme.spacing(a, b, c)` | Split into individual `themeCssVariables.spacing[N]` references |
| `css` tag inside `styled` templates | Linaria `css` returns class name, not CSS text; use plain strings inside `styled` |
| Interpolation returns `false` / `undefined` | wyw-in-js requires `string \| number`; use ternary `? : ''` instead of `&&` |
| Block interpolations (multiple declarations) | Split into one interpolation per CSS property |
| `var(--x)px` concatenation | Use `calc(var(--x) * 1px)` |
| `styled(motion.div)` stripped by wyw-in-js | Use `motion.create(StyledBase)` pattern |
| `styled(Component)` with no `className` prop | Add `className` support to wrapped component or use wrapper div |
| Complex multi-prop style branching | Use `computeDynamicStyles` + `style={}` + `var()` references |
---
## Validation Checklist (per PR)
- [ ] `npx nx lint:diff-with-main twenty-front` passes
- [ ] `npx nx typecheck twenty-front` passes
- [ ] `npx nx test twenty-front` passes
- [ ] Visual spot-check of affected components in the app
- [ ] No remaining `@emotion/styled` or `@emotion/react` imports in migrated files
-4
View File
@@ -83,10 +83,6 @@ export default [
sourceTag: 'scope:frontend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
},
{
sourceTag: 'scope:zapier',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:zapier'],
},
],
},
],
-283
View File
@@ -1,283 +0,0 @@
# npm-Based App Distribution for Twenty
*Technical Design Document -- February 2026*
## Overview
Add npm registry support for distributing Twenty apps (public and private), with per-AppRegistration registry overrides, direct tarball upload as escape hatch, and version upgrade detection.
## Assumptions
- The marketplace install flow (currently a TODO in the resolver and frontend) will be implemented separately. This plan provides the infrastructure that install flow will call into.
- The existing `app:dev` flow (individual file uploads via CLI) remains unchanged.
- The existing GitHub-based marketplace discovery remains as a curated fallback.
## Architecture
```
Developer
/ | \
npm publish | twenty app:push
/ | \
npmjs.com Private Reg Server REST Upload
| | |
v v v
[Discovery Layer] [Direct Upload]
npm search API |
GitHub curated list |
| |
v |
MarketplaceService |
(sourcePackage in DTO) |
| |
v v
AppPackageResolverService <--------+
.npmrc generation
yarn add / tarball extract
|
v
ApplicationSyncService (existing)
WorkspaceMigrationRunnerService
|
v
AppRegistration + Application entities
```
## Phase 1: Entity and Config Changes
### 1a. Extend AppRegistrationEntity
Add four columns to `application-registration.entity.ts`:
- **`sourcePackage`** (text, nullable) -- npm package name, e.g. `"twenty-app-fireflies"` or `"@myorg/twenty-app-crm"`. Null for tarball-only or OAuth-only apps.
- **`tarballFileId`** (uuid, nullable) -- FK to a FileEntity storing a directly-uploaded `.tar.gz`. Null when the app comes from npm.
- **`registryUrl`** (text, nullable) -- per-registration npm registry override. Null means "use the server default `APP_REGISTRY_URL`." This is how a single server can pull public apps from npmjs.com while pulling `@mycompany/*` apps from GitHub Packages.
- **`latestAvailableVersion`** (text, nullable) -- cached latest version from the registry, updated periodically. Compared against `Application.version` to surface upgrade availability.
**Source resolution priority:**
1. `sourcePackage` is set → resolve from npm via `yarn add`
2. `tarballFileId` is set → extract from file storage
3. Neither → OAuth-only app, no server-side code
### 1b. Extend ApplicationEntity.sourceType
Widen the `sourceType` union from `'local'` to `'local' | 'npm' | 'tarball'`:
- `'local'` -- existing behavior (CLI `app:dev`, individual file uploads, workspace-custom)
- `'npm'` -- installed from an npm registry via `yarn add`
- `'tarball'` -- installed from a directly-uploaded tarball
This lets the system distinguish how an app was installed, which matters for upgrade logic (npm apps can check the registry for newer versions; tarball apps cannot).
### 1c. Add server-wide config variables
Add a new `APP_REGISTRY_CONFIG` group to ConfigVariablesGroup:
- **`APP_REGISTRY_URL`** (string, default `https://registry.npmjs.org`) -- default npm registry URL
- **`APP_REGISTRY_TOKEN`** (string, optional, sensitive) -- auth token for the default registry
### 1d. Generate migration
TypeORM migration adding `sourcePackage`, `tarballFileId`, `registryUrl`, `latestAvailableVersion` to `core.applicationRegistration`.
## Phase 2: App Package Resolver Service
### 2a. Create AppPackageResolverService
New service with core method:
```
resolvePackage(appRegistration, options?: { targetVersion? }) → ResolvedPackage | null
```
Returns `{ manifestPath, packageJsonPath, filesDir }` or null for OAuth-only apps.
**Resolution logic:**
```
if sourcePackage:
1. Determine registry: appRegistration.registryUrl ?? APP_REGISTRY_URL
2. Determine auth token for the resolved registry
3. Generate temporary .npmrc in an isolated working directory
4. Run: yarn add <sourcePackage>@<targetVersion ?? latest>
5. Read manifest from node_modules/<sourcePackage>/.twenty/output/manifest.json
6. Return paths
if tarballFileId:
1. Download tarball from FileStorageService
2. Extract to temporary directory
3. Read manifest from extracted files
4. Return paths
else:
return null (OAuth-only)
```
### 2b. Isolated working directories
Each resolution runs in a temporary directory under `{os.tmpdir()}/twenty-app-resolver/{uuid}/`. This avoids contaminating the server's own `node_modules` and isolates apps from each other. Cleaned up after files are copied to storage.
### 2c. .npmrc generation
For scoped packages (`@scope/twenty-app-*`):
```
@scope:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=TOKEN
```
For unscoped packages with a non-default registry:
```
registry=https://my-verdaccio.internal:4873
//my-verdaccio.internal:4873/:_authToken=TOKEN
```
### 2d. Post-resolution file transfer
After resolving, copies files into the app's storage path using the existing FileStorageService layout:
```
{workspaceId}/{applicationUniversalIdentifier}/
built-logic-function/...
built-front-component/...
dependencies/package.json
dependencies/yarn.lock
public-asset/...
source/...
```
This reuses the same FileFolder enum paths that `app:dev` uses, so downstream ApplicationSyncService works unchanged.
## Phase 3: Marketplace Discovery via npm
### 3a. Update MarketplaceService
Add npm-based discovery alongside the existing GitHub path:
- Query npm search API: `GET {registryUrl}/-/v1/search?text=keywords:twenty-app&size=250`
- Map each result to MarketplaceAppDTO using package.json metadata
**Merge strategy:**
1. Fetch from npm search API (apps with `keywords: ["twenty-app"]`)
2. Fetch from GitHub (existing curated list)
3. Merge by `universalIdentifier` -- GitHub entries override npm entries (allowing curation)
4. Cache merged result with existing 1-hour TTL
### 3b. Add sourcePackage to MarketplaceAppDTO
The DTO needs a `sourcePackage: string | null` field so the install flow knows which npm package to resolve. For npm-discovered apps, this is the package name. For GitHub-only apps, this is null.
## Phase 4: Version Upgrade Support
### 4a. Create AppUpgradeService
**Periodic version check (npm-sourced apps only):**
- Fetches `{registryUrl}/{sourcePackage}/latest` from the npm registry
- Stores result in `AppRegistration.latestAvailableVersion`
- Frontend compares against `Application.version` to show "Update available"
**Upgrade trigger:**
1. Resolve the new version via AppPackageResolverService
2. Sync via existing ApplicationSyncService (triggers workspace migration for schema changes)
3. Update Application.version
**Rollback strategy:** If sync fails (e.g., migration validation error), re-resolve the previous version and re-sync. This is possible because npm retains all published versions.
### 4b. Version check scheduling
Lightweight cron or check-on-access pattern. Iterates over AppRegistrations where `sourcePackage IS NOT NULL` and calls `checkForUpdates()`. Frequency: once per hour, matching the existing marketplace cache TTL.
## Phase 5: SDK CLI Commands
### 5a. Finalize `twenty app:build`
Ensure `.twenty/output/` is npm-publishable. The build step generates a `package.json` in the output directory:
```json
{
"name": "twenty-app-fireflies",
"version": "1.2.0",
"keywords": ["twenty-app"],
"twenty": {
"universalIdentifier": "a4df0c0f-c65e-44e5-8436-24814182d4ac"
},
"files": ["manifest.json", "built-logic-function", "built-front-component", "public-asset"]
}
```
The developer then publishes with standard `npm publish` -- no custom command needed.
### 5b. `twenty app:pack` (new command)
```
twenty app:pack [appPath]
```
- Runs `app:build` if `.twenty/output/` doesn't exist or is stale
- Uses existing TarballService to create `{name}-{version}.tar.gz`
- Outputs the file path for manual distribution
### 5c. `twenty app:push` (new command)
```
twenty app:push [appPath] --server <url> --token <token>
```
- Runs `app:pack` to produce the tarball
- Reads universalIdentifier from manifest to find or create the AppRegistration
- Uploads via `POST /api/app-registrations/upload-tarball`
- Reports success with the registration ID
- Reuses `twenty auth:login` credentials if `--server` is not specified
## Phase 6: Server Tarball Upload Endpoint
### 6a. REST controller
```
POST /api/app-registrations/upload-tarball
Content-Type: multipart/form-data
Body: tarball file + optional universalIdentifier
```
**Validation:**
- Max file size: 50MB
- Must be a valid `.tar.gz`
- Extracted contents must contain `manifest.json` with a valid `universalIdentifier`
- The `universalIdentifier` must not conflict with an existing registration owned by a different user
**Flow:**
1. Extract tarball to temp directory
2. Validate manifest structure
3. Find or create AppRegistration by universalIdentifier
4. Store tarball in FileStorageService under `FileFolder.AppTarball`
5. Set `tarballFileId` on the AppRegistration
6. Return the AppRegistration entity
### 6b. Add FileFolder.AppTarball
New enum value `AppTarball = 'app-tarball'` in FileFolder.
## Key Design Decisions
| Decision | Rationale |
|---|---|
| Per-AppRegistration registry override | `registryUrl` on the entity allows mixing registries. Public apps from npmjs.com, private from GitHub Packages/Verdaccio. Server-wide `APP_REGISTRY_URL` is the fallback. |
| npm publish is standard | No custom publish infra. Free versioning, README, `npm audit`, download stats, proven auth model. |
| Tarball as escape hatch | Air-gapped environments, CI pipelines, one-off installs. Cannot auto-upgrade. |
| sourceType distinction | `'npm' \| 'tarball' \| 'local'` lets the system know which upgrade path is available. Only npm apps can check for newer versions. |
| Backward compatible | `app:dev` flow unchanged. GitHub marketplace unchanged. All new fields nullable. |
| Upgrade rollback | Re-resolve previous version from npm on failure. Safe because npm never deletes published versions. |
## Edge Cases
- **npm unreachable**: Timeout after 30s, throw clear error. App remains at current installed version.
- **Package name conflicts**: The `universalIdentifier` in the `twenty` field of `package.json` is the source of truth, not the npm package name. Two packages with the same universalIdentifier conflict at the AppRegistration level (unique index).
- **Scoped vs unscoped packages**: Both work. Scoped packages naturally route to a private registry via `.npmrc` scope mapping.
- **Multiple workspaces, same server**: AppRegistration is server-level (core schema). Application is workspace-level. One AppRegistration can be installed in multiple workspaces at different versions.
Binary file not shown.
+1 -1
View File
@@ -126,7 +126,7 @@
"configurations": {
"ci": {
"ci": true,
"maxWorkers": 1
"maxWorkers": 3
},
"coverage": {
"coverageReporters": ["lcov", "text"]
+15 -12
View File
@@ -48,7 +48,8 @@
"react-responsive": "^9.0.2",
"react-router-dom": "^6.4.4",
"react-tooltip": "^5.13.1",
"remark-gfm": "^4.0.1",
"recoil": "^0.7.7",
"remark-gfm": "^3.0.1",
"rxjs": "^7.2.0",
"semver": "^7.5.4",
"slash": "^5.1.0",
@@ -83,17 +84,17 @@
"@sentry/types": "^8",
"@storybook-community/storybook-addon-cookie": "^5.0.0",
"@storybook/addon-coverage": "^3.0.0",
"@storybook/addon-docs": "^10.2.13",
"@storybook/addon-links": "^10.2.13",
"@storybook/addon-vitest": "^10.2.13",
"@storybook/addon-docs": "^10.1.11",
"@storybook/addon-links": "^10.1.11",
"@storybook/addon-vitest": "^10.1.11",
"@storybook/icons": "^2.0.1",
"@storybook/react-vite": "^10.2.13",
"@storybook/react-vite": "^10.1.11",
"@storybook/test-runner": "^0.24.2",
"@stylistic/eslint-plugin": "^1.5.0",
"@swc-node/register": "1.11.1",
"@swc-node/register": "1.8.0",
"@swc/cli": "^0.3.12",
"@swc/core": "1.15.11",
"@swc/helpers": "~0.5.18",
"@swc/core": "1.13.3",
"@swc/helpers": "~0.5.2",
"@swc/jest": "^0.2.39",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
@@ -136,7 +137,7 @@
"@typescript-eslint/parser": "^8.39.0",
"@typescript-eslint/utils": "^8.39.0",
"@typescript/native-preview": "^7.0.0-dev.20260116.1",
"@vitejs/plugin-react-swc": "4.2.3",
"@vitejs/plugin-react-swc": "3.11.0",
"@vitest/browser-playwright": "^4.0.18",
"@vitest/coverage-istanbul": "^4.0.18",
"@vitest/coverage-v8": "^4.0.18",
@@ -159,7 +160,7 @@
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.4",
"eslint-plugin-simple-import-sort": "^10.0.0",
"eslint-plugin-storybook": "^10.2.13",
"eslint-plugin-storybook": "^10.1.11",
"eslint-plugin-unicorn": "^56.0.1",
"eslint-plugin-unused-imports": "^3.0.0",
"http-server": "^14.1.1",
@@ -175,9 +176,9 @@
"raw-loader": "^4.0.2",
"rimraf": "^5.0.5",
"source-map-support": "^0.5.20",
"storybook": "^10.2.13",
"storybook": "^10.1.11",
"storybook-addon-mock-date": "2.0.0",
"storybook-addon-pseudo-states": "^10.2.13",
"storybook-addon-pseudo-states": "^10.1.11",
"supertest": "^6.1.3",
"ts-jest": "^29.1.1",
"ts-loader": "^9.2.3",
@@ -201,6 +202,8 @@
"type-fest": "4.10.1",
"typescript": "5.9.2",
"graphql-redis-subscriptions/ioredis": "^5.6.0",
"prosemirror-view": "1.40.0",
"prosemirror-transform": "1.10.4",
"@lingui/core": "5.1.2",
"@types/qs": "6.9.16"
},
+7 -9
View File
@@ -31,6 +31,10 @@ See Twenty application documentation https://docs.twenty.com/developers/extend/c
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# If you don't use yarn@4
corepack enable
yarn install
# Get help and list all available commands
yarn twenty help
@@ -41,7 +45,7 @@ yarn twenty auth:login
yarn twenty entity:add
# Start dev mode: watches, builds, and syncs local changes to your workspace
# (also auto-generates typed API clients — CoreApiClient and MetadataApiClient — in node_modules/twenty-sdk/generated)
# (also auto-generates a typed API client in node_modules/twenty-sdk/generated)
yarn twenty app:dev
# Watch your application's function logs
@@ -50,9 +54,6 @@ yarn twenty function:logs
# Execute a function with a JSON payload
yarn twenty function:execute -n my-function -p '{"key": "value"}'
# Execute the pre-install function
yarn twenty function:execute --preInstall
# Execute the post-install function
yarn twenty function:execute --postInstall
@@ -88,14 +89,12 @@ In interactive mode, you can pick from:
- **Example front component** — a React UI component (`front-components/hello-world.tsx`)
- **Example view** — a saved view for the example object (`views/example-view.ts`)
- **Example navigation menu item** — a sidebar link (`navigation-menu-items/example-navigation-menu-item.ts`)
- **Example skill** — an AI agent skill definition (`skills/example-skill.ts`)
## What gets scaffolded
**Core files (always created):**
- `application-config.ts` — Application metadata configuration
- `roles/default-role.ts` — Default role for logic functions
- `logic-functions/pre-install.ts` — Pre-install logic function (runs before app installation)
- `logic-functions/post-install.ts` — Post-install logic function (runs after app installation)
- TypeScript configuration, ESLint, package.json, .gitignore
- A prewired `twenty` script that delegates to the `twenty` CLI from twenty-sdk
@@ -107,14 +106,13 @@ In interactive mode, you can pick from:
- `front-components/hello-world.tsx` — Example front component
- `views/example-view.ts` — Example saved view for the example object
- `navigation-menu-items/example-navigation-menu-item.ts` — Example sidebar navigation link
- `skills/example-skill.ts` — Example AI agent skill definition
## Next steps
- Run `yarn twenty help` to see all available commands.
- Use `yarn twenty auth:login` to authenticate with your Twenty workspace.
- Explore the generated project and add your first entity with `yarn twenty entity:add` (logic functions, front components, objects, roles, views, navigation menu items, skills).
- Explore the generated project and add your first entity with `yarn twenty entity:add` (logic functions, front components, objects, roles, views, navigation menu items).
- Use `yarn twenty app:dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time.
- Two typed API clients are autogenerated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated`: `CoreApiClient` (for workspace data via `/graphql`) and `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`).
- Types are autogenerated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated`.
## Publish your application
+1 -2
View File
@@ -1,5 +1,5 @@
const jestConfig = {
displayName: 'create-twenty-app',
displayName: 'twenty-cli',
preset: '../../jest.preset.js',
testEnvironment: 'node',
transformIgnorePatterns: ['../../node_modules/'],
@@ -15,7 +15,6 @@ const jestConfig = {
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'^package.json$': '<rootDir>/package.json',
},
moduleFileExtensions: ['ts', 'js'],
extensionsToTreatAsEsm: ['.ts'],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "0.6.3",
"version": "0.6.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
@@ -1,12 +0,0 @@
## Base documentation
- Documentation: https://docs.twenty.com/developers/extend/capabilities/apps
- Rich app example: https://github.com/twentyhq/twenty/tree/main/packages/twenty-sdk/src/cli/__tests__/apps/rich-app
## UUID requirement
- All generated UUIDs must be valid UUID v4.
## Common Pitfalls
- Creating an object without an index view associated. Unless this is a technical object, user will need to visualize it.
- Creating a view without a navigationMenuItem associated. This will make the view available on the left sidebar.
@@ -36,10 +36,6 @@ yarn twenty function:execute # Execute a function with JSON payload
yarn twenty app:uninstall # Uninstall app from workspace
```
## LLMs instructions
Main docs and pitfalls are available in LLMS.md file.
## Learn More
To learn more about Twenty applications, take a look at the following resources:
@@ -114,7 +114,6 @@ export class CreateAppCommand {
includeExampleFrontComponent: false,
includeExampleView: false,
includeExampleNavigationMenuItem: false,
includeExampleSkill: false,
};
}
@@ -126,7 +125,6 @@ export class CreateAppCommand {
includeExampleFrontComponent: true,
includeExampleView: true,
includeExampleNavigationMenuItem: true,
includeExampleSkill: true,
};
}
@@ -166,11 +164,6 @@ export class CreateAppCommand {
value: 'navigationMenuItem',
checked: true,
},
{
name: 'Example skill (AI agent skill definition)',
value: 'skill',
checked: true,
},
],
},
]);
@@ -196,7 +189,6 @@ export class CreateAppCommand {
includeExampleView: includeView,
includeExampleNavigationMenuItem:
selectedExamples.includes('navigationMenuItem'),
includeExampleSkill: selectedExamples.includes('skill'),
};
}
@@ -233,9 +225,9 @@ export class CreateAppCommand {
console.log('');
console.log(chalk.blue('Next steps:'));
console.log(chalk.gray(` cd ${dirName}`));
console.log(
chalk.gray(' yarn twenty auth:login # Authenticate with Twenty'),
);
console.log(chalk.gray(' yarn twenty app:dev # Start dev mode'));
console.log(chalk.gray(` corepack enable # if you don't use yarn@4`));
console.log(chalk.gray(` yarn install # if you don't use yarn@4`));
console.log(chalk.gray(' yarn auth:login # Authenticate with Twenty'));
console.log(chalk.gray(' yarn app:dev # Start dev mode'));
}
}
@@ -7,5 +7,4 @@ export type ExampleOptions = {
includeExampleFrontComponent: boolean;
includeExampleView: boolean;
includeExampleNavigationMenuItem: boolean;
includeExampleSkill: boolean;
};
@@ -1,11 +1,10 @@
import { type ExampleOptions } from '@/types/scaffolding-options';
import { GENERATED_DIR } from 'twenty-shared/application';
import { copyBaseApplicationProject } from '@/utils/app-template';
import * as fs from 'fs-extra';
import { tmpdir } from 'os';
import { join } from 'path';
import createTwentyAppPackageJson from 'package.json';
import { tmpdir } from 'os';
import { copyBaseApplicationProject } from '@/utils/app-template';
import { type ExampleOptions } from '@/types/scaffolding-options';
// Mock fs-extra's copy function to skip copying base template (not available during tests)
jest.mock('fs-extra', () => {
const actual = jest.requireActual('fs-extra');
return {
@@ -24,13 +23,11 @@ const ALL_EXAMPLES: ExampleOptions = {
includeExampleFrontComponent: true,
includeExampleView: true,
includeExampleNavigationMenuItem: true,
includeExampleSkill: true,
};
const NO_EXAMPLES: ExampleOptions = {
includeExampleObject: false,
includeExampleField: false,
includeExampleSkill: false,
includeExampleLogicFunction: false,
includeExampleFrontComponent: false,
includeExampleView: false,
@@ -41,6 +38,7 @@ describe('copyBaseApplicationProject', () => {
let testAppDirectory: string;
beforeEach(async () => {
// Create a unique temp directory for each test
testAppDirectory = join(
tmpdir(),
`test-twenty-app-${Date.now()}-${Math.random().toString(36).slice(2)}`,
@@ -50,6 +48,7 @@ describe('copyBaseApplicationProject', () => {
});
afterEach(async () => {
// Clean up temp directory after each test
if (testAppDirectory && (await fs.pathExists(testAppDirectory))) {
await fs.remove(testAppDirectory);
}
@@ -64,12 +63,15 @@ describe('copyBaseApplicationProject', () => {
exampleOptions: ALL_EXAMPLES,
});
// Verify src/ folder exists
const srcAppPath = join(testAppDirectory, 'src');
expect(await fs.pathExists(srcAppPath)).toBe(true);
// Verify application-config.ts exists in src/
const appConfigPath = join(srcAppPath, APPLICATION_FILE_NAME);
expect(await fs.pathExists(appConfigPath)).toBe(true);
// Verify default-role.ts exists in src/
const roleConfigPath = join(srcAppPath, 'roles', DEFAULT_ROLE_FILE_NAME);
expect(await fs.pathExists(roleConfigPath)).toBe(true);
});
@@ -89,9 +91,7 @@ describe('copyBaseApplicationProject', () => {
const packageJson = await fs.readJson(packageJsonPath);
expect(packageJson.name).toBe('my-test-app');
expect(packageJson.version).toBe('0.1.0');
expect(packageJson.devDependencies['twenty-sdk']).toBe(
createTwentyAppPackageJson.version,
);
expect(packageJson.dependencies['twenty-sdk']).toBe('latest');
expect(packageJson.scripts['twenty']).toBe('twenty');
});
@@ -109,7 +109,7 @@ describe('copyBaseApplicationProject', () => {
const gitignoreContent = await fs.readFile(gitignorePath, 'utf8');
expect(gitignoreContent).toContain('/node_modules');
expect(gitignoreContent).toContain(GENERATED_DIR);
expect(gitignoreContent).toContain('generated');
});
it('should create yarn.lock file', async () => {
@@ -140,22 +140,27 @@ describe('copyBaseApplicationProject', () => {
const appConfigPath = join(testAppDirectory, 'src', APPLICATION_FILE_NAME);
const appConfigContent = await fs.readFile(appConfigPath, 'utf8');
// Verify it uses defineApplication
expect(appConfigContent).toContain(
"import { defineApplication } from 'twenty-sdk'",
);
expect(appConfigContent).toContain('export default defineApplication({');
// Verify it imports the role identifier
expect(appConfigContent).toContain(
"import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role'",
);
// Verify display name and description
expect(appConfigContent).toContain("displayName: 'My Test App'");
expect(appConfigContent).toContain("description: 'A test application'");
// Verify it has a universalIdentifier (UUID format)
expect(appConfigContent).toMatch(
/universalIdentifier: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/,
);
// Verify it references the role
expect(appConfigContent).toContain(
'defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER',
);
@@ -178,24 +183,29 @@ describe('copyBaseApplicationProject', () => {
);
const roleConfigContent = await fs.readFile(roleConfigPath, 'utf8');
// Verify it uses defineRole
expect(roleConfigContent).toContain(
"import { defineRole } from 'twenty-sdk'",
);
expect(roleConfigContent).toContain('export default defineRole({');
// Verify it exports the universal identifier constant
expect(roleConfigContent).toContain(
'export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER',
);
// Verify role label includes app name
expect(roleConfigContent).toContain(
"label: 'My Test App default function role'",
);
// Verify default permissions
expect(roleConfigContent).toContain('canReadAllObjectRecords: true');
expect(roleConfigContent).toContain('canUpdateAllObjectRecords: true');
expect(roleConfigContent).toContain('canSoftDeleteAllObjectRecords: true');
expect(roleConfigContent).toContain('canDestroyAllObjectRecords: false');
// Verify it has a universalIdentifier (UUID format)
expect(roleConfigContent).toMatch(
/universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER/,
);
@@ -210,6 +220,7 @@ describe('copyBaseApplicationProject', () => {
exampleOptions: ALL_EXAMPLES,
});
// Verify fs.copy was called with correct destination
expect(fs.copy).toHaveBeenCalledTimes(1);
expect(fs.copy).toHaveBeenCalledWith(
expect.stringContaining('base-application'),
@@ -233,6 +244,7 @@ describe('copyBaseApplicationProject', () => {
});
it('should generate unique UUIDs for each application', async () => {
// Create first app
const firstAppDir = join(testAppDirectory, 'app1');
await fs.ensureDir(firstAppDir);
await copyBaseApplicationProject({
@@ -243,6 +255,7 @@ describe('copyBaseApplicationProject', () => {
exampleOptions: ALL_EXAMPLES,
});
// Create second app
const secondAppDir = join(testAppDirectory, 'app2');
await fs.ensureDir(secondAppDir);
await copyBaseApplicationProject({
@@ -253,6 +266,7 @@ describe('copyBaseApplicationProject', () => {
exampleOptions: ALL_EXAMPLES,
});
// Read both app configs
const firstAppConfig = await fs.readFile(
join(firstAppDir, 'src', APPLICATION_FILE_NAME),
'utf8',
@@ -262,6 +276,7 @@ describe('copyBaseApplicationProject', () => {
'utf8',
);
// Extract UUIDs using regex
const uuidRegex =
/universalIdentifier: '([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
const firstUuid = firstAppConfig.match(uuidRegex)?.[1];
@@ -273,6 +288,7 @@ describe('copyBaseApplicationProject', () => {
});
it('should generate unique role UUIDs for each application', async () => {
// Create first app
const firstAppDir = join(testAppDirectory, 'app1');
await fs.ensureDir(firstAppDir);
await copyBaseApplicationProject({
@@ -283,6 +299,7 @@ describe('copyBaseApplicationProject', () => {
exampleOptions: ALL_EXAMPLES,
});
// Create second app
const secondAppDir = join(testAppDirectory, 'app2');
await fs.ensureDir(secondAppDir);
await copyBaseApplicationProject({
@@ -303,6 +320,7 @@ describe('copyBaseApplicationProject', () => {
'utf8',
);
// Extract UUIDs using regex
const uuidRegex =
/DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =\s*'([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
const firstUuid = firstRoleConfig.match(uuidRegex)?.[1];
@@ -354,18 +372,6 @@ describe('copyBaseApplicationProject', () => {
),
),
).toBe(true);
// Install functions should always exist
expect(
await fs.pathExists(
join(srcPath, 'logic-functions', 'pre-install.ts'),
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, 'logic-functions', 'post-install.ts'),
),
).toBe(true);
});
});
@@ -381,6 +387,7 @@ describe('copyBaseApplicationProject', () => {
const srcPath = join(testAppDirectory, 'src');
// Core files should exist
expect(await fs.pathExists(join(srcPath, APPLICATION_FILE_NAME))).toBe(
true,
);
@@ -388,18 +395,7 @@ describe('copyBaseApplicationProject', () => {
await fs.pathExists(join(srcPath, 'roles', DEFAULT_ROLE_FILE_NAME)),
).toBe(true);
// Install functions should always exist (not gated by exampleOptions)
expect(
await fs.pathExists(
join(srcPath, 'logic-functions', 'pre-install.ts'),
),
).toBe(true);
expect(
await fs.pathExists(
join(srcPath, 'logic-functions', 'post-install.ts'),
),
).toBe(true);
// Example files should not exist
expect(
await fs.pathExists(join(srcPath, 'objects', 'example-object.ts')),
).toBe(false);
@@ -441,7 +437,6 @@ describe('copyBaseApplicationProject', () => {
exampleOptions: {
includeExampleObject: false,
includeExampleField: false,
includeExampleSkill: false,
includeExampleLogicFunction: false,
includeExampleFrontComponent: true,
includeExampleView: false,
@@ -477,7 +472,6 @@ describe('copyBaseApplicationProject', () => {
appDirectory: testAppDirectory,
exampleOptions: {
includeExampleObject: false,
includeExampleSkill: false,
includeExampleField: false,
includeExampleLogicFunction: true,
includeExampleFrontComponent: false,
@@ -677,124 +671,4 @@ describe('copyBaseApplicationProject', () => {
expect(content).toContain('position: 0');
});
});
describe('pre-install logic function', () => {
it('should create pre-install.ts with definePreInstallLogicFunction and typed payload', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: NO_EXAMPLES,
});
const preInstallPath = join(
testAppDirectory,
'src',
'logic-functions',
'pre-install.ts',
);
expect(await fs.pathExists(preInstallPath)).toBe(true);
const content = await fs.readFile(preInstallPath, 'utf8');
expect(content).toContain(
"import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk'",
);
expect(content).toContain(
'export default definePreInstallLogicFunction({',
);
expect(content).toContain("name: 'pre-install'");
expect(content).toContain('timeoutSeconds: 300');
expect(content).toContain(
'const handler = async (payload: InstallLogicFunctionPayload): Promise<void>',
);
expect(content).toContain('payload.previousVersion');
// Verify it has a universalIdentifier (UUID format)
expect(content).toMatch(
/universalIdentifier: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/,
);
});
it('should always create pre-install.ts regardless of example options', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: NO_EXAMPLES,
});
const preInstallPath = join(
testAppDirectory,
'src',
'logic-functions',
'pre-install.ts',
);
expect(await fs.pathExists(preInstallPath)).toBe(true);
});
});
describe('post-install logic function', () => {
it('should create post-install.ts with definePostInstallLogicFunction and typed payload', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: NO_EXAMPLES,
});
const postInstallPath = join(
testAppDirectory,
'src',
'logic-functions',
'post-install.ts',
);
expect(await fs.pathExists(postInstallPath)).toBe(true);
const content = await fs.readFile(postInstallPath, 'utf8');
expect(content).toContain(
"import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk'",
);
expect(content).toContain(
'export default definePostInstallLogicFunction({',
);
expect(content).toContain("name: 'post-install'");
expect(content).toContain('timeoutSeconds: 300');
expect(content).toContain(
'const handler = async (payload: InstallLogicFunctionPayload): Promise<void>',
);
expect(content).toContain('payload.previousVersion');
// Verify it has a universalIdentifier (UUID format)
expect(content).toMatch(
/universalIdentifier: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/,
);
});
it('should always create post-install.ts regardless of example options', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
exampleOptions: NO_EXAMPLES,
});
const postInstallPath = join(
testAppDirectory,
'src',
'logic-functions',
'post-install.ts',
);
expect(await fs.pathExists(postInstallPath)).toBe(true);
});
});
});
@@ -4,7 +4,6 @@ import { v4 } from 'uuid';
import { ASSETS_DIR } from 'twenty-shared/application';
import { type ExampleOptions } from '@/types/scaffolding-options';
import createTwentyAppPackageJson from 'package.json';
const SRC_FOLDER = 'src';
@@ -90,20 +89,6 @@ export const copyBaseApplicationProject = async ({
});
}
if (exampleOptions.includeExampleSkill) {
await createExampleSkill({
appDirectory: sourceFolderPath,
fileFolder: 'skills',
fileName: 'example-skill.ts',
});
}
await createDefaultPreInstallFunction({
appDirectory: sourceFolderPath,
fileFolder: 'logic-functions',
fileName: 'pre-install.ts',
});
await createDefaultPostInstallFunction({
appDirectory: sourceFolderPath,
fileFolder: 'logic-functions',
@@ -147,7 +132,8 @@ generated
# dev
/dist/
.twenty
.twenty/*
!.twenty/output/
# production
/build
@@ -274,36 +260,6 @@ export default defineLogicFunction({
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createDefaultPreInstallFunction = async ({
appDirectory,
fileFolder,
fileName,
}: {
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const universalIdentifier = v4();
const content = `import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Pre install logic function executed successfully!', payload.previousVersion);
};
export default definePreInstallLogicFunction({
universalIdentifier: '${universalIdentifier}',
name: 'pre-install',
description: 'Runs before installation to prepare the application.',
timeoutSeconds: 300,
handler,
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createDefaultPostInstallFunction = async ({
appDirectory,
fileFolder,
@@ -315,14 +271,16 @@ const createDefaultPostInstallFunction = async ({
}) => {
const universalIdentifier = v4();
const content = `import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
const content = `import { defineLogicFunction } from 'twenty-sdk';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Post install logic function executed successfully!', payload.previousVersion);
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '${universalIdentifier}';
const handler = async (): Promise<void> => {
console.log('Post install logic function executed successfully!');
};
export default definePostInstallLogicFunction({
universalIdentifier: '${universalIdentifier}',
export default defineLogicFunction({
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,
@@ -466,36 +424,6 @@ export default defineNavigationMenuItem({
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createExampleSkill = async ({
appDirectory,
fileFolder,
fileName,
}: {
appDirectory: string;
fileFolder?: string;
fileName: string;
}) => {
const universalIdentifier = v4();
const content = `import { defineSkill } from 'twenty-sdk';
export const EXAMPLE_SKILL_UNIVERSAL_IDENTIFIER =
'${universalIdentifier}';
export default defineSkill({
universalIdentifier: EXAMPLE_SKILL_UNIVERSAL_IDENTIFIER,
name: 'example-skill',
label: 'Example Skill',
description: 'A sample skill for your application',
icon: 'IconBrain',
content: 'Add your skill instructions here. Skills provide context and capabilities to AI agents.',
});
`;
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
};
const createApplicationConfig = async ({
displayName,
description,
@@ -511,12 +439,14 @@ const createApplicationConfig = async ({
}) => {
const content = `import { defineApplication } from 'twenty-sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
export default defineApplication({
universalIdentifier: '${v4()}',
displayName: '${displayName}',
description: '${description ?? ''}',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
`;
@@ -546,9 +476,10 @@ const createPackageJson = async ({
lint: 'eslint',
'lint:fix': 'eslint --fix',
},
dependencies: {},
dependencies: {
'twenty-sdk': 'latest',
},
devDependencies: {
'twenty-sdk': createTwentyAppPackageJson.version,
typescript: '^5.9.3',
'@types/node': '^24.7.2',
'@types/react': '^18.2.0',
@@ -6,13 +6,7 @@ const execPromise = promisify(exec);
export const install = async (root: string) => {
try {
await execPromise('corepack enable', { cwd: root });
} catch (error: any) {
console.warn(chalk.yellow('corepack enabled failed:'), error.stderr);
}
try {
await execPromise('yarn install', { cwd: root });
await execPromise('yarn', { cwd: root });
} catch (error: any) {
console.error(chalk.red('yarn install failed:'), error.stdout);
}
+1 -2
View File
@@ -11,8 +11,7 @@
"noEmit": true,
"types": ["jest", "node"],
"paths": {
"@/*": ["./src/*"],
"package.json": ["./package.json"]
"@/*": ["./src/*"]
},
"jsx": "react"
},
@@ -1,37 +1,2 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn
# codegen
generated
# testing
/coverage
# dev
/dist/
.twenty
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# typescript
*.tsbuildinfo
.yarn/install-state.gz
.env
@@ -2,6 +2,25 @@
Used to manage billing and telemetry of self-hosted instances
## Requirements
- twenty-cli `npm install -g twenty-cli`
- an `apiKey`. Go to `https://twenty.com/settings/api-webhooks` to generate one
## Install to your Twenty workspace
```bash
twenty auth login
twenty app sync
```
## Environment Variables
This application requires the following environment variables to be set:
- `TWENTY_API_URL`: The Twenty instance API URL where selfHostingUser records will be created
- `TWENTY_API_KEY`: API key for authentication (generate at `/settings/api-webhooks`)
## Features
### Telemetry Webhook
@@ -9,13 +9,26 @@
},
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"auth:login": "twenty auth login",
"auth:logout": "twenty auth logout",
"auth:status": "twenty auth status",
"auth:switch": "twenty auth switch",
"auth:list": "twenty auth list",
"app:dev": "twenty app dev",
"app:sync": "twenty app sync",
"entity:add": "twenty entity add",
"function:logs": "twenty function logs",
"function:execute": "twenty function execute",
"app:uninstall": "twenty app uninstall",
"help": "twenty help",
"lint": "eslint",
"lint:fix": "eslint --fix"
},
"dependencies": {
"twenty-sdk": "0.3.1"
},
"devDependencies": {
"@types/node": "^24.7.2",
"twenty-sdk": "0.6.2"
"@types/node": "^24.7.2"
},
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/src/constants/schemas/appManifest.schema.json",
"universalIdentifier": "a7070f46-3158-4b40-828f-8e6b1febc233"
@@ -0,0 +1,19 @@
import { defineApp } from 'twenty-sdk';
export default defineApp({
universalIdentifier: '94f7db30-59e5-4b09-a5fe-64cd3d4a65b0',
displayName: 'Self Hosting',
description: 'Used to manage billing and telemetry of self-hosted instances',
applicationVariables: {
TWENTY_API_KEY: {
universalIdentifier: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d',
description: 'Twenty API key for creating selfHostingUser records',
isSecret: true,
},
TWENTY_API_URL: {
universalIdentifier: 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e',
description: 'Twenty API URL (e.g., https://api.twenty.com)',
isSecret: false,
},
},
});
@@ -0,0 +1,18 @@
import { FieldType, defineObject } from 'twenty-sdk';
export default defineObject({
universalIdentifier: '06f3fb53-599e-4c6b-9df6-8f731973afd7',
nameSingular: 'selfHostingUser',
namePlural: 'selfHostingUsers',
labelSingular: 'Self Hosting User',
labelPlural: 'Self Hosting Users',
fields: [
{
type: FieldType.EMAILS,
name: 'email',
label: 'Email',
description: 'The email of the self hosting user',
universalIdentifier: 'a4b7892c-431a-4d44-973e-a5481652704f',
},
],
});
@@ -0,0 +1,132 @@
import { defineFunction } from 'twenty-sdk';
import { createClient } from '../../generated';
// TODO: import from twenty-sdk when 0.4.0 is deployed
type ServerlessFunctionEvent<TBody = object> = {
headers: Record<string, string | undefined>;
queryStringParameters: Record<string, string | undefined>;
pathParameters: Record<string, string | undefined>;
body: TBody | null;
isBase64Encoded: boolean;
requestContext: {
http: {
method: string;
path: string;
};
};
};
type TelemetryEventPayload = {
action: string;
timestamp: string;
version: string;
payload: {
userId: string | null;
workspaceId: string | null;
payload?: {
events?: Array<{
userId?: string;
userEmail?: string;
userFirstName?: string;
userLastName?: string;
locale?: string;
serverUrl?: string;
}>;
};
};
};
export const main = async (
params: ServerlessFunctionEvent<TelemetryEventPayload>,
): Promise<{ success: boolean; message: string; error?: string }> => {
try {
const { action, payload } = params.body || {};
if (action !== 'user_signup') {
return {
success: true,
message: `Event type '${action}' ignored`,
};
}
const userEmail =
payload?.payload?.events?.[0]?.userEmail ||
payload?.payload?.events?.[0]?.userId;
if (!userEmail) {
return {
success: false,
message: 'No email found in telemetry event',
error: 'Missing userEmail in payload',
};
}
if (
userEmail.toLowerCase().includes('example') ||
userEmail.toLowerCase().includes('test')
) {
return {
success: true,
message: `Email '${userEmail}' ignored (contains test/example data)`,
};
}
const client = createClient({
url: `${process.env.TWENTY_API_URL}/graphql`,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
},
});
// Create or update selfHostingUser record
const result = await client.mutation({
createSelfHostingUser: {
__args: {
data: {
name:
payload?.payload?.events?.[0]?.userFirstName +
' ' +
payload?.payload?.events?.[0]?.userLastName,
email: {
primaryEmail: userEmail,
additionalEmails: null,
},
},
upsert: true,
},
id: true,
email: {
primaryEmail: true,
},
},
});
return {
success: true,
message: `Self hosting user created/updated: ${result.createSelfHostingUser?.id}`,
};
} catch (error) {
return {
success: false,
message: 'Failed to process telemetry event',
error: error instanceof Error ? error.message : String(error),
};
}
};
export default defineFunction({
universalIdentifier: '10104201-622b-4a5e-9f27-8f2af19b2a3c',
name: 'telemetry-webhook',
timeoutSeconds: 5,
handler: main,
triggers: [
{
universalIdentifier: '7c8e3f5a-9b4c-4d1e-8f2a-1b3c4d5e6f7a',
type: 'route',
path: '/webhook/telemetry',
httpMethod: 'POST',
isAuthRequired: false,
},
],
});
@@ -1,10 +0,0 @@
import { defineApplication } from 'twenty-sdk';
import { UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers.constant';
export default defineApplication({
universalIdentifier: '94f7db30-59e5-4b09-a5fe-64cd3d4a65b0',
displayName: 'Self Hosting',
description: 'Used to manage billing and telemetry of self-hosted instances',
defaultRoleUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.roles.defaultRole.universalIdentifier,
});
@@ -1,120 +0,0 @@
export const UNIVERSAL_IDENTIFIERS = {
objects: {
selfHostingUser: {
universalIdentifier: '06f3fb53-599e-4c6b-9df6-8f731973afd7',
fields: {
name: { universalIdentifier: '682cccbf-9f37-4290-a94c-902c771f61e4' },
email: { universalIdentifier: 'a4b7892c-431a-4d44-973e-a5481652704f' },
personId: {
universalIdentifier: 'b453a43c-1512-48ca-8604-db750ad3ffb8',
},
domain: {
universalIdentifier: '1dfa7d4e-c8f5-4639-b58e-3392a8789f76',
},
userWorkspaceId: {
universalIdentifier: '297a7d6b-e407-4b2d-8c03-8964bc1b7805',
},
userId: {
universalIdentifier: '5c7ba3ce-1473-4e3d-8e7c-31816fcb87d8',
},
locale: {
universalIdentifier: '7b39df37-a22e-4f38-ae77-91cf3ee7c076',
},
serverUrl: {
universalIdentifier: 'f2516b77-2912-4cbb-8838-46ac5a5465d9',
},
serverId: {
universalIdentifier: 'e68a2b15-786d-4e9d-a74d-6d6d577ae721',
},
numberOfEmailsWithSameDomain: {
universalIdentifier: '0bf05db0-6771-4400-91ca-1579ec11e76e',
},
isEnriched: {
universalIdentifier: 'fefe9fd6-23ae-4046-b60b-64d17e9ff7ed',
},
triedToBeEnriched: {
universalIdentifier: 'd32c8cc3-8855-453d-bb7d-9c9c0b3f2128',
},
isPersonalEmail: {
universalIdentifier: 'f4568391-9474-4ed8-8cbb-e36d86e0f5f9',
},
isTwenty: {
universalIdentifier: 'b1acef1f-7c10-47a9-899e-aaca45b36e04',
},
personCity: {
universalIdentifier: 'ca733484-e595-4257-9ca9-9a7802fb8bcb',
},
personCountry: {
universalIdentifier: '18c06357-1b50-4d5b-82cf-1f71f286fbe4',
},
personJobFunction: {
universalIdentifier: '26e7e2c7-ea83-41e0-8c07-1fc2549a3fb4',
},
personJobTitle: {
universalIdentifier: '177908e9-1ca6-4762-9518-0df966d3e9fc',
},
personLinkedIn: {
universalIdentifier: '3515683f-7f9f-4b6d-9b16-614824d277b7',
},
personSeniority: {
universalIdentifier: '8b63855a-5915-4d6a-a6ed-ef7d8f8e5dd1',
},
companyAlexaRank: {
universalIdentifier: '7c61335b-cd4b-4eae-8b02-0db746913e36',
},
companyAnnualRevenue: {
universalIdentifier: 'a2367973-aa12-42c2-9577-fe868f61b83b',
},
companyAnnualRevenuePrinted: {
universalIdentifier: 'bc02b6af-8f48-4fde-920d-1fd3e2a8557b',
},
companyDescription: {
universalIdentifier: 'a9bb622e-56b6-42ba-8b03-17a47d707409',
},
companyEmployees: {
universalIdentifier: '8e1dbc58-d444-470f-b8fe-9eed8da4b59e',
},
companyFoundedYear: {
universalIdentifier: '3cf95527-5064-43ab-bf5e-421eb45fac5f',
},
companyFundingLatestStage: {
universalIdentifier: 'a7dcd92a-6811-490b-a8dd-fad1c19091a1',
},
companyFundingTotalAmount: {
universalIdentifier: '6fca8a11-b49a-4081-a7c9-9646f43ad7aa',
},
companyFundingTotalAmountPrinted: {
universalIdentifier: '0078f0f0-2262-4c74-aaf8-4061c6c8a1f3',
},
companyIndustries: {
universalIdentifier: '6b971b9c-6ef5-4497-989e-f9a7c72720cf',
},
companyIndustry: {
universalIdentifier: 'ab84e651-d35b-4e02-8d69-1740af3e22f7',
},
companyLinkedIn: {
universalIdentifier: '4c44b956-f880-434f-b4cd-854b82076e56',
},
companyName: {
universalIdentifier: '1a25412b-f9ce-4406-ac53-f20d1ab8c5ea',
},
companyTags: {
universalIdentifier: 'ceb64d0b-1203-4c6d-af00-39b668f5f891',
},
companyTech: {
universalIdentifier: '11dd57c3-06bb-4722-bb65-96d0a899ca91',
},
},
},
},
roles: {
defaultRole: {
universalIdentifier: '66972e19-9fdb-4336-87ce-442a17fd179c',
},
},
views: {
selfHostingUserView: {
universalIdentifier: 'e903f0ee-52cb-4537-aca8-8940e30b023d',
},
},
};
@@ -1,29 +0,0 @@
import {
defineField,
FieldType,
RelationType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
import { UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers.constant';
export const SELF_HOSTING_USER_ID_UNIVERSAL_IDENTIFIER =
'9507f244-fdea-47d5-a734-725d4dae43da';
export default defineField({
universalIdentifier: SELF_HOSTING_USER_ID_UNIVERSAL_IDENTIFIER,
name: 'selfHostingUsers',
label: 'Self hosting users',
description: 'Self hosting user related to the person',
type: FieldType.RELATION,
relationTargetFieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personId
.universalIdentifier,
relationTargetObjectMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.universalIdentifier,
objectUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
isNullable: true,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,94 +0,0 @@
import {
defineLogicFunction,
type DatabaseEventPayload,
type ObjectRecordCreateEvent,
type ObjectRecordUpdateEvent,
} from 'twenty-sdk';
import { SELF_HOSTING_USER_NAME_SINGULAR } from 'src/objects/selfHostingUser.object';
import { type SelfHostingUser } from 'twenty-sdk/generated/core';
import { CoreApiClient } from 'twenty-sdk/generated';
const handler = async (
params: DatabaseEventPayload<
| ObjectRecordCreateEvent<SelfHostingUser>
| ObjectRecordUpdateEvent<SelfHostingUser>
>,
) => {
const [object, action] = params.name.split('.');
if (object !== SELF_HOSTING_USER_NAME_SINGULAR) {
return;
}
if (!['created', 'updated'].includes(action)) {
return;
}
const email = params.properties.after.email?.primaryEmail;
if (!email) {
return;
}
const client = new CoreApiClient();
const { people } = await client.query({
people: {
edges: { node: { id: true } },
__args: {
filter: {
emails: {
primaryEmail: { eq: email },
},
},
},
},
});
let personId = people?.edges[0]?.node?.id;
if (!personId) {
const { createPerson } = await client.mutation({
createPerson: {
__args: {
data: {
name: {
firstName: params.properties.after.name?.firstName,
lastName: params.properties.after.name?.lastName,
},
emails: {
primaryEmail: email,
},
},
},
id: true,
},
});
personId = createPerson?.id;
}
await client.mutation({
updateSelfHostingUser: {
__args: {
id: params.properties.after.id,
data: {
personId,
},
},
id: true,
},
});
};
export default defineLogicFunction({
universalIdentifier: '87f0293a-997a-4c7b-85e2-e77462ccf0c5',
name: 'match-telemetry-event-with-people',
description:
'Matches self hosting users with existing people based on email address',
timeoutSeconds: 10,
handler,
databaseEventTriggerSettings: {
eventName: `${SELF_HOSTING_USER_NAME_SINGULAR}.*`,
},
});
@@ -1,136 +0,0 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk';
import { CoreApiClient } from 'twenty-sdk/generated';
import { type TelemetryEvent } from 'src/logic-functions/types/telemetry-event.type';
export const main = async (
params: RoutePayload<TelemetryEvent>,
): Promise<{
success: boolean;
message: string;
error?: string;
}> => {
try {
const {
action,
workspaceId,
userWorkspaceId,
userId,
userEmail,
userFirstName,
userLastName,
locale,
serverUrl,
serverId,
} = params.body || {};
if (action !== 'user_signup') {
return {
success: true,
message: `Event type '${action}' ignored`,
};
}
if (!userEmail) {
return {
success: true,
message: 'No email found in telemetry event',
};
}
if (
userEmail.toLowerCase().includes('example') ||
userEmail.toLowerCase().includes('test')
) {
return {
success: true,
message: `Email '${userEmail}' ignored (contains test/example data)`,
};
}
const client = new CoreApiClient();
let existingSelfHostingUserId: string | undefined = undefined;
try {
const { selfHostingUser: existingSelfHostingUser } = await client.query({
selfHostingUser: {
__args: {
filter: {
email: { primaryEmail: { eq: userEmail } },
},
},
id: true,
},
});
existingSelfHostingUserId = existingSelfHostingUser?.id;
} catch {
//
}
if (existingSelfHostingUserId) {
await client.mutation({
updateSelfHostingUser: {
__args: {
id: existingSelfHostingUserId,
data: {
name: { firstName: userFirstName, lastName: userLastName },
email: { primaryEmail: userEmail, additionalEmails: null },
userWorkspaceId,
userId,
locale,
serverUrl,
serverId,
},
},
id: true,
},
});
return {
success: true,
message: `Self hosting user ${existingSelfHostingUserId} updated`,
};
}
const { createSelfHostingUser } = await client.mutation({
createSelfHostingUser: {
__args: {
data: {
name: { firstName: userFirstName, lastName: userLastName },
email: { primaryEmail: userEmail, additionalEmails: null },
workspaceId,
userWorkspaceId,
userId,
locale,
serverUrl,
serverId,
},
},
id: true,
},
});
return {
success: true,
message: `Self hosting user ${createSelfHostingUser?.id} created`,
};
} catch (error) {
return {
success: false,
message: 'Failed to process telemetry event',
error: error instanceof Error ? error.message : String(error),
};
}
};
export default defineLogicFunction({
universalIdentifier: '10104201-622b-4a5e-9f27-8f2af19b2a3c',
name: 'telemetry-webhook',
timeoutSeconds: 10,
handler: main,
httpRouteTriggerSettings: {
path: '/webhook/telemetry',
httpMethod: 'POST',
isAuthRequired: false,
},
});
@@ -1,12 +0,0 @@
export type TelemetryEvent = {
action: string;
workspaceId?: string;
userWorkspaceId?: string;
userId: string;
userEmail?: string;
userFirstName?: string;
userLastName?: string;
locale?: string;
serverUrl: string;
serverId: string;
};
@@ -1,11 +0,0 @@
import { defineNavigationMenuItem } from 'twenty-sdk';
import { UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers.constant';
export default defineNavigationMenuItem({
universalIdentifier: 'fe3aaca4-9eda-4565-b215-5d268fbf8164',
name: 'Self host user',
icon: 'IconList',
position: 1,
viewUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.views.selfHostingUserView.universalIdentifier,
});
@@ -1,354 +0,0 @@
import {
defineObject,
FieldType,
RelationType,
OnDeleteAction,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
import { UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers.constant';
import { SELF_HOSTING_USER_ID_UNIVERSAL_IDENTIFIER } from 'src/fields/self-hosting-user-id';
export const SELF_HOSTING_USER_NAME_SINGULAR = 'selfHostingUser';
export default defineObject({
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.universalIdentifier,
nameSingular: SELF_HOSTING_USER_NAME_SINGULAR,
namePlural: 'selfHostingUsers',
labelSingular: 'Self Hosting User',
labelPlural: 'Self Hosting Users',
fields: [
{
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personId
.universalIdentifier,
name: 'person',
label: 'Person',
description: 'Person matching with the self hosting user',
type: FieldType.RELATION,
relationTargetFieldMetadataUniversalIdentifier:
SELF_HOSTING_USER_ID_UNIVERSAL_IDENTIFIER,
relationTargetObjectMetadataUniversalIdentifier:
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
isNullable: true,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'personId',
},
},
{
type: FieldType.FULL_NAME,
name: 'name',
label: 'Name',
description: 'Name of the self hosting user',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.name
.universalIdentifier,
},
{
type: FieldType.EMAILS,
name: 'email',
label: 'Email',
description: 'The email of the self hosting user',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.email
.universalIdentifier,
},
{
type: FieldType.LINKS,
name: 'domain',
label: 'Domain',
description:
'Domain extracted from the email address (e.g. domain.com / https://domain.com/)',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.domain
.universalIdentifier,
},
{
type: FieldType.UUID,
name: 'userWorkspaceId',
label: 'User workspace Id',
description: 'User workspace id of the self hosting user',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.userWorkspaceId
.universalIdentifier,
},
{
type: FieldType.UUID,
name: 'userId',
label: 'User Id',
description: 'User id of the self hosting user',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.userId
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'locale',
label: 'Locale',
description: 'Locale of the self hosting user',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.locale
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'serverUrl',
label: 'Server url',
description: 'Server url of the self hosting user',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.serverUrl
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'serverId',
label: 'Server id',
description: 'Server id of the self hosting user',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.serverId
.universalIdentifier,
},
{
type: FieldType.NUMBER,
name: 'numberOfEmailsWithSameDomain',
label: 'Number of Emails with Same Domain',
description:
'Aggregated count of self hosting users sharing the same business domain',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.numberOfEmailsWithSameDomain.universalIdentifier,
},
{
type: FieldType.BOOLEAN,
name: 'isEnriched',
label: 'Is Enriched',
description: 'Whether the record has been enriched',
defaultValue: false,
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.isEnriched
.universalIdentifier,
},
{
type: FieldType.BOOLEAN,
name: 'triedToBeEnriched',
label: 'Tried to Be Enriched',
description: 'Whether an enrichment attempt has been made',
defaultValue: false,
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.triedToBeEnriched
.universalIdentifier,
},
{
type: FieldType.BOOLEAN,
name: 'isPersonalEmail',
label: 'Is Personal Email',
description: 'Whether the email is a personal email address',
defaultValue: true,
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.isPersonalEmail
.universalIdentifier,
},
{
type: FieldType.BOOLEAN,
name: 'isTwenty',
label: 'Is Twenty',
description: 'Whether the user is from Twenty',
defaultValue: false,
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.isTwenty
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'personCity',
label: 'Person City',
description: 'City of the person',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personCity
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'personCountry',
label: 'Person Country',
description: 'Country of the person',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personCountry
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'personJobFunction',
label: 'Person Job Function',
description: 'Job function of the person',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personJobFunction
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'personJobTitle',
label: 'Person Job Title',
description: 'Job title of the person',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personJobTitle
.universalIdentifier,
},
{
type: FieldType.LINKS,
name: 'personLinkedIn',
label: 'Person LinkedIn',
description: 'LinkedIn profile of the person',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personLinkedIn
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'personSeniority',
label: 'Person Seniority',
description: 'Seniority level of the person',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personSeniority
.universalIdentifier,
},
{
type: FieldType.NUMBER,
name: 'companyAlexaRank',
label: 'Company Alexa Rank',
description: 'Alexa rank of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyAlexaRank
.universalIdentifier,
},
{
type: FieldType.CURRENCY,
name: 'companyAnnualRevenue',
label: 'Company Annual Revenue',
description: 'Annual revenue of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyAnnualRevenue.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'companyAnnualRevenuePrinted',
label: 'Company Annual Revenue Printed',
description: 'Formatted annual revenue of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyAnnualRevenuePrinted.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'companyDescription',
label: 'Company Description',
description: 'Description of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyDescription
.universalIdentifier,
},
{
type: FieldType.NUMBER,
name: 'companyEmployees',
label: 'Company Employees',
description: 'Number of employees at the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyEmployees
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'companyFoundedYear',
label: 'Company Founded Year',
description: 'Year the company was founded',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyFoundedYear
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'companyFundingLatestStage',
label: 'Company Funding Latest Stage',
description: 'Latest funding stage of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyFundingLatestStage.universalIdentifier,
},
{
type: FieldType.NUMBER,
name: 'companyFundingTotalAmount',
label: 'Company Funding Total Amount',
description: 'Total funding amount of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyFundingTotalAmount.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'companyFundingTotalAmountPrinted',
label: 'Company Funding Total Amount Printed',
description: 'Formatted total funding amount of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyFundingTotalAmountPrinted.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'companyIndustries',
label: 'Company Industries',
description: 'Industries the company operates in',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyIndustries
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'companyIndustry',
label: 'Company Industry',
description: 'Primary industry of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyIndustry
.universalIdentifier,
},
{
type: FieldType.LINKS,
name: 'companyLinkedIn',
label: 'Company LinkedIn',
description: 'LinkedIn page of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyLinkedIn
.universalIdentifier,
},
{
type: FieldType.TEXT,
name: 'companyName',
label: 'Company Name',
description: 'Name of the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyName
.universalIdentifier,
},
{
type: FieldType.ARRAY,
name: 'companyTags',
label: 'Company Tags',
description: 'Tags associated with the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyTags
.universalIdentifier,
},
{
type: FieldType.ARRAY,
name: 'companyTech',
label: 'Company Tech',
description: 'Technologies used by the company',
universalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyTech
.universalIdentifier,
},
],
});
@@ -1,13 +0,0 @@
import { defineRole } from 'twenty-sdk';
import { UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers.constant';
export default defineRole({
universalIdentifier:
UNIVERSAL_IDENTIFIERS.roles.defaultRole.universalIdentifier,
label: 'default role',
description: 'Add a description for your role',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: false,
});
@@ -1,308 +0,0 @@
import { defineView } from 'twenty-sdk';
import { UNIVERSAL_IDENTIFIERS } from 'src/constants/universal-identifiers.constant';
export default defineView({
universalIdentifier:
UNIVERSAL_IDENTIFIERS.views.selfHostingUserView.universalIdentifier,
name: 'Self hosting users',
objectUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.universalIdentifier,
icon: 'IconList',
position: 0,
fields: [
{
universalIdentifier: '243a2401-cd13-440c-8dcd-649e26df36bc',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.name
.universalIdentifier,
position: 0,
isVisible: true,
size: 150,
},
{
universalIdentifier: 'dfa75ef8-d40d-416f-9f1c-3e86edfa9fce',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.email
.universalIdentifier,
position: 1,
isVisible: true,
size: 150,
},
{
universalIdentifier: '15cc9215-eb48-4487-a92e-a25d8e99702f',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.domain
.universalIdentifier,
position: 2,
isVisible: true,
size: 200,
},
{
universalIdentifier: '0f9e4f63-3664-443a-9f06-8a6cc04c1d90',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personId
.universalIdentifier,
position: 2.1,
isVisible: true,
size: 200,
},
{
universalIdentifier: 'dcf88ae8-e71d-452f-b51e-d88cbc6dd273',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.userWorkspaceId
.universalIdentifier,
position: 3,
isVisible: true,
},
{
universalIdentifier: 'aad70516-936b-41d1-b6c6-961a22299761',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.userId
.universalIdentifier,
position: 4,
isVisible: true,
},
{
universalIdentifier: '8c210eb0-bdda-476e-9f98-42f909872f2a',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.locale
.universalIdentifier,
position: 5,
isVisible: true,
},
{
universalIdentifier: '367abe85-11c4-440f-80a2-663edd6b4231',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.serverUrl
.universalIdentifier,
position: 6,
isVisible: true,
},
{
universalIdentifier: '32c199d6-ebf3-434b-81b4-e2b59a0518b7',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.serverId
.universalIdentifier,
position: 6.1,
isVisible: true,
},
{
universalIdentifier: '924ee786-ab93-44be-9d21-941ff9ffe1ac',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.numberOfEmailsWithSameDomain.universalIdentifier,
position: 7,
isVisible: true,
},
{
universalIdentifier: '2feadf3d-e251-4356-add8-7fa70dea5401',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.isEnriched
.universalIdentifier,
position: 8,
isVisible: true,
},
{
universalIdentifier: 'de252ae6-c723-4bf7-96cf-d93f5a539f36',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.triedToBeEnriched
.universalIdentifier,
position: 9,
isVisible: true,
},
{
universalIdentifier: 'b121e8e6-b3eb-4f6c-b67e-c7c6d19e1bc5',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.isPersonalEmail
.universalIdentifier,
position: 10,
isVisible: true,
},
{
universalIdentifier: '0ada0bcc-8d6b-4df6-bcc1-78ba14cb04e6',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.isTwenty
.universalIdentifier,
position: 11,
isVisible: true,
},
{
universalIdentifier: 'ec7c8d51-ea63-41bd-9eb1-995835b94218',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personCity
.universalIdentifier,
position: 12,
isVisible: true,
},
{
universalIdentifier: '7522dd84-0d23-48e7-85dd-f0a8d9e275f8',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personCountry
.universalIdentifier,
position: 13,
isVisible: true,
},
{
universalIdentifier: '54191cb9-4d5c-466e-affb-d9ba4adeff87',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personJobFunction
.universalIdentifier,
position: 14,
isVisible: true,
size: 200,
},
{
universalIdentifier: 'ace75fc7-fb20-4e53-a9a2-6a7529befaf0',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personJobTitle
.universalIdentifier,
position: 15,
isVisible: true,
size: 180,
},
{
universalIdentifier: 'a0b42d61-4553-42eb-aca4-327b9bf9f30e',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personLinkedIn
.universalIdentifier,
position: 16,
isVisible: true,
},
{
universalIdentifier: '74bc7dd2-fe53-4ff4-8778-2768f3439571',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.personSeniority
.universalIdentifier,
position: 17,
isVisible: true,
size: 180,
},
{
universalIdentifier: '61b34f41-8d56-472d-ab1e-414703c6ca12',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyAlexaRank
.universalIdentifier,
position: 18,
isVisible: true,
},
{
universalIdentifier: '5bb7d36b-6a73-4832-b41e-f67130a4708f',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyAnnualRevenue.universalIdentifier,
position: 19,
isVisible: true,
},
{
universalIdentifier: 'ae6f23ce-006c-41dd-82a1-e9fe7b65bce3',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyAnnualRevenuePrinted.universalIdentifier,
position: 20,
isVisible: true,
size: 250,
},
{
universalIdentifier: 'dd2a4728-a743-43bb-b096-9e7bd5125e56',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyDescription
.universalIdentifier,
position: 21,
isVisible: true,
size: 200,
},
{
universalIdentifier: 'ecca02c9-db2e-41e2-b571-b5db75054b56',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyEmployees
.universalIdentifier,
position: 22,
isVisible: true,
},
{
universalIdentifier: '2e76775b-f8b8-4184-8cd3-72d2b93edaa2',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyFoundedYear
.universalIdentifier,
position: 23,
isVisible: true,
size: 200,
},
{
universalIdentifier: 'a7eb002c-6f0c-48ba-a9eb-247c498ad9bd',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyFundingLatestStage.universalIdentifier,
position: 24,
isVisible: true,
size: 240,
},
{
universalIdentifier: '61be97f6-20da-4b2b-861d-32345e0f9953',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyFundingTotalAmount.universalIdentifier,
position: 25,
isVisible: true,
},
{
universalIdentifier: '55720810-3120-4e76-bcf2-2da9517edbbc',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields
.companyFundingTotalAmountPrinted.universalIdentifier,
position: 26,
isVisible: true,
size: 280,
},
{
universalIdentifier: '01e31752-cbc1-499a-8ecf-504dd402d7e2',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyIndustries
.universalIdentifier,
position: 27,
isVisible: true,
size: 200,
},
{
universalIdentifier: '2e1c8b8b-469b-483e-8348-1fe3d1764e17',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyIndustry
.universalIdentifier,
position: 28,
isVisible: true,
size: 180,
},
{
universalIdentifier: '976cc8ae-6cf8-4c30-8da4-5bf61e799893',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyLinkedIn
.universalIdentifier,
position: 29,
isVisible: true,
},
{
universalIdentifier: '5f0776b3-2849-4b9b-82f0-baa38c6d889d',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyName
.universalIdentifier,
position: 30,
isVisible: true,
},
{
universalIdentifier: '86f0397a-2924-4e5c-a610-3c9ad7bb4923',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyTags
.universalIdentifier,
position: 31,
isVisible: true,
},
{
universalIdentifier: '562084f4-1242-4e60-868b-1d9b268a35b0',
fieldMetadataUniversalIdentifier:
UNIVERSAL_IDENTIFIERS.objects.selfHostingUser.fields.companyTech
.universalIdentifier,
position: 32,
isVisible: true,
},
],
});
@@ -5,14 +5,13 @@
"declaration": true,
"outDir": "./dist",
"rootDir": ".",
"jsx": "react-jsx",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"importHelpers": true,
"allowUnreachableCode": false,
"strict": true,
"strictNullChecks": true,
"alwaysStrict": true,
"noImplicitAny": true,
"strictBindCallApply": false,
@@ -27,5 +26,10 @@
"~/*": ["./*"]
}
},
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
"exclude": [
"node_modules",
"dist",
"**/*.test.ts",
"**/*.spec.ts"
]
}
File diff suppressed because it is too large Load Diff
@@ -7,9 +7,9 @@ This document outlines the best practices you should follow when working on the
## State management
React and Jotai handle state management in the codebase.
React and Recoil handle state management in the codebase.
### Use Jotai atoms to store state
### Use `useRecoilState` to store state
It's good practice to create as many atoms as you need to store your state.
@@ -20,16 +20,13 @@ It's better to use extra atoms than trying to be too concise with props drilling
</Warning>
```tsx
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
export const myAtomState = createAtomState<string>({
export const myAtomState = atom({
key: 'myAtomState',
defaultValue: 'default value',
default: 'default value',
});
export const MyComponent = () => {
const [myAtom, setMyAtom] = useAtomState(myAtomState);
const [myAtom, setMyAtom] = useRecoilState(myAtomState);
return (
<div>
@@ -46,7 +43,7 @@ export const MyComponent = () => {
Avoid using `useRef` to store state.
If you want to store state, you should use `useState` or Jotai atoms with `useAtomState`.
If you want to store state, you should use `useState` or `useRecoilState`.
See [how to manage re-renders](#managing-re-renders) if you feel like you need `useRef` to prevent some re-renders from happening.
@@ -86,8 +83,8 @@ You can apply the same for data fetching logic, with Apollo hooks.
// ❌ Bad, will cause re-renders even if data is not changing,
// because useEffect needs to be re-evaluated
export const PageComponent = () => {
const [data, setData] = useAtomState(dataState);
const [someDependency] = useAtomState(someDependencyState);
const [data, setData] = useRecoilState(dataState);
const [someDependency] = useRecoilState(someDependencyState);
useEffect(() => {
if(someDependency !== data) {
@@ -99,7 +96,9 @@ export const PageComponent = () => {
};
export const App = () => (
<PageComponent />
<RecoilRoot>
<PageComponent />
</RecoilRoot>
);
```
@@ -107,14 +106,14 @@ export const App = () => (
// ✅ Good, will not cause re-renders if data is not changing,
// because useEffect is re-evaluated in another sibling component
export const PageComponent = () => {
const [data, setData] = useAtomState(dataState);
const [data, setData] = useRecoilState(dataState);
return <div>{data}</div>;
};
export const PageData = () => {
const [data, setData] = useAtomState(dataState);
const [someDependency] = useAtomState(someDependencyState);
const [data, setData] = useRecoilState(dataState);
const [someDependency] = useRecoilState(someDependencyState);
useEffect(() => {
if(someDependency !== data) {
@@ -126,16 +125,16 @@ export const PageData = () => {
};
export const App = () => (
<>
<RecoilRoot>
<PageData />
<PageComponent />
</>
</RecoilRoot>
);
```
### Use atom family states and selectors
### Use recoil family states and recoil family selectors
Atom family states and selectors are a great way to avoid re-renders.
Recoil family states and selectors are a great way to avoid re-renders.
They are useful when you need to store a list of items.
@@ -83,9 +83,9 @@ See [Hooks](https://react.dev/learn/reusing-logic-with-custom-hooks) for more de
### States
Contains the state management logic. [Jotai](https://jotai.org) handles this.
Contains the state management logic. [RecoilJS](https://recoiljs.org) handles this.
- Selectors: Derived atoms (using `createAtomSelector`) compute values from other atoms and are automatically memoized.
- Selectors: See [RecoilJS Selectors](https://recoiljs.org/docs/basic-tutorial/selectors) for more details.
React's built-in state management still handles state within a component.
@@ -52,7 +52,7 @@ The project has a clean and simple stack, with minimal boilerplate code.
- [React](https://react.dev/)
- [Apollo](https://www.apollographql.com/docs/)
- [GraphQL Codegen](https://the-guild.dev/graphql/codegen)
- [Jotai](https://jotai.org/)
- [Recoil](https://recoiljs.org/docs/introduction/core-concepts)
- [TypeScript](https://www.typescriptlang.org/)
**Testing**
@@ -76,7 +76,7 @@ To avoid unnecessary [re-renders](/developers/contribute/capabilities/frontend-d
### State Management
[Jotai](https://jotai.org/) handles state management.
[Recoil](https://recoiljs.org/docs/introduction/core-concepts) handles state management.
See [best practices](/developers/contribute/capabilities/frontend-development/best-practices-front#state-management) for more information on state management.
@@ -159,7 +159,7 @@ export enum PageHotkeyScope {
}
```
Internally, the currently selected scope is stored in a Jotai atom that is shared across the application :
Internally, the currently selected scope is stored in a Recoil state that is shared across the application :
```tsx
export const currentHotkeyScopeState = createState<HotkeyScope>({
@@ -168,10 +168,10 @@ export const currentHotkeyScopeState = createState<HotkeyScope>({
});
```
But this atom should never be handled manually ! We'll see how to use it in the next section.
But this Recoil state should never be handled manually ! We'll see how to use it in the next section.
## How is it working internally?
We made a thin wrapper on top of [react-hotkeys-hook](https://react-hotkeys-hook.vercel.app/docs/intro) that makes it more performant and avoids unnecessary re-renders.
We also create a Jotai atom to handle the hotkey scope state and make it available everywhere in the application.
We also create a Recoil state to handle the hotkey scope state and make it available everywhere in the application.
@@ -14,7 +14,6 @@ Apps let you build and manage Twenty customizations **as code**. Instead of conf
**What you can do today:**
- Define custom objects and fields as code (managed data model)
- Build logic functions with custom triggers
- Define skills for AI agents
- Deploy the same app across multiple workspaces
## Prerequisites
@@ -31,6 +30,13 @@ Create a new app using the official scaffolder, then authenticate and start deve
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# If you don't use yarn@4
corepack enable
yarn install
# Authenticate using your API key (you'll be prompted)
yarn twenty auth:login
# Start dev mode: automatically syncs local changes to your workspace
yarn twenty app:dev
```
@@ -38,7 +44,7 @@ yarn twenty app:dev
The scaffolder supports three modes for controlling which example files are included:
```bash filename="Terminal"
# Default (exhaustive): all examples (object, field, logic function, front component, view, navigation menu item, skill)
# Default (exhaustive): all examples (object, field, logic function, front component, view, navigation menu item)
npx create-twenty-app@latest my-app
# Minimal: only core files (application-config.ts and default-role.ts)
@@ -60,9 +66,6 @@ yarn twenty function:logs
# Execute a function by name
yarn twenty function:execute -n my-function -p '{"name": "test"}'
# Execute the pre-install function
yarn twenty function:execute --preInstall
# Execute the post-install function
yarn twenty function:execute --postInstall
@@ -82,7 +85,7 @@ When you run `npx create-twenty-app@latest my-twenty-app`, the scaffolder:
- Copies a minimal base application into `my-twenty-app/`
- Adds a local `twenty-sdk` dependency and Yarn 4 configuration
- Creates config files and scripts wired to the `twenty` CLI
- Generates core files (application config, default function role, pre-install and post-install functions) plus example files based on the scaffolding mode
- Generates core files (application config, default function role, post-install function) plus example files based on the scaffolding mode
A freshly scaffolded app with the default `--exhaustive` mode looks like this:
@@ -109,19 +112,16 @@ my-twenty-app/
│ └── example-field.ts # Example standalone field definition
├── logic-functions/
│ ├── hello-world.ts # Example logic function
│ ├── pre-install.ts # Pre-install logic function
│ └── post-install.ts # Post-install logic function
├── front-components/
│ └── hello-world.tsx # Example front component
├── views/
│ └── example-view.ts # Example saved view definition
── navigation-menu-items/
└── example-navigation-menu-item.ts # Example sidebar navigation link
└── skills/
└── example-skill.ts # Example AI agent skill definition
── navigation-menu-items/
└── example-navigation-menu-item.ts # Example sidebar navigation link
```
With `--minimal`, only the core files are created (`application-config.ts`, `roles/default-role.ts`, `logic-functions/pre-install.ts`, and `logic-functions/post-install.ts`). With `--interactive`, you choose which example files to include.
With `--minimal`, only the core files are created (`application-config.ts`, `roles/default-role.ts`, and `logic-functions/post-install.ts`). With `--interactive`, you choose which example files to include.
At a high level:
@@ -142,14 +142,11 @@ The SDK detects entities by parsing your TypeScript files for **`export default
|-----------------|-------------|
| `defineObject()` | Custom object definitions |
| `defineLogicFunction()` | Logic function definitions |
| `definePreInstallLogicFunction()` | Pre-install logic function (runs before installation) |
| `definePostInstallLogicFunction()` | Post-install logic function (runs after installation) |
| `defineFrontComponent()` | Front component definitions |
| `defineRole()` | Role definitions |
| `defineField()` | Field extensions for existing objects |
| `defineView()` | Saved view definitions |
| `defineNavigationMenuItem()` | Navigation menu item definitions |
| `defineSkill()` | AI agent skill definitions |
<Note>
**File naming is flexible.** Entity detection is AST-based — the SDK scans your source files for the `export default define<Entity>({...})` pattern. You can organize your files and folders however you like. Grouping by entity type (e.g., `logic-functions/`, `roles/`) is just a convention for code organization, not a requirement.
@@ -169,8 +166,8 @@ export default defineObject({
Later commands will add more files and folders:
- `yarn twenty app:dev` will auto-generate two typed API clients in `node_modules/twenty-sdk/generated`: `CoreApiClient` (for workspace data via `/graphql`) and `MetadataApiClient` (for workspace configuration and file uploads via `/metadata`).
- `yarn twenty entity:add` will add entity definition files under `src/` for your custom objects, functions, front components, roles, skills, and more.
- `yarn twenty app:dev` will auto-generate a typed API client in `node_modules/twenty-sdk/generated` (typed Twenty client + workspace types).
- `yarn twenty entity:add` will add entity definition files under `src/` for your custom objects, functions, front components, or roles.
## Authentication
@@ -218,14 +215,11 @@ The SDK provides helper functions for defining your app entities. As described i
| `defineApplication()` | Configure application metadata (required, one per app) |
| `defineObject()` | Define custom objects with fields |
| `defineLogicFunction()` | Define logic functions with handlers |
| `definePreInstallLogicFunction()` | Define a pre-install logic function (one per app) |
| `definePostInstallLogicFunction()` | Define a post-install logic function (one per app) |
| `defineFrontComponent()` | Define front components for custom UI |
| `defineRole()` | Configure role permissions and object access |
| `defineField()` | Extend existing objects with additional fields |
| `defineView()` | Define saved views for objects |
| `defineNavigationMenuItem()` | Define sidebar navigation links |
| `defineSkill()` | Define AI agent skills |
These functions validate your configuration at build time and provide IDE autocompletion and type safety.
@@ -326,7 +320,6 @@ Every app has a single `application-config.ts` file that describes:
- **Who the app is**: identifiers, display name, and description.
- **How its functions run**: which role they use for permissions.
- **(Optional) variables**: keyvalue pairs exposed to your functions as environment variables.
- **(Optional) pre-install function**: a logic function that runs before the app is installed.
- **(Optional) post-install function**: a logic function that runs after the app is installed.
Use `defineApplication()` to define your application configuration:
@@ -335,6 +328,7 @@ Use `defineApplication()` to define your application configuration:
// src/application-config.ts
import { defineApplication } from 'twenty-sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
export default defineApplication({
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
@@ -350,6 +344,7 @@ export default defineApplication({
},
},
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
```
@@ -357,7 +352,7 @@ Notes:
- `universalIdentifier` fields are deterministic IDs you own; generate them once and keep them stable across syncs.
- `applicationVariables` become environment variables for your functions (for example, `DEFAULT_RECIPIENT_NAME` is available as `process.env.DEFAULT_RECIPIENT_NAME`).
- `defaultRoleUniversalIdentifier` must match the role file (see below).
- Pre-install and post-install functions are automatically detected during the manifest build. See [Pre-install functions](#pre-install-functions) and [Post-install functions](#post-install-functions).
- `postInstallLogicFunctionUniversalIdentifier` (optional) points to a logic function that runs automatically after the app is installed. See [Post-install functions](#post-install-functions).
#### Roles and permissions
@@ -430,10 +425,10 @@ Each function file uses `defineLogicFunction()` to export a configuration with a
// src/app/createPostCard.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk';
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk';
import { CoreApiClient, type Person } from 'twenty-sdk/generated';
import Twenty, { type Person } from '~/generated';
const handler = async (params: RoutePayload) => {
const client = new CoreApiClient();
const client = new Twenty(); // generated typed client
const name = 'name' in params.queryStringParameters
? params.queryStringParameters.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
: 'Hello world';
@@ -490,43 +485,6 @@ Notes:
- The `triggers` array is optional. Functions without triggers can be used as utility functions called by other functions.
- You can mix multiple trigger types in a single function.
### Pre-install functions
A pre-install function is a logic function that runs automatically before your app is installed on a workspace. This is useful for validation tasks, prerequisite checks, or preparing workspace state before the main installation proceeds.
When you scaffold a new app with `create-twenty-app`, a pre-install function is generated for you at `src/logic-functions/pre-install.ts`:
```typescript
// src/logic-functions/pre-install.ts
import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Pre install logic function executed successfully!', payload.previousVersion);
};
export default definePreInstallLogicFunction({
universalIdentifier: '<generated-uuid>',
name: 'pre-install',
description: 'Runs before installation to prepare the application.',
timeoutSeconds: 300,
handler,
});
```
You can also manually execute the pre-install function at any time using the CLI:
```bash filename="Terminal"
yarn twenty function:execute --preInstall
```
Key points:
- Pre-install functions use `definePreInstallLogicFunction()` — a specialized variant that omits trigger settings (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `isTool`).
- The handler receives an `InstallLogicFunctionPayload` with `{ previousVersion: string }` — the version of the app that was previously installed (or an empty string for fresh installs).
- Only one pre-install function is allowed per application. The manifest build will error if more than one is detected.
- The function's `universalIdentifier` is automatically set as `preInstallLogicFunctionUniversalIdentifier` on the application manifest during the build — you do not need to reference it in `defineApplication()`.
- The default timeout is set to 300 seconds (5 minutes) to allow for longer preparation tasks.
- Pre-install functions do not need triggers — they are invoked by the platform before installation or manually via `function:execute --preInstall`.
### Post-install functions
A post-install function is a logic function that runs automatically after your app is installed on a workspace. This is useful for one-time setup tasks such as seeding default data, creating initial records, or configuring workspace settings.
@@ -535,14 +493,16 @@ When you scaffold a new app with `create-twenty-app`, a post-install function is
```typescript
// src/logic-functions/post-install.ts
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
import { defineLogicFunction } from 'twenty-sdk';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Post install logic function executed successfully!', payload.previousVersion);
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
const handler = async (): Promise<void> => {
console.log('Post install logic function executed successfully!');
};
export default definePostInstallLogicFunction({
universalIdentifier: '<generated-uuid>',
export default defineLogicFunction({
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,
@@ -550,6 +510,17 @@ export default definePostInstallLogicFunction({
});
```
The function is wired into your app by referencing its universal identifier in `application-config.ts`:
```typescript
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
export default defineApplication({
// ...
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
```
You can also manually execute the post-install function at any time using the CLI:
```bash filename="Terminal"
@@ -557,10 +528,8 @@ yarn twenty function:execute --postInstall
```
Key points:
- Post-install functions use `definePostInstallLogicFunction()` — a specialized variant that omits trigger settings (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `isTool`).
- The handler receives an `InstallLogicFunctionPayload` with `{ previousVersion: string }` — the version of the app that was previously installed (or an empty string for fresh installs).
- Only one post-install function is allowed per application. The manifest build will error if more than one is detected.
- The function's `universalIdentifier` is automatically set as `postInstallLogicFunctionUniversalIdentifier` on the application manifest during the build — you do not need to reference it in `defineApplication()`.
- Post-install functions are standard logic functions — they use `defineLogicFunction()` like any other function.
- The `postInstallLogicFunctionUniversalIdentifier` field in `defineApplication()` is optional. If omitted, no function runs after installation.
- The default timeout is set to 300 seconds (5 minutes) to allow for longer setup tasks like data seeding.
- Post-install functions do not need triggers — they are invoked by the platform during installation or manually via `function:execute --postInstall`.
@@ -668,10 +637,10 @@ To mark a logic function as a tool, set `isTool: true` and provide a `toolInputS
```typescript
// src/logic-functions/enrich-company.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk';
import { CoreApiClient } from 'twenty-sdk/generated';
import Twenty from '~/generated';
const handler = async (params: { companyName: string; domain?: string }) => {
const client = new CoreApiClient();
const client = new Twenty();
const result = await client.mutation({
createTask: {
@@ -729,7 +698,7 @@ Key points:
Front components let you build custom React components that render within Twenty's UI. Use `defineFrontComponent()` to define components with built-in validation:
```typescript
// src/front-components/my-widget.tsx
// src/my-widget.front-component.tsx
import { defineFrontComponent } from 'twenty-sdk';
const MyWidget = () => {
@@ -751,66 +720,27 @@ export default defineFrontComponent({
Key points:
- Front components are React components that render in isolated contexts within Twenty.
- Use the `*.front-component.tsx` file suffix for automatic detection.
- The `component` field references your React component.
- Components are built and synced automatically during `yarn twenty app:dev`.
You can create new front components in two ways:
- **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new front component.
- **Manual**: Create a new `.tsx` file and use `defineFrontComponent()`, following the same pattern.
- **Manual**: Create a new `*.front-component.tsx` file and use `defineFrontComponent()`.
### Skills
### Generated typed client
Skills define reusable instructions and capabilities that AI agents can use within your workspace. Use `defineSkill()` to define skills with built-in validation:
The typed client is auto-generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated` based on your workspace schema. Use it in your functions:
```typescript
// src/skills/example-skill.ts
import { defineSkill } from 'twenty-sdk';
import Twenty from '~/generated';
export default defineSkill({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
name: 'sales-outreach',
label: 'Sales Outreach',
description: 'Guides the AI agent through a structured sales outreach process',
icon: 'IconBrain',
content: `You are a sales outreach assistant. When reaching out to a prospect:
1. Research the company and recent news
2. Identify the prospect's role and likely pain points
3. Draft a personalized message referencing specific details
4. Keep the tone professional but conversational`,
});
```
Key points:
- `name` is a unique identifier string for the skill (kebab-case recommended).
- `label` is the human-readable display name shown in the UI.
- `content` contains the skill instructions — this is the text the AI agent uses.
- `icon` (optional) sets the icon displayed in the UI.
- `description` (optional) provides additional context about the skill's purpose.
You can create new skills in two ways:
- **Scaffolded**: Run `yarn twenty entity:add` and choose the option to add a new skill.
- **Manual**: Create a new file and use `defineSkill()`, following the same pattern.
### Generated typed clients
Two typed clients are auto-generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated` based on your workspace schema:
- **`CoreApiClient`** — queries the `/graphql` endpoint for workspace data
- **`MetadataApiClient`** — queries the `/metadata` endpoint for workspace configuration and file uploads
```typescript
import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/generated';
const client = new CoreApiClient();
const client = new Twenty();
const { me } = await client.query({ me: { id: true, displayName: true } });
const metadataClient = new MetadataApiClient();
const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id: true } });
```
Both clients are re-generated automatically by `yarn twenty app:dev` whenever your objects or fields change.
The client is re-generated automatically by `yarn twenty app:dev` whenever your objects or fields change.
#### Runtime credentials in logic functions
@@ -824,51 +754,6 @@ Notes:
- The API key's permissions are determined by the role referenced in your `application-config.ts` via `defaultRoleUniversalIdentifier`. This is the default role used by logic functions of your application.
- Applications can define roles to follow leastprivilege. Grant only the permissions your functions need, then point `defaultRoleUniversalIdentifier` to that role's universal identifier.
#### Uploading files
The generated `MetadataApiClient` includes an `uploadFile` method for attaching files to file-type fields on your workspace objects. Because standard GraphQL clients do not support multipart file uploads natively, the client provides this dedicated method that implements the [GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec) under the hood.
```typescript
import { MetadataApiClient } from 'twenty-sdk/generated';
import * as fs from 'fs';
const metadataClient = new MetadataApiClient();
const fileBuffer = fs.readFileSync('./invoice.pdf');
const uploadedFile = await metadataClient.uploadFile(
fileBuffer, // file contents as a Buffer
'invoice.pdf', // filename
'application/pdf', // MIME type (defaults to 'application/octet-stream')
'58a0a314-d7ea-4865-9850-7fb84e72f30b', // field universal identifier
);
console.log(uploadedFile);
// { id: '...', path: '...', size: 12345, createdAt: '...', url: 'https://...' }
```
The method signature:
```typescript
uploadFile(
fileBuffer: Buffer,
filename: string,
contentType: string,
fieldMetadataUniversalIdentifier: string,
): Promise<{ id: string; path: string; size: number; createdAt: string; url: string }>
```
| Parameter | Type | Description |
|-----------|------|-------------|
| `fileBuffer` | `Buffer` | The raw file contents |
| `filename` | `string` | The name of the file (used for storage and display) |
| `contentType` | `string` | MIME type of the file (defaults to `application/octet-stream` if omitted) |
| `fieldMetadataUniversalIdentifier` | `string` | The `universalIdentifier` of the file-type field on your object |
Key points:
- The `uploadFile` method is available on `MetadataApiClient` because the upload mutation is resolved by the `/metadata` endpoint.
- It uses the field's `universalIdentifier` (not its workspace-specific ID), so your upload code works across any workspace where your app is installed — consistent with how apps reference fields everywhere else.
- The returned `url` is a signed URL you can use to access the uploaded file.
### Hello World example
@@ -11,7 +11,7 @@ title: كائنات مخصصة
## مخطط على مستوى عالي
<div style={{textAlign: 'center'}}>
<img src="/images/docs/server/custom-object-schema.png" alt="مخطط على مستوى عالي" />
<img src="/images/docs/server/custom-object-schema.png" alt="مخطط على مستوى عالي" />
</div>
<br />
@@ -27,7 +27,7 @@ title: كائنات مخصصة
لإضافة كائن مخصص، سيقوم عضو مساحة العمل بالاستعلام عن واجهة برمجة التطبيقات /metadata. يقوم هذا بتحديث البيانات الوصفية وفقًا لذلك ويحسب مخطط GraphQL استنادًا إلى البيانات الوصفية، ويخزنها في ذاكرة GQL للاستخدام لاحقًا.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/server/add-custom-objects.jpeg" alt="استعلام واجهة برمجة التطبيقات /metadata لإضافة الكائنات المخصصة" />
<img src="/images/docs/server/add-custom-objects.jpeg" alt="استعلام واجهة برمجة التطبيقات /metadata لإضافة الكائنات المخصصة" />
</div>
<br />
@@ -35,5 +35,5 @@ title: كائنات مخصصة
لجلب البيانات، تتضمن العملية إجراء استعلامات من خلال نقطة النهاية /graphql وتمريرها من خلال محلل الاستعلام.
<div style={{textAlign: 'center'}}>
<img src="/images/docs/server/custom-object-schema.png" alt="استعلام نقطة النهاية /graphql لجلب البيانات" />
<img src="/images/docs/server/custom-object-schema.png" alt="استعلام نقطة النهاية /graphql لجلب البيانات" />
</div>
@@ -60,11 +60,9 @@ npx nx run twenty-server:command workspace:sync-metadata -f
```
<Warning>
سيؤدي هذا إلى إسقاط قاعدة البيانات وإعادة تشغيل الهجرات والبذور.
سيؤدي هذا إلى إسقاط قاعدة البيانات وإعادة تشغيل الهجرات والبذور.
تأكد من عمل نسخة احتياطية لأي بيانات تريد الاحتفاظ بها قبل تشغيل هذا الأمر.
تأكد من عمل نسخة احتياطية لأي بيانات تريد الاحتفاظ بها قبل تشغيل هذا الأمر.
</Warning>
## "التقنية المستخدمة"
@@ -43,9 +43,7 @@ cp .env.example .env
## التطوير
<Warning>
تأكد من تشغيل `yarn build` قبل أي أمر `zapier`.
تأكد من تشغيل `yarn build` قبل أي أمر `zapier`.
</Warning>
### تجربة
@@ -6,29 +6,24 @@ title: أفضل الممارسات
## إدارة الحالة
تقوم React و Jotai بإدارة الحالة في قاعدة الشيفرة.
تقوم React و Recoil بإدارة الحالة في قاعدة الشيفرة.
### استخدم ذرات Jotai لتخزين الحالة
### استخدم `useRecoilState` لتخزين الحالة
من الجيد إنشاء أكبر عدد ممكن من الذرات لتخزين الحالة الخاصة بك.
<Warning>
من الأفضل استخدام ذرات إضافية بدلاً من محاولة أن تكون مقتضبًا باستخدام تمرير الخصائص.
من الأفضل استخدام ذرات إضافية بدلاً من محاولة أن تكون مقتضبًا باستخدام تمرير الخصائص.
</Warning>
```tsx
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
export const myAtomState = createAtomState<string>({
export const myAtomState = atom({
key: 'myAtomState',
defaultValue: 'default value',
default: 'default value',
});
export const MyComponent = () => {
const [myAtom, setMyAtom] = useAtomState(myAtomState);
const [myAtom, setMyAtom] = useRecoilState(myAtomState);
return (
<div>
@@ -45,7 +40,7 @@ export const MyComponent = () => {
تجنب استخدام `useRef` لتخزين الحالة.
إذا كنت ترغب في تخزين الحالة، يجب أن تستخدم `useState` أو ذرات Jotai مع `useAtomState`.
If you want to store state, you should use `useState` or `useRecoilState`.
انظر [كيفية إدارة إعادة العرض](#managing-re-renders) إذا شعرت أنك بحاجة إلى `useRef` لمنع بعض إعادة العرض من الحدوث.
@@ -82,11 +77,11 @@ If you feel like you need to add a `useEffect` in your root component, you shoul
يمكنك تطبيق نفس الشيء على منطق جلب البيانات، مع الخُطافات Apollo.
```tsx
// ❌ سيئ، سيتسبب في إعادة التصيير حتى إذا لم تتغير البيانات،
// ❌ سيّئ، سيتسبب في إعادة التصيير حتى إذا لم تتغير البيانات،
// لأن useEffect يحتاج إلى إعادة التقييم
export const PageComponent = () => {
const [data, setData] = useAtomState(dataState);
const [someDependency] = useAtomState(someDependencyState);
const [data, setData] = useRecoilState(dataState);
const [someDependency] = useRecoilState(someDependencyState);
useEffect(() => {
if(someDependency !== data) {
@@ -98,7 +93,9 @@ export const PageComponent = () => {
};
export const App = () => (
<PageComponent />
<RecoilRoot>
<PageComponent />
</RecoilRoot>
);
```
@@ -106,14 +103,14 @@ export const App = () => (
// ✅ جيّد، لن يتسبب في إعادة التصيير إذا لم تتغير البيانات،
// لأن useEffect يُعاد تقييمه في مكوّن شقيق آخر
export const PageComponent = () => {
const [data, setData] = useAtomState(dataState);
const [data, setData] = useRecoilState(dataState);
return <div>{data}</div>;
};
export const PageData = () => {
const [data, setData] = useAtomState(dataState);
const [someDependency] = useAtomState(someDependencyState);
const [data, setData] = useRecoilState(dataState);
const [someDependency] = useRecoilState(someDependencyState);
useEffect(() => {
if(someDependency !== data) {
@@ -125,16 +122,16 @@ export const PageData = () => {
};
export const App = () => (
<>
<RecoilRoot>
<PageData />
<PageComponent />
</>
</RecoilRoot>
);
```
### استخدم حالات عائلة الذرات والمحددات
### استخدم حالات عائلة Recoil ومحددات عائلة Recoil
تُعد حالات عائلة الذرات والمحددات طريقة رائعة لتجنّب عمليات إعادة التصيير.
حالات عائلة Recoil والمحددات تعتبر طريقة رائعة لتجنب إعادة العرض.
إنها مفيدة عندما تحتاج إلى تخزين قائمة من العناصر.
@@ -82,9 +82,9 @@ module1
### الحالات
تشمل منطق إدارة الحالة. [Jotai](https://jotai.org) يتولّى ذلك.
تشمل منطق إدارة الحالة. [RecoilJS](https://recoiljs.org) يتولّى ذلك.
* المحددات: الذرات المشتقة (باستخدام `createAtomSelector`) تحسب قيماً من ذرات أخرى وتخزن نتائجها في الذاكرة مؤقتاً تلقائياً.
* المحددات: انظر [RecoilJS Selectors](https://recoiljs.org/docs/basic-tutorial/selectors) لمزيد من التفاصيل.
لا تزال إدارة الحالة المدمجة في React تتولّى الحالة داخل المكوّن.
@@ -49,7 +49,7 @@ title: أوامر الواجهة الأمامية
* "[React](https://react.dev/)"
* "[Apollo](https://www.apollographql.com/docs/)"
* "[GraphQL Codegen](https://the-guild.dev/graphql/codegen)"
* [Jotai](https://jotai.org/)
* "[Recoil](https://recoiljs.org/docs/introduction/core-concepts)"
* "[TypeScript](https://www.typescriptlang.org/)"
**الاختبار**
@@ -73,7 +73,7 @@ To avoid unnecessary [re-renders](/l/ar/developers/contribute/capabilities/front
### "إدارة الحالة"
[Jotai](https://jotai.org/) يتعامل مع إدارة الحالة.
"[Recoil](https://recoiljs.org/docs/introduction/core-concepts) يتعامل مع إدارة الحالة."
"راجع [أفضل الممارسات](/l/ar/developers/contribute/capabilities/frontend-development/best-practices-front#state-management) لمزيد من المعلومات حول إدارة الحالة."
@@ -160,7 +160,7 @@ export enum PageHotkeyScope {
}
```
داخليًا، يتم تخزين النطاق المحدد حاليًا في ذرة Jotai مشتركة عبر التطبيق:
داخليًا، يتم تخزين النطاق المحدد حاليًا في حالة Recoil مشتركة عبر التطبيق:
```tsx
export const currentHotkeyScopeState = createState<HotkeyScope>({
@@ -169,10 +169,10 @@ export const currentHotkeyScopeState = createState<HotkeyScope>({
});
```
لكن لا يجب التعامل مع هذه الذرة يدويًا! سنرى كيف يمكن استخدامها في القسم التالي.
لكن لا يجب التعامل مع هذه الحالة Recoil يدويًا! سنرى كيف يمكن استخدامها في القسم التالي.
## كيف يعمل داخليًا؟
قمنا بإنشاء غلاف رقيق فوق [react-hotkeys-hook](https://react-hotkeys-hook.vercel.app/docs/intro) والذي يجعله أكثر كفاءة ويتجنب عمليات إعادة التقديم غير الضرورية.
ونقوم أيضًا بإنشاء ذرة Jotai للتعامل مع حالة نطاق مفاتيح الاختصار وجعلها متاحة في جميع أنحاء التطبيق.
ونقوم أيضًا بإنشاء حالة Recoil للتعامل مع حالة نطاق المفتاح وجعلها متاحة في جميع أنحاء التطبيق.
@@ -13,9 +13,7 @@ info: تعرّف على كيفية التعاون باستخدام Figma الخ
تتوفر المميزات الرئيسية فقط للمستخدمين الذين قاموا بتسجيل الدخول، مثل وضع المطور والقدرة على اختيار إطار مخصص.
<Warning>
لن تتمكن من التعاون بفعالية بدون حساب.
لن تتمكن من التعاون بفعالية بدون حساب.
</Warning>
## هيكل فيجما
@@ -6,66 +6,66 @@ description: الدليل للمساهمين (أو المطورين الفضول
## المتطلبات الأساسية
<Tabs>
<Tab title="Linux و MacOS">
<Tab title="Linux و MacOS">
قبل أن تتمكن من تثبيت واستخدام Twenty، تأكد من تثبيت الأمور التالية على جهاز الكمبيوتر الخاص بك:
قبل أن تتمكن من تثبيت واستخدام Twenty، تأكد من تثبيت الأمور التالية على جهاز الكمبيوتر الخاص بك:
* [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git)
* [Node v24.5.0](https://nodejs.org/en/download)
* [yarn v4](https://yarnpkg.com/getting-started/install)
* [nvm](https://github.com/nvm-sh/nvm/blob/master/README.md)
* [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git)
* [Node v24.5.0](https://nodejs.org/en/download)
* [yarn v4](https://yarnpkg.com/getting-started/install)
* [nvm](https://github.com/nvm-sh/nvm/blob/master/README.md)
<Warning>
لن يعمل `npm` ، يجب عليك استخدام `yarn` بدلًا من ذلك. يأتي Yarn الآن مع Node.js، لذا لست بحاجة إلى تثبيته بشكل منفصل.
عليك فقط تشغيل `corepack enable` لتفعيل Yarn إذا لم تقم بذلك بعد.
</Warning>
<Warning>
لن يعمل `npm` ، يجب عليك استخدام `yarn` بدلًا من ذلك. يأتي Yarn الآن مع Node.js، لذا لست بحاجة إلى تثبيته بشكل منفصل.
عليك فقط تشغيل `corepack enable` لتفعيل Yarn إذا لم تقم بذلك بعد.
</Warning>
</Tab>
</Tab>
<Tab title="ويندوز (WSL)">
1. ثبّت WSL
افتح PowerShell كمسؤول ثم نفّذ:
<Tab title="ويندوز (WSL)">
```powershell
wsl --install
```
1. ثبّت WSL
افتح PowerShell كمسؤول ثم نفّذ:
```powershell
wsl --install
```
يجب أن ترى الآن مطالبة لإعادة تشغيل جهاز الكمبيوتر الخاص بك. إذا لم يكن كذلك، فأعد تشغيله يدويًا.
يجب أن ترى الآن مطالبة لإعادة تشغيل جهاز الكمبيوتر الخاص بك. إذا لم يكن كذلك، فأعد تشغيله يدويًا.
عند إعادة التشغيل، ستُفتح نافذة PowerShell وسيتم تثبيت Ubuntu. قد يستغرق هذا وقتًا طويلاً.
سترى مطالبة لإنشاء اسم المستخدم وكلمة المرور لتثبيت Ubuntu الخاص بك.
عند إعادة التشغيل، ستُفتح نافذة PowerShell وسيتم تثبيت Ubuntu. قد يستغرق هذا وقتًا طويلاً.
سترى مطالبة لإنشاء اسم المستخدم وكلمة المرور لتثبيت Ubuntu الخاص بك.
2. تثبيت وإعداد git
2. تثبيت وإعداد git
```bash
sudo apt-get install git
```bash
sudo apt-get install git
git config --global user.name "Your Name"
git config --global user.name "Your Name"
git config --global user.email "youremail@domain.com"
```
git config --global user.email "youremail@domain.com"
```
3. تثبيت nvm و node.js و yarn
3. تثبيت nvm و node.js و yarn
<Warning>
استخدم `nvm` لتثبيت نسخة `node` الصحيحة. الملف `.nvmrc` يضمن استخدام جميع المشاركين لنفس النسخة.
</Warning>
<Warning>
استخدم `nvm` لتثبيت نسخة `node` الصحيحة. الملف `.nvmrc` يضمن استخدام جميع المشاركين لنفس النسخة.
</Warning>
```bash
sudo apt-get install curl
```bash
sudo apt-get install curl
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash
```
أغلق وأعد فتح برنامجك الطرفي لاستخدام nvm. ثم قم بتشغيل الأوامر التالية.
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash
```
```bash
أغلق وأعد فتح برنامجك الطرفي لاستخدام nvm. ثم قم بتشغيل الأوامر التالية.
nvm install # يثبت إصدار node الموصى به
```bash
nvm use # استخدم إصدار node الموصى به
nvm install # يثبت إصدار node الموصى به
corepack enable
```
nvm use # استخدم إصدار node الموصى به
</Tab>
corepack enable
```
</Tab>
</Tabs>
---
@@ -75,19 +75,19 @@ corepack enable
في الطرفية الخاصة بك، قم بتشغيل الأمر التالي.
<Tabs>
<Tab title="SSH (موصى به)">
إذا لم تكن قد أعددت مفاتيح SSH بالفعل، يمكنك معرفة كيفية القيام بذلك [هنا](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/about-ssh).
```bash
git clone git@github.com:twentyhq/twenty.git
```
</Tab>
<Tab title="HTTPS">
<Tab title="SSH (موصى به)">
إذا لم تكن قد أعددت مفاتيح SSH بالفعل، يمكنك معرفة كيفية القيام بذلك [هنا](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/about-ssh).
```bash
git clone https://github.com/twentyhq/twenty.git
```
```bash
git clone git@github.com:twentyhq/twenty.git
```
</Tab>
</Tab>
<Tab title="HTTPS">
```bash
git clone https://github.com/twentyhq/twenty.git
```
</Tab>
</Tabs>
## الخطوة 2: انتقل إلى جذر المشروع
@@ -104,16 +104,20 @@ cd twenty
<Tab title="Linux">
**الخيار 1 (المفضل):** لتوفير قاعدة بياناتك محليًا:
استخدم الرابط التالي لتثبيت Postgresql على جهاز Linux الخاص بك: [تثبيت Postgresql](https://www.postgresql.org/download/linux/)
```bash
psql postgres -c "CREATE DATABASE \"default\";" -c "CREATE DATABASE test;"
```
ملاحظة: قد تحتاج إلى إضافة `sudo -u postgres` إلى الأمر قبل `psql` لتجنب أخطاء الإذن.
**الخيار 2:** إذا كنت قد قمت بتثبيت docker:
```bash
make -C packages/twenty-docker postgres-on-docker
```
</Tab>
<Tab title="نظام Mac OS">
**الخيار 1 (المفضل):** لتوفير قاعدة بياناتك محليًا مع `brew`:
@@ -125,6 +129,7 @@ cd twenty
```
يمكنك التحقق مما إذا كان خادم PostgreSQL يعمل بتنفيذ:
```bash
brew services list
```
@@ -133,6 +138,7 @@ cd twenty
عبر Homebrew على MacOS. بدلاً من ذلك، فإنه ينشئ دور PostgreSQL يطابق
اسم المستخدم الخاص بك في MacOS (مثل "john").
للتحقق وإنشاء المستخدم `postgres` إذا لزم الأمر، اتبع هذه الخطوات:
```bash
# قم بالاتصال بPostgreSQL
psql postgres
@@ -141,48 +147,59 @@ cd twenty
```
بمجرد أن تكون عند مطالبة psql (postgres=#)، قم بتشغيل:
```bash
# قائمة الأدوار الموجودة في PostgreSQL
\du
```
```bash
# قائمة الأدوار الموجودة في PostgreSQL
\du
```
سترى مخرجات مشابهة ل:
```bash
اسم الأدوار | الخصائص | عضو في
-----------+-------------+-----------
john | مشرف نظام | {}
```
```bash
اسم الأدوار | الخصائص | عضو في
-----------+-------------+-----------
john | مشرف نظام | {}
```
إذا لم ترَ دور `postgres` مدرجًا، انتقل إلى الخطوة التالية.
قم بإنشاء دور `postgres` يدويًا:
```bash
CREATE ROLE postgres WITH SUPERUSER LOGIN;
```
```bash
CREATE ROLE postgres WITH SUPERUSER LOGIN;
```
يقوم هذا بإنشاء دور مشرف نظام باسم `postgres` مع إمكانية تسجيل الدخول.
```bash
اسم الدور | الخصائص | عضو في
-----------+-------------+-----------
postgres | مشرف نظام | {}
john | مشرف نظام | {}
```
```bash
اسم الدور | الخصائص | عضو في
-----------+-------------+-----------
postgres | مشرف نظام | {}
john | مشرف نظام | {}
```
**الخيار 2:** إذا كنت قد قمت بتثبيت docker:
```bash
make -C packages/twenty-docker postgres-on-docker
```
</Tab>
<Tab title="ويندوز (WSL)">
يجب أن تُنفذ جميع الخطوات التالية في تيرمينال WSL (داخل جهازك الافتراضي)
**الخيار 1:** لتوفير قاعدة بيانات Postgresql الخاصة بك محليًا:
استخدم الرابط التالي لتثبيت Postgresql على جهاز Linux الافتراضي الخاص بك: [تثبيت Postgresql](https://www.postgresql.org/download/linux/)
```bash
psql postgres -c "CREATE DATABASE \"default\";" -c "CREATE DATABASE test;"
```
ملاحظة: قد تحتاج إلى إضافة `sudo -u postgres` إلى الأمر قبل `psql` لتجنب أخطاء الإذن.
**الخيار 2:** إذا كنت قد قمت بتثبيت docker:
تشغيل Docker على WSL يضيف طبقة إضافية من التعقيد.
استخدم هذا الخيار فقط إذا كنت مرتاحًا مع الخطوات الإضافية المتضمنة، بما في ذلك تشغيل [Docker Desktop WSL2](https://docs.docker.com/desktop/wsl).
```bash
make -C packages/twenty-docker postgres-on-docker
```
@@ -201,28 +218,35 @@ cd twenty
استخدم الرابط التالي لتثبيت Redis على جهاز Linux: [تثبيت Redis](https://redis.io/docs/latest/operate/oss_and_stack/install/install-redis/install-redis-on-linux/)
**الخيار 2:** إذا كنت قد قمت بتثبيت docker:
```bash
make -C packages/twenty-docker redis-on-docker
```
</Tab>
<Tab title="Mac OS">
**الخيار 1 (المفضل):** لتوفير Redis الخاص بك محليًا مع `brew`:
```bash
brew install redis
```
ابدأ خادم redis الخاص بك:
`brew services start redis`
**الخيار 2:** إذا كنت قد قمت بتثبيت docker:
```bash
make -C packages/twenty-docker redis-on-docker
```
</Tab>
<Tab title="ويندوز (WSL)">
**الخيار 1:** لتوفير Redis الخاص بك محليًا:
استخدم الرابط التالي لتثبيت Redis على جهاز Linux الافتراضي الخاص بك: [تثبيت Redis](https://redis.io/docs/latest/operate/oss_and_stack/install/install-redis/install-redis-on-linux/)
**الخيار 2:** إذا كنت قد قمت بتثبيت docker:
```bash
make -C packages/twenty-docker redis-on-docker
```
@@ -243,7 +267,7 @@ cp ./packages/twenty-server/.env.example ./packages/twenty-server/.env
```
<Info>
**وضع تعدد مساحات العمل:** بشكل افتراضي، يعمل Twenty في وضع مساحة عمل واحدة حيث يمكن إنشاء مساحة عمل واحدة فقط. لتمكين دعم تعدد مساحات العمل (مفيد لاختبار الميزات المعتمدة على النطاقات الفرعية)، عيّن `IS_MULTIWORKSPACE_ENABLED=true` في ملف الخادم `.env`. راجع [وضع تعدد مساحات العمل](/l/ar/developers/self-host/capabilities/setup#multi-workspace-mode) للحصول على التفاصيل.
**وضع تعدد مساحات العمل:** بشكل افتراضي، يعمل Twenty في وضع مساحة عمل واحدة حيث يمكن إنشاء مساحة عمل واحدة فقط. لتمكين دعم تعدد مساحات العمل (مفيد لاختبار الميزات المعتمدة على النطاقات الفرعية)، عيّن `IS_MULTIWORKSPACE_ENABLED=true` في ملف الخادم `.env`. راجع [وضع تعدد مساحات العمل](/l/ar/developers/self-host/capabilities/setup#multi-workspace-mode) للحصول على التفاصيل.
</Info>
## الخطوة 6: تثبيت التبعيات
@@ -263,12 +287,15 @@ yarn
اعتمادًا على توزيعة Linux الخاصة بك، قد يتم بدء خادم Redis تلقائيًا.
إذا لم يكن كذلك، تحقق من [دليل تثبيت Redis](https://redis.io/docs/latest/operate/oss_and_stack/install/install-redis/) لتوزيعتك.
</Tab>
<Tab title="نظام Mac OS">
من المفترض أن يكون Redis قد تم تشغيله بالفعل. إذا لم يكن كذلك، قم بتشغيل:
من المفترض أن يكون Redis قد تم تشغيله بالفعل. إذا لم يكن كذلك، قم بتشغيل:
```bash
brew services start redis
```
</Tab>
<Tab title="ويندوز (WSL)">
اعتمادًا على توزيعة Linux الخاصة بك، قد يتم بدء خادم Redis تلقائيًا.
إذا لم يكن كذلك، تحقق من [دليل تثبيت ريديس](https://redis.io/docs/latest/operate/oss_and_stack/install/install-redis/) لتوزيعتك.
@@ -25,6 +25,7 @@ Twenty مفتوح المصدر ويرحب بمساهمات المجتمع. سو
<Card title="تقارير الأخطاء والطلبات" icon="bug" href="/l/ar/developers/contribute/capabilities/bug-and-requests">
أبلغ عن المشكلات أو اطلب ميزات
</Card>
<Card title="تطوير الواجهة الأمامية" icon="browser" href="/l/ar/developers/contribute/capabilities/frontend-development">
ساهم في واجهة المستخدم
</Card>
@@ -17,7 +17,7 @@ import { VimeoEmbed } from '/snippets/vimeo-embed.mdx';
* **وثائق مخصصة**: يتم إنشاؤها خصيصًا لنموذج بيانات مساحة عملك
<Note>
وثائق واجهة برمجة التطبيقات المخصصة لك متاحة ضمن **الإعدادات → API & Webhooks** بعد إنشاء مفتاح API. نظرًا لأن Twenty تُنشئ واجهات برمجة تطبيقات تتطابق مع نموذج البيانات المخصص لديك، فإن الوثائق فريدة لمساحة عملك.
وثائق واجهة برمجة التطبيقات المخصصة لك متاحة ضمن **الإعدادات → API & Webhooks** بعد إنشاء مفتاح API. نظرًا لأن Twenty تُنشئ واجهات برمجة تطبيقات تتطابق مع نموذج البيانات المخصص لديك، فإن الوثائق فريدة لمساحة عملك.
</Note>
## نوعا واجهات برمجة التطبيقات
@@ -81,7 +81,7 @@ Authorization: Bearer YOUR_API_KEY
<VimeoEmbed videoId="928786722" title="إنشاء مفتاح API" />
<Warning>
يمنح مفتاح API الخاص بك الوصول إلى بيانات حساسة. لا تشاركه مع خدمات غير موثوقة. إذا تم اختراقه، عطّلْه فوراً وأنشئ مفتاحاً جديداً.
يمنح مفتاح API الخاص بك الوصول إلى بيانات حساسة. لا تشاركه مع خدمات غير موثوقة. إذا تم اختراقه، عطّلْه فوراً وأنشئ مفتاحاً جديداً.
</Warning>
### تعيين دور لمفتاح API
@@ -143,5 +143,5 @@ Authorization: Bearer YOUR_API_KEY
| **حجم الدفعة** | 60 سجل لكل استدعاء |
<Tip>
استخدم عمليات الدفعات لزيادة الإنتاجية — عالج ما يصل إلى 60 سجلًا في استدعاء API واحد بدلاً من إجراء طلبات فردية.
استخدم عمليات الدفعات لزيادة الإنتاجية — عالج ما يصل إلى 60 سجلًا في استدعاء API واحد بدلاً من إجراء طلبات فردية.
</Tip>
@@ -4,7 +4,7 @@ description: أنشئ وأدِر تخصيصات Twenty على هيئة كود.
---
<Warning>
التطبيقات حاليًا في مرحلة الاختبار الألفا. الميزة تعمل لكنها لا تزال قيد التطور.
التطبيقات حاليًا في مرحلة الاختبار الألفا. الميزة تعمل لكنها لا تزال قيد التطور.
</Warning>
## ما هي التطبيقات؟
@@ -15,7 +15,6 @@ description: أنشئ وأدِر تخصيصات Twenty على هيئة كود.
* عرِّف كائنات وحقولًا مخصصة على شكل كود (نموذج بيانات مُدار)
* أنشئ وظائف منطقية مع مشغلات مخصصة
* حدد المهارات لوكلاء الذكاء الاصطناعي
* انشر التطبيق نفسه عبر مساحات عمل متعددة
## المتطلبات الأساسية
@@ -32,6 +31,13 @@ description: أنشئ وأدِر تخصيصات Twenty على هيئة كود.
npx create-twenty-app@latest my-twenty-app
cd my-twenty-app
# إذا كنت لا تستخدم yarn@4
corepack enable
yarn install
# قم بالمصادقة باستخدام مفتاح واجهة برمجة التطبيقات الخاص بك (سيُطلب منك ذلك)
yarn twenty auth:login
# ابدأ وضع التطوير: يُزامن التغييرات المحلية تلقائيًا مع مساحة العمل الخاصة بك
yarn twenty app:dev
```
@@ -39,7 +45,7 @@ yarn twenty app:dev
يدعم المُنشئ ثلاثة أوضاع للتحكم في ملفات الأمثلة التي سيتم تضمينها:
```bash filename="Terminal"
# الافتراضي (شامل): جميع الأمثلة (كائن، حقل، دالة منطقية، مكوّن الواجهة الأمامية، عرض، عنصر قائمة التنقل، مهارة)
# الافتراضي (شامل): جميع الأمثلة (كائن، حقل، دالة منطقية، مكوّن الواجهة الأمامية، عرض، عنصر قائمة التنقل)
npx create-twenty-app@latest my-app
# الأدنى: الملفات الأساسية فقط (application-config.ts و default-role.ts)
@@ -52,25 +58,22 @@ npx create-twenty-app@latest my-app --interactive
من هنا يمكنك:
```bash filename="Terminal"
# أضف كيانًا جديدًا إلى تطبيقك (موجّه)
# Add a new entity to your application (guided)
yarn twenty entity:add
# راقب سجلات وظائف تطبيقك
# Watch your application's function logs
yarn twenty function:logs
# نفّذ وظيفة بالاسم
# Execute a function by name
yarn twenty function:execute -n my-function -p '{"name": "test"}'
# نفّذ دالة ما قبل التثبيت
yarn twenty function:execute --preInstall
# نفّذ دالة ما بعد التثبيت
# Execute the post-install function
yarn twenty function:execute --postInstall
# أزل تثبيت التطبيق من مساحة العمل الحالية
# Uninstall the application from the current workspace
yarn twenty app:uninstall
# اعرض مساعدة الأوامر
# Display commands' help
yarn twenty help
```
@@ -83,7 +86,7 @@ yarn twenty help
* ينسخ تطبيقًا أساسيًا مصغّرًا إلى `my-twenty-app/`
* يضيف اعتمادًا محليًا `twenty-sdk` وتهيئة Yarn 4
* ينشئ ملفات ضبط ونصوصًا مرتبطة بـ `twenty` CLI
* يُنشئ الملفات الأساسية (تهيئة التطبيق، دور الدالة الافتراضي، دالتا ما قبل التثبيت وما بعد التثبيت) بالإضافة إلى ملفات أمثلة استنادًا إلى وضع الإنشاء.
* يُنشئ الملفات الأساسية (تهيئة التطبيق، دور الدالة الافتراضي، دالة ما بعد التثبيت) بالإضافة إلى ملفات الأمثلة بحسب وضع الإنشاء
يبدو التطبيق المُنشأ حديثًا باستخدام الوضع الافتراضي `--exhaustive` كما يلي:
@@ -110,19 +113,16 @@ my-twenty-app/
│ └── example-field.ts # تعريف حقل مستقل — مثال
├── logic-functions/
│ ├── hello-world.ts # دالة منطقية — مثال
│ ├── pre-install.ts # دالة منطقية لما قبل التثبيت
│ └── post-install.ts # دالة منطقية لما بعد التثبيت
├── front-components/
│ └── hello-world.tsx # مكوّن واجهة أمامية — مثال
├── views/
│ └── example-view.ts # تعريف عرض محفوظ — مثال
── navigation-menu-items/
└── example-navigation-menu-item.ts # رابط تنقّل في الشريط الجانبي — مثال
└── skills/
└── example-skill.ts # تعريف مهارة لوكيل الذكاء الاصطناعي — مثال
── navigation-menu-items/
└── example-navigation-menu-item.ts # رابط تنقّل في الشريط الجانبي — مثال
```
مع `--minimal`، سيتم إنشاء الملفات الأساسية فقط (`application-config.ts`، `roles/default-role.ts`، `logic-functions/pre-install.ts`، و`logic-functions/post-install.ts`). مع `--interactive`، تختار ملفات الأمثلة التي تريد تضمينها.
مع `--minimal`، سيتم إنشاء الملفات الأساسية فقط (`application-config.ts` و`roles/default-role.ts` و`logic-functions/post-install.ts`). مع `--interactive`، تختار ملفات الأمثلة التي تريد تضمينها.
بشكل عام:
@@ -139,21 +139,18 @@ my-twenty-app/
يكتشف SDK الكيانات عبر تحليل ملفات TypeScript الخاصة بك بحثًا عن استدعاءات **`export default define<Entity>({...})`**. يحتوي كل نوع كيان على دالة مساعدة مقابلة يتم تصديرها من `twenty-sdk`:
| دالة مساعدة | نوع الكيان |
| ---------------------------------- | ---------------------------------------------- |
| `defineObject()` | تعريفات كائنات مخصصة |
| `defineLogicFunction()` | تعريفات الوظائف المنطقية |
| `definePreInstallLogicFunction()` | دالة منطقية لما قبل التثبيت (تعمل قبل التثبيت) |
| `definePostInstallLogicFunction()` | دالة منطقية لما بعد التثبيت (تعمل بعد التثبيت) |
| `defineFrontComponent()` | Front component definitions |
| `defineRole()` | تعريفات الأدوار |
| `defineField()` | امتدادات الحقول للكائنات الموجودة |
| `defineView()` | تعريفات العروض المحفوظة |
| `defineNavigationMenuItem()` | تعريفات عناصر قائمة التنقل |
| `defineSkill()` | تعريفات مهارات وكيل الذكاء الاصطناعي |
| دالة مساعدة | نوع الكيان |
| ---------------------------- | --------------------------------- |
| `defineObject()` | تعريفات كائنات مخصصة |
| `defineLogicFunction()` | تعريفات الوظائف المنطقية |
| `defineFrontComponent()` | Front component definitions |
| `defineRole()` | تعريفات الأدوار |
| `defineField()` | امتدادات الحقول للكائنات الموجودة |
| `defineView()` | تعريفات العروض المحفوظة |
| `defineNavigationMenuItem()` | تعريفات عناصر قائمة التنقل |
<Note>
**تسمية الملفات مرنة.** يعتمد اكتشاف الكيانات على بنية الشجرة المجردة (AST) — إذ يقوم SDK بفحص ملفات المصدر لديك بحثًا عن النمط `export default define<Entity>({...})`. يمكنك تنظيم ملفاتك ومجلداتك كيفما تشاء. التجميع حسب نوع الكيان (مثلًا، `logic-functions/` و`roles/`) هو مجرد عرف لتنظيم الشيفرة، وليس مطلبًا إلزاميًا.
**تسمية الملفات مرنة.** يعتمد اكتشاف الكيانات على بنية الشجرة المجردة (AST) — إذ يقوم SDK بفحص ملفات المصدر لديك بحثًا عن النمط `export default define<Entity>({...})`. يمكنك تنظيم ملفاتك ومجلداتك كيفما تشاء. التجميع حسب نوع الكيان (مثلًا، `logic-functions/` و`roles/`) هو مجرد عرف لتنظيم الشيفرة، وليس مطلبًا إلزاميًا.
</Note>
مثال على كيان تم اكتشافه:
@@ -171,8 +168,8 @@ export default defineObject({
ستضيف الأوامر اللاحقة مزيدًا من الملفات والمجلدات:
* سيقوم `yarn twenty app:dev` بتوليد عميلين API مضبوطي الأنواع تلقائيًا في `node_modules/twenty-sdk/generated`: `CoreApiClient` (لبيانات مساحة العمل عبر `/graphql`) و`MetadataApiClient` (لتكوين مساحة العمل وتحميل الملفات عبر `/metadata`).
* `yarn twenty entity:add` سيضيف ملفات تعريف الكيانات ضمن `src/` لكائناتك المخصّصة، والوظائف، ومكوّنات الواجهة الأمامية، والأدوار، والمهارات، وغير ذلك.
* `yarn twenty app:dev` سيولّد تلقائيًا عميل API مضبوط الأنواع في `node_modules/twenty-sdk/generated` (عميل Twenty مضبوط الأنواع + أنواع مساحة العمل).
* `yarn twenty entity:add` سيضيف ملفات تعريف الكيانات تحت `src/` لكائناتك المخصصة أو الوظائف أو المكونات الواجهية أو الأدوار.
## المصادقة
@@ -215,19 +212,16 @@ yarn twenty auth:status
يوفّر SDK دوالًا مساعدة لتعريف كيانات تطبيقك. كما هو موضح في [اكتشاف الكيانات](#entity-detection)، يجب استخدام `export default define<Entity>({...})` كي يتم اكتشاف كياناتك:
| دالة | الغرض |
| ---------------------------------- | ---------------------------------------------------- |
| `defineApplication()` | تهيئة بيانات التعريف للتطبيق (مطلوب، واحد لكل تطبيق) |
| `defineObject()` | تعريف كائنات مخصصة مع حقول |
| `defineLogicFunction()` | تعريف وظائف منطقية مع معالجات |
| `definePreInstallLogicFunction()` | تعريف دالة منطقية لما قبل التثبيت (واحدة لكل تطبيق) |
| `definePostInstallLogicFunction()` | تعريف دالة منطقية لما بعد التثبيت (واحدة لكل تطبيق) |
| `defineFrontComponent()` | عرِّف مكوّنات أمامية لواجهة مستخدم مخصّصة |
| `defineRole()` | تهيئة صلاحيات الدور والوصول إلى الكائنات |
| `defineField()` | وسّع الكائنات الموجودة بحقول إضافية |
| `defineView()` | تعريف العروض المحفوظة للكائنات |
| `defineNavigationMenuItem()` | تعريف روابط التنقل في الشريط الجانبي |
| `defineSkill()` | عرّف مهارات وكيل الذكاء الاصطناعي |
| دالة | الغرض |
| ---------------------------- | ---------------------------------------------------- |
| `defineApplication()` | تهيئة بيانات التعريف للتطبيق (مطلوب، واحد لكل تطبيق) |
| `defineObject()` | تعريف كائنات مخصصة مع حقول |
| `defineLogicFunction()` | تعريف وظائف منطقية مع معالجات |
| `defineFrontComponent()` | عرِّف مكوّنات أمامية لواجهة مستخدم مخصّصة |
| `defineRole()` | تهيئة صلاحيات الدور والوصول إلى الكائنات |
| `defineField()` | وسّع الكائنات الموجودة بحقول إضافية |
| `defineView()` | تعريف العروض المحفوظة للكائنات |
| `defineNavigationMenuItem()` | تعريف روابط التنقل في الشريط الجانبي |
تتحقق هذه الدوال من تكوينك وقت البناء وتوفّر إكمالًا تلقائيًا في بيئة التطوير وأمان الأنواع.
@@ -313,11 +307,11 @@ export default defineObject({
* يمكنك إنشاء كائنات جديدة باستخدام `yarn twenty entity:add`، والذي يرشدك خلال التسمية والحقول والعلاقات.
<Note>
**يتم إنشاء الحقول الأساسية تلقائيًا.** عند تعريف كائن مخصص، يضيف Twenty تلقائيًا حقولًا قياسية
مثل `id` و`name` و`createdAt` و`updatedAt` و`createdBy` و`updatedBy` و`deletedAt`.
لا تحتاج إلى تعريف هذه في مصفوفة `fields` — أضف فقط حقولك المخصصة.
يمكنك تجاوز الحقول الافتراضية من خلال تعريف حقل بالاسم نفسه في مصفوفة `fields` الخاصة بك،
لكن هذا غير مستحسن.
**يتم إنشاء الحقول الأساسية تلقائيًا.** عند تعريف كائن مخصص، يضيف Twenty تلقائيًا حقولًا قياسية
مثل `id` و`name` و`createdAt` و`updatedAt` و`createdBy` و`updatedBy` و`deletedAt`.
لا تحتاج إلى تعريف هذه في مصفوفة `fields` — أضف فقط حقولك المخصصة.
يمكنك تجاوز الحقول الافتراضية من خلال تعريف حقل بالاسم نفسه في مصفوفة `fields` الخاصة بك،
لكن هذا غير مستحسن.
</Note>
### تكوين التطبيق (application-config.ts)
@@ -327,7 +321,6 @@ export default defineObject({
* **هوية التطبيق**: المعرفات، اسم العرض، والوصف.
* **كيفية تشغيل وظائفه**: الدور الذي تستخدمه للأذونات.
* **متغيرات (اختياري)**: أزواج مفتاح-قيمة تُعرض لوظائفك كمتغيرات بيئة.
* **(اختياري) دالة ما قبل التثبيت**: دالة منطقية تعمل قبل تثبيت التطبيق.
* **(Optional) post-install function**: a logic function that runs after the app is installed.
Use `defineApplication()` to define your application configuration:
@@ -336,21 +329,23 @@ Use `defineApplication()` to define your application configuration:
// src/application-config.ts
import { defineApplication } from 'twenty-sdk';
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
export default defineApplication({
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
displayName: 'تطبيق Twenty الخاص بي',
description: 'أول تطبيق لي لـ Twenty',
displayName: 'My Twenty App',
description: 'My first Twenty app',
icon: 'IconWorld',
applicationVariables: {
DEFAULT_RECIPIENT_NAME: {
universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de',
description: 'الاسم الافتراضي للمستلم للبطاقات البريدية',
description: 'Default recipient name for postcards',
value: 'Jane Doe',
isSecret: false,
},
},
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
```
@@ -359,7 +354,7 @@ export default defineApplication({
* حقول `universalIdentifier` هي معرّفات حتمية تخصك؛ أنشئها مرة واحدة واحتفظ بها ثابتة عبر عمليات المزامنة.
* `applicationVariables` تصبح متغيرات بيئة لوظائفك (على سبيل المثال، `DEFAULT_RECIPIENT_NAME` متاح كـ `process.env.DEFAULT_RECIPIENT_NAME`).
* `defaultRoleUniversalIdentifier` يجب أن يطابق ملف الدور (انظر أدناه).
* يتم اكتشاف دوال ما قبل التثبيت وما بعد التثبيت تلقائيًا أثناء إنشاء ملف البيان. راجع [دوال ما قبل التثبيت](#pre-install-functions) و[دوال ما بعد التثبيت](#post-install-functions).
* `postInstallLogicFunctionUniversalIdentifier` (optional) points to a logic function that runs automatically after the app is installed. See [Post-install functions](#post-install-functions).
#### الأدوار والصلاحيات
@@ -433,10 +428,10 @@ export default defineRole({
// src/app/createPostCard.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk';
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk';
import { CoreApiClient, type Person } from 'twenty-sdk/generated';
import Twenty, { type Person } from '~/generated';
const handler = async (params: RoutePayload) => {
const client = new CoreApiClient();
const client = new Twenty(); // generated typed client
const name = 'name' in params.queryStringParameters
? params.queryStringParameters.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
: 'Hello world';
@@ -498,44 +493,6 @@ export default defineLogicFunction({
* المصفوفة `triggers` اختيارية. يمكن استخدام الوظائف بدون مشغلات كوظائف مساعدة تُستدعى بواسطة وظائف أخرى.
* يمكنك مزج أنواع متعددة من المشغلات في وظيفة واحدة.
### دوال ما قبل التثبيت
دالة ما قبل التثبيت هي دالة منطقية تعمل تلقائيًا قبل تثبيت تطبيقك على مساحة عمل. يفيد ذلك في مهام التحقق، وفحص المتطلبات المسبقة، أو تجهيز حالة مساحة العمل قبل متابعة التثبيت الرئيسي.
عند إنشاء هيكل تطبيق جديد باستخدام `create-twenty-app`، يتم إنشاء دالة ما قبل التثبيت لك في `src/logic-functions/pre-install.ts`:
```typescript
// src/logic-functions/pre-install.ts
import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Pre install logic function executed successfully!', payload.previousVersion);
};
export default definePreInstallLogicFunction({
universalIdentifier: '<generated-uuid>',
name: 'pre-install',
description: 'Runs before installation to prepare the application.',
timeoutSeconds: 300,
handler,
});
```
يمكنك أيضًا تنفيذ دالة ما قبل التثبيت يدويًا في أي وقت باستخدام CLI:
```bash filename="Terminal"
yarn twenty function:execute --preInstall
```
النقاط الرئيسية:
* تستخدم دوال ما قبل التثبيت `definePreInstallLogicFunction()` — وهو إصدار متخصص يستبعد إعدادات المُشغِّل (`cronTriggerSettings` و`databaseEventTriggerSettings` و`httpRouteTriggerSettings` و`isTool`).
* يتلقى المُعالج `InstallLogicFunctionPayload` يحوي `{ previousVersion: string }` — إصدار التطبيق الذي كان مُثبّتًا سابقًا (أو سلسلة فارغة للتثبيتات الجديدة).
* يُسمح بدالة ما قبل التثبيت واحدة فقط لكل تطبيق. سيُنتج إنشاء ملف البيان خطأً إذا تم اكتشاف أكثر من واحدة.
* يتم تعيين `universalIdentifier` للدالة تلقائيًا كـ `preInstallLogicFunctionUniversalIdentifier` في بيان التطبيق أثناء الإنشاء — لست بحاجة إلى الإشارة إليه في `defineApplication()`.
* تم ضبط المهلة الافتراضية على 300 ثانية (5 دقائق) للسماح بمهام التحضير الأطول.
* لا تحتاج دوال ما قبل التثبيت إلى مُشغِّلات — إذ يستدعيها النظام الأساسي قبل التثبيت أو يدويًا عبر `function:execute --preInstall`.
### Post-install functions
A post-install function is a logic function that runs automatically after your app is installed on a workspace. This is useful for one-time setup tasks such as seeding default data, creating initial records, or configuring workspace settings.
@@ -544,14 +501,16 @@ A post-install function is a logic function that runs automatically after your a
```typescript
// src/logic-functions/post-install.ts
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
import { defineLogicFunction } from 'twenty-sdk';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Post install logic function executed successfully!', payload.previousVersion);
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
const handler = async (): Promise<void> => {
console.log('Post install logic function executed successfully!');
};
export default definePostInstallLogicFunction({
universalIdentifier: '<generated-uuid>',
export default defineLogicFunction({
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
name: 'post-install',
description: 'Runs after installation to set up the application.',
timeoutSeconds: 300,
@@ -559,6 +518,17 @@ export default definePostInstallLogicFunction({
});
```
يتم ربط الدالة بتطبيقك من خلال الإشارة إلى المعرِّف العالمي الخاص بها في `application-config.ts`:
```typescript
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
export default defineApplication({
// ...
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
});
```
يمكنك أيضًا تنفيذ دالة ما بعد التثبيت يدويًا في أي وقت باستخدام CLI:
```bash filename="Terminal"
@@ -567,35 +537,35 @@ yarn twenty function:execute --postInstall
النقاط الرئيسية:
* تستخدم دوال ما بعد التثبيت `definePostInstallLogicFunction()` — وهو إصدار متخصص يستبعد إعدادات المُشغِّل (`cronTriggerSettings` و`databaseEventTriggerSettings` و`httpRouteTriggerSettings` و`isTool`).
* يتلقى المُعالج `InstallLogicFunctionPayload` يحوي `{ previousVersion: string }` — إصدار التطبيق الذي كان مُثبّتًا سابقًا (أو سلسلة فارغة للتثبيتات الجديدة).
* يُسمح بدالة ما بعد التثبيت واحدة فقط لكل تطبيق. سيُنتج إنشاء ملف البيان خطأً إذا تم اكتشاف أكثر من واحدة.
* يتم تعيين `universalIdentifier` للدالة تلقائيًا كـ `postInstallLogicFunctionUniversalIdentifier` في بيان التطبيق أثناء الإنشاء — لست بحاجة إلى الإشارة إليه في `defineApplication()`.
* دوال ما بعد التثبيت هي دوال منطقية قياسية — فهي تستخدم `defineLogicFunction()` مثل أي دالة أخرى.
* حقل `postInstallLogicFunctionUniversalIdentifier` في `defineApplication()` اختياري. إذا تم تجاهله، لن يتم تشغيل أي دالة بعد التثبيت.
* تم تعيين مهلة افتراضية إلى 300 ثانية (5 دقائق) للسماح بمهام الإعداد الأطول مثل تهيئة البيانات.
* لا تحتاج دوال ما بعد التثبيت إلى مُشغِّلات — حيث يستدعيها النظام الأساسي أثناء التثبيت أو يدويًا عبر `function:execute --postInstall`.
### حمولة مشغل المسار
<Warning>
**تغيير غير متوافق (v1.16، يناير 2026):** لقد تغير تنسيق حمولة مشغل المسار. قبل v1.16، كانت معلمات الاستعلام، ومعلمات المسار، وجسم الطلب تُرسل مباشرةً كحمولة. بدءًا من v1.16، أصبحت متداخلة داخل كائن منظَّم `RoutePayload`.
**تغيير غير متوافق (v1.16، يناير 2026):** لقد تغير تنسيق حمولة مشغل المسار. قبل v1.16، كانت معلمات الاستعلام، ومعلمات المسار، وجسم الطلب تُرسل مباشرةً كحمولة. بدءًا من v1.16، أصبحت متداخلة داخل كائن منظَّم `RoutePayload`.
**قبل v1.16:**
```typescript
const handler = async (params) => {
const { param1, param2 } = params; // Direct access
};
```
**قبل v1.16:**
**بعد v1.16:**
```typescript
const handler = async (event: RoutePayload) => {
const { param1, param2 } = event.body; // Access via .body
const { queryParam } = event.queryStringParameters;
const { id } = event.pathParameters;
};
```
```typescript
const handler = async (params) => {
const { param1, param2 } = params; // Direct access
};
```
**لترحيل الدوال الحالية:** حدّث المعالج لديك لفكّ البنية من `event.body` أو `event.queryStringParameters` أو `event.pathParameters` بدلاً من القراءة مباشرةً من كائن params.
**بعد v1.16:**
```typescript
const handler = async (event: RoutePayload) => {
const { param1, param2 } = event.body; // Access via .body
const { queryParam } = event.queryStringParameters;
const { id } = event.pathParameters;
};
```
**لترحيل الدوال الحالية:** حدّث المعالج لديك لفكّ البنية من `event.body` أو `event.queryStringParameters` أو `event.pathParameters` بدلاً من القراءة مباشرةً من كائن params.
</Warning>
عندما يستدعي مشغّل المسار وظيفتك المنطقية، يتلقى كائنًا من النوع `RoutePayload` يتبع تنسيق AWS HTTP API v2. استورد النوع من `twenty-sdk`:
@@ -678,10 +648,10 @@ const handler = async (event: RoutePayload) => {
```typescript
// src/logic-functions/enrich-company.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk';
import { CoreApiClient } from 'twenty-sdk/generated';
import Twenty from '~/generated';
const handler = async (params: { companyName: string; domain?: string }) => {
const client = new CoreApiClient();
const client = new Twenty();
const result = await client.mutation({
createTask: {
@@ -731,7 +701,7 @@ export default defineLogicFunction({
* يمكنك دمج `isTool` مع المشغِّلات — إذ يمكن للدالة أن تكون أداة (قابلة للاستدعاء من قِبل وكلاء الذكاء الاصطناعي) وأن تُشغَّل بواسطة أحداث (cron، وأحداث قاعدة البيانات، والمسارات) في الوقت نفسه.
<Note>
**اكتب `description` جيدًا.** يعتمد وكلاء الذكاء الاصطناعي على حقل `description` الخاص بالدالة لتحديد وقت استخدام الأداة. كن محددًا بشأن ما تفعله الأداة ومتى ينبغي استدعاؤها.
**اكتب `description` جيدًا.** يعتمد وكلاء الذكاء الاصطناعي على حقل `description` الخاص بالدالة لتحديد وقت استخدام الأداة. كن محددًا بشأن ما تفعله الأداة ومتى ينبغي استدعاؤها.
</Note>
### المكوّنات الأمامية
@@ -739,7 +709,7 @@ export default defineLogicFunction({
تتيح لك المكوّنات الأمامية إنشاء مكوّنات React مخصّصة تُعرَض داخل واجهة مستخدم Twenty. استخدم `defineFrontComponent()` لتعريف مكوّنات مع تحقّق مدمج:
```typescript
// src/front-components/my-widget.tsx
// src/my-widget.front-component.tsx
import { defineFrontComponent } from 'twenty-sdk';
const MyWidget = () => {
@@ -762,67 +732,27 @@ export default defineFrontComponent({
النقاط الرئيسية:
* المكوّنات الأمامية هي مكوّنات React تُعرَض ضمن سياقات معزولة داخل Twenty.
* استخدم لاحقة الملف `*.front-component.tsx` للاكتشاف التلقائي.
* يشير الحقل `component` إلى مكوّن React الخاص بك.
* يتم بناء المكوّنات ومزامنتها تلقائيًا أثناء `yarn twenty app:dev`.
يمكنك إنشاء مكوّنات أمامية جديدة بطريقتين:
* **مُنشأ بالقالب**: شغّل `yarn twenty entity:add` واختر خيار إضافة مكوّن أمامي جديد.
* **يدوي**: أنشئ ملفًا جديدًا `.tsx` واستخدم `defineFrontComponent()` مع اتباع النمط نفسه.
* **يدوي**: أنشئ ملفًا جديدًا `*.front-component.tsx` واستخدم `defineFrontComponent()`.
### المهارات
### عميل مُولَّد مضبوط الأنواع
تُحدِّد المهارات تعليمات وإمكانات قابلة لإعادة الاستخدام يمكن لوكلاء الذكاء الاصطناعي استخدامها داخل مساحة العمل لديك. استخدم `defineSkill()` لتعريف مهارات مع تحقّق مدمج:
يُولَّد العميل مضبوط الأنواع تلقائيًا بواسطة `yarn twenty app:dev` ويُخزَّن في `node_modules/twenty-sdk/generated` استنادًا إلى مخطط مساحة العمل لديك. استخدمه في وظائفك:
```typescript
// src/skills/example-skill.ts
import { defineSkill } from 'twenty-sdk';
import Twenty from '~/generated';
export default defineSkill({
universalIdentifier: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
name: 'sales-outreach',
label: 'التواصل البيعي',
description: 'يرشد وكيل الذكاء الاصطناعي خلال عملية منظّمة للتواصل البيعي',
icon: 'IconBrain',
content: `أنت مساعد للتواصل البيعي. عند التواصل مع عميل محتمل:
1. ابحث عن الشركة وآخر الأخبار
2. حدِّد دور العميل المحتمل ونقاط الألم المرجّحة
3. صِغ رسالة مخصّصة تشير إلى تفاصيل محدّدة
4. حافظ على نبرة احترافية ولكن حوارية`,
});
```
النقاط الرئيسية:
* `name` هي سلسلة معرّف فريدة للمهارة (يُنصَح باستخدام kebab-case).
* `label` هو اسم العرض المقروء للبشر الظاهر في واجهة المستخدم.
* `content` يحتوي على تعليمات المهارة — وهو النص الذي يستخدمه وكيل الذكاء الاصطناعي.
* `icon` (اختياري) يحدّد الأيقونة المعروضة في واجهة المستخدم.
* `description` (اختياري) يوفّر سياقًا إضافيًا حول غرض المهارة.
يمكنك إنشاء مهارات جديدة بطريقتين:
* **مُنشأ بالقالب**: شغِّل `yarn twenty entity:add` واختر خيار إضافة مهارة جديدة.
* **يدوي**: أنشئ ملفًا جديدًا واستخدم `defineSkill()` مع اتباع النمط نفسه.
### عملاء مُولَّدون مضبوطو الأنواع
يتم توليد عميلين مضبوطي الأنواع تلقائيًا بواسطة `yarn twenty app:dev` وتخزينهما في `node_modules/twenty-sdk/generated` استنادًا إلى مخطط مساحة العمل لديك:
* **`CoreApiClient`** — يُجري استعلامات إلى نقطة النهاية `/graphql` للحصول على بيانات مساحة العمل
* **`MetadataApiClient`** — يستعلم عن نقطة النهاية `/metadata` لتكوين مساحة العمل وتحميل الملفات.
```typescript
import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/generated';
const client = new CoreApiClient();
const client = new Twenty();
const { me } = await client.query({ me: { id: true, displayName: true } });
const metadataClient = new MetadataApiClient();
const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id: true } });
```
يُعاد توليد كلا العميلين تلقائيًا بواسطة `yarn twenty app:dev` كلما تغيّرت كائناتك أو حقولك.
يُعاد توليد العميل تلقائيًا بواسطة `yarn twenty app:dev` كلما تغيّرت كائناتك أو حقولك.
#### بيانات الاعتماد وقت التشغيل في الوظائف المنطقية
@@ -837,53 +767,6 @@ const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id
* تُحدَّد أذونات مفتاح واجهة برمجة التطبيقات بواسطة الدور المشار إليه في `application-config.ts` عبر `defaultRoleUniversalIdentifier`. هذا هو الدور الافتراضي الذي تستخدمه الوظائف المنطقية في تطبيقك.
* يمكن للتطبيقات تعريف أدوار لاتباع مبدأ أقل الامتياز. امنح فقط الأذونات التي تحتاجها وظائفك، ثم وجّه `defaultRoleUniversalIdentifier` إلى المعرّف الشامل لذلك الدور.
#### رفع الملفات
يتضمن `MetadataApiClient` المُولَّد طريقة `uploadFile` لإرفاق الملفات بالحقول من نوع ملف ضمن كائنات مساحة العمل الخاصة بك. نظرًا لأن عملاء GraphQL القياسيون لا يدعمون تحميل الملفات متعددة الأجزاء افتراضيًا، يوفر العميل هذه الطريقة المخصصة التي تطبق [مواصفة طلب GraphQL متعدد الأجزاء](https://github.com/jaydenseric/graphql-multipart-request-spec) في الخلفية.
```typescript
import { MetadataApiClient } from 'twenty-sdk/generated';
import * as fs from 'fs';
const metadataClient = new MetadataApiClient();
const fileBuffer = fs.readFileSync('./invoice.pdf');
const uploadedFile = await metadataClient.uploadFile(
fileBuffer, // file contents as a Buffer
'invoice.pdf', // filename
'application/pdf', // MIME type (defaults to 'application/octet-stream')
'58a0a314-d7ea-4865-9850-7fb84e72f30b', // field universal identifier
);
console.log(uploadedFile);
// { id: '...', path: '...', size: 12345, createdAt: '...', url: 'https://...' }
```
توقيع الطريقة:
```typescript
uploadFile(
fileBuffer: Buffer,
filename: string,
contentType: string,
fieldMetadataUniversalIdentifier: string,
): Promise<{ id: string; path: string; size: number; createdAt: string; url: string }>
```
| المعلمة | النوع | الوصف |
| ---------------------------------- | -------- | ---------------------------------------------------------------------------------- |
| `fileBuffer` | `Buffer` | المحتوى الخام للملف |
| `filename` | `string` | اسم الملف (يُستخدم للتخزين والعرض) |
| `contentType` | `string` | نوع MIME للملف (القيمة الافتراضية هي `application/octet-stream` إذا لم يتم تحديده) |
| `fieldMetadataUniversalIdentifier` | `string` | قيمة `universalIdentifier` لحقل نوع الملف في كائنك |
النقاط الرئيسية:
* تتوفر طريقة `uploadFile` على `MetadataApiClient` لأن عملية الـ mutation الخاصة بالرفع تُعالَج عبر نقطة النهاية `/metadata`.
* تستخدم `universalIdentifier` الخاص بالحقل (وليس المعرّف الخاص بمساحة العمل)، ليعمل كود الرفع لديك عبر أي مساحة عمل مُثبَّت فيها تطبيقك — بما يتماشى مع كيفية إشارة التطبيقات إلى الحقول في كل مكان آخر.
* العنوان `url` المُعاد هو عنوان URL موقّع يمكنك استخدامه للوصول إلى الملف المرفوع.
### مثال Hello World
استكشف مثالًا بسيطًا شاملًا من البداية إلى النهاية يوضح الكائنات والوظائف المنطقية والمكوّنات الأمامية ومشغّلات متعددة [هنا](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/hello-world):
@@ -62,7 +62,7 @@ import { VimeoEmbed } from '/snippets/vimeo-embed.mdx';
| `الطابع الزمني` | وقت حدوث الحدث (UTC) |
<Note>
استجب بحالة **HTTP 2xx** (200-299) لتأكيد الاستلام. تُسجَّل الاستجابات غير 2xx كإخفاقات في التسليم.
استجب بحالة **HTTP 2xx** (200-299) لتأكيد الاستلام. تُسجَّل الاستجابات غير 2xx كإخفاقات في التسليم.
</Note>
## التحقق من صحة خطاف الويب
@@ -23,9 +23,11 @@ description: وسّع وظائف Twenty باستخدام واجهات برمجة
<Card title="واجهات برمجة التطبيقات" icon="كود" href="/l/ar/developers/extend/capabilities/apis">
اتصل بـ Twenty برمجياً
</Card>
<Card title="الويب هوكس" icon="bell" href="/l/ar/developers/extend/capabilities/webhooks">
احصل على إشعارات بالأحداث في الوقت الفعلي
</Card>
<Card title="التطبيقات" icon="puzzle-piece" href="/l/ar/developers/extend/capabilities/apps">
أنشئ تخصيصات كرمز برمجي (ألفا)
</Card>
@@ -3,7 +3,7 @@ title: بنقرة واحدة مع Docker Compose
---
<Warning>
الحاويات الخاصة بدوكر مخصصة للاستضافة الإنتاجية أو الاستضافة الذاتية، للتحقيق يرجى التحقق من [الإعداد المحلي](/l/ar/developers/contribute/capabilities/local-setup).
الحاويات الخاصة بدوكر مخصصة للاستضافة الإنتاجية أو الاستضافة الذاتية، للتحقيق يرجى التحقق من [الإعداد المحلي](/l/ar/developers/contribute/capabilities/local-setup).
</Warning>
## نظرة عامة
@@ -5,7 +5,7 @@ title: إعداد
# إدارة الإعدادات
<Warning>
**هل هي المرة الأولى التي تقوم فيها بالتثبيت؟** اتبع [دليل تثبيت Docker Compose](/l/ar/developers/self-host/capabilities/docker-compose) لتشغيل Twenty، ثم عد هنا للإعداد.
**هل هي المرة الأولى التي تقوم فيها بالتثبيت؟** اتبع [دليل تثبيت Docker Compose](/l/ar/developers/self-host/capabilities/docker-compose) لتشغيل Twenty، ثم عد هنا للإعداد.
</Warning>
يوفر Twenty **وضعين للإعداد** ليلائم احتياجات النشر المختلفة:
@@ -26,7 +26,7 @@ IS_CONFIG_VARIABLES_IN_DB_ENABLED=true # افتراضي
4. تسري التغييرات على الفور (خلال 15 ثانية لعمليات النشر متعددة الحاويات)
<Warning>
**نشرات متعددة الحاويات:** عند استخدام إعدادات قاعدة البيانات (`IS_CONFIG_VARIABLES_IN_DB_ENABLED=true`)، يقوم كل من حاويات الخادم والعامل بالقراءة من نفس قاعدة البيانات. التغييرات في لوحة الإدارة تؤثر عليهما تلقائيًا، مما يلغي الحاجة إلى تكرار متغيرات البيئة بين الحاويات (باستثناء متغيرات البنية التحتية).
**نشرات متعددة الحاويات:** عند استخدام إعدادات قاعدة البيانات (`IS_CONFIG_VARIABLES_IN_DB_ENABLED=true`)، يقوم كل من حاويات الخادم والعامل بالقراءة من نفس قاعدة البيانات. التغييرات في لوحة الإدارة تؤثر عليهما تلقائيًا، مما يلغي الحاجة إلى تكرار متغيرات البيئة بين الحاويات (باستثناء متغيرات البنية التحتية).
</Warning>
**ما يمكنك تكوينه عبر لوحة الإدارة:**
@@ -41,10 +41,10 @@ IS_CONFIG_VARIABLES_IN_DB_ENABLED=true # افتراضي
![متغيرات تكوين لوحة الإدارة](/images/user-guide/setup/admin-panel-config-variables.png)
<Warning>
كل متغير موثق بوصف في لوحة الإدارة الخاصة بك في **الإعدادات → لوحة الإدارة → متغيرات التكوين**.
بعض إعدادات البنية التحتية مثل اتصالات قاعدة البيانات (`PG_DATABASE_URL`)، عناوين الخوادم (`SERVER_URL`)، وأسرار التطبيقات (`APP_SECRET`) يمكن ضبطها فقط عبر ملف `.env`.
كل متغير موثق بوصف في لوحة الإدارة الخاصة بك في **الإعدادات → لوحة الإدارة → متغيرات التكوين**.
بعض إعدادات البنية التحتية مثل اتصالات قاعدة البيانات (`PG_DATABASE_URL`)، عناوين الخوادم (`SERVER_URL`)، وأسرار التطبيقات (`APP_SECRET`) يمكن ضبطها فقط عبر ملف `.env`.
[مرجع تقني كامل →](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts)
[مرجع تقني كامل →](https://github.com/twentyhq/twenty/blob/main/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts)
</Warning>
## 2. إعداد بيئي فقط
@@ -93,7 +93,7 @@ DEFAULT_SUBDOMAIN=app # default value
* إعدادات خاصة بمساحة العمل مثل النطاق الفرعي والنطاق المخصص تصبح متاحة ضمن إعدادات مساحة العمل
<Warning>
**إعداد خاص بالبيئة فقط:** لا يمكن تكوين `IS_MULTIWORKSPACE_ENABLED` إلا عبر ملف `.env` ويتطلب إعادة تشغيل. لا يمكن تغييره عبر لوحة الإدارة.
**إعداد خاص بالبيئة فقط:** لا يمكن تكوين `IS_MULTIWORKSPACE_ENABLED` إلا عبر ملف `.env` ويتطلب إعادة تشغيل. لا يمكن تغييره عبر لوحة الإدارة.
</Warning>
### تكوين DNS لوضع تعدد مساحات العمل
@@ -149,7 +149,7 @@ IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS=true
* `AUTH_GOOGLE_APIS_CALLBACK_URL=https://{your-domain}/auth/google-apis/get-access-token`
<Warning>
**وضع بيئي فقط:** إذا كنت قد ضبطت `IS_CONFIG_VARIABLES_IN_DB_ENABLED=false`، فأضف هذه المتغيرات إلى ملف `.env` الخاص بك بدلاً من ذلك.
**وضع بيئي فقط:** إذا كنت قد ضبطت `IS_CONFIG_VARIABLES_IN_DB_ENABLED=false`، فأضف هذه المتغيرات إلى ملف `.env` الخاص بك بدلاً من ذلك.
</Warning>
**النطاقات المطلوبة** (يتم تكوينها تلقائيًا):
@@ -168,7 +168,7 @@ IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS=true
## تكامل Microsoft 365
<Warning>
يجب على المستخدمين الحصول على [ترخيص Microsoft 365](https://admin.microsoft.com/Adminportal/Home) ليتمكنوا من استخدام تقويم API ورسائل. لن يتمكنوا من مزامنة حسابهم في Twenty دون واحد منها.
يجب على المستخدمين الحصول على [ترخيص Microsoft 365](https://admin.microsoft.com/Adminportal/Home) ليتمكنوا من استخدام تقويم API ورسائل. لن يتمكنوا من مزامنة حسابهم في Twenty دون واحد منها.
</Warning>
### إنشاء مشروع في Microsoft Azure
@@ -211,7 +211,7 @@ IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS=true
* `AUTH_MICROSOFT_APIS_CALLBACK_URL=https://{your-domain}/auth/microsoft-apis/get-access-token`
<Warning>
**وضع بيئي فقط:** إذا كنت قد ضبطت `IS_CONFIG_VARIABLES_IN_DB_ENABLED=false`، فأضف هذه المتغيرات إلى ملف `.env` الخاص بك بدلاً من ذلك.
**وضع بيئي فقط:** إذا كنت قد ضبطت `IS_CONFIG_VARIABLES_IN_DB_ENABLED=false`، فأضف هذه المتغيرات إلى ملف `.env` الخاص بك بدلاً من ذلك.
</Warning>
### تكوين النطاقات
@@ -256,45 +256,40 @@ yarn command:prod cron:workflow:automated-cron-trigger
3. قم بضبط إعدادات SMTP الخاصة بك:
<ArticleTabs label1="جيميل" label2="أوفيس 365" label3="Smtp4dev">
<ArticleTab>
ستحتاج إلى توفير [كلمة مرور التطبيق](https://support.google.com/accounts/answer/185833).
ستحتاج إلى توفير [كلمة مرور التطبيق](https://support.google.com/accounts/answer/185833).
* EMAIL_DRIVER=smtp
* EMAIL_SMTP_HOST=smtp.gmail.com
* EMAIL_SMTP_PORT=465
* EMAIL_SMTP_USER=gmail_email_address
* EMAIL_SMTP_PASSWORD='gmail_app_password'
</ArticleTab>
<ArticleTab>
تذكر أنه إذا كنت تشغل التحقق بعاملين، ستحتاج إلى توفير [كلمة مرور التطبيق](https://support.microsoft.com/en-us/account-billing/manage-app-passwords-for-two-step-verification-d6dc8c6d-4bf7-4851-ad95-6d07799387e9).
* EMAIL_DRIVER=smtp
* EMAIL_SMTP_HOST=smtp.office365.com
* EMAIL_SMTP_PORT=587
* EMAIL_SMTP_USER=office365_email_address
* EMAIL_SMTP_PASSWORD='office365_password'
</ArticleTab>
<ArticleTab>
**smtp4dev** هو خادم بريد إلكتروني مزيف للتطوير والاختبار.
* قم بتشغيل صورة smtp4dev: `docker run --rm -it -p 8090:80 -p 2525:25 rnwood/smtp4dev`
* الوصول إلى واجهة المستخدم smtp4dev هنا: [http://localhost:8090](http://localhost:8090)
* حدد المتغيرات التالية:
* EMAIL_DRIVER=smtp
* EMAIL_SMTP_HOST=localhost
* EMAIL_SMTP_PORT=2525
* EMAIL_SMTP_HOST=smtp.gmail.com
* EMAIL_SMTP_PORT=465
* EMAIL_SMTP_USER=gmail_email_address
* EMAIL_SMTP_PASSWORD='gmail_app_password'
</ArticleTab>
<ArticleTab>
تذكر أنه إذا كنت تشغل التحقق بعاملين، ستحتاج إلى توفير [كلمة مرور التطبيق](https://support.microsoft.com/en-us/account-billing/manage-app-passwords-for-two-step-verification-d6dc8c6d-4bf7-4851-ad95-6d07799387e9).
* EMAIL_DRIVER=smtp
* EMAIL_SMTP_HOST=smtp.office365.com
* EMAIL_SMTP_PORT=587
* EMAIL_SMTP_USER=office365_email_address
* EMAIL_SMTP_PASSWORD='office365_password'
</ArticleTab>
<ArticleTab>
**smtp4dev** هو خادم بريد إلكتروني مزيف للتطوير والاختبار.
* قم بتشغيل صورة smtp4dev: `docker run --rm -it -p 8090:80 -p 2525:25 rnwood/smtp4dev`
* الوصول إلى واجهة المستخدم smtp4dev هنا: [http://localhost:8090](http://localhost:8090)
* حدد المتغيرات التالية:
* EMAIL_DRIVER=smtp
* EMAIL_SMTP_HOST=localhost
* EMAIL_SMTP_PORT=2525
</ArticleTab>
</ArticleTabs>
<Warning>
**وضع بيئي فقط:** إذا كنت قد ضبطت `IS_CONFIG_VARIABLES_IN_DB_ENABLED=false`، فأضف هذه المتغيرات إلى ملف `.env` الخاص بك بدلاً من ذلك.
**وضع بيئي فقط:** إذا كنت قد ضبطت `IS_CONFIG_VARIABLES_IN_DB_ENABLED=false`، فأضف هذه المتغيرات إلى ملف `.env` الخاص بك بدلاً من ذلك.
</Warning>
## الوظائف المنطقية
@@ -302,7 +297,7 @@ yarn command:prod cron:workflow:automated-cron-trigger
تدعم Twenty الوظائف المنطقية لعمليات سير العمل والمنطق المخصص. يتم تكوين بيئة التنفيذ عبر متغير البيئة `SERVERLESS_TYPE`.
<Warning>
**ملاحظة أمنية:** يقوم برنامج التشغيل المحلي (`SERVERLESS_TYPE=LOCAL`) بتشغيل الشيفرة مباشرةً على المضيف ضمن عملية Node.js من دون عزل. يجب استخدامه فقط للشيفرة الموثوقة أثناء التطوير. بالنسبة لعمليات النشر الإنتاجية التي تتعامل مع شيفرة غير موثوق بها، نوصي بشدة باستخدام `SERVERLESS_TYPE=LAMBDA` أو `SERVERLESS_TYPE=DISABLED`.
**ملاحظة أمنية:** يقوم برنامج التشغيل المحلي (`SERVERLESS_TYPE=LOCAL`) بتشغيل الشيفرة مباشرةً على المضيف ضمن عملية Node.js من دون عزل. يجب استخدامه فقط للشيفرة الموثوقة أثناء التطوير. بالنسبة لعمليات النشر الإنتاجية التي تتعامل مع شيفرة غير موثوق بها، نوصي بشدة باستخدام `SERVERLESS_TYPE=LAMBDA` أو `SERVERLESS_TYPE=DISABLED`.
</Warning>
### برامج التشغيل المتاحة
@@ -338,5 +333,5 @@ SERVERLESS_TYPE=DISABLED
```
<Note>
عند استخدام `SERVERLESS_TYPE=DISABLED`، ستؤدي أي محاولة لتنفيذ وظيفة منطقية إلى إرجاع خطأ. يكون هذا مفيدًا إذا كنت ترغب في تشغيل Twenty من دون إمكانات الوظائف المنطقية.
عند استخدام `SERVERLESS_TYPE=DISABLED`، ستؤدي أي محاولة لتنفيذ وظيفة منطقية إلى إرجاع خطأ. يكون هذا مفيدًا إذا كنت ترغب في تشغيل Twenty من دون إمكانات الوظائف المنطقية.
</Note>
@@ -23,6 +23,7 @@ description: قم بنشر Twenty وإدارته على البنية التحت
<Card title="Docker Compose" icon="docker" href="/l/ar/developers/self-host/capabilities/docker-compose">
إعداد سريع باستخدام Docker
</Card>
<Card title="مزودو الخدمات السحابية" icon="cloud" href="/l/ar/developers/self-host/capabilities/cloud-providers">
انشر على AWS أو GCP أو Azure
</Card>
@@ -10,54 +10,46 @@ image: /images/user-guide/tips/light-bulb.png
رسالة مختصرة تعرض معلومات إضافية عند تفاعل المستخدم مع عنصر.
<Tabs>
<Tab title="استخدام">
<Tab title="استخدام">
```jsx
import { AppTooltip } from "@/ui/display/tooltip/AppTooltip";
```jsx
import { AppTooltip } from "@/ui/display/tooltip/AppTooltip";
export const MyComponent = () => {
return (
<>
<p id="hoverText" style={{ display: "inline-block" }}>
رؤى العملاء
</p>
<AppTooltip
className
anchorSelect="#hoverText"
content="استكشاف سلوك العملاء وتفضيلاتهم"
delayHide={0}
offset={6}
noArrow={false}
isOpen={true}
place="bottom"
positionStrategy="absolute"
/>
</>
);
};
```
</Tab>
<Tab title="المحددات">
| المحددات | النوع | الوصف |
| ------------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| اسم الفئة | نص | فئة CSS اختيارية للتنسيق الإضافي |
| اختيار الربط | محدد CSS | المحدِّد لمرساة التلميح (العنصر الذي يُفعّل التلميح) |
| المحتوى | نص | المحتوى الذي تريد عرضه داخل التلميح |
| تأخير الإخفاء | رقم | التأخير بالثواني قبل إخفاء التلميح بعد مغادرة المؤشر للمرساة |
| الإزاحة | رقم | الإزاحة بالبكسل لتحديد موضع التلميح |
| بدون سهم | قيمة منطقية | إذا كانت القيمة `صحيح`, سيتم إخفاء السهم في المربط التنبيهي |
| مفتوح | قيمة منطقية | إذا كانت القيمة `صحيح`, يكون المربط التنبيهي مفتوحًا افتراضيًا |
| المكان | نص `PlacesType` من `react-tooltip` | يحدد موضع المربط التنبيهي. تتضمن القيم `bottom`، `left`، `right`، `top`، `top-start`، `top-end`، `right-start`، `right-end`، `bottom-start`، `bottom-end`، `left-start`، و`left-end` |
| استراتيجية الوضعية | نص `PositionStrategy` من `react-tooltip` | استراتيجية وضعية للمربط التنبيهي. له قيمتان: `absolute` و`fixed` |
</Tab>
export const MyComponent = () => {
return (
<>
<p id="hoverText" style={{ display: "inline-block" }}>
رؤى العملاء
</p>
<AppTooltip
className
anchorSelect="#hoverText"
content="استكشاف سلوك العملاء وتفضيلاتهم"
delayHide={0}
offset={6}
noArrow={false}
isOpen={true}
place="bottom"
positionStrategy="absolute"
/>
</>
);
};
```
</Tab>
<Tab title="المحددات">
| المحددات | النوع | الوصف |
| ------------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| اسم الفئة | نص | فئة CSS اختيارية للتنسيق الإضافي |
| اختيار الربط | محدد CSS | المحدِّد لمرساة التلميح (العنصر الذي يُفعّل التلميح) |
| المحتوى | نص | المحتوى الذي تريد عرضه داخل التلميح |
| تأخير الإخفاء | رقم | التأخير بالثواني قبل إخفاء التلميح بعد مغادرة المؤشر للمرساة |
| الإزاحة | رقم | الإزاحة بالبكسل لتحديد موضع التلميح |
| بدون سهم | قيمة منطقية | إذا كانت القيمة `صحيح`, سيتم إخفاء السهم في المربط التنبيهي |
| مفتوح | قيمة منطقية | إذا كانت القيمة `صحيح`, يكون المربط التنبيهي مفتوحًا افتراضيًا |
| المكان | نص `PlacesType` من `react-tooltip` | يحدد موضع المربط التنبيهي. تتضمن القيم `bottom`، `left`، `right`، `top`، `top-start`، `top-end`، `right-start`، `right-end`، `bottom-start`، `bottom-end`، `left-start`، و`left-end` |
| استراتيجية الوضعية | نص `PositionStrategy` من `react-tooltip` | استراتيجية وضعية للمربط التنبيهي. له قيمتان: `absolute` و`fixed` |
</Tab>
</Tabs>
## نص متجاوز مع تلميح
@@ -65,30 +57,22 @@ export const MyComponent = () => {
يعالج النص الزائد ويعرض مربط تنبيهي عند فيضان النص.
<Tabs>
<Tab title="استخدام">
<Tab title="استخدام">
```jsx
import { OverflowingTextWithTooltip } from 'twenty-ui/display';
```jsx
import { OverflowingTextWithTooltip } from 'twenty-ui/display';
export const MyComponent = () => {
const crmTaskDescription =
'المتابعة مع العميل بشأن استفساره الأخير عن المنتج. مناقشة خيارات التسعير، ومعالجة أي مخاوف، وتقديم معلومات إضافية عن المنتج. تسجيل تفاصيل المحادثة في نظام إدارة علاقات العملاء (CRM) للرجوع إليها لاحقاً.';
export const MyComponent = () => {
const crmTaskDescription =
'المتابعة مع العميل بشأن استفساره الأخير عن المنتج. مناقشة خيارات التسعير، ومعالجة أي مخاوف، وتقديم معلومات إضافية عن المنتج. تسجيل تفاصيل المحادثة في نظام إدارة علاقات العملاء (CRM) للرجوع إليها لاحقاً.';
return <OverflowingTextWithTooltip text={crmTaskDescription} />;
};
```
</Tab>
<Tab title="الخصائص">
| الخصائص | النوع | الوصف |
| ------- | ------ | --------------------------------------------- |
| نص | string | المحتوى الذي تريد عرضه في منطقة النص المتجاوز |
</Tab>
return <OverflowingTextWithTooltip text={crmTaskDescription} />;
};
```
</Tab>
<Tab title="الخصائص">
| الخصائص | النوع | الوصف |
| ------- | ------ | --------------------------------------------- |
| نص | string | المحتوى الذي تريد عرضه في منطقة النص المتجاوز |
</Tab>
</Tabs>
@@ -10,25 +10,19 @@ image: /images/user-guide/tasks/tasks_header.png
يمثل إجراءً ناجحًا أو مكتملًا.
<Tabs>
<Tab title="استخدام">
<Tab title="استخدام">
```jsx
import { Checkmark } from 'twenty-ui/display';
```jsx
import { Checkmark } from 'twenty-ui/display';
export const MyComponent = () => {
return <Checkmark />;
};
```
</Tab>
<Tab title="المحددات">
يمتد `React.ComponentPropsWithoutRef<'div'>` و يقبل جميع خصائص عنصر `div` العادي.
</Tab>
export const MyComponent = () => {
return <Checkmark />;
};
```
</Tab>
<Tab title="المحددات">
يمتد `React.ComponentPropsWithoutRef<'div'>` و يقبل جميع خصائص عنصر `div` العادي.
</Tab>
</Tabs>
## علامة صحيح متحركة
@@ -36,38 +30,29 @@ export const MyComponent = () => {
يمثل رمز علامة صحيح مع ميزة الإضافة للحركة.
<Tabs>
<Tab title="استخدام">
```jsx
import { AnimatedCheckmark } from 'twenty-ui/display';
<Tab title="استخدام">
```jsx
import { AnimatedCheckmark } from 'twenty-ui/display';
export const MyComponent = () => {
return (
<AnimatedCheckmark
isAnimating={true}
color="green"
duration={0.5}
size={30}
/>
);
};
```
</Tab>
<Tab title="الخصائص">
| الخصائص | النوع | الوصف | الإعداد الافتراضي |
| ----------- | ----------- | ------------------------------------- | ----------------- |
| isAnimating | قيمة منطقية | يتحكم فيما إذا كانت علامة صحيح متحركة | خاطئ |
| اللون | string | لون علامة الاختيار | |
| المدة | رقم | مدة الحركة بالثواني | 0.5 ثانية |
| الحجم | رقم | حجم علامة الاختيار | 28 بكسل |
</Tab>
export const MyComponent = () => {
return (
<AnimatedCheckmark
isAnimating={true}
color="green"
duration={0.5}
size={30}
/>
);
};
```
</Tab>
<Tab title="الخصائص">
| الخصائص | النوع | الوصف | الإعداد الافتراضي |
| ----------- | ----------- | ------------------------------------- | ----------------- |
| isAnimating | قيمة منطقية | يتحكم فيما إذا كانت علامة صحيح متحركة | خاطئ |
| اللون | string | لون علامة الاختيار | |
| المدة | رقم | مدة الحركة بالثواني | 0.5 ثانية |
| الحجم | رقم | حجم علامة الاختيار | 28 بكسل |
</Tab>
</Tabs>
@@ -10,48 +10,40 @@ image: /images/user-guide/github/github-header.png
عنصر مرئي يمكن استخدامه كحاوية قابلة للنقر أو غير قابلة للنقر، مع علامة وعناصر اختيارية يسار ويمين، وخيارات تصميم متنوعة لعرض العلامات والبطاقات.
<Tabs>
<Tab title="استخدام">
```jsx
import { Chip } from 'twenty-ui/components';
<Tab title="استخدام">
export const MyComponent = () => {
return (
<Chip
size="large"
label="Clickable Chip"
clickable={true}
variant="highlighted"
accent="text-primary"
leftComponent
rightComponent
maxWidth="200px"
className
/>
);
};
```jsx
import { Chip } from 'twenty-ui/components';
```
</Tab>
export const MyComponent = () => {
return (
<Chip
size="large"
label="Clickable Chip"
clickable={true}
variant="highlighted"
accent="text-primary"
leftComponent
rightComponent
maxWidth="200px"
className
/>
);
};
```
</Tab>
<Tab title="المحددات">
| المحددات | النوع | الوصف |
| ------------------ | ----------------------- | ---------------------------------------------------------------------- |
| linkToEntity | نص | الرابط إلى الكيان |
| معرف الكيان | نص | المعرف الفريد للكيان |
| الاسم | نص | اسم الكيان |
| رابط الصورة | نص | s picture", |
| نوع الصورة الرمزية | نوع الصورة الرمزية | نوع الصورة الرمزية التي تريد عرضها. لديه خياران: `مستدير` و `مربع` |
| التنوع | تعداد EntityChipVariant | تنوع الرقاقة الكيانية التي ترغب في عرضها. لديه خياران: `عادي` و `شفاف` |
| الأيقونة اليسرى | مكون رمز | مكون React يمثل رمزًا. يظهر على الجانب الأيسر من الرقاقة |
</Tab>
<Tab title="المحددات">
| المحددات | النوع | الوصف |
| ------------------ | ----------------------- | ---------------------------------------------------------------------- |
| linkToEntity | نص | الرابط إلى الكيان |
| معرف الكيان | نص | المعرف الفريد للكيان |
| الاسم | نص | اسم الكيان |
| رابط الصورة | نص | s picture", |
| نوع الصورة الرمزية | نوع الصورة الرمزية | نوع الصورة الرمزية التي تريد عرضها. لديه خياران: `مستدير` و `مربع` |
| التنوع | تعداد EntityChipVariant | تنوع الرقاقة الكيانية التي ترغب في عرضها. لديه خياران: `عادي` و `شفاف` |
| الأيقونة اليسرى | مكون رمز | مكون React يمثل رمزًا. يظهر على الجانب الأيسر من الرقاقة |
</Tab>
</Tabs>
## الأمثلة
@@ -108,47 +100,39 @@ export const MyComponent = () => {
عنصر يشبه الرقاقة لعرض معلومات عن كيان.
<Tabs>
<Tab title="الاستخدام">
```jsx
import { BrowserRouter as Router } from 'react-router-dom';
import { IconTwentyStar } from 'twenty-ui/display';
import { Chip } from 'twenty-ui/components';
<Tab title="الاستخدام">
export const MyComponent = () => {
return (
<Router>
<Chip
linkToEntity="/entity-link"
entityId="entityTest"
name="Entity name"
pictureUrl=""
avatarType="rounded"
variant="regular"
LeftIcon={IconTwentyStar}
/>
</Router>
);
};
```
</Tab>
```jsx
import { BrowserRouter as Router } from 'react-router-dom';
import { IconTwentyStar } from 'twenty-ui/display';
import { Chip } from 'twenty-ui/components';
export const MyComponent = () => {
return (
<Router>
<Chip
linkToEntity="/entity-link"
entityId="entityTest"
name="Entity name"
pictureUrl=""
avatarType="rounded"
variant="regular"
LeftIcon={IconTwentyStar}
/>
</Router>
);
};
```
</Tab>
<Tab title="الخصائص">
| الخصائص | النوع | الوصف |
| ------------------ | ----------------------- | ---------------------------------------------------------------------- |
| linkToEntity | نص | الرابط إلى الكيان |
| معرف الكيان | نص | المعرف الفريد للكيان |
| الاسم | نص | اسم الكيان |
| رابط الصورة | نص | s picture", |
| نوع الصورة الرمزية | نوع الصورة الرمزية | نوع الصورة الرمزية التي تريد عرضها. لديه خياران: `مستدير` و `مربع` |
| التنوع | تعداد EntityChipVariant | تنوع الرقاقة الكيانية التي ترغب في عرضها. لديه خياران: `عادي` و `شفاف` |
| الأيقونة اليسرى | مكون رمز | مكون React يمثل رمزًا. يظهر على الجانب الأيسر من الرقاقة |
</Tab>
<Tab title="الخصائص">
| الخصائص | النوع | الوصف |
| ------------------ | ----------------------- | ---------------------------------------------------------------------- |
| linkToEntity | نص | الرابط إلى الكيان |
| معرف الكيان | نص | المعرف الفريد للكيان |
| الاسم | نص | اسم الكيان |
| رابط الصورة | نص | s picture", |
| نوع الصورة الرمزية | نوع الصورة الرمزية | نوع الصورة الرمزية التي تريد عرضها. لديه خياران: `مستدير` و `مربع` |
| التنوع | تعداد EntityChipVariant | تنوع الرقاقة الكيانية التي ترغب في عرضها. لديه خياران: `عادي` و `شفاف` |
| الأيقونة اليسرى | مكون رمز | مكون React يمثل رمزًا. يظهر على الجانب الأيسر من الرقاقة |
</Tab>
</Tabs>
@@ -14,44 +14,35 @@ image: /images/user-guide/objects/objects.png
نستخدم أيقونات Tabler لـ React في جميع أنحاء التطبيق.
<Tabs>
<Tab title="التثبيت">
<br />
<Tab title="التثبيت">
<br />
```
yarn add @tabler/icons-react
```
</Tab>
```
yarn add @tabler/icons-react
```
<Tab title="الإزاحة">
يمكنك استيراد كل أيقونة كمكون. إليك مثال:
</Tab>
<br />
<Tab title="الإزاحة">
```jsx
import { IconArrowLeft } from "@tabler/icons-react";
يمكنك استيراد كل أيقونة كمكون. إليك مثال:
<br />
```jsx
import { IconArrowLeft } from "@tabler/icons-react";
export const MyComponent = () => {
return <IconArrowLeft color="red" size={48} />;
};
```
</Tab>
<Tab title="الإزاحة">
| الإزاحة | النوع | الوصف | الإعداد الافتراضي |
| ----------- | ------ | -------------------------------- | ----------------- |
| الحجم | رقم | ارتفاع وعرض الأيقونة بالبكسل | 24 |
| اللون | string | لون الأيقونات | اللون الحالي |
| الخط العريض | رقم | عرض الخط العريض للأيقونة بالبكسل | 2 |
</Tab>
export const MyComponent = () => {
return <IconArrowLeft color="red" size={48} />;
};
```
</Tab>
<Tab title="الإزاحة">
| الإزاحة | النوع | الوصف | الإعداد الافتراضي |
| ----------- | ------ | -------------------------------- | ----------------- |
| الحجم | رقم | ارتفاع وعرض الأيقونة بالبكسل | 24 |
| اللون | string | لون الأيقونات | اللون الحالي |
| الخط العريض | رقم | عرض الخط العريض للأيقونة بالبكسل | 2 |
</Tab>
</Tabs>
## أيقونات مخصصة
@@ -63,29 +54,20 @@ export const MyComponent = () => {
يعرض أيقونة دفتر العناوين.
<Tabs>
<Tab title="الاستخدام">
```jsx
import { IconAddressBook } from 'twenty-ui/display';
<Tab title="الاستخدام">
```jsx
import { IconAddressBook } from 'twenty-ui/display';
export const MyComponent = () => {
return <IconAddressBook size={24} stroke={2} />;
};
```
</Tab>
<Tab title="خصائص">
| خصائص | النوع | الوصف | الإعداد الافتراضي |
| ----------- | ----- | -------------------------------- | ----------------- |
| الحجم | رقم | ارتفاع وعرض الأيقونة بالبكسل | 24 |
| الخط العريض | رقم | عرض الخط العريض للأيقونة بالبكسل | 2 |
</Tab>
export const MyComponent = () => {
return <IconAddressBook size={24} stroke={2} />;
};
```
</Tab>
<Tab title="خصائص">
| خصائص | النوع | الوصف | الإعداد الافتراضي |
| ----------- | ----- | -------------------------------- | ----------------- |
| الحجم | رقم | ارتفاع وعرض الأيقونة بالبكسل | 24 |
| الخط العريض | رقم | عرض الخط العريض للأيقونة بالبكسل | 2 |
</Tab>
</Tabs>
@@ -10,38 +10,29 @@ image: /images/user-guide/table-views/table.png
مكوّن لتصنيف المحتوى أو وسمه بصريًا.
<Tabs>
<Tab title="استخدام">
```jsx
import { Tag } from "@/ui/display/tag/components/Tag";
<Tab title="استخدام">
```jsx
import { Tag } from "@/ui/display/tag/components/Tag";
export const MyComponent = () => {
return (
<Tag
className
color="red"
text="Urgent"
onClick={() => console.log("click")}
/>
);
};
```
</Tab>
<Tab title="المحددات">
| المحددات | النوع | الوصف |
| --------- | ----- | --------------------------------------------------------------------------------------------------------------------- |
| اسم الفئة | نص | اسم اختياري لتنسيقات إضافية |
| اللون | نص | لون العلامة. الخيارات تشمل: `أخضر`, `تركواز`, `سماوي`, `أزرق`, `أرجواني`, `وردي`, `أحمر`, `برتقالي`, `أصفر`, `رمادي`. |
| نص | نص | محتوى العلامة |
| عند_النقر | دالة | دالة اختيارية تُستدعى عند نقر المستخدم على العلامة |
</Tab>
export const MyComponent = () => {
return (
<Tag
className
color="red"
text="Urgent"
onClick={() => console.log("click")}
/>
);
};
```
</Tab>
<Tab title="المحددات">
| المحددات | النوع | الوصف |
| --------- | ----- | --------------------------------------------------------------------------------------------------------------------- |
| اسم الفئة | نص | اسم اختياري لتنسيقات إضافية |
| اللون | نص | لون العلامة. الخيارات تشمل: `أخضر`, `تركواز`, `سماوي`, `أزرق`, `أرجواني`, `وردي`, `أحمر`, `برتقالي`, `أصفر`, `رمادي`. |
| نص | نص | محتوى العلامة |
| عند_النقر | دالة | دالة اختيارية تُستدعى عند نقر المستخدم على العلامة |
</Tab>
</Tabs>
@@ -10,28 +10,22 @@ image: /images/user-guide/api/api.png
يستخدم محرر نصوص غني يعتمد على الكتل من [BlockNote](https://www.blocknotejs.org/) للسماح للمستخدمين بتحرير وعرض كتل المحتوى.
<Tabs>
<Tab title="استخدام">
<Tab title="استخدام">
```jsx
import { useBlockNote } from "@blocknote/react";
import { BlockEditor } from "@/ui/input/editor/components/BlockEditor";
```jsx
import { useBlockNote } from "@blocknote/react";
import { BlockEditor } from "@/ui/input/editor/components/BlockEditor";
export const MyComponent = () => {
const BlockNoteEditor = useBlockNote();
export const MyComponent = () => {
const BlockNoteEditor = useBlockNote();
return <BlockEditor editor={BlockNoteEditor} />;
};
```
</Tab>
return <BlockEditor editor={BlockNoteEditor} />;
};
```
</Tab>
<Tab title="المحددات">
| المحددات | النوع | الوصف |
| -------- | ----------------- | ------------------------ |
| محرر | `BlockNoteEditor` | مثيل أو تكوين محرر الكتل |
</Tab>
<Tab title="المحددات">
| المحددات | النوع | الوصف |
| -------- | ----------------- | ------------------------ |
| محرر | `BlockNoteEditor` | مثيل أو تكوين محرر الكتل |
</Tab>
</Tabs>
@@ -12,511 +12,428 @@ image: /images/user-guide/views/filter.png
## زر
<Tabs>
<Tab title="27332A2E2F2745">
```jsx
import { Button } from "@/ui/input/button/components/Button";
<Tab title="27332A2E2F2745">
```jsx
import { Button } from "@/ui/input/button/components/Button";
export const MyComponent = () => {
return (
<Button
className
Icon={null}
title="Title"
fullWidth={false}
variant="primary"
size="medium"
position="standalone"
accent="default"
soon={false}
disabled={false}
focus={true}
onClick={() => console.log("click")}
/>
);
};
```
</Tab>
<Tab title="خصائص">
| خصائص | النوع | الوصف |
| --------- | --------------------- | ---------------------------------------------------------------------------------- |
| className | string | اسم فئة اختياري لتنسيقات إضافية |
| أيقونة | `React.ComponentType` | مكون رمز اختياري يُعرض داخل الزر |
| العنوان | string | محتوى نص الزر |
| عرض كامل | قيمة منطقية | يُحدد إذا كان الزر يجب أن يمتد ليغطي العرض الكامل للحاوية الخاصة به |
| التنوع | string | النمط المرئي للزر. تشمل الخيارات `primary`، `secondary`، و`tertiary`. |
| الحجم | string | حجم الزر. يوجد خياران: `صغير` و `متوسط` |
| الموقع | string | موقع الزر بالنسبة لأخوته. تشمل الخيارات: `standalone`، `left`، `right`، و`middle`. |
| accent | string | موقع الزر بالنسبة لأخوته. تشمل الخيارات: `default`، `blue`، `danger` |
| قريباً | قيمة منطقية | يشير إلى ما إذا كان الزر معلمًا "قريبًا" (مثل الميزات القادمة) |
| معطل | قيمة منطقية | يحدد إذا كان الزر معطل أم لا |
| تركيز | قيمة منطقية | يحدد إذا كان الزر في وضع التركيز |
| عند النقر | وظيفة | وظيفة رد فعل تنطلق عند نقر المستخدم على الزر |
</Tab>
export const MyComponent = () => {
return (
<Button
className
Icon={null}
title="Title"
fullWidth={false}
variant="primary"
size="medium"
position="standalone"
accent="default"
soon={false}
disabled={false}
focus={true}
onClick={() => console.log("click")}
/>
);
};
```
</Tab>
<Tab title="خصائص">
| خصائص | النوع | الوصف |
| --------- | --------------------- | ---------------------------------------------------------------------------------- |
| className | string | اسم فئة اختياري لتنسيقات إضافية |
| أيقونة | `React.ComponentType` | مكون رمز اختياري يُعرض داخل الزر |
| العنوان | string | محتوى نص الزر |
| عرض كامل | قيمة منطقية | يُحدد إذا كان الزر يجب أن يمتد ليغطي العرض الكامل للحاوية الخاصة به |
| التنوع | string | النمط المرئي للزر. تشمل الخيارات `primary`، `secondary`، و`tertiary`. |
| الحجم | string | حجم الزر. يوجد خياران: `صغير` و `متوسط` |
| الموقع | string | موقع الزر بالنسبة لأخوته. تشمل الخيارات: `standalone`، `left`، `right`، و`middle`. |
| accent | string | موقع الزر بالنسبة لأخوته. تشمل الخيارات: `default`، `blue`، `danger` |
| قريباً | قيمة منطقية | يشير إلى ما إذا كان الزر معلمًا "قريبًا" (مثل الميزات القادمة) |
| معطل | قيمة منطقية | يحدد إذا كان الزر معطل أم لا |
| تركيز | قيمة منطقية | يحدد إذا كان الزر في وضع التركيز |
| عند النقر | وظيفة | وظيفة رد فعل تنطلق عند نقر المستخدم على الزر |
</Tab>
</Tabs>
## مجموعة الأزرار
<Tabs>
<Tab title="استخدام">
```jsx
import { Button } from "@/ui/input/button/components/Button";
import { ButtonGroup } from "@/ui/input/button/components/ButtonGroup";
<Tab title="استخدام">
```jsx
import { Button } from "@/ui/input/button/components/Button";
import { ButtonGroup } from "@/ui/input/button/components/ButtonGroup";
export const MyComponent = () => {
return (
<ButtonGroup variant="primary" size="large" accent="blue" className>
<Button
className
Icon={null}
title="Button 1"
fullWidth={false}
variant="primary"
size="medium"
position="standalone"
accent="blue"
soon={false}
disabled={false}
focus={false}
onClick={() => console.log("click")}
/>
<Button
className
Icon={null}
title="Button 2"
fullWidth={false}
variant="secondary"
size="medium"
position="left"
accent="blue"
soon={false}
disabled={false}
focus={false}
onClick={() => console.log("click")}
/>
<Button
className
Icon={null}
title="Button 3"
fullWidth={false}
variant="tertiary"
size="medium"
position="right"
accent="blue"
soon={false}
disabled={false}
focus={false}
onClick={() => console.log("click")}
/>
</ButtonGroup>
);
};
export const MyComponent = () => {
return (
<ButtonGroup variant="primary" size="large" accent="blue" className>
<Button
className
Icon={null}
title="Button 1"
fullWidth={false}
variant="primary"
size="medium"
position="standalone"
accent="blue"
soon={false}
disabled={false}
focus={false}
onClick={() => console.log("click")}
/>
<Button
className
Icon={null}
title="Button 2"
fullWidth={false}
variant="secondary"
size="medium"
position="left"
accent="blue"
soon={false}
disabled={false}
focus={false}
onClick={() => console.log("click")}
/>
<Button
className
Icon={null}
title="Button 3"
fullWidth={false}
variant="tertiary"
size="medium"
position="right"
accent="blue"
soon={false}
disabled={false}
focus={false}
onClick={() => console.log("click")}
/>
</ButtonGroup>
);
};
```
</Tab>
```
</Tab>
<Tab title="الخصائص">
| الخصائص | النوع | الوصف |
| --------- | --------- | -------------------------------------------------------------------------------------- |
| التنوع | string | النمط المرئي للأزرار داخل المجموعة. تشمل الخيارات `primary`، `secondary`، و`tertiary`. |
| الحجم | string | حجم الأزرار داخل المجموعة. يوجد خياران: `medium` و`small`. |
| accent | نص | لون تمييز الأزرار داخل المجموعة. تشمل الخيارات `default`، `blue` و`danger`. |
| className | string | اسم فئة اختياري لتنسيقات إضافية |
| الأبناء | ReactNode | مجموعة من عناصر React تمثل الأزرار الفردية داخل المجموعة |
</Tab>
<Tab title="الخصائص">
| الخصائص | النوع | الوصف |
| --------- | --------- | -------------------------------------------------------------------------------------- |
| التنوع | string | النمط المرئي للأزرار داخل المجموعة. تشمل الخيارات `primary`، `secondary`، و`tertiary`. |
| الحجم | string | حجم الأزرار داخل المجموعة. يوجد خياران: `medium` و`small`. |
| accent | نص | لون تمييز الأزرار داخل المجموعة. تشمل الخيارات `default`، `blue` و`danger`. |
| className | string | اسم فئة اختياري لتنسيقات إضافية |
| الأبناء | ReactNode | مجموعة من عناصر React تمثل الأزرار الفردية داخل المجموعة |
</Tab>
</Tabs>
## زر عائم
<Tabs>
<Tab title="الاستخدام">
<Tab title="الاستخدام">
```jsx
import { FloatingButton } from "@/ui/input/button/components/FloatingButton";
import { IconSearch } from "@tabler/icons-react";
```jsx
import { FloatingButton } from "@/ui/input/button/components/FloatingButton";
import { IconSearch } from "@tabler/icons-react";
export const MyComponent = () => {
return (
<FloatingButton
className
Icon={IconSearch}
title="Title"
size="medium"
position="standalone"
applyShadow={true}
applyBlur={true}
disabled={false}
focus={true}
/>
);
};
```
</Tab>
export const MyComponent = () => {
return (
<FloatingButton
className
Icon={IconSearch}
title="Title"
size="medium"
position="standalone"
applyShadow={true}
applyBlur={true}
disabled={false}
focus={true}
/>
);
};
```
</Tab>
<Tab title="الخصائص">
| الخصائص | النوع | الوصف |
| ------------ | --------------------- | --------------------------------------------------------------------------------- |
| className | string | اسم اختياري لتنسيقات إضافية |
| أيقونة | `React.ComponentType` | مكون أيقونة اختياري يظهر داخل الزر |
| العنوان | string | محتوى نص الزر |
| الحجم | string | حجم الزر. يوجد خياران: `صغير` و `متوسط` |
| الموقع | string | موقع الزر بالنسبة لأخوته. تشمل الخيارات: `standalone`، `left`، `middle`، `right`. |
| تطبيق الظل | قيمة منطقية | يحدد إذا ما سيتم تطبيق الظلال على الزر |
| تطبيق الضباب | قيمة منطقية | يحدد ما إذا كان ينبغي تطبيق تأثير الضباب على الزر |
| معطل | قيمة منطقية | يحدد ما إذا كان الزر معطل |
| تركيز | قيمة منطقية | يحدد إذا كان الزر في وضع التركيز |
</Tab>
<Tab title="الخصائص">
| الخصائص | النوع | الوصف |
| ------------ | --------------------- | --------------------------------------------------------------------------------- |
| className | string | اسم اختياري لتنسيقات إضافية |
| أيقونة | `React.ComponentType` | مكون أيقونة اختياري يظهر داخل الزر |
| العنوان | string | محتوى نص الزر |
| الحجم | string | حجم الزر. يوجد خياران: `صغير` و `متوسط` |
| الموقع | string | موقع الزر بالنسبة لأخوته. تشمل الخيارات: `standalone`، `left`، `middle`، `right`. |
| تطبيق الظل | قيمة منطقية | يحدد إذا ما سيتم تطبيق الظلال على الزر |
| تطبيق الضباب | قيمة منطقية | يحدد ما إذا كان ينبغي تطبيق تأثير الضباب على الزر |
| معطل | قيمة منطقية | يحدد ما إذا كان الزر معطل |
| تركيز | قيمة منطقية | يحدد إذا كان الزر في وضع التركيز |
</Tab>
</Tabs>
## مجموعة الأزرار العائمة
<Tabs>
<Tab title="الاستخدام">
<Tab title="الاستخدام">
```jsx
import { FloatingButton } from "@/ui/input/button/components/FloatingButton";
import { FloatingButtonGroup } from "@/ui/input/button/components/FloatingButtonGroup";
import { IconClipboardText, IconCheckbox } from "@tabler/icons-react";
```jsx
import { FloatingButton } from "@/ui/input/button/components/FloatingButton";
import { FloatingButtonGroup } from "@/ui/input/button/components/FloatingButtonGroup";
import { IconClipboardText, IconCheckbox } from "@tabler/icons-react";
export const MyComponent = () => {
return (
<FloatingButtonGroup size="small">
<FloatingButton
className
Icon={IconClipboardText}
title
size="small"
position="standalone"
applyShadow={true}
applyBlur={true}
disabled={false}
focus={true}
/>
<FloatingButton
className
Icon={IconCheckbox}
title
size="small"
position="standalone"
applyShadow={true}
applyBlur={true}
disabled={false}
/>
</FloatingButtonGroup>
);
};
```
</Tab>
<Tab title="خصائص">
| خصائص | النوع | الوصف | الإعداد الافتراضي |
| ------- | --------- | -------------------------------------------------------- | ----------------- |
| الحجم | string | حجم الزر. يوجد خياران: `صغير` و `متوسط` | صغير |
| الأبناء | ReactNode | مجموعة من عناصر React تمثل الأزرار الفردية داخل المجموعة | |
</Tab>
export const MyComponent = () => {
return (
<FloatingButtonGroup size="small">
<FloatingButton
className
Icon={IconClipboardText}
title
size="small"
position="standalone"
applyShadow={true}
applyBlur={true}
disabled={false}
focus={true}
/>
<FloatingButton
className
Icon={IconCheckbox}
title
size="small"
position="standalone"
applyShadow={true}
applyBlur={true}
disabled={false}
/>
</FloatingButtonGroup>
);
};
```
</Tab>
<Tab title="خصائص">
| خصائص | النوع | الوصف | الإعداد الافتراضي |
| ------- | --------- | -------------------------------------------------------- | ----------------- |
| الحجم | string | حجم الزر. يوجد خياران: `صغير` و `متوسط` | صغير |
| الأبناء | ReactNode | مجموعة من عناصر React تمثل الأزرار الفردية داخل المجموعة | |
</Tab>
</Tabs>
## زر رمز عائم
<Tabs>
<Tab title="الاستخدام">
<Tab title="الاستخدام">
```jsx
import { FloatingIconButton } from "@/ui/input/button/components/FloatingIconButton";
import { IconSearch } from "@tabler/icons-react";
```jsx
import { FloatingIconButton } from "@/ui/input/button/components/FloatingIconButton";
import { IconSearch } from "@tabler/icons-react";
export const MyComponent = () => {
return (
<FloatingIconButton
className
Icon={IconSearch}
size="small"
position="standalone"
applyShadow={true}
applyBlur={true}
disabled={false}
focus={false}
onClick={() => console.log("click")}
isActive={true}
/>
);
};
```
</Tab>
<Tab title="الخصائص">
| الخصائص | النوع | الوصف |
| ------------ | --------------------- | ---------------------------------------------------------------------------------- |
| className | نص | اسم اختياري لتنسيقات إضافية |
| أيقونة | `React.ComponentType` | مكون أيقونة اختياري يظهر داخل الزر |
| الحجم | نص | حجم الزر. يوجد خياران: `صغير` و `متوسط` |
| الموقع | نص | موقع الزر بالنسبة لأخوته. تشمل الخيارات: `standalone`، `left`، `right`، و`middle`. |
| تطبيق الظل | قيمة منطقية | يحدد إذا ما سيتم تطبيق الظلال على الزر |
| تطبيق الضباب | قيمة منطقية | يحدد ما إذا كان ينبغي تطبيق تأثير الضباب على الزر |
| معطل | قيمة منطقية | يحدد ما إذا كان الزر معطل |
| تركيز | قيمة منطقية | يحدد إذا كان الزر في وضع التركيز |
| عند النقر | وظيفة | وظيفة رد فعل تنطلق عند نقر المستخدم على الزر |
| فعّال | قيمة منطقية | يحدد إذا كان الزر في وضع فعّال |
</Tab>
export const MyComponent = () => {
return (
<FloatingIconButton
className
Icon={IconSearch}
size="small"
position="standalone"
applyShadow={true}
applyBlur={true}
disabled={false}
focus={false}
onClick={() => console.log("click")}
isActive={true}
/>
);
};
```
</Tab>
<Tab title="الخصائص">
| الخصائص | النوع | الوصف |
| ------------ | --------------------- | ---------------------------------------------------------------------------------- |
| className | نص | اسم اختياري لتنسيقات إضافية |
| أيقونة | `React.ComponentType` | مكون أيقونة اختياري يظهر داخل الزر |
| الحجم | نص | حجم الزر. يوجد خياران: `صغير` و `متوسط` |
| الموقع | نص | موقع الزر بالنسبة لأخوته. تشمل الخيارات: `standalone`، `left`، `right`، و`middle`. |
| تطبيق الظل | قيمة منطقية | يحدد إذا ما سيتم تطبيق الظلال على الزر |
| تطبيق الضباب | قيمة منطقية | يحدد ما إذا كان ينبغي تطبيق تأثير الضباب على الزر |
| معطل | قيمة منطقية | يحدد ما إذا كان الزر معطل |
| تركيز | قيمة منطقية | يحدد إذا كان الزر في وضع التركيز |
| عند النقر | وظيفة | وظيفة رد فعل تنطلق عند نقر المستخدم على الزر |
| فعّال | قيمة منطقية | يحدد إذا كان الزر في وضع فعّال |
</Tab>
</Tabs>
## مجموعة أزرار الرموز العائمة
<Tabs>
<Tab title="الاستخدام">
<Tab title="الاستخدام">
```jsx
import { FloatingIconButtonGroup } from "@/ui/input/button/components/FloatingIconButtonGroup";
import { IconClipboardText, IconCheckbox } from "@tabler/icons-react";
```jsx
import { FloatingIconButtonGroup } from "@/ui/input/button/components/FloatingIconButtonGroup";
import { IconClipboardText, IconCheckbox } from "@tabler/icons-react";
export const MyComponent = () => {
const iconButtons = [
{
Icon: IconClipboardText,
onClick: () => console.log("Button 1 clicked"),
isActive: true,
},
{
Icon: IconCheckbox,
onClick: () => console.log("Button 2 clicked"),
isActive: true,
},
];
export const MyComponent = () => {
const iconButtons = [
{
Icon: IconClipboardText,
onClick: () => console.log("Button 1 clicked"),
isActive: true,
},
{
Icon: IconCheckbox,
onClick: () => console.log("Button 2 clicked"),
isActive: true,
},
];
return (
<FloatingIconButtonGroup
className
size="small"
iconButtons={iconButtons} />
);
};
return (
<FloatingIconButtonGroup
className
size="small"
iconButtons={iconButtons} />
);
};
```
</Tab>
<Tab title="الخصائص">
| الخصائص | النوع | الوصف |
| ------------ | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| className | string | اسم اختياري لتنسيقات إضافية |
| الحجم | string | حجم الزر. يوجد خياران: `صغير` و `متوسط` |
| أزرار الرموز | array | مجموعة من الكائنات، يمثل كل منها زر رمز في المجموعة. يجب أن يشمل كل كائن مكون الرمز الذي تريد عرضه في الزر، الوظيفة التي ترغب في استدعائها عند نقر المستخدم على الزر، وما إذا كان الزر ينبغي أن يكون نشطًا أم لا. |
</Tab>
```
</Tab>
<Tab title="الخصائص">
| الخصائص | النوع | الوصف |
| ------------ | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| className | string | اسم اختياري لتنسيقات إضافية |
| الحجم | string | حجم الزر. يوجد خياران: `صغير` و `متوسط` |
| أزرار الرموز | array | مجموعة من الكائنات، يمثل كل منها زر رمز في المجموعة. يجب أن يشمل كل كائن مكون الرمز الذي تريد عرضه في الزر، الوظيفة التي ترغب في استدعائها عند نقر المستخدم على الزر، وما إذا كان الزر ينبغي أن يكون نشطًا أم لا. |
</Tab>
</Tabs>
## زر خفيف
<Tabs>
<Tab title="الاستخدام">
<Tab title="الاستخدام">
```jsx
import { LightButton } from "@/ui/input/button/components/LightButton";
```jsx
import { LightButton } from "@/ui/input/button/components/LightButton";
export const MyComponent = () => {
return <LightButton
className
icon={null}
title="Title"
accent="secondary"
active={false}
disabled={false}
focus={true}
onClick={()=>console.log('click')}
/>;
};
```
</Tab>
<Tab title="الإزاحة">
| الإزاحة | النوع | الوصف |
| --------- | ----------------- | -------------------------------------------------------------------------- |
| className | string | اسم اختياري لتنسيقات إضافية |
| أيقونة | `React.ReactNode` | الرمز الذي تريد عرضه في الزر |
| العنوان | string | محتوى نص الزر |
| accent | string | موقع الزر بالنسبة لأخوته. لون الزر المميز تشمل الخيارات: `ثانوي` و `ثالثي` |
| نشط | قيمة منطقية | يحدد إذا كان الزر في وضع فعّال |
| معطل | قيمة منطقية | يحدد ما إذا كان الزر معطل |
| تركيز | قيمة منطقية | يحدد إذا كان الزر في وضع التركيز |
| عند النقر | وظيفة | وظيفة رد فعل تنطلق عند نقر المستخدم على الزر |
</Tab>
export const MyComponent = () => {
return <LightButton
className
icon={null}
title="Title"
accent="secondary"
active={false}
disabled={false}
focus={true}
onClick={()=>console.log('click')}
/>;
};
```
</Tab>
<Tab title="الإزاحة">
| الإزاحة | النوع | الوصف |
| --------- | ----------------- | -------------------------------------------------------------------------- |
| className | string | اسم اختياري لتنسيقات إضافية |
| أيقونة | `React.ReactNode` | الرمز الذي تريد عرضه في الزر |
| العنوان | string | محتوى نص الزر |
| accent | string | موقع الزر بالنسبة لأخوته. لون الزر المميز تشمل الخيارات: `ثانوي` و `ثالثي` |
| نشط | قيمة منطقية | يحدد إذا كان الزر في وضع فعّال |
| معطل | قيمة منطقية | يحدد ما إذا كان الزر معطل |
| تركيز | قيمة منطقية | يحدد إذا كان الزر في وضع التركيز |
| عند النقر | وظيفة | وظيفة رد فعل تنطلق عند نقر المستخدم على الزر |
</Tab>
</Tabs>
## زر أيقونة خفيف
<Tabs>
<Tab title="الاستخدام">
<Tab title="الاستخدام">
```jsx
import { LightIconButton } from "@/ui/input/button/components/LightIconButton";
import { IconSearch } from "@tabler/icons-react";
```jsx
import { LightIconButton } from "@/ui/input/button/components/LightIconButton";
import { IconSearch } from "@tabler/icons-react";
export const MyComponent = () => {
return (
<LightIconButton
className
testId="test1"
Icon={IconSearch}
title="Title"
size="small"
accent="secondary"
active={true}
disabled={false}
focus={true}
onClick={() => console.log("click")}
/>
);
};
```
</Tab>
<Tab title="العناصر">
| العناصر | النوع | الوصف |
| --------- | --------------------- | ------------------------------------------------ |
| className | string | اسم اختياري لتنسيقات إضافية |
| testId | string | معرف اختبار للزر |
| أيقونة | `React.ComponentType` | مكون أيقونة اختياري يظهر داخل الزر |
| العنوان | string | محتوى نصي للزر |
| الحجم | string | حجم الزر. يوجد خياران: `صغير` و `متوسط` |
| accent | string | لون الزر المميز تشمل الخيارات: `ثانوي` و `ثالثي` |
| نشط | قيمة منطقية | يحدد ما إذا كان الزر في حالة نشطة |
| معطل | قيمة منطقية | يحدد ما إذا كان الزر معطلاً |
| التركيز | قيمة منطقية | يشير إلى ما إذا كان الزر لديه تركيز |
| عند النقر | function | وظيفة رد اتصال تتفعّل عند نقر المستخدم على الزر |
</Tab>
export const MyComponent = () => {
return (
<LightIconButton
className
testId="test1"
Icon={IconSearch}
title="Title"
size="small"
accent="secondary"
active={true}
disabled={false}
focus={true}
onClick={() => console.log("click")}
/>
);
};
```
</Tab>
<Tab title="العناصر">
| العناصر | النوع | الوصف |
| --------- | --------------------- | ------------------------------------------------ |
| className | string | اسم اختياري لتنسيقات إضافية |
| testId | string | معرف اختبار للزر |
| أيقونة | `React.ComponentType` | مكون أيقونة اختياري يظهر داخل الزر |
| العنوان | string | محتوى نصي للزر |
| الحجم | string | حجم الزر. يوجد خياران: `صغير` و `متوسط` |
| accent | string | لون الزر المميز تشمل الخيارات: `ثانوي` و `ثالثي` |
| نشط | قيمة منطقية | يحدد ما إذا كان الزر في حالة نشطة |
| معطل | قيمة منطقية | يحدد ما إذا كان الزر معطلاً |
| التركيز | قيمة منطقية | يشير إلى ما إذا كان الزر لديه تركيز |
| عند النقر | function | وظيفة رد اتصال تتفعّل عند نقر المستخدم على الزر |
</Tab>
</Tabs>
## الزر الرئيسي
<Tabs>
<Tab title="الاستخدام">
<Tab title="الاستخدام">
```jsx
import { MainButton } from "@/ui/input/button/components/MainButton";
import { IconCheckbox } from "@tabler/icons-react";
```jsx
import { MainButton } from "@/ui/input/button/components/MainButton";
import { IconCheckbox } from "@tabler/icons-react";
export const MyComponent = () => {
return (
<MainButton
title="Checkbox"
fullWidth={false}
variant="primary"
soon={false}
Icon={IconCheckbox}
/>
);
};
```
</Tab>
<Tab title="العناصر">
| العناصر | النوع | الوصف |
| -------------- | -------------------------------- | -------------------------------------------------------------- |
| العنوان | string | محتوى نصي للزر |
| عرض كامل | قيمة منطقية | يحدد ما إذا كان الزر يجب أن يمتد على كامل عرض الحاوية |
| التنوع | string | النمط البصري للزر. تشمل الخيارات `أساسي` و `ثانوي` |
| قريباً | قيمة منطقية | يشير إلى ما إذا كان الزر معلمًا "قريبًا" (مثل الميزات القادمة) |
| أيقونة | `React.ComponentType` | مكون أيقونة اختياري يظهر داخل الزر |
| خصائص زر React | `React.ComponentProps<'button'>` | كل خصائص زر HTML القياسية مدعومة |
</Tab>
export const MyComponent = () => {
return (
<MainButton
title="Checkbox"
fullWidth={false}
variant="primary"
soon={false}
Icon={IconCheckbox}
/>
);
};
```
</Tab>
<Tab title="العناصر">
| العناصر | النوع | الوصف |
| -------------- | -------------------------------- | -------------------------------------------------------------- |
| العنوان | string | محتوى نصي للزر |
| عرض كامل | قيمة منطقية | يحدد ما إذا كان الزر يجب أن يمتد على كامل عرض الحاوية |
| التنوع | string | النمط البصري للزر. تشمل الخيارات `أساسي` و `ثانوي` |
| قريباً | قيمة منطقية | يشير إلى ما إذا كان الزر معلمًا "قريبًا" (مثل الميزات القادمة) |
| أيقونة | `React.ComponentType` | مكون أيقونة اختياري يظهر داخل الزر |
| خصائص زر React | `React.ComponentProps<'button'>` | كل خصائص زر HTML القياسية مدعومة |
</Tab>
</Tabs>
## زر أيقونة دائري
<Tabs>
<Tab title="الاستخدام">
<Tab title="الاستخدام">
```jsx
import { RoundedIconButton } from "@/ui/input/button/components/RoundedIconButton";
import { IconSearch } from "@tabler/icons-react";
```jsx
import { RoundedIconButton } from "@/ui/input/button/components/RoundedIconButton";
import { IconSearch } from "@tabler/icons-react";
export const MyComponent = () => {
return (
<RoundedIconButton
Icon={IconSearch}
/>
);
};
```
</Tab>
<Tab title="العناصر">
| العناصر | النوع | الوصف |
| -------------- | ----------------------------------------------- | ----- |
| أيقونة | `React.ComponentType` | |
| خصائص زر React | `React.ButtonHTMLAttributes<HTMLButtonElement>` | |
</Tab>
export const MyComponent = () => {
return (
<RoundedIconButton
Icon={IconSearch}
/>
);
};
```
</Tab>
<Tab title="العناصر">
| العناصر | النوع | الوصف |
| -------------- | ----------------------------------------------- | ----- |
| أيقونة | `React.ComponentType` | |
| خصائص زر React | `React.ButtonHTMLAttributes<HTMLButtonElement>` | |
</Tab>
</Tabs>
@@ -10,41 +10,35 @@ image: /images/user-guide/tasks/tasks_header.png
يُستخدم عندما يحتاج المستخدم إلى اختيار قيم متعددة من بين عدة خيارات.
<Tabs>
<Tab title="استخدام">
<Tab title="استخدام">
```jsx
import { Checkbox } from "twenty-ui/display";
```jsx
import { Checkbox } from "twenty-ui/display";
export const MyComponent = () => {
return (
<Checkbox
checked={true}
indeterminate={false}
onChange={() => console.log("onChange function fired")}
onCheckedChange={() => console.log("onCheckedChange function fired")}
variant="primary"
size="small"
shape="squared"
/>
);
};
```
</Tab>
export const MyComponent = () => {
return (
<Checkbox
checked={true}
indeterminate={false}
onChange={() => console.log("onChange function fired")}
onCheckedChange={() => console.log("onCheckedChange function fired")}
variant="primary"
size="small"
shape="squared"
/>
);
};
```
</Tab>
<Tab title="المحددات">
| المحددات | النوع | الوصف |
| ------------------------ | ----------- | ----------------------------------------------------------------------------- |
| مختار | قيمة منطقية | يشير إلى ما إذا كان مربع الاختيار محددًا |
| غير محدد | قيمة منطقية | يشير إلى ما إذا كان مربع الاختيار في حالة غير محددة (لا هو محدد ولا غير محدد) |
| عند التغيير | دالة | الدالة التي ترغب في تفعيلها عند تغيير حالة مربع الاختيار |
| عند تغيير الحالة المحددة | دالة | الدالة التي ترغب في تفعيلها عند تغيّر حالة `checked` |
| نموذج | نص | النمط البصري للصندوق. تتضمن الخيارات: 'أساسي'، 'ثانوي'، و 'ثالثي' |
| الحجم | نص | حجم مربع الاختيار. له خياران: `small` و `large` |
| الشكل | نص | شكل مربع الاختيار. لديه خياران: 'مربع' و 'مدور' |
</Tab>
<Tab title="المحددات">
| المحددات | النوع | الوصف |
| ------------------------ | ----------- | ----------------------------------------------------------------------------- |
| مختار | قيمة منطقية | يشير إلى ما إذا كان مربع الاختيار محددًا |
| غير محدد | قيمة منطقية | يشير إلى ما إذا كان مربع الاختيار في حالة غير محددة (لا هو محدد ولا غير محدد) |
| عند التغيير | دالة | الدالة التي ترغب في تفعيلها عند تغيير حالة مربع الاختيار |
| عند تغيير الحالة المحددة | دالة | الدالة التي ترغب في تفعيلها عند تغيّر حالة `checked` |
| نموذج | نص | النمط البصري للصندوق. تتضمن الخيارات: 'أساسي'، 'ثانوي'، و 'ثالثي' |
| الحجم | نص | حجم مربع الاختيار. له خياران: `small` و `large` |
| الشكل | نص | شكل مربع الاختيار. لديه خياران: 'مربع' و 'مدور' |
</Tab>
</Tabs>
@@ -12,36 +12,28 @@ image: /images/user-guide/fields/field.png
يمثل مخططات ألوان مختلفة ومخصص بشكل خاص للمواضيع الفاتحة والداكنة.
<Tabs>
<Tab title="27332A2E2F2745">
<Tab title="27332A2E2F2745">
```jsx
import { ColorSchemeCard } from "twenty-ui/display";
```jsx
import { ColorSchemeCard } from "twenty-ui/display";
export const MyComponent = () => {
return (
<ColorSchemeCard
variant="Dark"
selected={true}
/>
);
};
```
</Tab>
<Tab title="خصائص">
| خصائص | النوع | الوصف | الإعداد الافتراضي |
| ------------ | --------------------------------------- | ---------------------------------------------------------------------- | ----------------- |
| التنوع | string | نوع مخطط الألوان. تشمل الخيارات `داكنة`, `فاتحة`, و `النظام` | فاتح |
| المحدد | قيمة منطقية | إذا كان `صحيح`, يتم عرض علامة الاختيار للدلالة على مخطط الألوان المحدد | |
| خصائص إضافية | `React.ComponentPropsWithoutRef<'div'>` | خصائص عنصر `div` العادي في HTML | |
</Tab>
export const MyComponent = () => {
return (
<ColorSchemeCard
variant="Dark"
selected={true}
/>
);
};
```
</Tab>
<Tab title="خصائص">
| خصائص | النوع | الوصف | الإعداد الافتراضي |
| ------------ | --------------------------------------- | ---------------------------------------------------------------------- | ----------------- |
| التنوع | string | نوع مخطط الألوان. تشمل الخيارات `داكنة`, `فاتحة`, و `النظام` | فاتح |
| المحدد | قيمة منطقية | إذا كان `صحيح`, يتم عرض علامة الاختيار للدلالة على مخطط الألوان المحدد | |
| خصائص إضافية | `React.ComponentPropsWithoutRef<'div'>` | خصائص عنصر `div` العادي في HTML | |
</Tab>
</Tabs>
## منتقي مخطط الألوان
@@ -49,31 +41,23 @@ export const MyComponent = () => {
يتيح للمستخدمين اختيار بين مخططات الألوان المختلفة.
<Tabs>
<Tab title="الاستخدام">
<Tab title="الاستخدام">
```jsx
import { ColorSchemePicker } from "twenty-ui/display";
```jsx
import { ColorSchemePicker } from "twenty-ui/display";
export const MyComponent = () => {
return <ColorSchemePicker
value="Dark"
onChange
/>;
};
```
</Tab>
<Tab title="خصائص">
| خصائص | النوع | الوصف |
| ----------- | ------------------- | ------------------------------------------------------------- |
| القيمة | `طريقة عرض الألوان` | مخطط الألوان المحدد حاليًا |
| عند التغيير | function | الدالة التي ترغب في تفعيلها عندما يختار المستخدم نظام الألوان |
</Tab>
export const MyComponent = () => {
return <ColorSchemePicker
value="Dark"
onChange
/>;
};
```
</Tab>
<Tab title="خصائص">
| خصائص | النوع | الوصف |
| ----------- | ------------------- | ------------------------------------------------------------- |
| القيمة | `طريقة عرض الألوان` | مخطط الألوان المحدد حاليًا |
| عند التغيير | function | الدالة التي ترغب في تفعيلها عندما يختار المستخدم نظام الألوان |
</Tab>
</Tabs>

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