Compare commits
72
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82f9a193f3 | ||
|
|
19bca23043 | ||
|
|
90fb67c90b | ||
|
|
e75057a1df | ||
|
|
1b900a5f32 | ||
|
|
6e913c2f91 | ||
|
|
e676c3fea6 | ||
|
|
5dfe9b7b49 | ||
|
|
670d88d373 | ||
|
|
c5af23afc2 | ||
|
|
2f4cd688fd | ||
|
|
0ca6325f0d | ||
|
|
26b7e0a97d | ||
|
|
868c019089 | ||
|
|
90658a8476 | ||
|
|
43678cb782 | ||
|
|
b52ba1b3e3 | ||
|
|
4f48a5d001 | ||
|
|
a2b727cd7f | ||
|
|
1d26940960 | ||
|
|
16aa8fc72a | ||
|
|
cf0ad46587 | ||
|
|
9cea7c47e9 | ||
|
|
0223975bbd | ||
|
|
8d47d8ae38 | ||
|
|
68d2297338 | ||
|
|
1db2a40961 | ||
|
|
159bb9d70a | ||
|
|
b341704a0e | ||
|
|
d5d0f5d994 | ||
|
|
012d819557 | ||
|
|
9fe2a07c55 | ||
|
|
63f17eec2c | ||
|
|
e806d36099 | ||
|
|
9f5a8735c9 | ||
|
|
c0e6aa1c0b | ||
|
|
9342b16aad | ||
|
|
cfad24da48 | ||
|
|
86fbf69e95 | ||
|
|
9667b3f369 | ||
|
|
76c7639eb3 | ||
|
|
4ed09a3feb | ||
|
|
def5ea5764 | ||
|
|
81698ff32c | ||
|
|
c0c51f2ef5 | ||
|
|
3bbaff801a | ||
|
|
2d2fc06265 | ||
|
|
8a7a19f312 | ||
|
|
2f0103faa5 | ||
|
|
08bfbfda45 | ||
|
|
c025a0c2b8 | ||
|
|
5e19361494 | ||
|
|
f11d76d6cf | ||
|
|
3aedce9af7 | ||
|
|
8e8ecfb8a3 | ||
|
|
120096346a | ||
|
|
fde8168a85 | ||
|
|
2674589b44 | ||
|
|
f325509d96 | ||
|
|
a9649b06d5 | ||
|
|
0b4bf97f35 | ||
|
|
4ea8245387 | ||
|
|
0fa8063054 | ||
|
|
546114d07f | ||
|
|
92824c6533 | ||
|
|
bc4ae35bc4 | ||
|
|
1b805a36f3 | ||
|
|
f7ea1d9500 | ||
|
|
cbb0f212cf | ||
|
|
0af980a783 | ||
|
|
e01b641a05 | ||
|
|
50be97422d |
@@ -36,18 +36,18 @@ export const userByIdState = createAtomFamilyState<User | null, string>({
|
||||
|
||||
## Jotai Hooks
|
||||
```typescript
|
||||
// useAtomState - read and write (like useRecoilState)
|
||||
// useAtomState - read and write
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
|
||||
// useAtomStateValue - read only (like useRecoilValue)
|
||||
// useAtomStateValue - read only
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
|
||||
// useSetAtomState - write only (like useSetRecoilState)
|
||||
// useSetAtomState - write only
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
```
|
||||
|
||||
## Provider
|
||||
Jotai works without a Provider by default (unlike Recoil's RecoilRoot). For scoped stores or testing, use `Provider` from `jotai`.
|
||||
Jotai works without a Provider by default. For scoped stores or testing, use `Provider` from `jotai`.
|
||||
|
||||
## Local State Guidelines
|
||||
```typescript
|
||||
|
||||
@@ -19,4 +19,10 @@ runs:
|
||||
uses: nrwl/nx-set-shas@v4
|
||||
- name: Run affected command
|
||||
shell: bash
|
||||
run: npx nx affected --nxBail --configuration=${{ inputs.configuration }} -t=${{ inputs.tasks }} --parallel=${{ inputs.parallel }} --exclude='*,!tag:${{ inputs.tag }}' ${{ inputs.args }}
|
||||
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
|
||||
@@ -19,8 +19,11 @@ 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-${{ inputs.key }}-${{ github.ref_name }}" >> "${GITHUB_OUTPUT}"
|
||||
echo "CACHE_PRIMARY_KEY_PREFIX=v4-${CACHE_KEY}-${REF_NAME}" >> "${GITHUB_OUTPUT}"
|
||||
- name: Restore cache
|
||||
uses: actions/cache/restore@v4
|
||||
id: restore-cache
|
||||
|
||||
@@ -16,8 +16,6 @@ env:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
checks: write
|
||||
|
||||
jobs:
|
||||
changed-files-check:
|
||||
@@ -584,182 +582,16 @@ jobs:
|
||||
echo "::warning::REST Metadata API analysis tool error - continuing workflow"
|
||||
fi
|
||||
|
||||
- name: Comment API Changes on PR
|
||||
- name: Upload breaking changes report
|
||||
if: always()
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
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: breaking-changes-report
|
||||
path: |
|
||||
*-diff.md
|
||||
*-diff.json
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
|
||||
- name: Cleanup servers
|
||||
if: always()
|
||||
@@ -771,16 +603,4 @@ 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
|
||||
|
||||
|
||||
@@ -9,10 +9,7 @@ on:
|
||||
types: [opened, synchronize, reopened, closed]
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
checks: write
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: depot-ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
task: [lint, typecheck, test, validate]
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
ci-zapier-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: depot-ubuntu-24.04
|
||||
needs: [changed-files-check, zapier-test]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
|
||||
@@ -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; print(urllib.parse.quote('$BRANCH', safe=''))")
|
||||
ENCODED_BRANCH=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "$BRANCH")
|
||||
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
|
||||
@@ -157,18 +157,11 @@ jobs:
|
||||
"PG_DATABASE_URL": "postgres://postgres:postgres@localhost:5432/default"
|
||||
}
|
||||
}
|
||||
- name: Post response to source issue
|
||||
- name: Dispatch response to ci-privileged
|
||||
if: always()
|
||||
uses: actions/github-script@v7
|
||||
uses: peter-evans/repository-dispatch@v2
|
||||
with:
|
||||
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})`
|
||||
});
|
||||
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)) }}}'
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
# 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: depot-ubuntu-24.04
|
||||
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 }}
|
||||
@@ -0,0 +1,71 @@
|
||||
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) }}}'
|
||||
@@ -1,14 +1,10 @@
|
||||
name: 'Preview Environment Dispatch'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
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:
|
||||
@@ -24,7 +20,19 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
trigger-preview:
|
||||
if: github.event.action == 'opened' || github.event.action == 'synchronize' || github.event.action == 'reopened' || (github.event.action == 'labeled' && github.event.label.name == 'preview-app')
|
||||
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'
|
||||
)
|
||||
)
|
||||
timeout-minutes: 5
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
@@ -35,3 +43,11 @@ 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) }}}'
|
||||
|
||||
@@ -2,7 +2,6 @@ name: 'Preview Environment Keep Alive'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
repository_dispatch:
|
||||
@@ -55,14 +54,15 @@ jobs:
|
||||
port: 3000
|
||||
|
||||
- name: Start services with correct SERVER_URL
|
||||
env:
|
||||
TUNNEL_URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}
|
||||
run: |
|
||||
cd packages/twenty-docker/
|
||||
|
||||
# Update the SERVER_URL with the tunnel URL
|
||||
echo "Setting SERVER_URL to ${{ steps.expose-tunnel.outputs.tunnel-url }}"
|
||||
echo "Setting SERVER_URL to $TUNNEL_URL"
|
||||
sed -i '/SERVER_URL=/d' .env
|
||||
echo "" >> .env
|
||||
echo "SERVER_URL=${{ steps.expose-tunnel.outputs.tunnel-url }}" >> .env
|
||||
echo "SERVER_URL=$TUNNEL_URL" >> .env
|
||||
|
||||
# Start the services
|
||||
echo "Docker compose up..."
|
||||
@@ -99,54 +99,26 @@ jobs:
|
||||
fi
|
||||
working-directory: ./
|
||||
|
||||
- name: Output tunnel URL to logs
|
||||
- name: Output tunnel URL
|
||||
env:
|
||||
TUNNEL_URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}
|
||||
run: |
|
||||
echo "✅ Preview Environment Ready!"
|
||||
echo "🔗 Preview URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}"
|
||||
echo "🔗 Preview URL: $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: Post comment on PR
|
||||
uses: actions/github-script@v6
|
||||
- name: Upload tunnel URL artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
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: tunnel-url
|
||||
path: tunnel-url.txt
|
||||
retention-days: 1
|
||||
|
||||
- name: Keep tunnel alive for 5 hours
|
||||
run: timeout 300m sleep 18000 # Stop on whichever we reach first (300m or 5hour sleep)
|
||||
|
||||
@@ -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 [Recoil](https://recoiljs.org/), [Emotion](https://emotion.sh/) and [Lingui](https://lingui.dev/)
|
||||
- [React](https://reactjs.org/), with [Jotai](https://jotai.org/), [Emotion](https://emotion.sh/) and [Lingui](https://lingui.dev/)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
# 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.
@@ -126,7 +126,7 @@
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"ci": true,
|
||||
"maxWorkers": 3
|
||||
"maxWorkers": 1
|
||||
},
|
||||
"coverage": {
|
||||
"coverageReporters": ["lcov", "text"]
|
||||
|
||||
+8
-10
@@ -48,7 +48,7 @@
|
||||
"react-responsive": "^9.0.2",
|
||||
"react-router-dom": "^6.4.4",
|
||||
"react-tooltip": "^5.13.1",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"rxjs": "^7.2.0",
|
||||
"semver": "^7.5.4",
|
||||
"slash": "^5.1.0",
|
||||
@@ -83,11 +83,11 @@
|
||||
"@sentry/types": "^8",
|
||||
"@storybook-community/storybook-addon-cookie": "^5.0.0",
|
||||
"@storybook/addon-coverage": "^3.0.0",
|
||||
"@storybook/addon-docs": "^10.1.11",
|
||||
"@storybook/addon-links": "^10.1.11",
|
||||
"@storybook/addon-vitest": "^10.1.11",
|
||||
"@storybook/addon-docs": "^10.2.13",
|
||||
"@storybook/addon-links": "^10.2.13",
|
||||
"@storybook/addon-vitest": "^10.2.13",
|
||||
"@storybook/icons": "^2.0.1",
|
||||
"@storybook/react-vite": "^10.1.11",
|
||||
"@storybook/react-vite": "^10.2.13",
|
||||
"@storybook/test-runner": "^0.24.2",
|
||||
"@stylistic/eslint-plugin": "^1.5.0",
|
||||
"@swc-node/register": "1.11.1",
|
||||
@@ -159,7 +159,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.1.11",
|
||||
"eslint-plugin-storybook": "^10.2.13",
|
||||
"eslint-plugin-unicorn": "^56.0.1",
|
||||
"eslint-plugin-unused-imports": "^3.0.0",
|
||||
"http-server": "^14.1.1",
|
||||
@@ -175,9 +175,9 @@
|
||||
"raw-loader": "^4.0.2",
|
||||
"rimraf": "^5.0.5",
|
||||
"source-map-support": "^0.5.20",
|
||||
"storybook": "^10.1.11",
|
||||
"storybook": "^10.2.13",
|
||||
"storybook-addon-mock-date": "2.0.0",
|
||||
"storybook-addon-pseudo-states": "^10.1.11",
|
||||
"storybook-addon-pseudo-states": "^10.2.13",
|
||||
"supertest": "^6.1.3",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-loader": "^9.2.3",
|
||||
@@ -201,8 +201,6 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -41,7 +41,7 @@ yarn twenty auth:login
|
||||
yarn twenty entity:add
|
||||
|
||||
# Start dev mode: watches, builds, and syncs local changes to your workspace
|
||||
# (also auto-generates a typed API client in node_modules/twenty-sdk/generated)
|
||||
# (also auto-generates typed API clients — CoreApiClient and MetadataApiClient — in node_modules/twenty-sdk/generated)
|
||||
yarn twenty app:dev
|
||||
|
||||
# Watch your application's function logs
|
||||
@@ -50,6 +50,9 @@ 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
|
||||
|
||||
@@ -92,6 +95,7 @@ In interactive mode, you can pick from:
|
||||
**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
|
||||
@@ -110,7 +114,7 @@ In interactive mode, you can pick from:
|
||||
- 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).
|
||||
- Use `yarn twenty app:dev` while you iterate — it watches, builds, and syncs changes to your workspace in real time.
|
||||
- Types are auto‑generated by `yarn twenty app:dev` and stored in `node_modules/twenty-sdk/generated`.
|
||||
- Two typed API clients are auto‑generated 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`).
|
||||
|
||||
|
||||
## Publish your application
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "0.6.1",
|
||||
"version": "0.6.2",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
- 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.
|
||||
|
||||
@@ -27,5 +27,5 @@
|
||||
"~/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts", "**/*.integration-test.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
testTimeout: 120_000,
|
||||
hookTimeout: 120_000,
|
||||
include: ['src/**/*.integration-test.ts'],
|
||||
env: {
|
||||
NODE_ENV: 'integration',
|
||||
TWENTY_API_URL: 'http://localhost:3000',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -115,6 +115,7 @@ export class CreateAppCommand {
|
||||
includeExampleView: false,
|
||||
includeExampleNavigationMenuItem: false,
|
||||
includeExampleSkill: false,
|
||||
includeIntegrationTest: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -127,6 +128,7 @@ export class CreateAppCommand {
|
||||
includeExampleView: true,
|
||||
includeExampleNavigationMenuItem: true,
|
||||
includeExampleSkill: true,
|
||||
includeIntegrationTest: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -171,19 +173,32 @@ export class CreateAppCommand {
|
||||
value: 'skill',
|
||||
checked: true,
|
||||
},
|
||||
{
|
||||
name: 'Integration test (vitest test verifying app installation)',
|
||||
value: 'integrationTest',
|
||||
checked: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const includeField = selectedExamples.includes('field');
|
||||
const includeView = selectedExamples.includes('view');
|
||||
const includeIntegrationTest =
|
||||
selectedExamples.includes('integrationTest');
|
||||
const includeObject =
|
||||
selectedExamples.includes('object') || includeField || includeView;
|
||||
selectedExamples.includes('object') ||
|
||||
includeField ||
|
||||
includeView ||
|
||||
includeIntegrationTest;
|
||||
|
||||
if ((includeField || includeView) && !selectedExamples.includes('object')) {
|
||||
if (
|
||||
(includeField || includeView || includeIntegrationTest) &&
|
||||
!selectedExamples.includes('object')
|
||||
) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'Note: Example object auto-included because example field/view depends on it.',
|
||||
'Note: Example object auto-included because example field/view/integration test depends on it.',
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -197,6 +212,7 @@ export class CreateAppCommand {
|
||||
includeExampleNavigationMenuItem:
|
||||
selectedExamples.includes('navigationMenuItem'),
|
||||
includeExampleSkill: selectedExamples.includes('skill'),
|
||||
includeIntegrationTest,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,4 +8,5 @@ export type ExampleOptions = {
|
||||
includeExampleView: boolean;
|
||||
includeExampleNavigationMenuItem: boolean;
|
||||
includeExampleSkill: boolean;
|
||||
includeIntegrationTest: boolean;
|
||||
};
|
||||
|
||||
@@ -5,7 +5,6 @@ import * as fs from 'fs-extra';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
// 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 {
|
||||
@@ -25,6 +24,7 @@ const ALL_EXAMPLES: ExampleOptions = {
|
||||
includeExampleView: true,
|
||||
includeExampleNavigationMenuItem: true,
|
||||
includeExampleSkill: true,
|
||||
includeIntegrationTest: true,
|
||||
};
|
||||
|
||||
const NO_EXAMPLES: ExampleOptions = {
|
||||
@@ -35,13 +35,13 @@ const NO_EXAMPLES: ExampleOptions = {
|
||||
includeExampleFrontComponent: false,
|
||||
includeExampleView: false,
|
||||
includeExampleNavigationMenuItem: false,
|
||||
includeIntegrationTest: false,
|
||||
};
|
||||
|
||||
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)}`,
|
||||
@@ -51,7 +51,6 @@ describe('copyBaseApplicationProject', () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up temp directory after each test
|
||||
if (testAppDirectory && (await fs.pathExists(testAppDirectory))) {
|
||||
await fs.remove(testAppDirectory);
|
||||
}
|
||||
@@ -66,15 +65,12 @@ 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);
|
||||
});
|
||||
@@ -143,27 +139,22 @@ 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',
|
||||
);
|
||||
@@ -186,29 +177,24 @@ 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/,
|
||||
);
|
||||
@@ -223,7 +209,6 @@ 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'),
|
||||
@@ -247,7 +232,6 @@ 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({
|
||||
@@ -258,7 +242,6 @@ describe('copyBaseApplicationProject', () => {
|
||||
exampleOptions: ALL_EXAMPLES,
|
||||
});
|
||||
|
||||
// Create second app
|
||||
const secondAppDir = join(testAppDirectory, 'app2');
|
||||
await fs.ensureDir(secondAppDir);
|
||||
await copyBaseApplicationProject({
|
||||
@@ -269,7 +252,6 @@ describe('copyBaseApplicationProject', () => {
|
||||
exampleOptions: ALL_EXAMPLES,
|
||||
});
|
||||
|
||||
// Read both app configs
|
||||
const firstAppConfig = await fs.readFile(
|
||||
join(firstAppDir, 'src', APPLICATION_FILE_NAME),
|
||||
'utf8',
|
||||
@@ -279,7 +261,6 @@ 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];
|
||||
@@ -291,7 +272,6 @@ 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({
|
||||
@@ -302,7 +282,6 @@ describe('copyBaseApplicationProject', () => {
|
||||
exampleOptions: ALL_EXAMPLES,
|
||||
});
|
||||
|
||||
// Create second app
|
||||
const secondAppDir = join(testAppDirectory, 'app2');
|
||||
await fs.ensureDir(secondAppDir);
|
||||
await copyBaseApplicationProject({
|
||||
@@ -323,7 +302,6 @@ 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];
|
||||
@@ -375,6 +353,24 @@ describe('copyBaseApplicationProject', () => {
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
await fs.pathExists(
|
||||
join(srcPath, '__tests__', 'app-install.integration-test.ts'),
|
||||
),
|
||||
).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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -390,7 +386,6 @@ describe('copyBaseApplicationProject', () => {
|
||||
|
||||
const srcPath = join(testAppDirectory, 'src');
|
||||
|
||||
// Core files should exist
|
||||
expect(await fs.pathExists(join(srcPath, APPLICATION_FILE_NAME))).toBe(
|
||||
true,
|
||||
);
|
||||
@@ -398,7 +393,18 @@ describe('copyBaseApplicationProject', () => {
|
||||
await fs.pathExists(join(srcPath, 'roles', DEFAULT_ROLE_FILE_NAME)),
|
||||
).toBe(true);
|
||||
|
||||
// Example files should not exist
|
||||
// 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);
|
||||
|
||||
expect(
|
||||
await fs.pathExists(join(srcPath, 'objects', 'example-object.ts')),
|
||||
).toBe(false);
|
||||
@@ -427,6 +433,11 @@ describe('copyBaseApplicationProject', () => {
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
await fs.pathExists(
|
||||
join(srcPath, '__tests__', 'app-install.integration-test.ts'),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -445,6 +456,7 @@ describe('copyBaseApplicationProject', () => {
|
||||
includeExampleFrontComponent: true,
|
||||
includeExampleView: false,
|
||||
includeExampleNavigationMenuItem: false,
|
||||
includeIntegrationTest: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -482,6 +494,7 @@ describe('copyBaseApplicationProject', () => {
|
||||
includeExampleFrontComponent: false,
|
||||
includeExampleView: false,
|
||||
includeExampleNavigationMenuItem: false,
|
||||
includeIntegrationTest: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -676,4 +689,194 @@ 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('integration test', () => {
|
||||
it('should create app-install.integration-test.ts with correct structure when enabled', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
exampleOptions: ALL_EXAMPLES,
|
||||
});
|
||||
|
||||
const testPath = join(
|
||||
testAppDirectory,
|
||||
'src',
|
||||
'__tests__',
|
||||
'app-install.integration-test.ts',
|
||||
);
|
||||
|
||||
expect(await fs.pathExists(testPath)).toBe(true);
|
||||
|
||||
const content = await fs.readFile(testPath, 'utf8');
|
||||
|
||||
expect(content).toContain(
|
||||
"import { appBuild, appUninstall } from 'twenty-sdk/cli'",
|
||||
);
|
||||
expect(content).toContain(
|
||||
"import { MetadataApiClient } from 'twenty-sdk/generated'",
|
||||
);
|
||||
expect(content).toContain('assertServerIsReachable');
|
||||
expect(content).toContain('appBuild');
|
||||
expect(content).toContain('appUninstall');
|
||||
expect(content).toContain('findManyApplications');
|
||||
});
|
||||
|
||||
it('should include vitest and test scripts in package.json when enabled', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
exampleOptions: ALL_EXAMPLES,
|
||||
});
|
||||
|
||||
const packageJson = await fs.readJson(
|
||||
join(testAppDirectory, 'package.json'),
|
||||
);
|
||||
|
||||
expect(packageJson.scripts.test).toBe('vitest run');
|
||||
expect(packageJson.scripts['test:watch']).toBe('vitest');
|
||||
expect(packageJson.devDependencies.vitest).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not include vitest or test scripts when disabled', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
exampleOptions: NO_EXAMPLES,
|
||||
});
|
||||
|
||||
const packageJson = await fs.readJson(
|
||||
join(testAppDirectory, 'package.json'),
|
||||
);
|
||||
|
||||
expect(packageJson.scripts.test).toBeUndefined();
|
||||
expect(packageJson.scripts['test:watch']).toBeUndefined();
|
||||
expect(packageJson.devDependencies.vitest).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +22,11 @@ export const copyBaseApplicationProject = async ({
|
||||
}) => {
|
||||
await fs.copy(join(__dirname, './constants/base-application'), appDirectory);
|
||||
|
||||
await createPackageJson({ appName, appDirectory });
|
||||
await createPackageJson({
|
||||
appName,
|
||||
appDirectory,
|
||||
includeIntegrationTest: exampleOptions.includeIntegrationTest,
|
||||
});
|
||||
|
||||
await createGitignore(appDirectory);
|
||||
|
||||
@@ -97,6 +101,20 @@ export const copyBaseApplicationProject = async ({
|
||||
});
|
||||
}
|
||||
|
||||
if (exampleOptions.includeIntegrationTest) {
|
||||
await createIntegrationTest({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: '__tests__',
|
||||
fileName: 'app-install.integration-test.ts',
|
||||
});
|
||||
}
|
||||
|
||||
await createDefaultPreInstallFunction({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'logic-functions',
|
||||
fileName: 'pre-install.ts',
|
||||
});
|
||||
|
||||
await createDefaultPostInstallFunction({
|
||||
appDirectory: sourceFolderPath,
|
||||
fileFolder: 'logic-functions',
|
||||
@@ -268,6 +286,36 @@ 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,
|
||||
@@ -279,16 +327,14 @@ const createDefaultPostInstallFunction = async ({
|
||||
}) => {
|
||||
const universalIdentifier = v4();
|
||||
|
||||
const content = `import { defineLogicFunction } from 'twenty-sdk';
|
||||
const content = `import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
|
||||
|
||||
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '${universalIdentifier}';
|
||||
|
||||
const handler = async (): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!');
|
||||
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!', payload.previousVersion);
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
export default definePostInstallLogicFunction({
|
||||
universalIdentifier: '${universalIdentifier}',
|
||||
name: 'post-install',
|
||||
description: 'Runs after installation to set up the application.',
|
||||
timeoutSeconds: 300,
|
||||
@@ -462,6 +508,113 @@ export default defineSkill({
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const createIntegrationTest = async ({
|
||||
appDirectory,
|
||||
fileFolder,
|
||||
fileName,
|
||||
}: {
|
||||
appDirectory: string;
|
||||
fileFolder?: string;
|
||||
fileName: string;
|
||||
}) => {
|
||||
const content = `import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { appBuild, appUninstall } from 'twenty-sdk/cli';
|
||||
import { MetadataApiClient } from 'twenty-sdk/generated';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
const APP_PATH = path.resolve(__dirname, '../..');
|
||||
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
|
||||
|
||||
const readApiKeyFromConfig = (): string | undefined => {
|
||||
const configPath = path.join(os.homedir(), '.twenty', 'config.json');
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
const defaultProfile = config.profiles?.default;
|
||||
|
||||
return defaultProfile?.apiKey ?? config.apiKey;
|
||||
};
|
||||
|
||||
const assertServerIsReachable = async () => {
|
||||
try {
|
||||
const response = await fetch(\`\${TWENTY_API_URL}/healthz\`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(\`Server returned \${response.status}\`);
|
||||
}
|
||||
} catch {
|
||||
throw new Error(
|
||||
\`Twenty server is not reachable at \${TWENTY_API_URL}. \` +
|
||||
'Make sure the server is running before executing integration tests.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
describe('App installation', () => {
|
||||
let appInstalled = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
await assertServerIsReachable();
|
||||
|
||||
const buildResult = await appBuild({
|
||||
appPath: APP_PATH,
|
||||
onProgress: (message: string) => console.log(\`[build] \${message}\`),
|
||||
});
|
||||
|
||||
if (!buildResult.success) {
|
||||
throw new Error(
|
||||
\`App build failed: \${buildResult.error?.message ?? 'Unknown error'}\`,
|
||||
);
|
||||
}
|
||||
|
||||
appInstalled = true;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!appInstalled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uninstallResult = await appUninstall({ appPath: APP_PATH });
|
||||
|
||||
if (!uninstallResult.success) {
|
||||
console.warn(
|
||||
\`App uninstall failed: \${uninstallResult.error?.message ?? 'Unknown error'}\`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should find the installed app in the applications list', async () => {
|
||||
const apiKey = readApiKeyFromConfig();
|
||||
const metadataClient = new MetadataApiClient({
|
||||
url: \`\${TWENTY_API_URL}/metadata\`,
|
||||
headers: {
|
||||
Authorization: \`Bearer \${apiKey}\`,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await metadataClient.query({
|
||||
findManyApplications: {
|
||||
id: true,
|
||||
name: true,
|
||||
universalIdentifier: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.findManyApplications.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
`;
|
||||
|
||||
await fs.ensureDir(join(appDirectory, fileFolder ?? ''));
|
||||
await fs.writeFile(join(appDirectory, fileFolder ?? '', fileName), content);
|
||||
};
|
||||
|
||||
const createApplicationConfig = async ({
|
||||
displayName,
|
||||
description,
|
||||
@@ -477,14 +630,12 @@ 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,
|
||||
});
|
||||
`;
|
||||
|
||||
@@ -495,10 +646,33 @@ export default defineApplication({
|
||||
const createPackageJson = async ({
|
||||
appName,
|
||||
appDirectory,
|
||||
includeIntegrationTest,
|
||||
}: {
|
||||
appName: string;
|
||||
appDirectory: string;
|
||||
includeIntegrationTest: boolean;
|
||||
}) => {
|
||||
const scripts: Record<string, string> = {
|
||||
twenty: 'twenty',
|
||||
lint: 'eslint',
|
||||
'lint:fix': 'eslint --fix',
|
||||
};
|
||||
|
||||
const devDependencies: Record<string, string> = {
|
||||
typescript: '^5.9.3',
|
||||
'@types/node': '^24.7.2',
|
||||
'@types/react': '^18.2.0',
|
||||
react: '^18.2.0',
|
||||
eslint: '^9.32.0',
|
||||
'typescript-eslint': '^8.50.0',
|
||||
};
|
||||
|
||||
if (includeIntegrationTest) {
|
||||
scripts.test = 'vitest run';
|
||||
scripts['test:watch'] = 'vitest';
|
||||
devDependencies.vitest = '^3.1.1';
|
||||
}
|
||||
|
||||
const packageJson = {
|
||||
name: appName,
|
||||
version: '0.1.0',
|
||||
@@ -509,22 +683,11 @@ const createPackageJson = async ({
|
||||
yarn: '>=4.0.2',
|
||||
},
|
||||
packageManager: 'yarn@4.9.2',
|
||||
scripts: {
|
||||
twenty: 'twenty',
|
||||
lint: 'eslint',
|
||||
'lint:fix': 'eslint --fix',
|
||||
},
|
||||
scripts,
|
||||
dependencies: {
|
||||
'twenty-sdk': 'latest',
|
||||
},
|
||||
devDependencies: {
|
||||
typescript: '^5.9.3',
|
||||
'@types/node': '^24.7.2',
|
||||
'@types/react': '^18.2.0',
|
||||
react: '^18.2.0',
|
||||
eslint: '^9.32.0',
|
||||
'typescript-eslint': '^8.50.0',
|
||||
},
|
||||
devDependencies,
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
.yarn/install-state.gz
|
||||
.env
|
||||
.twenty
|
||||
|
||||
@@ -13,12 +13,15 @@
|
||||
"dev": "twenty app dev",
|
||||
"sync": "twenty app sync",
|
||||
"uninstall": "twenty app uninstall",
|
||||
"auth": "twenty auth login"
|
||||
"auth": "twenty auth login",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"twenty-sdk": "0.2.4"
|
||||
"twenty-sdk": "portal:../../twenty-sdk"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2"
|
||||
"@types/node": "^24.7.2",
|
||||
"vitest": "^3.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { appBuild, appUninstall } from 'twenty-sdk/cli';
|
||||
import { MetadataApiClient } from 'twenty-sdk/generated';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
const APP_PATH = path.resolve(__dirname, '../..');
|
||||
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:3000';
|
||||
|
||||
const POST_CARD_OBJECT_UNIVERSAL_IDENTIFIER =
|
||||
'54b589ca-eeed-4950-a176-358418b85c05';
|
||||
|
||||
const readApiKeyFromConfig = (): string | undefined => {
|
||||
const configPath = path.join(os.homedir(), '.twenty', 'config.json');
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
const defaultProfile = config.profiles?.default;
|
||||
|
||||
return defaultProfile?.apiKey ?? config.apiKey;
|
||||
};
|
||||
|
||||
const assertServerIsReachable = async () => {
|
||||
try {
|
||||
const response = await fetch(`${TWENTY_API_URL}/healthz`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server returned ${response.status}`);
|
||||
}
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Twenty server is not reachable at ${TWENTY_API_URL}. ` +
|
||||
'Make sure the server is running before executing integration tests.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
describe('Hello World app installation', () => {
|
||||
let appInstalled = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
await assertServerIsReachable();
|
||||
|
||||
const buildResult = await appBuild({
|
||||
appPath: APP_PATH,
|
||||
onProgress: (message: string) => console.log(`[build] ${message}`),
|
||||
});
|
||||
|
||||
if (!buildResult.success) {
|
||||
throw new Error(
|
||||
`App build failed: ${buildResult.error?.message ?? 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
|
||||
appInstalled = true;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!appInstalled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uninstallResult = await appUninstall({ appPath: APP_PATH });
|
||||
|
||||
if (!uninstallResult.success) {
|
||||
console.warn(
|
||||
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have the postCard object in object metadata after installation', async () => {
|
||||
const apiKey = readApiKeyFromConfig();
|
||||
const metadataClient = new MetadataApiClient({
|
||||
url: `${TWENTY_API_URL}/metadata`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await metadataClient.query({
|
||||
objects: {
|
||||
__args: {
|
||||
paging: { first: 200 },
|
||||
filter: {},
|
||||
},
|
||||
edges: {
|
||||
node: {
|
||||
id: true,
|
||||
universalIdentifier: true,
|
||||
nameSingular: true,
|
||||
namePlural: true,
|
||||
isActive: true,
|
||||
isCustom: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const postCardObject = result.objects.edges.find(
|
||||
(edge) =>
|
||||
edge.node.universalIdentifier ===
|
||||
POST_CARD_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
);
|
||||
|
||||
expect(postCardObject).toMatchObject({
|
||||
node: {
|
||||
nameSingular: 'postCard',
|
||||
namePlural: 'postCards',
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,67 +1,55 @@
|
||||
import type {
|
||||
FunctionConfig,
|
||||
DatabaseEventPayload,
|
||||
ObjectRecordCreateEvent,
|
||||
CronPayload,
|
||||
import {
|
||||
defineLogicFunction,
|
||||
type CronPayload,
|
||||
type DatabaseEventPayload,
|
||||
type ObjectRecordCreateEvent,
|
||||
} from 'twenty-sdk';
|
||||
import Twenty, { type Person } from '../../generated';
|
||||
import { CoreApiClient as Twenty, type CoreSchema } from 'twenty-sdk/generated';
|
||||
|
||||
type CreateNewPostCardParams =
|
||||
| { name?: string }
|
||||
| DatabaseEventPayload<ObjectRecordCreateEvent<Person>>
|
||||
| DatabaseEventPayload<ObjectRecordCreateEvent<CoreSchema.Person>>
|
||||
| CronPayload;
|
||||
|
||||
export const main = async (params: CreateNewPostCardParams) => {
|
||||
try {
|
||||
const client = new Twenty();
|
||||
const handler = async (params: CreateNewPostCardParams) => {
|
||||
const client = new Twenty();
|
||||
|
||||
const name =
|
||||
'name' in params
|
||||
? params.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
|
||||
: 'Hello world';
|
||||
const name =
|
||||
'name' in params
|
||||
? params.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
|
||||
: 'Hello world';
|
||||
|
||||
const createPostCard = await client.mutation({
|
||||
createPostCard: {
|
||||
__args: {
|
||||
data: {
|
||||
name,
|
||||
},
|
||||
const createPostCard = await client.mutation({
|
||||
createPostCard: {
|
||||
__args: {
|
||||
data: {
|
||||
name,
|
||||
},
|
||||
name: true,
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
name: true,
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('createPostCard result', createPostCard);
|
||||
console.log('createPostCard result', createPostCard);
|
||||
|
||||
return createPostCard;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw error;
|
||||
}
|
||||
return createPostCard;
|
||||
};
|
||||
|
||||
export const config: FunctionConfig = {
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||||
name: 'create-new-post-card',
|
||||
timeoutSeconds: 2,
|
||||
triggers: [
|
||||
{
|
||||
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
|
||||
type: 'route',
|
||||
path: '/post-card/create',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
|
||||
type: 'cron',
|
||||
pattern: '0 0 1 1 *', // Every year 1st of January
|
||||
},
|
||||
{
|
||||
universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
|
||||
type: 'databaseEvent',
|
||||
eventName: 'person.created',
|
||||
},
|
||||
],
|
||||
};
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/post-card/create',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
cronTriggerSettings: {
|
||||
pattern: '0 0 1 1 *',
|
||||
},
|
||||
databaseEventTriggerSettings: {
|
||||
eventName: 'person.created',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type ApplicationConfig } from 'twenty-sdk';
|
||||
import { defineApplication } from 'twenty-sdk';
|
||||
|
||||
const config: ApplicationConfig = {
|
||||
export default defineApplication({
|
||||
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
|
||||
displayName: 'Hello World',
|
||||
description: 'A simple hello world app',
|
||||
@@ -14,6 +14,4 @@ const config: ApplicationConfig = {
|
||||
},
|
||||
},
|
||||
defaultRoleUniversalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
|
||||
};
|
||||
|
||||
export default config;
|
||||
});
|
||||
|
||||
@@ -1,112 +1,89 @@
|
||||
import { type Note } from '../../generated';
|
||||
import { defineObject, FieldType } from 'twenty-sdk';
|
||||
|
||||
import {
|
||||
type AddressField,
|
||||
Field,
|
||||
FieldType,
|
||||
type FullNameField,
|
||||
Object,
|
||||
OnDeleteAction,
|
||||
Relation,
|
||||
RelationType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk';
|
||||
const POST_CARD_STATUS = {
|
||||
DRAFT: 'DRAFT',
|
||||
SENT: 'SENT',
|
||||
DELIVERED: 'DELIVERED',
|
||||
RETURNED: 'RETURNED',
|
||||
} as const;
|
||||
|
||||
enum PostCardStatus {
|
||||
DRAFT = 'DRAFT',
|
||||
SENT = 'SENT',
|
||||
DELIVERED = 'DELIVERED',
|
||||
RETURNED = 'RETURNED',
|
||||
}
|
||||
|
||||
@Object({
|
||||
export default defineObject({
|
||||
universalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
|
||||
nameSingular: 'postCard',
|
||||
namePlural: 'postCards',
|
||||
labelSingular: 'Post card',
|
||||
labelPlural: 'Post cards',
|
||||
description: ' A post card object',
|
||||
description: 'A post card object',
|
||||
icon: 'IconMail',
|
||||
})
|
||||
export class PostCard {
|
||||
@Field({
|
||||
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
|
||||
type: FieldType.TEXT,
|
||||
label: 'Content',
|
||||
description: "Postcard's content",
|
||||
icon: 'IconAbc',
|
||||
})
|
||||
content: string;
|
||||
|
||||
@Field({
|
||||
universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac',
|
||||
type: FieldType.FULL_NAME,
|
||||
label: 'Recipient name',
|
||||
icon: 'IconUser',
|
||||
})
|
||||
recipientName: FullNameField;
|
||||
|
||||
@Field({
|
||||
universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266',
|
||||
type: FieldType.ADDRESS,
|
||||
label: 'Recipient address',
|
||||
icon: 'IconHome',
|
||||
})
|
||||
recipientAddress: AddressField;
|
||||
|
||||
@Field({
|
||||
universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
|
||||
type: FieldType.SELECT,
|
||||
label: 'Status',
|
||||
icon: 'IconSend',
|
||||
defaultValue: `'${PostCardStatus.DRAFT}'`,
|
||||
options: [
|
||||
{
|
||||
value: PostCardStatus.DRAFT,
|
||||
label: 'Draft',
|
||||
position: 0,
|
||||
color: 'gray',
|
||||
},
|
||||
{
|
||||
value: PostCardStatus.SENT,
|
||||
label: 'Sent',
|
||||
position: 1,
|
||||
color: 'orange',
|
||||
},
|
||||
{
|
||||
value: PostCardStatus.DELIVERED,
|
||||
label: 'Delivered',
|
||||
position: 2,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
value: PostCardStatus.RETURNED,
|
||||
label: 'Returned',
|
||||
position: 3,
|
||||
color: 'orange',
|
||||
},
|
||||
],
|
||||
})
|
||||
status: PostCardStatus;
|
||||
|
||||
@Relation({
|
||||
universalIdentifier: 'c9e2b4f4-b9ad-4427-9b42-9971b785edfe',
|
||||
type: RelationType.ONE_TO_MANY,
|
||||
label: 'Notes',
|
||||
icon: 'IconComment',
|
||||
inverseSideTargetUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.note.universalIdentifier,
|
||||
onDelete: OnDeleteAction.CASCADE,
|
||||
})
|
||||
notes: Note[];
|
||||
|
||||
@Field({
|
||||
universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433',
|
||||
type: FieldType.DATE_TIME,
|
||||
label: 'Delivered at',
|
||||
icon: 'IconCheck',
|
||||
isNullable: true,
|
||||
defaultValue: null,
|
||||
})
|
||||
deliveredAt?: Date;
|
||||
}
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
|
||||
type: FieldType.TEXT,
|
||||
name: 'content',
|
||||
label: 'Content',
|
||||
description: "Postcard's content",
|
||||
icon: 'IconAbc',
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'c6aa31f3-da76-4ac6-889f-475e226009ac',
|
||||
type: FieldType.FULL_NAME,
|
||||
name: 'recipientName',
|
||||
label: 'Recipient name',
|
||||
icon: 'IconUser',
|
||||
},
|
||||
{
|
||||
universalIdentifier: '95045777-a0ad-49ec-98f9-22f9fc0c8266',
|
||||
type: FieldType.ADDRESS,
|
||||
name: 'recipientAddress',
|
||||
label: 'Recipient address',
|
||||
icon: 'IconHome',
|
||||
},
|
||||
{
|
||||
universalIdentifier: '87b675b8-dd8c-4448-b4ca-20e5a2234a1e',
|
||||
type: FieldType.SELECT,
|
||||
name: 'status',
|
||||
label: 'Status',
|
||||
icon: 'IconSend',
|
||||
defaultValue: `'${POST_CARD_STATUS.DRAFT}'`,
|
||||
options: [
|
||||
{
|
||||
id: 'a1b2c3d4-0001-4000-8000-000000000001',
|
||||
value: POST_CARD_STATUS.DRAFT,
|
||||
label: 'Draft',
|
||||
position: 0,
|
||||
color: 'gray',
|
||||
},
|
||||
{
|
||||
id: 'a1b2c3d4-0002-4000-8000-000000000002',
|
||||
value: POST_CARD_STATUS.SENT,
|
||||
label: 'Sent',
|
||||
position: 1,
|
||||
color: 'orange',
|
||||
},
|
||||
{
|
||||
id: 'a1b2c3d4-0003-4000-8000-000000000003',
|
||||
value: POST_CARD_STATUS.DELIVERED,
|
||||
label: 'Delivered',
|
||||
position: 2,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
id: 'a1b2c3d4-0004-4000-8000-000000000004',
|
||||
value: POST_CARD_STATUS.RETURNED,
|
||||
label: 'Returned',
|
||||
position: 3,
|
||||
color: 'orange',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'e06abe72-5b44-4e7f-93be-afc185a3c433',
|
||||
type: FieldType.DATE_TIME,
|
||||
name: 'deliveredAt',
|
||||
label: 'Delivered at',
|
||||
icon: 'IconCheck',
|
||||
isNullable: true,
|
||||
defaultValue: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PermissionFlag, type RoleConfig } from 'twenty-sdk';
|
||||
import { defineRole, PermissionFlag } from 'twenty-sdk';
|
||||
|
||||
export const functionRole: RoleConfig = {
|
||||
export default defineRole({
|
||||
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
|
||||
label: 'Default function role',
|
||||
description: 'Default role for function Twenty client',
|
||||
@@ -14,7 +14,7 @@ export const functionRole: RoleConfig = {
|
||||
canBeAssignedToApiKeys: false,
|
||||
objectPermissions: [
|
||||
{
|
||||
objectUniversalIdentifier: '9f9882af-170c-4879-b013-f9628b77c050',
|
||||
objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
@@ -23,11 +23,11 @@ export const functionRole: RoleConfig = {
|
||||
],
|
||||
fieldPermissions: [
|
||||
{
|
||||
objectUniversalIdentifier: '9f9882af-170c-4879-b013-f9628b77c050',
|
||||
fieldUniversalIdentifier: 'b2c37dc0-8ae7-470e-96cd-1476b47dfaff',
|
||||
objectUniversalIdentifier: '54b589ca-eeed-4950-a176-358418b85c05',
|
||||
fieldUniversalIdentifier: '58a0a314-d7ea-4865-9850-7fb84e72f30b',
|
||||
canReadFieldValue: false,
|
||||
canUpdateFieldValue: false,
|
||||
},
|
||||
],
|
||||
permissionFlags: [PermissionFlag.APPLICATIONS],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
testTimeout: 120_000,
|
||||
hookTimeout: 120_000,
|
||||
include: ['src/**/*.integration-test.ts'],
|
||||
env: {
|
||||
// The SDK's ConfigService reads credentials from ~/.twenty/config.json
|
||||
// but falls back to a temp dir when NODE_ENV=test.
|
||||
NODE_ENV: 'integration',
|
||||
// MetadataApiClient defaults to TWENTY_API_URL for its GraphQL endpoint.
|
||||
TWENTY_API_URL: 'http://localhost:3000',
|
||||
},
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
+19
-18
@@ -7,9 +7,9 @@ This document outlines the best practices you should follow when working on the
|
||||
|
||||
## State management
|
||||
|
||||
React and Recoil handle state management in the codebase.
|
||||
React and Jotai handle state management in the codebase.
|
||||
|
||||
### Use `useRecoilState` to store state
|
||||
### Use Jotai atoms to store state
|
||||
|
||||
It's good practice to create as many atoms as you need to store your state.
|
||||
|
||||
@@ -20,13 +20,16 @@ It's better to use extra atoms than trying to be too concise with props drilling
|
||||
</Warning>
|
||||
|
||||
```tsx
|
||||
export const myAtomState = atom({
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
|
||||
export const myAtomState = createAtomState<string>({
|
||||
key: 'myAtomState',
|
||||
default: 'default value',
|
||||
defaultValue: 'default value',
|
||||
});
|
||||
|
||||
export const MyComponent = () => {
|
||||
const [myAtom, setMyAtom] = useRecoilState(myAtomState);
|
||||
const [myAtom, setMyAtom] = useAtomState(myAtomState);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -43,7 +46,7 @@ export const MyComponent = () => {
|
||||
|
||||
Avoid using `useRef` to store state.
|
||||
|
||||
If you want to store state, you should use `useState` or `useRecoilState`.
|
||||
If you want to store state, you should use `useState` or Jotai atoms with `useAtomState`.
|
||||
|
||||
See [how to manage re-renders](#managing-re-renders) if you feel like you need `useRef` to prevent some re-renders from happening.
|
||||
|
||||
@@ -83,8 +86,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] = useRecoilState(dataState);
|
||||
const [someDependency] = useRecoilState(someDependencyState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
const [someDependency] = useAtomState(someDependencyState);
|
||||
|
||||
useEffect(() => {
|
||||
if(someDependency !== data) {
|
||||
@@ -96,9 +99,7 @@ export const PageComponent = () => {
|
||||
};
|
||||
|
||||
export const App = () => (
|
||||
<RecoilRoot>
|
||||
<PageComponent />
|
||||
</RecoilRoot>
|
||||
<PageComponent />
|
||||
);
|
||||
```
|
||||
|
||||
@@ -106,14 +107,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] = useRecoilState(dataState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
|
||||
return <div>{data}</div>;
|
||||
};
|
||||
|
||||
export const PageData = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [someDependency] = useRecoilState(someDependencyState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
const [someDependency] = useAtomState(someDependencyState);
|
||||
|
||||
useEffect(() => {
|
||||
if(someDependency !== data) {
|
||||
@@ -125,16 +126,16 @@ export const PageData = () => {
|
||||
};
|
||||
|
||||
export const App = () => (
|
||||
<RecoilRoot>
|
||||
<>
|
||||
<PageData />
|
||||
<PageComponent />
|
||||
</RecoilRoot>
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
### Use recoil family states and recoil family selectors
|
||||
### Use atom family states and selectors
|
||||
|
||||
Recoil family states and selectors are a great way to avoid re-renders.
|
||||
Atom family states and selectors are a great way to avoid re-renders.
|
||||
|
||||
They are useful when you need to store a list of items.
|
||||
|
||||
|
||||
+2
-2
@@ -83,9 +83,9 @@ See [Hooks](https://react.dev/learn/reusing-logic-with-custom-hooks) for more de
|
||||
|
||||
### States
|
||||
|
||||
Contains the state management logic. [RecoilJS](https://recoiljs.org) handles this.
|
||||
Contains the state management logic. [Jotai](https://jotai.org) handles this.
|
||||
|
||||
- Selectors: See [RecoilJS Selectors](https://recoiljs.org/docs/basic-tutorial/selectors) for more details.
|
||||
- Selectors: Derived atoms (using `createAtomSelector`) compute values from other atoms and are automatically memoized.
|
||||
|
||||
React's built-in state management still handles state within a component.
|
||||
|
||||
|
||||
+2
-2
@@ -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)
|
||||
- [Recoil](https://recoiljs.org/docs/introduction/core-concepts)
|
||||
- [Jotai](https://jotai.org/)
|
||||
- [TypeScript](https://www.typescriptlang.org/)
|
||||
|
||||
**Testing**
|
||||
@@ -76,7 +76,7 @@ To avoid unnecessary [re-renders](/developers/contribute/capabilities/frontend-d
|
||||
|
||||
### State Management
|
||||
|
||||
[Recoil](https://recoiljs.org/docs/introduction/core-concepts) handles state management.
|
||||
[Jotai](https://jotai.org/) handles state management.
|
||||
|
||||
See [best practices](/developers/contribute/capabilities/frontend-development/best-practices-front#state-management) for more information on state management.
|
||||
|
||||
|
||||
+3
-3
@@ -159,7 +159,7 @@ export enum PageHotkeyScope {
|
||||
}
|
||||
```
|
||||
|
||||
Internally, the currently selected scope is stored in a Recoil state that is shared across the application :
|
||||
Internally, the currently selected scope is stored in a Jotai atom that is shared across the application :
|
||||
|
||||
```tsx
|
||||
export const currentHotkeyScopeState = createState<HotkeyScope>({
|
||||
@@ -168,10 +168,10 @@ export const currentHotkeyScopeState = createState<HotkeyScope>({
|
||||
});
|
||||
```
|
||||
|
||||
But this Recoil state should never be handled manually ! We'll see how to use it in the next section.
|
||||
But this atom 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 Recoil state to handle the hotkey scope state and make it available everywhere in the application.
|
||||
We also create a Jotai atom to handle the hotkey scope state and make it available everywhere in the application.
|
||||
|
||||
@@ -60,6 +60,9 @@ 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
|
||||
|
||||
@@ -79,7 +82,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, post-install function) plus example files based on the scaffolding mode
|
||||
- Generates core files (application config, default function role, pre-install and post-install functions) plus example files based on the scaffolding mode
|
||||
|
||||
A freshly scaffolded app with the default `--exhaustive` mode looks like this:
|
||||
|
||||
@@ -106,6 +109,7 @@ 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
|
||||
@@ -117,7 +121,7 @@ my-twenty-app/
|
||||
└── example-skill.ts # Example AI agent skill definition
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
At a high level:
|
||||
|
||||
@@ -138,6 +142,8 @@ 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 |
|
||||
@@ -163,7 +169,7 @@ export default defineObject({
|
||||
|
||||
Later commands will add more files and folders:
|
||||
|
||||
- `yarn twenty app:dev` will auto-generate a typed API client in `node_modules/twenty-sdk/generated` (typed Twenty client + workspace types).
|
||||
- `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.
|
||||
|
||||
## Authentication
|
||||
@@ -212,6 +218,8 @@ 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 |
|
||||
@@ -318,6 +326,7 @@ 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**: key–value 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:
|
||||
@@ -326,7 +335,6 @@ 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',
|
||||
@@ -342,7 +350,6 @@ export default defineApplication({
|
||||
},
|
||||
},
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -350,7 +357,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).
|
||||
- `postInstallLogicFunctionUniversalIdentifier` (optional) points to a logic function that runs automatically after the app is installed. See [Post-install functions](#post-install-functions).
|
||||
- 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).
|
||||
|
||||
#### Roles and permissions
|
||||
|
||||
@@ -423,10 +430,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 Twenty, { type Person } from '~/generated';
|
||||
import { CoreApiClient, type Person } from 'twenty-sdk/generated';
|
||||
|
||||
const handler = async (params: RoutePayload) => {
|
||||
const client = new Twenty(); // generated typed client
|
||||
const client = new CoreApiClient();
|
||||
const name = 'name' in params.queryStringParameters
|
||||
? params.queryStringParameters.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
|
||||
: 'Hello world';
|
||||
@@ -483,6 +490,43 @@ 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.
|
||||
@@ -491,16 +535,14 @@ When you scaffold a new app with `create-twenty-app`, a post-install function is
|
||||
|
||||
```typescript
|
||||
// src/logic-functions/post-install.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
|
||||
|
||||
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
|
||||
|
||||
const handler = async (): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!');
|
||||
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!', payload.previousVersion);
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
export default definePostInstallLogicFunction({
|
||||
universalIdentifier: '<generated-uuid>',
|
||||
name: 'post-install',
|
||||
description: 'Runs after installation to set up the application.',
|
||||
timeoutSeconds: 300,
|
||||
@@ -508,17 +550,6 @@ export default defineLogicFunction({
|
||||
});
|
||||
```
|
||||
|
||||
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"
|
||||
@@ -526,8 +557,10 @@ yarn twenty function:execute --postInstall
|
||||
```
|
||||
|
||||
Key points:
|
||||
- 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.
|
||||
- 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()`.
|
||||
- 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`.
|
||||
|
||||
@@ -635,10 +668,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 Twenty from '~/generated';
|
||||
import { CoreApiClient } from 'twenty-sdk/generated';
|
||||
|
||||
const handler = async (params: { companyName: string; domain?: string }) => {
|
||||
const client = new Twenty();
|
||||
const client = new CoreApiClient();
|
||||
|
||||
const result = await client.mutation({
|
||||
createTask: {
|
||||
@@ -696,7 +729,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/my-widget.front-component.tsx
|
||||
// src/front-components/my-widget.tsx
|
||||
import { defineFrontComponent } from 'twenty-sdk';
|
||||
|
||||
const MyWidget = () => {
|
||||
@@ -718,14 +751,13 @@ 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 `*.front-component.tsx` file and use `defineFrontComponent()`.
|
||||
- **Manual**: Create a new `.tsx` file and use `defineFrontComponent()`, following the same pattern.
|
||||
|
||||
### Skills
|
||||
|
||||
@@ -761,18 +793,24 @@ 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 client
|
||||
### Generated typed clients
|
||||
|
||||
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:
|
||||
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 Twenty from '~/generated';
|
||||
import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/generated';
|
||||
|
||||
const client = new Twenty();
|
||||
const client = new CoreApiClient();
|
||||
const { me } = await client.query({ me: { id: true, displayName: true } });
|
||||
|
||||
const metadataClient = new MetadataApiClient();
|
||||
const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id: true } });
|
||||
```
|
||||
|
||||
The client is re-generated automatically by `yarn twenty app:dev` whenever your objects or fields change.
|
||||
Both clients are re-generated automatically by `yarn twenty app:dev` whenever your objects or fields change.
|
||||
|
||||
#### Runtime credentials in logic functions
|
||||
|
||||
@@ -788,17 +826,17 @@ Notes:
|
||||
|
||||
#### Uploading files
|
||||
|
||||
The generated `Twenty` client 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.
|
||||
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 Twenty from '~/generated';
|
||||
import { MetadataApiClient } from 'twenty-sdk/generated';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const client = new Twenty();
|
||||
const metadataClient = new MetadataApiClient();
|
||||
|
||||
const fileBuffer = fs.readFileSync('./invoice.pdf');
|
||||
|
||||
const uploadedFile = await client.uploadFile(
|
||||
const uploadedFile = await metadataClient.uploadFile(
|
||||
fileBuffer, // file contents as a Buffer
|
||||
'invoice.pdf', // filename
|
||||
'application/pdf', // MIME type (defaults to 'application/octet-stream')
|
||||
@@ -828,7 +866,7 @@ uploadFile(
|
||||
| `fieldMetadataUniversalIdentifier` | `string` | The `universalIdentifier` of the file-type field on your object |
|
||||
|
||||
Key points:
|
||||
- The method sends the file to the **metadata endpoint** (not the main GraphQL endpoint), where the upload mutation is resolved.
|
||||
- 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.
|
||||
|
||||
|
||||
+20
-19
@@ -6,9 +6,9 @@ title: أفضل الممارسات
|
||||
|
||||
## إدارة الحالة
|
||||
|
||||
تقوم React و Recoil بإدارة الحالة في قاعدة الشيفرة.
|
||||
تقوم React و Jotai بإدارة الحالة في قاعدة الشيفرة.
|
||||
|
||||
### استخدم `useRecoilState` لتخزين الحالة
|
||||
### استخدم ذرات Jotai لتخزين الحالة
|
||||
|
||||
من الجيد إنشاء أكبر عدد ممكن من الذرات لتخزين الحالة الخاصة بك.
|
||||
|
||||
@@ -19,13 +19,16 @@ title: أفضل الممارسات
|
||||
</Warning>
|
||||
|
||||
```tsx
|
||||
export const myAtomState = atom({
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
|
||||
export const myAtomState = createAtomState<string>({
|
||||
key: 'myAtomState',
|
||||
default: 'default value',
|
||||
defaultValue: 'default value',
|
||||
});
|
||||
|
||||
export const MyComponent = () => {
|
||||
const [myAtom, setMyAtom] = useRecoilState(myAtomState);
|
||||
const [myAtom, setMyAtom] = useAtomState(myAtomState);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -42,7 +45,7 @@ export const MyComponent = () => {
|
||||
|
||||
تجنب استخدام `useRef` لتخزين الحالة.
|
||||
|
||||
If you want to store state, you should use `useState` or `useRecoilState`.
|
||||
إذا كنت ترغب في تخزين الحالة، يجب أن تستخدم `useState` أو ذرات Jotai مع `useAtomState`.
|
||||
|
||||
انظر [كيفية إدارة إعادة العرض](#managing-re-renders) إذا شعرت أنك بحاجة إلى `useRef` لمنع بعض إعادة العرض من الحدوث.
|
||||
|
||||
@@ -79,11 +82,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] = useRecoilState(dataState);
|
||||
const [someDependency] = useRecoilState(someDependencyState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
const [someDependency] = useAtomState(someDependencyState);
|
||||
|
||||
useEffect(() => {
|
||||
if(someDependency !== data) {
|
||||
@@ -95,9 +98,7 @@ export const PageComponent = () => {
|
||||
};
|
||||
|
||||
export const App = () => (
|
||||
<RecoilRoot>
|
||||
<PageComponent />
|
||||
</RecoilRoot>
|
||||
<PageComponent />
|
||||
);
|
||||
```
|
||||
|
||||
@@ -105,14 +106,14 @@ export const App = () => (
|
||||
// ✅ جيّد، لن يتسبب في إعادة التصيير إذا لم تتغير البيانات،
|
||||
// لأن useEffect يُعاد تقييمه في مكوّن شقيق آخر
|
||||
export const PageComponent = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
|
||||
return <div>{data}</div>;
|
||||
};
|
||||
|
||||
export const PageData = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [someDependency] = useRecoilState(someDependencyState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
const [someDependency] = useAtomState(someDependencyState);
|
||||
|
||||
useEffect(() => {
|
||||
if(someDependency !== data) {
|
||||
@@ -124,16 +125,16 @@ export const PageData = () => {
|
||||
};
|
||||
|
||||
export const App = () => (
|
||||
<RecoilRoot>
|
||||
<>
|
||||
<PageData />
|
||||
<PageComponent />
|
||||
</RecoilRoot>
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
### استخدم حالات عائلة Recoil ومحددات عائلة Recoil
|
||||
### استخدم حالات عائلة الذرات والمحددات
|
||||
|
||||
حالات عائلة Recoil والمحددات تعتبر طريقة رائعة لتجنب إعادة العرض.
|
||||
تُعد حالات عائلة الذرات والمحددات طريقة رائعة لتجنّب عمليات إعادة التصيير.
|
||||
|
||||
إنها مفيدة عندما تحتاج إلى تخزين قائمة من العناصر.
|
||||
|
||||
|
||||
+2
-2
@@ -82,9 +82,9 @@ module1
|
||||
|
||||
### الحالات
|
||||
|
||||
تشمل منطق إدارة الحالة. [RecoilJS](https://recoiljs.org) يتولّى ذلك.
|
||||
تشمل منطق إدارة الحالة. [Jotai](https://jotai.org) يتولّى ذلك.
|
||||
|
||||
* المحددات: انظر [RecoilJS Selectors](https://recoiljs.org/docs/basic-tutorial/selectors) لمزيد من التفاصيل.
|
||||
* المحددات: الذرات المشتقة (باستخدام `createAtomSelector`) تحسب قيماً من ذرات أخرى وتخزن نتائجها في الذاكرة مؤقتاً تلقائياً.
|
||||
|
||||
لا تزال إدارة الحالة المدمجة في React تتولّى الحالة داخل المكوّن.
|
||||
|
||||
|
||||
+2
-2
@@ -49,7 +49,7 @@ title: أوامر الواجهة الأمامية
|
||||
* "[React](https://react.dev/)"
|
||||
* "[Apollo](https://www.apollographql.com/docs/)"
|
||||
* "[GraphQL Codegen](https://the-guild.dev/graphql/codegen)"
|
||||
* "[Recoil](https://recoiljs.org/docs/introduction/core-concepts)"
|
||||
* [Jotai](https://jotai.org/)
|
||||
* "[TypeScript](https://www.typescriptlang.org/)"
|
||||
|
||||
**الاختبار**
|
||||
@@ -73,7 +73,7 @@ To avoid unnecessary [re-renders](/l/ar/developers/contribute/capabilities/front
|
||||
|
||||
### "إدارة الحالة"
|
||||
|
||||
"[Recoil](https://recoiljs.org/docs/introduction/core-concepts) يتعامل مع إدارة الحالة."
|
||||
[Jotai](https://jotai.org/) يتعامل مع إدارة الحالة.
|
||||
|
||||
"راجع [أفضل الممارسات](/l/ar/developers/contribute/capabilities/frontend-development/best-practices-front#state-management) لمزيد من المعلومات حول إدارة الحالة."
|
||||
|
||||
|
||||
+3
-3
@@ -160,7 +160,7 @@ export enum PageHotkeyScope {
|
||||
}
|
||||
```
|
||||
|
||||
داخليًا، يتم تخزين النطاق المحدد حاليًا في حالة Recoil مشتركة عبر التطبيق:
|
||||
داخليًا، يتم تخزين النطاق المحدد حاليًا في ذرة Jotai مشتركة عبر التطبيق:
|
||||
|
||||
```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) والذي يجعله أكثر كفاءة ويتجنب عمليات إعادة التقديم غير الضرورية.
|
||||
|
||||
ونقوم أيضًا بإنشاء حالة Recoil للتعامل مع حالة نطاق المفتاح وجعلها متاحة في جميع أنحاء التطبيق.
|
||||
ونقوم أيضًا بإنشاء ذرة Jotai للتعامل مع حالة نطاق مفاتيح الاختصار وجعلها متاحة في جميع أنحاء التطبيق.
|
||||
|
||||
@@ -52,22 +52,25 @@ 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"}'
|
||||
|
||||
# Execute the post-install function
|
||||
# نفّذ دالة ما قبل التثبيت
|
||||
yarn twenty function:execute --preInstall
|
||||
|
||||
# نفّذ دالة ما بعد التثبيت
|
||||
yarn twenty function:execute --postInstall
|
||||
|
||||
# Uninstall the application from the current workspace
|
||||
# أزل تثبيت التطبيق من مساحة العمل الحالية
|
||||
yarn twenty app:uninstall
|
||||
|
||||
# Display commands' help
|
||||
# اعرض مساعدة الأوامر
|
||||
yarn twenty help
|
||||
```
|
||||
|
||||
@@ -80,7 +83,7 @@ yarn twenty help
|
||||
* ينسخ تطبيقًا أساسيًا مصغّرًا إلى `my-twenty-app/`
|
||||
* يضيف اعتمادًا محليًا `twenty-sdk` وتهيئة Yarn 4
|
||||
* ينشئ ملفات ضبط ونصوصًا مرتبطة بـ `twenty` CLI
|
||||
* يُنشئ الملفات الأساسية (تهيئة التطبيق، دور الدالة الافتراضي، دالة ما بعد التثبيت) بالإضافة إلى ملفات الأمثلة بحسب وضع الإنشاء
|
||||
* يُنشئ الملفات الأساسية (تهيئة التطبيق، دور الدالة الافتراضي، دالتا ما قبل التثبيت وما بعد التثبيت) بالإضافة إلى ملفات أمثلة استنادًا إلى وضع الإنشاء.
|
||||
|
||||
يبدو التطبيق المُنشأ حديثًا باستخدام الوضع الافتراضي `--exhaustive` كما يلي:
|
||||
|
||||
@@ -107,6 +110,7 @@ my-twenty-app/
|
||||
│ └── example-field.ts # تعريف حقل مستقل — مثال
|
||||
├── logic-functions/
|
||||
│ ├── hello-world.ts # دالة منطقية — مثال
|
||||
│ ├── pre-install.ts # دالة منطقية لما قبل التثبيت
|
||||
│ └── post-install.ts # دالة منطقية لما بعد التثبيت
|
||||
├── front-components/
|
||||
│ └── hello-world.tsx # مكوّن واجهة أمامية — مثال
|
||||
@@ -118,7 +122,7 @@ my-twenty-app/
|
||||
└── example-skill.ts # تعريف مهارة لوكيل الذكاء الاصطناعي — مثال
|
||||
```
|
||||
|
||||
مع `--minimal`، سيتم إنشاء الملفات الأساسية فقط (`application-config.ts` و`roles/default-role.ts` و`logic-functions/post-install.ts`). مع `--interactive`، تختار ملفات الأمثلة التي تريد تضمينها.
|
||||
مع `--minimal`، سيتم إنشاء الملفات الأساسية فقط (`application-config.ts`، `roles/default-role.ts`، `logic-functions/pre-install.ts`، و`logic-functions/post-install.ts`). مع `--interactive`، تختار ملفات الأمثلة التي تريد تضمينها.
|
||||
|
||||
بشكل عام:
|
||||
|
||||
@@ -135,16 +139,18 @@ my-twenty-app/
|
||||
|
||||
يكتشف SDK الكيانات عبر تحليل ملفات TypeScript الخاصة بك بحثًا عن استدعاءات **`export default define<Entity>({...})`**. يحتوي كل نوع كيان على دالة مساعدة مقابلة يتم تصديرها من `twenty-sdk`:
|
||||
|
||||
| دالة مساعدة | نوع الكيان |
|
||||
| ---------------------------- | ------------------------------------ |
|
||||
| `defineObject()` | تعريفات كائنات مخصصة |
|
||||
| `defineLogicFunction()` | تعريفات الوظائف المنطقية |
|
||||
| `defineFrontComponent()` | Front component definitions |
|
||||
| `defineRole()` | تعريفات الأدوار |
|
||||
| `defineField()` | امتدادات الحقول للكائنات الموجودة |
|
||||
| `defineView()` | تعريفات العروض المحفوظة |
|
||||
| `defineNavigationMenuItem()` | تعريفات عناصر قائمة التنقل |
|
||||
| `defineSkill()` | تعريفات مهارات وكيل الذكاء الاصطناعي |
|
||||
| دالة مساعدة | نوع الكيان |
|
||||
| ---------------------------------- | ---------------------------------------------- |
|
||||
| `defineObject()` | تعريفات كائنات مخصصة |
|
||||
| `defineLogicFunction()` | تعريفات الوظائف المنطقية |
|
||||
| `definePreInstallLogicFunction()` | دالة منطقية لما قبل التثبيت (تعمل قبل التثبيت) |
|
||||
| `definePostInstallLogicFunction()` | دالة منطقية لما بعد التثبيت (تعمل بعد التثبيت) |
|
||||
| `defineFrontComponent()` | Front component definitions |
|
||||
| `defineRole()` | تعريفات الأدوار |
|
||||
| `defineField()` | امتدادات الحقول للكائنات الموجودة |
|
||||
| `defineView()` | تعريفات العروض المحفوظة |
|
||||
| `defineNavigationMenuItem()` | تعريفات عناصر قائمة التنقل |
|
||||
| `defineSkill()` | تعريفات مهارات وكيل الذكاء الاصطناعي |
|
||||
|
||||
<Note>
|
||||
**تسمية الملفات مرنة.** يعتمد اكتشاف الكيانات على بنية الشجرة المجردة (AST) — إذ يقوم SDK بفحص ملفات المصدر لديك بحثًا عن النمط `export default define<Entity>({...})`. يمكنك تنظيم ملفاتك ومجلداتك كيفما تشاء. التجميع حسب نوع الكيان (مثلًا، `logic-functions/` و`roles/`) هو مجرد عرف لتنظيم الشيفرة، وليس مطلبًا إلزاميًا.
|
||||
@@ -165,7 +171,7 @@ export default defineObject({
|
||||
|
||||
ستضيف الأوامر اللاحقة مزيدًا من الملفات والمجلدات:
|
||||
|
||||
* `yarn twenty app:dev` سيولّد تلقائيًا عميل API مضبوط الأنواع في `node_modules/twenty-sdk/generated` (عميل Twenty مضبوط الأنواع + أنواع مساحة العمل).
|
||||
* سيقوم `yarn twenty app:dev` بتوليد عميلين API مضبوطي الأنواع تلقائيًا في `node_modules/twenty-sdk/generated`: `CoreApiClient` (لبيانات مساحة العمل عبر `/graphql`) و`MetadataApiClient` (لتكوين مساحة العمل وتحميل الملفات عبر `/metadata`).
|
||||
* `yarn twenty entity:add` سيضيف ملفات تعريف الكيانات ضمن `src/` لكائناتك المخصّصة، والوظائف، ومكوّنات الواجهة الأمامية، والأدوار، والمهارات، وغير ذلك.
|
||||
|
||||
## المصادقة
|
||||
@@ -209,17 +215,19 @@ yarn twenty auth:status
|
||||
|
||||
يوفّر SDK دوالًا مساعدة لتعريف كيانات تطبيقك. كما هو موضح في [اكتشاف الكيانات](#entity-detection)، يجب استخدام `export default define<Entity>({...})` كي يتم اكتشاف كياناتك:
|
||||
|
||||
| دالة | الغرض |
|
||||
| ---------------------------- | ---------------------------------------------------- |
|
||||
| `defineApplication()` | تهيئة بيانات التعريف للتطبيق (مطلوب، واحد لكل تطبيق) |
|
||||
| `defineObject()` | تعريف كائنات مخصصة مع حقول |
|
||||
| `defineLogicFunction()` | تعريف وظائف منطقية مع معالجات |
|
||||
| `defineFrontComponent()` | عرِّف مكوّنات أمامية لواجهة مستخدم مخصّصة |
|
||||
| `defineRole()` | تهيئة صلاحيات الدور والوصول إلى الكائنات |
|
||||
| `defineField()` | وسّع الكائنات الموجودة بحقول إضافية |
|
||||
| `defineView()` | تعريف العروض المحفوظة للكائنات |
|
||||
| `defineNavigationMenuItem()` | تعريف روابط التنقل في الشريط الجانبي |
|
||||
| `defineSkill()` | عرّف مهارات وكيل الذكاء الاصطناعي |
|
||||
| دالة | الغرض |
|
||||
| ---------------------------------- | ---------------------------------------------------- |
|
||||
| `defineApplication()` | تهيئة بيانات التعريف للتطبيق (مطلوب، واحد لكل تطبيق) |
|
||||
| `defineObject()` | تعريف كائنات مخصصة مع حقول |
|
||||
| `defineLogicFunction()` | تعريف وظائف منطقية مع معالجات |
|
||||
| `definePreInstallLogicFunction()` | تعريف دالة منطقية لما قبل التثبيت (واحدة لكل تطبيق) |
|
||||
| `definePostInstallLogicFunction()` | تعريف دالة منطقية لما بعد التثبيت (واحدة لكل تطبيق) |
|
||||
| `defineFrontComponent()` | عرِّف مكوّنات أمامية لواجهة مستخدم مخصّصة |
|
||||
| `defineRole()` | تهيئة صلاحيات الدور والوصول إلى الكائنات |
|
||||
| `defineField()` | وسّع الكائنات الموجودة بحقول إضافية |
|
||||
| `defineView()` | تعريف العروض المحفوظة للكائنات |
|
||||
| `defineNavigationMenuItem()` | تعريف روابط التنقل في الشريط الجانبي |
|
||||
| `defineSkill()` | عرّف مهارات وكيل الذكاء الاصطناعي |
|
||||
|
||||
تتحقق هذه الدوال من تكوينك وقت البناء وتوفّر إكمالًا تلقائيًا في بيئة التطوير وأمان الأنواع.
|
||||
|
||||
@@ -319,6 +327,7 @@ export default defineObject({
|
||||
* **هوية التطبيق**: المعرفات، اسم العرض، والوصف.
|
||||
* **كيفية تشغيل وظائفه**: الدور الذي تستخدمه للأذونات.
|
||||
* **متغيرات (اختياري)**: أزواج مفتاح-قيمة تُعرض لوظائفك كمتغيرات بيئة.
|
||||
* **(اختياري) دالة ما قبل التثبيت**: دالة منطقية تعمل قبل تثبيت التطبيق.
|
||||
* **(Optional) post-install function**: a logic function that runs after the app is installed.
|
||||
|
||||
Use `defineApplication()` to define your application configuration:
|
||||
@@ -327,23 +336,21 @@ 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: 'My Twenty App',
|
||||
description: 'My first Twenty app',
|
||||
displayName: 'تطبيق Twenty الخاص بي',
|
||||
description: 'أول تطبيق لي لـ Twenty',
|
||||
icon: 'IconWorld',
|
||||
applicationVariables: {
|
||||
DEFAULT_RECIPIENT_NAME: {
|
||||
universalIdentifier: '19e94e59-d4fe-4251-8981-b96d0a9f74de',
|
||||
description: 'Default recipient name for postcards',
|
||||
description: 'الاسم الافتراضي للمستلم للبطاقات البريدية',
|
||||
value: 'Jane Doe',
|
||||
isSecret: false,
|
||||
},
|
||||
},
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -352,7 +359,7 @@ export default defineApplication({
|
||||
* حقول `universalIdentifier` هي معرّفات حتمية تخصك؛ أنشئها مرة واحدة واحتفظ بها ثابتة عبر عمليات المزامنة.
|
||||
* `applicationVariables` تصبح متغيرات بيئة لوظائفك (على سبيل المثال، `DEFAULT_RECIPIENT_NAME` متاح كـ `process.env.DEFAULT_RECIPIENT_NAME`).
|
||||
* `defaultRoleUniversalIdentifier` يجب أن يطابق ملف الدور (انظر أدناه).
|
||||
* `postInstallLogicFunctionUniversalIdentifier` (optional) points to a logic function that runs automatically after the app is installed. See [Post-install functions](#post-install-functions).
|
||||
* يتم اكتشاف دوال ما قبل التثبيت وما بعد التثبيت تلقائيًا أثناء إنشاء ملف البيان. راجع [دوال ما قبل التثبيت](#pre-install-functions) و[دوال ما بعد التثبيت](#post-install-functions).
|
||||
|
||||
#### الأدوار والصلاحيات
|
||||
|
||||
@@ -426,10 +433,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 Twenty, { type Person } from '~/generated';
|
||||
import { CoreApiClient, type Person } from 'twenty-sdk/generated';
|
||||
|
||||
const handler = async (params: RoutePayload) => {
|
||||
const client = new Twenty(); // generated typed client
|
||||
const client = new CoreApiClient();
|
||||
const name = 'name' in params.queryStringParameters
|
||||
? params.queryStringParameters.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
|
||||
: 'Hello world';
|
||||
@@ -491,6 +498,44 @@ 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.
|
||||
@@ -499,16 +544,14 @@ A post-install function is a logic function that runs automatically after your a
|
||||
|
||||
```typescript
|
||||
// src/logic-functions/post-install.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
|
||||
|
||||
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
|
||||
|
||||
const handler = async (): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!');
|
||||
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!', payload.previousVersion);
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
export default definePostInstallLogicFunction({
|
||||
universalIdentifier: '<generated-uuid>',
|
||||
name: 'post-install',
|
||||
description: 'Runs after installation to set up the application.',
|
||||
timeoutSeconds: 300,
|
||||
@@ -516,17 +559,6 @@ export default defineLogicFunction({
|
||||
});
|
||||
```
|
||||
|
||||
يتم ربط الدالة بتطبيقك من خلال الإشارة إلى المعرِّف العالمي الخاص بها في `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"
|
||||
@@ -535,8 +567,10 @@ yarn twenty function:execute --postInstall
|
||||
|
||||
النقاط الرئيسية:
|
||||
|
||||
* دوال ما بعد التثبيت هي دوال منطقية قياسية — فهي تستخدم `defineLogicFunction()` مثل أي دالة أخرى.
|
||||
* حقل `postInstallLogicFunctionUniversalIdentifier` في `defineApplication()` اختياري. إذا تم تجاهله، لن يتم تشغيل أي دالة بعد التثبيت.
|
||||
* تستخدم دوال ما بعد التثبيت `definePostInstallLogicFunction()` — وهو إصدار متخصص يستبعد إعدادات المُشغِّل (`cronTriggerSettings` و`databaseEventTriggerSettings` و`httpRouteTriggerSettings` و`isTool`).
|
||||
* يتلقى المُعالج `InstallLogicFunctionPayload` يحوي `{ previousVersion: string }` — إصدار التطبيق الذي كان مُثبّتًا سابقًا (أو سلسلة فارغة للتثبيتات الجديدة).
|
||||
* يُسمح بدالة ما بعد التثبيت واحدة فقط لكل تطبيق. سيُنتج إنشاء ملف البيان خطأً إذا تم اكتشاف أكثر من واحدة.
|
||||
* يتم تعيين `universalIdentifier` للدالة تلقائيًا كـ `postInstallLogicFunctionUniversalIdentifier` في بيان التطبيق أثناء الإنشاء — لست بحاجة إلى الإشارة إليه في `defineApplication()`.
|
||||
* تم تعيين مهلة افتراضية إلى 300 ثانية (5 دقائق) للسماح بمهام الإعداد الأطول مثل تهيئة البيانات.
|
||||
* لا تحتاج دوال ما بعد التثبيت إلى مُشغِّلات — حيث يستدعيها النظام الأساسي أثناء التثبيت أو يدويًا عبر `function:execute --postInstall`.
|
||||
|
||||
@@ -644,10 +678,10 @@ const handler = async (event: RoutePayload) => {
|
||||
```typescript
|
||||
// src/logic-functions/enrich-company.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
import Twenty from '~/generated';
|
||||
import { CoreApiClient } from 'twenty-sdk/generated';
|
||||
|
||||
const handler = async (params: { companyName: string; domain?: string }) => {
|
||||
const client = new Twenty();
|
||||
const client = new CoreApiClient();
|
||||
|
||||
const result = await client.mutation({
|
||||
createTask: {
|
||||
@@ -705,7 +739,7 @@ export default defineLogicFunction({
|
||||
تتيح لك المكوّنات الأمامية إنشاء مكوّنات React مخصّصة تُعرَض داخل واجهة مستخدم Twenty. استخدم `defineFrontComponent()` لتعريف مكوّنات مع تحقّق مدمج:
|
||||
|
||||
```typescript
|
||||
// src/my-widget.front-component.tsx
|
||||
// src/front-components/my-widget.tsx
|
||||
import { defineFrontComponent } from 'twenty-sdk';
|
||||
|
||||
const MyWidget = () => {
|
||||
@@ -728,14 +762,13 @@ export default defineFrontComponent({
|
||||
النقاط الرئيسية:
|
||||
|
||||
* المكوّنات الأمامية هي مكوّنات React تُعرَض ضمن سياقات معزولة داخل Twenty.
|
||||
* استخدم لاحقة الملف `*.front-component.tsx` للاكتشاف التلقائي.
|
||||
* يشير الحقل `component` إلى مكوّن React الخاص بك.
|
||||
* يتم بناء المكوّنات ومزامنتها تلقائيًا أثناء `yarn twenty app:dev`.
|
||||
|
||||
يمكنك إنشاء مكوّنات أمامية جديدة بطريقتين:
|
||||
|
||||
* **مُنشأ بالقالب**: شغّل `yarn twenty entity:add` واختر خيار إضافة مكوّن أمامي جديد.
|
||||
* **يدوي**: أنشئ ملفًا جديدًا `*.front-component.tsx` واستخدم `defineFrontComponent()`.
|
||||
* **يدوي**: أنشئ ملفًا جديدًا `.tsx` واستخدم `defineFrontComponent()` مع اتباع النمط نفسه.
|
||||
|
||||
### المهارات
|
||||
|
||||
@@ -772,18 +805,24 @@ export default defineSkill({
|
||||
* **مُنشأ بالقالب**: شغِّل `yarn twenty entity:add` واختر خيار إضافة مهارة جديدة.
|
||||
* **يدوي**: أنشئ ملفًا جديدًا واستخدم `defineSkill()` مع اتباع النمط نفسه.
|
||||
|
||||
### عميل مُولَّد مضبوط الأنواع
|
||||
### عملاء مُولَّدون مضبوطو الأنواع
|
||||
|
||||
يُولَّد العميل مضبوط الأنواع تلقائيًا بواسطة `yarn twenty app:dev` ويُخزَّن في `node_modules/twenty-sdk/generated` استنادًا إلى مخطط مساحة العمل لديك. استخدمه في وظائفك:
|
||||
يتم توليد عميلين مضبوطي الأنواع تلقائيًا بواسطة `yarn twenty app:dev` وتخزينهما في `node_modules/twenty-sdk/generated` استنادًا إلى مخطط مساحة العمل لديك:
|
||||
|
||||
* **`CoreApiClient`** — يُجري استعلامات إلى نقطة النهاية `/graphql` للحصول على بيانات مساحة العمل
|
||||
* **`MetadataApiClient`** — يستعلم عن نقطة النهاية `/metadata` لتكوين مساحة العمل وتحميل الملفات.
|
||||
|
||||
```typescript
|
||||
import Twenty from '~/generated';
|
||||
import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/generated';
|
||||
|
||||
const client = new Twenty();
|
||||
const client = new CoreApiClient();
|
||||
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` كلما تغيّرت كائناتك أو حقولك.
|
||||
|
||||
#### بيانات الاعتماد وقت التشغيل في الوظائف المنطقية
|
||||
|
||||
@@ -800,17 +839,17 @@ const { me } = await client.query({ me: { id: true, displayName: true } });
|
||||
|
||||
#### رفع الملفات
|
||||
|
||||
يتضمن العميل `Twenty` المُولَّد طريقة `uploadFile` لإرفاق الملفات بالحقول من نوع ملف ضمن كائنات مساحة العمل الخاصة بك. نظرًا لأن عملاء GraphQL القياسيون لا يدعمون تحميل الملفات متعددة الأجزاء افتراضيًا، يوفر العميل هذه الطريقة المخصصة التي تطبق [مواصفة طلب GraphQL متعدد الأجزاء](https://github.com/jaydenseric/graphql-multipart-request-spec) في الخلفية.
|
||||
يتضمن `MetadataApiClient` المُولَّد طريقة `uploadFile` لإرفاق الملفات بالحقول من نوع ملف ضمن كائنات مساحة العمل الخاصة بك. نظرًا لأن عملاء GraphQL القياسيون لا يدعمون تحميل الملفات متعددة الأجزاء افتراضيًا، يوفر العميل هذه الطريقة المخصصة التي تطبق [مواصفة طلب GraphQL متعدد الأجزاء](https://github.com/jaydenseric/graphql-multipart-request-spec) في الخلفية.
|
||||
|
||||
```typescript
|
||||
import Twenty from '~/generated';
|
||||
import { MetadataApiClient } from 'twenty-sdk/generated';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const client = new Twenty();
|
||||
const metadataClient = new MetadataApiClient();
|
||||
|
||||
const fileBuffer = fs.readFileSync('./invoice.pdf');
|
||||
|
||||
const uploadedFile = await client.uploadFile(
|
||||
const uploadedFile = await metadataClient.uploadFile(
|
||||
fileBuffer, // file contents as a Buffer
|
||||
'invoice.pdf', // filename
|
||||
'application/pdf', // MIME type (defaults to 'application/octet-stream')
|
||||
@@ -841,7 +880,7 @@ uploadFile(
|
||||
|
||||
النقاط الرئيسية:
|
||||
|
||||
* ترسل هذه الطريقة الملف إلى **نقطة نهاية البيانات الوصفية** (وليست نقطة النهاية الرئيسية لـ GraphQL)، حيث تُنفَّذ عملية الرفع.
|
||||
* تتوفر طريقة `uploadFile` على `MetadataApiClient` لأن عملية الـ mutation الخاصة بالرفع تُعالَج عبر نقطة النهاية `/metadata`.
|
||||
* تستخدم `universalIdentifier` الخاص بالحقل (وليس المعرّف الخاص بمساحة العمل)، ليعمل كود الرفع لديك عبر أي مساحة عمل مُثبَّت فيها تطبيقك — بما يتماشى مع كيفية إشارة التطبيقات إلى الحقول في كل مكان آخر.
|
||||
* العنوان `url` المُعاد هو عنوان URL موقّع يمكنك استخدامه للوصول إلى الملف المرفوع.
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ image: /images/user-guide/github/github-header.png
|
||||
<Tab title="استخدام">
|
||||
|
||||
```jsx
|
||||
import { RecoilRoot } from "recoil";
|
||||
import React, { useState } from "react";
|
||||
import { IconPicker } from "@/ui/input/components/IconPicker";
|
||||
|
||||
@@ -27,14 +26,12 @@ export const MyComponent = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<IconPicker
|
||||
disabled={false}
|
||||
onChange={handleIconChange}
|
||||
selectedIconKey={selectedIcon}
|
||||
variant="primary"
|
||||
/>
|
||||
</RecoilRoot>
|
||||
<IconPicker
|
||||
disabled={false}
|
||||
onChange={handleIconChange}
|
||||
selectedIconKey={selectedIcon}
|
||||
variant="primary"
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
@@ -13,7 +13,6 @@ image: /images/user-guide/what-is-twenty/20.png
|
||||
<Tab title="استخدام">
|
||||
|
||||
```jsx
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { IconTwentyStar } from 'twenty-ui/display';
|
||||
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
@@ -21,18 +20,16 @@ import { Select } from '@/ui/input/components/Select';
|
||||
export const MyComponent = () => {
|
||||
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<Select
|
||||
className
|
||||
disabled={false}
|
||||
label="Select an option"
|
||||
options={[
|
||||
{ value: 'option1', label: 'Option A', Icon: IconTwentyStar },
|
||||
{ value: 'option2', label: 'Option B', Icon: IconTwentyStar },
|
||||
]}
|
||||
value="option1"
|
||||
/>
|
||||
</RecoilRoot>
|
||||
<Select
|
||||
className
|
||||
disabled={false}
|
||||
label="Select an option"
|
||||
options={[
|
||||
{ value: 'option1', label: 'Option A', Icon: IconTwentyStar },
|
||||
{ value: 'option2', label: 'Option B', Icon: IconTwentyStar },
|
||||
]}
|
||||
value="option1"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -16,34 +16,31 @@ image: /images/user-guide/notes/notes_header.png
|
||||
<Tab title="27332A2E2F2745">
|
||||
|
||||
```jsx
|
||||
import { RecoilRoot } from "recoil";
|
||||
import { TextInput } from "@/ui/input/components/TextInput";
|
||||
|
||||
export const MyComponent = () => {
|
||||
const handleChange = (text) => {
|
||||
console.log("تم تغيير الإدخال:", text);
|
||||
console.log("Input changed:", text);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
console.log("تم ضغط المفتاح:", event.key);
|
||||
console.log("Key pressed:", event.key);
|
||||
};
|
||||
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<TextInput
|
||||
className
|
||||
label="اسم المستخدم"
|
||||
onChange={handleChange}
|
||||
fullWidth={false}
|
||||
disableHotkeys={false}
|
||||
error="اسم مستخدم غير صالح"
|
||||
onKeyDown={handleKeyDown}
|
||||
RightIcon={null}
|
||||
/>
|
||||
</RecoilRoot>
|
||||
<TextInput
|
||||
className
|
||||
label="Username"
|
||||
onChange={handleChange}
|
||||
fullWidth={false}
|
||||
disableHotkeys={false}
|
||||
error="Invalid username"
|
||||
onKeyDown={handleKeyDown}
|
||||
RightIcon={null}
|
||||
/>
|
||||
);
|
||||
};
|
||||
},{
|
||||
|
||||
```
|
||||
|
||||
|
||||
@@ -81,24 +78,21 @@ export const MyComponent = () => {
|
||||
<Tab title="الاستخدام">
|
||||
|
||||
```jsx
|
||||
import { RecoilRoot } from "recoil";
|
||||
import { AutosizeTextInput } from "@/ui/input/components/AutosizeTextInput";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<AutosizeTextInput
|
||||
onValidate={() => console.log("تم تشغيل الدالة onValidate")}
|
||||
minRows={1}
|
||||
placeholder="اكتب تعليقًا"
|
||||
onFocus={() => console.log("تم تشغيل الدالة onFocus")}
|
||||
variant="icon"
|
||||
buttonTitle
|
||||
value="المهمة: "
|
||||
/>
|
||||
</RecoilRoot>
|
||||
<AutosizeTextInput
|
||||
onValidate={() => console.log("onValidate function fired")}
|
||||
minRows={1}
|
||||
placeholder="Write a comment"
|
||||
onFocus={() => console.log("onFocus function fired")}
|
||||
variant="icon"
|
||||
buttonTitle
|
||||
value="Task: "
|
||||
/>
|
||||
);
|
||||
};},{
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
|
||||
+23
-22
@@ -6,9 +6,9 @@ Tento dokument popisuje osvědčené postupy, které byste měli dodržovat při
|
||||
|
||||
## Správa stavu
|
||||
|
||||
React a Recoil zajišťují správu stavu v kódu.
|
||||
React a Jotai zajišťují správu stavu v kódu.
|
||||
|
||||
### Použijte `useRecoilState` k ukládání stavu
|
||||
### Použijte atomy Jotai k ukládání stavu
|
||||
|
||||
Je dobrým zvykem vytvořit tolik atomů, kolik potřebujete ke správě stavu.
|
||||
|
||||
@@ -19,13 +19,16 @@ Je lepší použít více atomů než se snažit být příliš stručný s prop
|
||||
</Warning>
|
||||
|
||||
```tsx
|
||||
export const myAtomState = atom({
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
|
||||
export const myAtomState = createAtomState<string>({
|
||||
key: 'myAtomState',
|
||||
default: 'default value',
|
||||
defaultValue: 'default value',
|
||||
});
|
||||
|
||||
export const MyComponent = () => {
|
||||
const [myAtom, setMyAtom] = useRecoilState(myAtomState);
|
||||
const [myAtom, setMyAtom] = useAtomState(myAtomState);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -42,7 +45,7 @@ export const MyComponent = () => {
|
||||
|
||||
Vyhněte se používání `useRef` k ukládání stavu.
|
||||
|
||||
If you want to store state, you should use `useState` or `useRecoilState`.
|
||||
Pokud chcete ukládat stav, měli byste použít `useState` nebo atomy Jotai s `useAtomState`.
|
||||
|
||||
Podívejte se, jak spravovat překreslení, pokud máte pocit, že potřebujete `useRef`, abyste zabránili některým překreslením.
|
||||
|
||||
@@ -79,11 +82,11 @@ If you feel like you need to add a `useEffect` in your root component, you shoul
|
||||
Stejný postup můžete aplikovat na logiku získávání dat pomocí Apollo hooks.
|
||||
|
||||
```tsx
|
||||
// ❌ Špatně, způsobí překreslení i když se data nemění,
|
||||
// protože useEffect je třeba přehodnotit
|
||||
// ❌ Špatně, způsobí překreslení, i když se data nemění,
|
||||
// protože useEffect je třeba znovu vyhodnotit
|
||||
export const PageComponent = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [someDependency] = useRecoilState(someDependencyState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
const [someDependency] = useAtomState(someDependencyState);
|
||||
|
||||
useEffect(() => {
|
||||
if(someDependency !== data) {
|
||||
@@ -95,24 +98,22 @@ export const PageComponent = () => {
|
||||
};
|
||||
|
||||
export const App = () => (
|
||||
<RecoilRoot>
|
||||
<PageComponent />
|
||||
</RecoilRoot>
|
||||
);},{
|
||||
<PageComponent />
|
||||
);
|
||||
```
|
||||
|
||||
```tsx
|
||||
// ✅ Dobře, nezpůsobí překreslení, pokud se data nemění,
|
||||
// protože useEffect je přehodnoceno v další sourozené komponentě
|
||||
// protože useEffect je znovu vyhodnocen v jiné sourozenecké komponentě
|
||||
export const PageComponent = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
|
||||
return <div>{data}</div>;
|
||||
};
|
||||
|
||||
export const PageData = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [someDependency] = useRecoilState(someDependencyState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
const [someDependency] = useAtomState(someDependencyState);
|
||||
|
||||
useEffect(() => {
|
||||
if(someDependency !== data) {
|
||||
@@ -124,16 +125,16 @@ export const PageData = () => {
|
||||
};
|
||||
|
||||
export const App = () => (
|
||||
<RecoilRoot>
|
||||
<>
|
||||
<PageData />
|
||||
<PageComponent />
|
||||
</RecoilRoot>
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
### Použijte Recoil family states a Recoil family selectors
|
||||
### Použijte rodiny atomů a selektory
|
||||
|
||||
Stavy rodiny třísek a selektory jsou skvělý způsob, jak se vyhnout překreslování.
|
||||
Rodiny atomů a selektory jsou skvělým způsobem, jak se vyhnout překreslování.
|
||||
|
||||
Jsou užitečné, když potřebujete uložit seznam položek.
|
||||
|
||||
|
||||
+2
-2
@@ -82,9 +82,9 @@ Více podrobností naleznete v [Hooks](https://react.dev/learn/reusing-logic-wit
|
||||
|
||||
### Stavy
|
||||
|
||||
Obsahuje logiku správy stavů. To řeší [RecoilJS](https://recoiljs.org).
|
||||
Obsahuje logiku správy stavů. To řeší [Jotai](https://jotai.org).
|
||||
|
||||
* Selektory: Více podrobností naleznete v [RecoilJS Selectors](https://recoiljs.org/docs/basic-tutorial/selectors).
|
||||
* Selektory: Odvozené atomy (pomocí `createAtomSelector`) odvozují hodnoty z jiných atomů a jsou automaticky memoizovány.
|
||||
|
||||
Vestavěná správa stavů v Reactu stále spravuje stav uvnitř komponenty.
|
||||
|
||||
|
||||
+2
-2
@@ -53,7 +53,7 @@ Projekt má čistý a jednoduchý stack s minimálním počtem šablonových kó
|
||||
* [React](https://react.dev/)
|
||||
* [Apollo](https://www.apollographql.com/docs/)
|
||||
* [GraphQL Codegen](https://the-guild.dev/graphql/codegen)
|
||||
* [Recoil](https://recoiljs.org/docs/introduction/core-concepts)
|
||||
* [Jotai](https://jotai.org/)
|
||||
* [TypeScript](https://www.typescriptlang.org/)
|
||||
|
||||
**Testování**
|
||||
@@ -77,7 +77,7 @@ To avoid unnecessary [re-renders](/l/cs/developers/contribute/capabilities/front
|
||||
|
||||
### Správa stavu
|
||||
|
||||
[Recoil](https://recoiljs.org/docs/introduction/core-concepts) zajišťuje správu stavu.
|
||||
[Jotai](https://jotai.org/) zajišťuje správu stavu.
|
||||
|
||||
Podívejte se na [osvědčené postupy](/l/cs/developers/contribute/capabilities/frontend-development/best-practices-front#state-management) pro více informací o správě stavu.
|
||||
|
||||
|
||||
+3
-3
@@ -160,7 +160,7 @@ export enum PageHotkeyScope {
|
||||
}
|
||||
```
|
||||
|
||||
Interně je aktuálně vybraný rozsah uložen v Recoil stavu, který je sdílen napříč aplikací :
|
||||
Interně je aktuálně vybraný rozsah uložen v Jotai atomu, který je sdílen napříč aplikací :
|
||||
|
||||
```tsx
|
||||
export const currentHotkeyScopeState = createState<HotkeyScope>({
|
||||
@@ -169,10 +169,10 @@ export const currentHotkeyScopeState = createState<HotkeyScope>({
|
||||
});
|
||||
```
|
||||
|
||||
Ale tento Recoil stav by neměl být nikdy řízen ručně! Ukážeme si, jak jej používat v příští sekci.
|
||||
Ale tento atom by se nikdy neměl spravovat ručně ! Ukážeme si, jak jej používat v příští sekci.
|
||||
|
||||
## Jak to funguje interně?
|
||||
|
||||
Vytvořili jsme tenkou vrstvu nad [react-hotkeys-hook](https://react-hotkeys-hook.vercel.app/docs/intro), která je výkonnější a vyhýbá se zbytečným překreslením.
|
||||
|
||||
Také jsme vytvořili Recoil stav, abychom mohli řídit stav rozsahu klávesových zkratek a učinit jej dostupným kdekoli v aplikaci.
|
||||
Také vytváříme Jotai atom, abychom mohli řídit stav rozsahu klávesových zkratek a učinit jej dostupným kdekoli v aplikaci.
|
||||
|
||||
@@ -61,6 +61,9 @@ yarn twenty function:logs
|
||||
# Spusťte funkci podle názvu
|
||||
yarn twenty function:execute -n my-function -p '{"name": "test"}'
|
||||
|
||||
# Spusťte předinstalační funkci
|
||||
yarn twenty function:execute --preInstall
|
||||
|
||||
# Spusťte postinstalační funkci
|
||||
yarn twenty function:execute --postInstall
|
||||
|
||||
@@ -80,7 +83,7 @@ Když spustíte `npx create-twenty-app@latest my-twenty-app`, scaffolder:
|
||||
* Zkopíruje minimální základní aplikaci do `my-twenty-app/`
|
||||
* Přidá lokální závislost `twenty-sdk` a konfiguraci pro Yarn 4
|
||||
* Vytvoří konfigurační soubory a skripty napojené na `twenty` CLI
|
||||
* Vygeneruje základní soubory (konfigurace aplikace, výchozí role funkcí, postinstalační funkce) a k nim ukázkové soubory podle zvoleného režimu generování kostry
|
||||
* Vygeneruje základní soubory (konfigurace aplikace, výchozí role funkcí, předinstalační a postinstalační funkce) a k nim ukázkové soubory podle zvoleného režimu generování kostry
|
||||
|
||||
Čerstvě vygenerovaná aplikace s výchozím režimem `--exhaustive` vypadá takto:
|
||||
|
||||
@@ -107,6 +110,7 @@ my-twenty-app/
|
||||
│ └── example-field.ts # Ukázková samostatná definice pole
|
||||
├── logic-functions/
|
||||
│ ├── hello-world.ts # Ukázková logická funkce
|
||||
│ ├── pre-install.ts # Předinstalační logická funkce
|
||||
│ └── post-install.ts # Postinstalační logická funkce
|
||||
├── front-components/
|
||||
│ └── hello-world.tsx # Ukázková front-endová komponenta
|
||||
@@ -118,7 +122,7 @@ my-twenty-app/
|
||||
└── example-skill.ts # Ukázková definice dovednosti agenta AI
|
||||
```
|
||||
|
||||
S volbou `--minimal` se vytvoří pouze základní soubory (`application-config.ts`, `roles/default-role.ts` a `logic-functions/post-install.ts`). S volbou `--interactive` si vyberete, které ukázkové soubory chcete zahrnout.
|
||||
S volbou `--minimal` se vytvoří pouze základní soubory (`application-config.ts`, `roles/default-role.ts`, `logic-functions/pre-install.ts` a `logic-functions/post-install.ts`). S volbou `--interactive` si vyberete, které ukázkové soubory chcete zahrnout.
|
||||
|
||||
V kostce:
|
||||
|
||||
@@ -135,16 +139,18 @@ V kostce:
|
||||
|
||||
SDK detekuje entity analýzou vašich souborů TypeScript a hledá volání **`export default define<Entity>({...})`**. Každý typ entity má odpovídající pomocnou funkci exportovanou z `twenty-sdk`:
|
||||
|
||||
| Pomocná funkce | Typ entity |
|
||||
| ---------------------------- | ------------------------------------- |
|
||||
| `defineObject()` | Definice vlastních objektů |
|
||||
| `defineLogicFunction()` | Definice logických funkcí |
|
||||
| `defineFrontComponent()` | Definice frontendových komponent |
|
||||
| `defineRole()` | Definice rolí |
|
||||
| `defineField()` | Rozšíření polí u existujících objektů |
|
||||
| `defineView()` | Definice uložených zobrazení |
|
||||
| `defineNavigationMenuItem()` | Definice položek navigační nabídky |
|
||||
| `defineSkill()` | Definice dovedností agenta AI |
|
||||
| Pomocná funkce | Typ entity |
|
||||
| ---------------------------------- | --------------------------------------------------------- |
|
||||
| `defineObject()` | Definice vlastních objektů |
|
||||
| `defineLogicFunction()` | Definice logických funkcí |
|
||||
| `definePreInstallLogicFunction()` | Předinstalační logická funkce (spouští se před instalací) |
|
||||
| `definePostInstallLogicFunction()` | Postinstalační logická funkce (spouští se po instalaci) |
|
||||
| `defineFrontComponent()` | Definice frontendových komponent |
|
||||
| `defineRole()` | Definice rolí |
|
||||
| `defineField()` | Rozšíření polí u existujících objektů |
|
||||
| `defineView()` | Definice uložených zobrazení |
|
||||
| `defineNavigationMenuItem()` | Definice položek navigační nabídky |
|
||||
| `defineSkill()` | Definice dovedností agenta AI |
|
||||
|
||||
<Note>
|
||||
**Pojmenování souborů je flexibilní.** Detekce entit je založená na AST — SDK prochází vaše zdrojové soubory a hledá vzor `export default define<Entity>({...})`. Soubory a složky můžete organizovat, jak chcete. Seskupování podle typu entity (např. `logic-functions/`, `roles/`) je pouze konvence pro organizaci kódu, nikoli požadavek.
|
||||
@@ -165,7 +171,7 @@ export default defineObject({
|
||||
|
||||
Pozdější příkazy přidají další soubory a složky:
|
||||
|
||||
* `yarn twenty app:dev` automaticky vygeneruje typovaného klienta API v `node_modules/twenty-sdk/generated` (typovaný klient Twenty + typy pracovního prostoru).
|
||||
* `yarn twenty app:dev` automaticky vygeneruje dva typované API klienty v `node_modules/twenty-sdk/generated`: `CoreApiClient` (pro data pracovního prostoru přes `/graphql`) a `MetadataApiClient` (pro konfiguraci pracovního prostoru a nahrávání souborů přes `/metadata`).
|
||||
* `yarn twenty entity:add` přidá soubory s definicemi entit do `src/` pro vaše vlastní objekty, funkce, frontové komponenty, role, dovednosti a další.
|
||||
|
||||
## Ověření
|
||||
@@ -209,17 +215,19 @@ twenty-sdk poskytuje typované stavební bloky a pomocné funkce, které použí
|
||||
|
||||
SDK poskytuje pomocné funkce pro definování entit vaší aplikace. Jak je popsáno v [Detekce entit](#entity-detection), musíte použít `export default define<Entity>({...})`, aby byly vaše entity detekovány:
|
||||
|
||||
| Funkce | Účel |
|
||||
| ---------------------------- | ----------------------------------------------------------------- |
|
||||
| `defineApplication()` | Nakonfigurujte metadata aplikace (povinné, jedno na aplikaci) |
|
||||
| `defineObject()` | Definice vlastních objektů s poli |
|
||||
| `defineLogicFunction()` | Definice logických funkcí s obslužnými funkcemi |
|
||||
| `defineFrontComponent()` | Definujte frontendové komponenty pro vlastní uživatelské rozhraní |
|
||||
| `defineRole()` | Konfigurace oprávnění rolí a přístupu k objektům |
|
||||
| `defineField()` | Rozšiřte existující objekty o další pole |
|
||||
| `defineView()` | Definujte uložená zobrazení pro objekty |
|
||||
| `defineNavigationMenuItem()` | Definujte odkazy postranní navigace |
|
||||
| `defineSkill()` | Definuje dovednosti agenta AI |
|
||||
| Funkce | Účel |
|
||||
| ---------------------------------- | ----------------------------------------------------------------- |
|
||||
| `defineApplication()` | Nakonfigurujte metadata aplikace (povinné, jedno na aplikaci) |
|
||||
| `defineObject()` | Definice vlastních objektů s poli |
|
||||
| `defineLogicFunction()` | Definice logických funkcí s obslužnými funkcemi |
|
||||
| `definePreInstallLogicFunction()` | Definujte předinstalační logickou funkci (jedna na aplikaci) |
|
||||
| `definePostInstallLogicFunction()` | Definujte postinstalační logickou funkci (jedna na aplikaci) |
|
||||
| `defineFrontComponent()` | Definujte frontendové komponenty pro vlastní uživatelské rozhraní |
|
||||
| `defineRole()` | Konfigurace oprávnění rolí a přístupu k objektům |
|
||||
| `defineField()` | Rozšiřte existující objekty o další pole |
|
||||
| `defineView()` | Definujte uložená zobrazení pro objekty |
|
||||
| `defineNavigationMenuItem()` | Definujte odkazy postranní navigace |
|
||||
| `defineSkill()` | Definuje dovednosti agenta AI |
|
||||
|
||||
Tyto funkce validují vaši konfiguraci v době sestavení a poskytují automatické doplňování v IDE a typovou bezpečnost.
|
||||
|
||||
@@ -319,6 +327,7 @@ Každá aplikace má jeden soubor `application-config.ts`, který popisuje:
|
||||
* **Identitu aplikace**: identifikátory, zobrazovaný název a popis.
|
||||
* **Jak běží její funkce**: kterou roli používají pro oprávnění.
|
||||
* **(Volitelné) proměnné**: dvojice klíč–hodnota zpřístupněné vašim funkcím jako proměnné prostředí.
|
||||
* **(Volitelná) předinstalační funkce**: logická funkce, která se spouští před instalací aplikace.
|
||||
* **(Volitelná) postinstalační funkce**: logická funkce, která se spouští po instalaci aplikace.
|
||||
|
||||
Use `defineApplication()` to define your application configuration:
|
||||
@@ -327,7 +336,6 @@ 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',
|
||||
@@ -343,7 +351,6 @@ export default defineApplication({
|
||||
},
|
||||
},
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -352,7 +359,7 @@ Poznámky:
|
||||
* Pole `universalIdentifier` jsou deterministická ID, která vlastníte; vygenerujte je jednou a udržujte je stabilní napříč synchronizacemi.
|
||||
* `applicationVariables` se stanou proměnnými prostředí pro vaše funkce (například `DEFAULT_RECIPIENT_NAME` je dostupné jako `process.env.DEFAULT_RECIPIENT_NAME`).
|
||||
* `defaultRoleUniversalIdentifier` se musí shodovat se souborem role (viz níže).
|
||||
* `postInstallLogicFunctionUniversalIdentifier` (volitelné) odkazuje na logickou funkci, která se automaticky spustí po instalaci aplikace. Viz [Postinstalační funkce](#post-install-functions).
|
||||
* Předinstalační a postinstalační funkce jsou při sestavování manifestu automaticky detekovány. Viz [Předinstalační funkce](#pre-install-functions) a [Postinstalační funkce](#post-install-functions).
|
||||
|
||||
#### Role a oprávnění
|
||||
|
||||
@@ -426,10 +433,10 @@ Každý soubor funkce používá `defineLogicFunction()` k exportu konfigurace s
|
||||
// src/app/createPostCard.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk';
|
||||
import Twenty, { type Person } from '~/generated';
|
||||
import { CoreApiClient, type Person } from 'twenty-sdk/generated';
|
||||
|
||||
const handler = async (params: RoutePayload) => {
|
||||
const client = new Twenty(); // generated typed client
|
||||
const client = new CoreApiClient();
|
||||
const name = 'name' in params.queryStringParameters
|
||||
? params.queryStringParameters.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
|
||||
: 'Hello world';
|
||||
@@ -491,6 +498,44 @@ Poznámky:
|
||||
* Pole `triggers` je volitelné. Funkce bez spouštěčů lze použít jako pomocné funkce volané jinými funkcemi.
|
||||
* V jedné funkci můžete kombinovat více typů spouštěčů.
|
||||
|
||||
### Předinstalační funkce
|
||||
|
||||
Předinstalační funkce je logická funkce, která se automaticky spouští před instalací vaší aplikace v pracovním prostoru. To je užitečné pro validační úlohy, kontrolu předpokladů nebo přípravu stavu pracovního prostoru před zahájením hlavní instalace.
|
||||
|
||||
Když vygenerujete kostru nové aplikace pomocí `create-twenty-app`, vytvoří se pro vás předinstalační funkce v `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,
|
||||
});
|
||||
```
|
||||
|
||||
Předinstalační funkci můžete také kdykoli spustit ručně pomocí CLI:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty function:execute --preInstall
|
||||
```
|
||||
|
||||
Hlavní body:
|
||||
|
||||
* Předinstalační funkce používají `definePreInstallLogicFunction()` — specializovanou variantu, která vynechává nastavení spouštěčů (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `isTool`).
|
||||
* Obslužná funkce (handler) obdrží `InstallLogicFunctionPayload` s `{ previousVersion: string }` — verzi aplikace, která byla dříve nainstalována (nebo prázdný řetězec při čisté instalaci).
|
||||
* Na jednu aplikaci je povolena pouze jedna předinstalační funkce. Sestavení manifestu skončí chybou, pokud je zjištěna více než jedna.
|
||||
* Identifikátor `universalIdentifier` funkce se během sestavení automaticky nastaví v manifestu aplikace jako `preInstallLogicFunctionUniversalIdentifier` — není potřeba jej uvádět v `defineApplication()`.
|
||||
* Výchozí časový limit je nastaven na 300 sekund (5 minut), aby umožnil delší přípravné úlohy.
|
||||
* Předinstalační funkce nepotřebují spouštěče — platforma je vyvolává před instalací nebo je lze spustit ručně pomocí `function:execute --preInstall`.
|
||||
|
||||
### Postinstalační funkce
|
||||
|
||||
Postinstalační funkce je logická funkce, která se automaticky spouští po instalaci vaší aplikace do pracovního prostoru. To je užitečné pro jednorázové úlohy nastavení, jako je naplnění výchozími daty, vytvoření počátečních záznamů nebo konfigurace nastavení pracovního prostoru.
|
||||
@@ -499,16 +544,14 @@ Když vygenerujete kostru nové aplikace pomocí `create-twenty-app`, vytvoří
|
||||
|
||||
```typescript
|
||||
// src/logic-functions/post-install.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
|
||||
|
||||
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
|
||||
|
||||
const handler = async (): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!');
|
||||
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!', payload.previousVersion);
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
export default definePostInstallLogicFunction({
|
||||
universalIdentifier: '<generated-uuid>',
|
||||
name: 'post-install',
|
||||
description: 'Runs after installation to set up the application.',
|
||||
timeoutSeconds: 300,
|
||||
@@ -516,17 +559,6 @@ export default defineLogicFunction({
|
||||
});
|
||||
```
|
||||
|
||||
Funkce je připojena do vaší aplikace odkazem na její univerzální identifikátor v `application-config.ts`:
|
||||
|
||||
```typescript
|
||||
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
|
||||
|
||||
export default defineApplication({
|
||||
// ...
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
Postinstalační funkci můžete také kdykoli spustit ručně pomocí CLI:
|
||||
|
||||
```bash filename="Terminal"
|
||||
@@ -535,8 +567,10 @@ yarn twenty function:execute --postInstall
|
||||
|
||||
Hlavní body:
|
||||
|
||||
* Postinstalační funkce jsou standardní logické funkce — používají `defineLogicFunction()` stejně jako jakákoli jiná funkce.
|
||||
* Pole `postInstallLogicFunctionUniversalIdentifier` v `defineApplication()` je volitelné. Pokud je vynecháno, po instalaci se nespustí žádná funkce.
|
||||
* Postinstalační funkce používají `definePostInstallLogicFunction()` — specializovanou variantu, která vynechává nastavení spouštěčů (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `isTool`).
|
||||
* Obslužná funkce (handler) obdrží `InstallLogicFunctionPayload` s `{ previousVersion: string }` — verzi aplikace, která byla dříve nainstalována (nebo prázdný řetězec při čisté instalaci).
|
||||
* Na jednu aplikaci je povolena pouze jedna postinstalační funkce. Sestavení manifestu skončí chybou, pokud je zjištěna více než jedna.
|
||||
* Identifikátor `universalIdentifier` funkce se během sestavení automaticky nastaví v manifestu aplikace jako `postInstallLogicFunctionUniversalIdentifier` — není potřeba jej uvádět v `defineApplication()`.
|
||||
* Výchozí časový limit je nastaven na 300 sekund (5 minut), aby umožnil delší úlohy nastavení, jako je naplnění daty.
|
||||
* Postinstalační funkce nepotřebují spouštěče — jsou spouštěny platformou během instalace nebo ručně pomocí `function:execute --postInstall`.
|
||||
|
||||
@@ -644,10 +678,10 @@ Chcete-li označit logickou funkci jako nástroj, nastavte `isTool: true` a posk
|
||||
```typescript
|
||||
// src/logic-functions/enrich-company.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
import Twenty from '~/generated';
|
||||
import { CoreApiClient } from 'twenty-sdk/generated';
|
||||
|
||||
const handler = async (params: { companyName: string; domain?: string }) => {
|
||||
const client = new Twenty();
|
||||
const client = new CoreApiClient();
|
||||
|
||||
const result = await client.mutation({
|
||||
createTask: {
|
||||
@@ -705,7 +739,7 @@ Hlavní body:
|
||||
Frontendové komponenty vám umožňují vytvářet vlastní React komponenty, které se vykreslují v rozhraní Twenty. K definování komponent s vestavěnou validací použijte `defineFrontComponent()`:
|
||||
|
||||
```typescript
|
||||
// src/my-widget.front-component.tsx
|
||||
// src/front-components/my-widget.tsx
|
||||
import { defineFrontComponent } from 'twenty-sdk';
|
||||
|
||||
const MyWidget = () => {
|
||||
@@ -728,14 +762,13 @@ export default defineFrontComponent({
|
||||
Hlavní body:
|
||||
|
||||
* Frontendové komponenty jsou React komponenty, které se vykreslují v izolovaných kontextech v rámci Twenty.
|
||||
* Pro automatickou detekci použijte příponu souboru `*.front-component.tsx`.
|
||||
* Pole `component` odkazuje na vaši React komponentu.
|
||||
* Komponenty se během `yarn twenty app:dev` automaticky sestaví a synchronizují.
|
||||
|
||||
Nové frontendové komponenty můžete vytvořit dvěma způsoby:
|
||||
|
||||
* **Vygenerované**: Spusťte `yarn twenty entity:add` a zvolte možnost přidat novou frontendovou komponentu.
|
||||
* **Ruční**: Vytvořte nový soubor `*.front-component.tsx` a použijte `defineFrontComponent()`.
|
||||
* **Ruční**: Vytvořte nový soubor `.tsx` a použijte `defineFrontComponent()`, podle stejného vzoru.
|
||||
|
||||
### Dovednosti
|
||||
|
||||
@@ -772,18 +805,24 @@ Nové dovednosti můžete vytvářet dvěma způsoby:
|
||||
* **Vygenerované**: Spusťte `yarn twenty entity:add` a zvolte možnost přidat novou dovednost.
|
||||
* **Ruční**: Vytvořte nový soubor a použijte `defineSkill()` podle stejného vzoru.
|
||||
|
||||
### Generovaný typovaný klient
|
||||
### Generované typované klienty
|
||||
|
||||
Typovaný klient je automaticky generován pomocí `yarn twenty app:dev` a ukládá se do `node_modules/twenty-sdk/generated` podle schématu vašeho pracovního prostoru. Použijte jej ve svých funkcích:
|
||||
Dva typované klienty jsou automaticky generovány pomocí `yarn twenty app:dev` a ukládají se do `node_modules/twenty-sdk/generated` podle schématu vašeho pracovního prostoru:
|
||||
|
||||
* **`CoreApiClient`** — provádí dotazy na endpoint `/graphql` za účelem získání dat pracovního prostoru
|
||||
* **`MetadataApiClient`** — odesílá dotazy na endpoint `/metadata` pro konfiguraci pracovního prostoru a nahrávání souborů
|
||||
|
||||
```typescript
|
||||
import Twenty from '~/generated';
|
||||
import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/generated';
|
||||
|
||||
const client = new Twenty();
|
||||
const client = new CoreApiClient();
|
||||
const { me } = await client.query({ me: { id: true, displayName: true } });
|
||||
|
||||
const metadataClient = new MetadataApiClient();
|
||||
const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id: true } });
|
||||
```
|
||||
|
||||
Klient se automaticky znovu generuje pomocí `yarn twenty app:dev` kdykoli se změní vaše objekty nebo pole.
|
||||
Oba klienti se automaticky znovu generují pomocí `yarn twenty app:dev` kdykoli se změní vaše objekty nebo pole.
|
||||
|
||||
#### Běhové přihlašovací údaje v logických funkcích
|
||||
|
||||
@@ -800,17 +839,17 @@ Poznámky:
|
||||
|
||||
#### Nahrávání souborů
|
||||
|
||||
Vygenerovaný klient `Twenty` obsahuje metodu `uploadFile` pro připojování souborů k polím typu souboru u objektů ve vašem pracovním prostoru. Protože standardní klienti GraphQL nativně nepodporují nahrávání souborů pomocí multipart, klient poskytuje tuto speciální metodu, která interně implementuje [specifikaci multipart požadavků GraphQL](https://github.com/jaydenseric/graphql-multipart-request-spec).
|
||||
Vygenerovaný `MetadataApiClient` obsahuje metodu `uploadFile` pro připojování souborů k polím typu souboru u objektů ve vašem pracovním prostoru. Protože standardní klienti GraphQL nativně nepodporují nahrávání souborů pomocí multipart, klient poskytuje tuto speciální metodu, která interně implementuje [specifikaci multipart požadavků GraphQL](https://github.com/jaydenseric/graphql-multipart-request-spec).
|
||||
|
||||
```typescript
|
||||
import Twenty from '~/generated';
|
||||
import { MetadataApiClient } from 'twenty-sdk/generated';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const client = new Twenty();
|
||||
const metadataClient = new MetadataApiClient();
|
||||
|
||||
const fileBuffer = fs.readFileSync('./invoice.pdf');
|
||||
|
||||
const uploadedFile = await client.uploadFile(
|
||||
const uploadedFile = await metadataClient.uploadFile(
|
||||
fileBuffer, // file contents as a Buffer
|
||||
'invoice.pdf', // filename
|
||||
'application/pdf', // MIME type (defaults to 'application/octet-stream')
|
||||
@@ -841,7 +880,7 @@ uploadFile(
|
||||
|
||||
Hlavní body:
|
||||
|
||||
* Metoda odešle soubor na **koncový bod metadat** (nikoli na hlavní koncový bod GraphQL), kde se provede mutace nahrání.
|
||||
* Metoda `uploadFile` je k dispozici v `MetadataApiClient`, protože mutaci nahrávání obsluhuje endpoint `/metadata`.
|
||||
* Používá `universalIdentifier` pole (nikoli jeho ID specifické pro pracovní prostor), takže váš kód pro nahrávání funguje ve všech pracovních prostorech, kde je vaše aplikace nainstalována — v souladu s tím, jak aplikace odkazují na pole všude jinde.
|
||||
* Vrácená hodnota `url` je podepsaná adresa URL, kterou můžete použít k přístupu k nahranému souboru.
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ Rozbalovací výběr ikon, který uživatelům umožňuje vybrat ikonu ze seznam
|
||||
<Tab title="Použití">
|
||||
|
||||
```jsx
|
||||
import { RecoilRoot } from "recoil";
|
||||
import React, { useState } from "react";
|
||||
import { IconPicker } from "@/ui/input/components/IconPicker";
|
||||
|
||||
@@ -27,14 +26,12 @@ export const MyComponent = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<IconPicker
|
||||
disabled={false}
|
||||
onChange={handleIconChange}
|
||||
selectedIconKey={selectedIcon}
|
||||
variant="primary"
|
||||
/>
|
||||
</RecoilRoot>
|
||||
<IconPicker
|
||||
disabled={false}
|
||||
onChange={handleIconChange}
|
||||
selectedIconKey={selectedIcon}
|
||||
variant="primary"
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
@@ -13,7 +13,6 @@ Umožňuje uživatelům vybrat hodnotu z nabídky předdefinovaných možností.
|
||||
<Tab title="Použití">
|
||||
|
||||
```jsx
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { IconTwentyStar } from 'twenty-ui/display';
|
||||
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
@@ -21,18 +20,16 @@ import { Select } from '@/ui/input/components/Select';
|
||||
export const MyComponent = () => {
|
||||
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<Select
|
||||
className
|
||||
disabled={false}
|
||||
label="Vyberte možnost"
|
||||
options={[
|
||||
{ value: 'option1', label: 'Možnost A', Icon: IconTwentyStar },
|
||||
{ value: 'option2', label: 'Možnost B', Icon: IconTwentyStar },
|
||||
]}
|
||||
value="option1"
|
||||
/>
|
||||
</RecoilRoot>
|
||||
<Select
|
||||
className
|
||||
disabled={false}
|
||||
label="Vyberte možnost"
|
||||
options={[
|
||||
{ value: 'option1', label: 'Možnost A', Icon: IconTwentyStar },
|
||||
{ value: 'option2', label: 'Možnost B', Icon: IconTwentyStar },
|
||||
]}
|
||||
value="option1"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -16,34 +16,31 @@ Umožňuje uživatelům zadávat a upravovat text.
|
||||
<Tab title="Použití">
|
||||
|
||||
```jsx
|
||||
import { RecoilRoot } from "recoil";
|
||||
import { TextInput } from "@/ui/input/components/TextInput";
|
||||
|
||||
export const MyComponent = () => {
|
||||
const handleChange = (text) => {
|
||||
console.log("Input changed:", text);
|
||||
console.log("Změněn vstup:", text);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
console.log("Key pressed:", event.key);
|
||||
console.log("Stisknutá klávesa:", event.key);
|
||||
};
|
||||
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<TextInput
|
||||
className
|
||||
label="Username"
|
||||
onChange={handleChange}
|
||||
fullWidth={false}
|
||||
disableHotkeys={false}
|
||||
error="Invalid username"
|
||||
onKeyDown={handleKeyDown}
|
||||
RightIcon={null}
|
||||
/>
|
||||
</RecoilRoot>
|
||||
<TextInput
|
||||
className
|
||||
label="Uživatelské jméno"
|
||||
onChange={handleChange}
|
||||
fullWidth={false}
|
||||
disableHotkeys={false}
|
||||
error="Neplatné uživatelské jméno"
|
||||
onKeyDown={handleKeyDown}
|
||||
RightIcon={null}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
},{
|
||||
```
|
||||
|
||||
|
||||
@@ -81,22 +78,19 @@ Textová vstupní komponenta, která automaticky přizpůsobuje svou výšku na
|
||||
<Tab title="Použití">
|
||||
|
||||
```jsx
|
||||
import { RecoilRoot } from "recoil";
|
||||
import { AutosizeTextInput } from "@/ui/input/components/AutosizeTextInput";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<AutosizeTextInput
|
||||
onValidate={() => console.log("onValidate function fired")}
|
||||
minRows={1}
|
||||
placeholder="Write a comment"
|
||||
onFocus={() => console.log("onFocus function fired")}
|
||||
variant="icon"
|
||||
buttonTitle
|
||||
value="Task: "
|
||||
/>
|
||||
</RecoilRoot>
|
||||
<AutosizeTextInput
|
||||
onValidate={() => console.log("Funkce onValidate spuštěna")}
|
||||
minRows={1}
|
||||
placeholder="Napište komentář"
|
||||
onFocus={() => console.log("Funkce onFocus spuštěna")}
|
||||
variant="icon"
|
||||
buttonTitle
|
||||
value="Úkol: "
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
+23
-22
@@ -6,9 +6,9 @@ Dieses Dokument beschreibt die besten Praktiken, die Sie beim Arbeiten am Fronte
|
||||
|
||||
## Zustandsverwaltung
|
||||
|
||||
React und Recoil übernehmen die Zustandsverwaltung im Code.
|
||||
React und Jotai übernehmen die Zustandsverwaltung im Code.
|
||||
|
||||
### Verwenden Sie `useRecoilState`, um den Zustand zu speichern.
|
||||
### Verwenden Sie Jotai-Atome, um den Zustand zu speichern
|
||||
|
||||
Es ist eine gute Praxis, so viele Atome zu erstellen, wie Sie benötigen, um Ihren Zustand zu speichern.
|
||||
|
||||
@@ -19,13 +19,16 @@ Es ist besser, zusätzliche Atome zu verwenden, als zu versuchen, mit Prop Drill
|
||||
</Warning>
|
||||
|
||||
```tsx
|
||||
export const myAtomState = atom({
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
|
||||
export const myAtomState = createAtomState<string>({
|
||||
key: 'myAtomState',
|
||||
default: 'default value',
|
||||
defaultValue: 'default value',
|
||||
});
|
||||
|
||||
export const MyComponent = () => {
|
||||
const [myAtom, setMyAtom] = useRecoilState(myAtomState);
|
||||
const [myAtom, setMyAtom] = useAtomState(myAtomState);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -42,7 +45,7 @@ export const MyComponent = () => {
|
||||
|
||||
Vermeiden Sie die Verwendung von `useRef`, um den Zustand zu speichern.
|
||||
|
||||
If you want to store state, you should use `useState` or `useRecoilState`.
|
||||
Wenn Sie den Zustand speichern möchten, sollten Sie `useState` oder Jotai-Atome mit `useAtomState` verwenden.
|
||||
|
||||
Sehen Sie sich [an, wie Re-Renderings verwaltet werden können](#managing-re-renders), falls Sie das Gefühl haben, dass Sie `useRef` benötigen, um einige Re-Renderings zu verhindern.
|
||||
|
||||
@@ -79,11 +82,11 @@ If you feel like you need to add a `useEffect` in your root component, you shoul
|
||||
Dasselbe können Sie auch für die Datenabruflogik mit Apollo-Hooks anwenden.
|
||||
|
||||
```tsx
|
||||
// ❌ Bad, will cause re-renders even if data is not changing,
|
||||
// because useEffect needs to be re-evaluated
|
||||
// ❌ Schlecht, verursacht Re-Renders, auch wenn sich die Daten nicht ändern,
|
||||
// weil useEffect neu ausgewertet werden muss
|
||||
export const PageComponent = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [someDependency] = useRecoilState(someDependencyState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
const [someDependency] = useAtomState(someDependencyState);
|
||||
|
||||
useEffect(() => {
|
||||
if(someDependency !== data) {
|
||||
@@ -95,24 +98,22 @@ export const PageComponent = () => {
|
||||
};
|
||||
|
||||
export const App = () => (
|
||||
<RecoilRoot>
|
||||
<PageComponent />
|
||||
</RecoilRoot>
|
||||
<PageComponent />
|
||||
);
|
||||
```
|
||||
|
||||
```tsx
|
||||
// ✅ Good, will not cause re-renders if data is not changing,
|
||||
// because useEffect is re-evaluated in another sibling component
|
||||
// ✅ Gut, verursacht keine Re-Renders, wenn sich die Daten nicht ändern,
|
||||
// weil useEffect in einer anderen Geschwisterkomponente neu ausgewertet wird
|
||||
export const PageComponent = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
|
||||
return <div>{data}</div>;
|
||||
};
|
||||
|
||||
export const PageData = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [someDependency] = useRecoilState(someDependencyState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
const [someDependency] = useAtomState(someDependencyState);
|
||||
|
||||
useEffect(() => {
|
||||
if(someDependency !== data) {
|
||||
@@ -124,16 +125,16 @@ export const PageData = () => {
|
||||
};
|
||||
|
||||
export const App = () => (
|
||||
<RecoilRoot>
|
||||
<>
|
||||
<PageData />
|
||||
<PageComponent />
|
||||
</RecoilRoot>
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
### Verwenden Sie Recoil-Familienzustände und Recoil-Familienselektoren
|
||||
### Verwenden Sie Atom-Familienzustände und Selektoren
|
||||
|
||||
Recoil-Familienzustände und -Selektoren sind eine großartige Möglichkeit, Re-Renders zu vermeiden.
|
||||
Atom-Familienzustände und Selektoren sind eine großartige Möglichkeit, Re-Renders zu vermeiden.
|
||||
|
||||
Sie sind nützlich, wenn Sie eine Liste von Elementen speichern müssen.
|
||||
|
||||
|
||||
+2
-2
@@ -82,9 +82,9 @@ Weitere Details finden Sie unter [Hooks](https://react.dev/learn/reusing-logic-w
|
||||
|
||||
### Zustände
|
||||
|
||||
Enthält die State-Management-Logik. [RecoilJS](https://recoiljs.org) übernimmt dies.
|
||||
Enthält die State-Management-Logik. [Jotai](https://jotai.org) übernimmt dies.
|
||||
|
||||
* Selektoren: Weitere Einzelheiten finden Sie unter [RecoilJS Selektoren](https://recoiljs.org/docs/basic-tutorial/selectors).
|
||||
* Selektoren: Abgeleitete Atome (mit `createAtomSelector`) berechnen Werte aus anderen Atomen und werden automatisch memoisiert.
|
||||
|
||||
Die integrierte Zustandsverwaltung von React verwaltet weiterhin den Zustand innerhalb einer Komponente.
|
||||
|
||||
|
||||
+2
-2
@@ -53,7 +53,7 @@ Das Projekt hat einen sauberen und einfachen Stack mit minimalem Boilerplate-Cod
|
||||
* [React](https://react.dev/)
|
||||
* [Apollo](https://www.apollographql.com/docs/)
|
||||
* [GraphQL Codegen](https://the-guild.dev/graphql/codegen)
|
||||
* [Recoil](https://recoiljs.org/docs/introduction/core-concepts)
|
||||
* [Jotai](https://jotai.org/)
|
||||
* [TypeScript](https://www.typescriptlang.org/)
|
||||
|
||||
**Tests**
|
||||
@@ -77,7 +77,7 @@ To avoid unnecessary [re-renders](/l/de/developers/contribute/capabilities/front
|
||||
|
||||
### Zustandsverwaltung
|
||||
|
||||
[Recoil](https://recoiljs.org/docs/introduction/core-concepts) übernimmt die Zustandsverwaltung.
|
||||
[Jotai](https://jotai.org/) übernimmt die Zustandsverwaltung.
|
||||
|
||||
Siehe [Best Practices](/l/de/developers/contribute/capabilities/frontend-development/best-practices-front#state-management) für mehr Informationen zur Zustandsverwaltung.
|
||||
|
||||
|
||||
+3
-3
@@ -160,7 +160,7 @@ export enum PageHotkeyScope {
|
||||
}
|
||||
```
|
||||
|
||||
Intern wird der aktuell ausgewählte Bereich in einem Recoil-State gespeichert, der in der gesamten Anwendung geteilt wird:
|
||||
Intern wird der aktuell ausgewählte Bereich in einem Jotai-Atom gespeichert, das in der gesamten Anwendung geteilt wird:
|
||||
|
||||
```tsx
|
||||
export const currentHotkeyScopeState = createState<HotkeyScope>({
|
||||
@@ -169,10 +169,10 @@ export const currentHotkeyScopeState = createState<HotkeyScope>({
|
||||
});
|
||||
```
|
||||
|
||||
Aber dieser Recoil-State sollte niemals manuell bearbeitet werden! Wir werden im nächsten Abschnitt sehen, wie man es verwendet.
|
||||
Aber dieses Atom sollte niemals manuell bearbeitet werden! Wir werden im nächsten Abschnitt sehen, wie man es verwendet.
|
||||
|
||||
## Wie funktioniert es intern?
|
||||
|
||||
Wir haben eine dünne Schicht über [react-hotkeys-hook](https://react-hotkeys-hook.vercel.app/docs/intro) erstellt, die es leistungsfähiger macht und unnötige Neu-Renderings vermeidet.
|
||||
|
||||
Wir haben auch einen Recoil-State erstellt, um den Tastenkombinationsbereich zu verwalten und in der gesamten Anwendung verfügbar zu machen.
|
||||
Wir erstellen außerdem ein Jotai-Atom, um den Zustand des Tastenkombinationsbereichs zu verwalten und in der gesamten Anwendung verfügbar zu machen.
|
||||
|
||||
@@ -61,6 +61,9 @@ yarn twenty function:logs
|
||||
# Eine Funktion anhand ihres Namens ausführen
|
||||
yarn twenty function:execute -n my-function -p '{"name": "test"}'
|
||||
|
||||
# Die Pre-Installationsfunktion ausführen
|
||||
yarn twenty function:execute --preInstall
|
||||
|
||||
# Die Post-Installationsfunktion ausführen
|
||||
yarn twenty function:execute --postInstall
|
||||
|
||||
@@ -68,7 +71,7 @@ yarn twenty function:execute --postInstall
|
||||
yarn twenty app:uninstall
|
||||
|
||||
# Hilfe zu Befehlen anzeigen
|
||||
yarn twenty help
|
||||
yarn twenty help},{
|
||||
```
|
||||
|
||||
Siehe auch: die CLI-Referenzseiten für [create-twenty-app](https://www.npmjs.com/package/create-twenty-app) und [twenty-sdk CLI](https://www.npmjs.com/package/twenty-sdk).
|
||||
@@ -80,7 +83,7 @@ Wenn Sie `npx create-twenty-app@latest my-twenty-app` ausführen, erledigt der S
|
||||
* Kopiert eine minimale Basisanwendung nach `my-twenty-app/`
|
||||
* Fügt eine lokale `twenty-sdk`-Abhängigkeit und die Yarn-4-Konfiguration hinzu
|
||||
* Erstellt Konfigurationsdateien und Skripte, die an die `twenty`-CLI angebunden sind
|
||||
* Erzeugt Kerndateien (Anwendungskonfiguration, Standardrolle für Logikfunktionen, Post-Installationsfunktion) sowie Beispieldateien entsprechend dem Scaffolding-Modus
|
||||
* Erzeugt Kerndateien (Anwendungskonfiguration, Standardrolle für Logikfunktionen, Pre-Installations- und Post-Installationsfunktionen) sowie Beispieldateien entsprechend dem Scaffolding-Modus
|
||||
|
||||
Eine frisch erstellte App mit dem Standardmodus `--exhaustive` sieht so aus:
|
||||
|
||||
@@ -107,6 +110,7 @@ my-twenty-app/
|
||||
│ └── example-field.ts # Beispiel für eine eigenständige Felddefinition
|
||||
├── logic-functions/
|
||||
│ ├── hello-world.ts # Beispiel für eine Logikfunktion
|
||||
│ ├── pre-install.ts # Pre-Installations-Logikfunktion
|
||||
│ └── post-install.ts # Post-Installations-Logikfunktion
|
||||
├── front-components/
|
||||
│ └── hello-world.tsx # Beispiel für eine Frontend-Komponente
|
||||
@@ -118,7 +122,7 @@ my-twenty-app/
|
||||
└── example-skill.ts # Beispiel für eine Skill-Definition eines KI-Agenten
|
||||
```
|
||||
|
||||
Mit `--minimal` werden nur die Kerndateien erstellt (`application-config.ts`, `roles/default-role.ts` und `logic-functions/post-install.ts`). Mit `--interactive` wählst du aus, welche Beispieldateien enthalten sein sollen.
|
||||
Mit `--minimal` werden nur die Kerndateien erstellt (`application-config.ts`, `roles/default-role.ts`, `logic-functions/pre-install.ts` und `logic-functions/post-install.ts`). Mit `--interactive` wählst du aus, welche Beispieldateien enthalten sein sollen.
|
||||
|
||||
Auf hoher Ebene:
|
||||
|
||||
@@ -135,16 +139,18 @@ Auf hoher Ebene:
|
||||
|
||||
Das SDK erkennt Entitäten, indem es Ihre TypeScript-Dateien nach Aufrufen von **`export default define<Entity>({...})`** parst. Für jeden Entitätstyp gibt es eine entsprechende Hilfsfunktion, die aus `twenty-sdk` exportiert wird:
|
||||
|
||||
| Hilfsfunktion | Entitätstyp |
|
||||
| ---------------------------- | ----------------------------------------- |
|
||||
| `defineObject()` | Benutzerdefinierte Objektdefinitionen |
|
||||
| `defineLogicFunction()` | Definitionen von Logikfunktionen |
|
||||
| `defineFrontComponent()` | Definitionen von Frontend-Komponenten |
|
||||
| `defineRole()` | Rollendefinitionen |
|
||||
| `defineField()` | Felderweiterungen für bestehende Objekte |
|
||||
| `defineView()` | Gespeicherte View-Definitionen |
|
||||
| `defineNavigationMenuItem()` | Definitionen von Navigationsmenüeinträgen |
|
||||
| `defineSkill()` | Skill-Definitionen für KI-Agenten |
|
||||
| Hilfsfunktion | Entitätstyp |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------ |
|
||||
| `defineObject()` | Benutzerdefinierte Objektdefinitionen |
|
||||
| `defineLogicFunction()` | Definitionen von Logikfunktionen |
|
||||
| `definePreInstallLogicFunction()` | Pre-Installations-Logikfunktion (wird vor der Installation ausgeführt) |
|
||||
| `definePostInstallLogicFunction()` | Post-Installations-Logikfunktion (wird nach der Installation ausgeführt) |
|
||||
| `defineFrontComponent()` | Definitionen von Frontend-Komponenten |
|
||||
| `defineRole()` | Rollendefinitionen |
|
||||
| `defineField()` | Felderweiterungen für bestehende Objekte |
|
||||
| `defineView()` | Gespeicherte View-Definitionen |
|
||||
| `defineNavigationMenuItem()` | Definitionen von Navigationsmenüeinträgen |
|
||||
| `defineSkill()` | Skill-Definitionen für KI-Agenten |
|
||||
|
||||
<Note>
|
||||
**Dateibenennung ist flexibel.** Die Entitätserkennung ist AST-basiert — das SDK durchsucht Ihre Quelldateien nach dem Muster `export default define<Entity>({...})`. Sie können Ihre Dateien und Ordner nach Belieben organisieren. Die Gruppierung nach Entitätstyp (z. B. `logic-functions/`, `roles/`) ist lediglich eine Konvention zur Codeorganisation, keine Voraussetzung.
|
||||
@@ -165,7 +171,7 @@ export default defineObject({
|
||||
|
||||
Spätere Befehle fügen weitere Dateien und Ordner hinzu:
|
||||
|
||||
* `yarn twenty app:dev` generiert automatisch einen typisierten API-Client in `node_modules/twenty-sdk/generated` (typisierter Twenty-Client + Arbeitsbereichs-Typen).
|
||||
* `yarn twenty app:dev` generiert automatisch zwei typisierte API-Clients in `node_modules/twenty-sdk/generated`: `CoreApiClient` (für Arbeitsbereichsdaten über `/graphql`) und `MetadataApiClient` (für Arbeitsbereichskonfiguration und Datei-Uploads über `/metadata`).
|
||||
* `yarn twenty entity:add` fügt unter `src/` Entitätsdefinitionsdateien für Ihre benutzerdefinierten Objekte, Funktionen, Frontend-Komponenten, Rollen, Skills und mehr hinzu.
|
||||
|
||||
## Authentifizierung
|
||||
@@ -209,17 +215,19 @@ Das twenty-sdk stellt typisierte Bausteine und Hilfsfunktionen bereit, die Sie i
|
||||
|
||||
Das SDK stellt Hilfsfunktionen bereit, um die Entitäten Ihrer App zu definieren. Wie in [Entitätserkennung](#entity-detection) beschrieben, müssen Sie `export default define<Entity>({...})` verwenden, damit Ihre Entitäten erkannt werden:
|
||||
|
||||
| Funktion | Zweck |
|
||||
| ---------------------------- | -------------------------------------------------------------- |
|
||||
| `defineApplication()` | Anwendungsmetadaten konfigurieren (erforderlich, eine pro App) |
|
||||
| `defineObject()` | Benutzerdefinierte Objekte mit Feldern definieren |
|
||||
| `defineLogicFunction()` | Logikfunktionen mit Handlern definieren |
|
||||
| `defineFrontComponent()` | Frontend-Komponenten für benutzerdefinierte UI definieren |
|
||||
| `defineRole()` | Rollenberechtigungen und Objektzugriff konfigurieren |
|
||||
| `defineField()` | Bestehende Objekte mit zusätzlichen Feldern erweitern |
|
||||
| `defineView()` | Gespeicherte Views für Objekte definieren |
|
||||
| `defineNavigationMenuItem()` | Seitenleisten-Navigationslinks definieren |
|
||||
| `defineSkill()` | Definieren Sie Skills für KI-Agenten |
|
||||
| Funktion | Zweck |
|
||||
| ---------------------------------- | --------------------------------------------------------------- |
|
||||
| `defineApplication()` | Anwendungsmetadaten konfigurieren (erforderlich, eine pro App) |
|
||||
| `defineObject()` | Benutzerdefinierte Objekte mit Feldern definieren |
|
||||
| `defineLogicFunction()` | Logikfunktionen mit Handlern definieren |
|
||||
| `definePreInstallLogicFunction()` | Eine Pre-Installations-Logikfunktion definieren (eine pro App) |
|
||||
| `definePostInstallLogicFunction()` | Eine Post-Installations-Logikfunktion definieren (eine pro App) |
|
||||
| `defineFrontComponent()` | Frontend-Komponenten für benutzerdefinierte UI definieren |
|
||||
| `defineRole()` | Rollenberechtigungen und Objektzugriff konfigurieren |
|
||||
| `defineField()` | Bestehende Objekte mit zusätzlichen Feldern erweitern |
|
||||
| `defineView()` | Gespeicherte Views für Objekte definieren |
|
||||
| `defineNavigationMenuItem()` | Seitenleisten-Navigationslinks definieren |
|
||||
| `defineSkill()` | Definieren Sie Skills für KI-Agenten |
|
||||
|
||||
Diese Funktionen validieren Ihre Konfiguration zur Build-Zeit und bieten IDE-Autovervollständigung sowie Typsicherheit.
|
||||
|
||||
@@ -319,6 +327,7 @@ Jede App hat eine einzelne Datei `application-config.ts`, die Folgendes beschrei
|
||||
* **Was die App ist**: Bezeichner, Anzeigename und Beschreibung.
|
||||
* **Wie ihre Funktionen ausgeführt werden**: welche Rolle sie für Berechtigungen verwenden.
|
||||
* **(Optional) Variablen**: Schlüssel–Wert-Paare, die Ihren Funktionen als Umgebungsvariablen zur Verfügung gestellt werden.
|
||||
* **(Optional) Pre-Installationsfunktion**: eine Logikfunktion, die vor der Installation der App ausgeführt wird.
|
||||
* **(Optional) Post-Installationsfunktion**: eine Logikfunktion, die nach der Installation der App ausgeführt wird.
|
||||
|
||||
Verwenden Sie `defineApplication()`, um Ihre Anwendungskonfiguration zu definieren:
|
||||
@@ -327,7 +336,6 @@ Verwenden Sie `defineApplication()`, um Ihre Anwendungskonfiguration zu definier
|
||||
// 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',
|
||||
@@ -343,7 +351,6 @@ export default defineApplication({
|
||||
},
|
||||
},
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -352,7 +359,7 @@ Notizen:
|
||||
* `universalIdentifier`-Felder sind deterministische IDs, die Sie besitzen; generieren Sie sie einmal und halten Sie sie über Synchronisierungen hinweg stabil.
|
||||
* `applicationVariables` werden zu Umgebungsvariablen für Ihre Funktionen (zum Beispiel ist `DEFAULT_RECIPIENT_NAME` als `process.env.DEFAULT_RECIPIENT_NAME` verfügbar).
|
||||
* `defaultRoleUniversalIdentifier` muss mit der Rollendatei übereinstimmen (siehe unten).
|
||||
* `postInstallLogicFunctionUniversalIdentifier` (optional) verweist auf eine Logikfunktion, die nach der Installation der App automatisch ausgeführt wird. Siehe [Post-Installationsfunktionen](#post-install-functions).
|
||||
* Pre-Installations- und Post-Installationsfunktionen werden während des Manifest-Builds automatisch erkannt. Siehe [Pre-Installationsfunktionen](#pre-install-functions) und [Post-Installationsfunktionen](#post-install-functions).
|
||||
|
||||
#### Rollen und Berechtigungen
|
||||
|
||||
@@ -426,10 +433,10 @@ Jede Funktionsdatei verwendet `defineLogicFunction()`, um eine Konfiguration mit
|
||||
// src/app/createPostCard.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk';
|
||||
import Twenty, { type Person } from '~/generated';
|
||||
import { CoreApiClient, type Person } from 'twenty-sdk/generated';
|
||||
|
||||
const handler = async (params: RoutePayload) => {
|
||||
const client = new Twenty(); // generated typed client
|
||||
const client = new CoreApiClient();
|
||||
const name = 'name' in params.queryStringParameters
|
||||
? params.queryStringParameters.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
|
||||
: 'Hello world';
|
||||
@@ -491,6 +498,44 @@ Notizen:
|
||||
* Das Array `triggers` ist optional. Funktionen ohne Trigger können als von anderen Funktionen aufgerufene Utility-Funktionen verwendet werden.
|
||||
* Sie können mehrere Trigger-Typen in einer Funktion kombinieren.
|
||||
|
||||
### Pre-Installationsfunktionen
|
||||
|
||||
Eine Pre-Installationsfunktion ist eine Logikfunktion, die automatisch ausgeführt wird, bevor deine App in einem Arbeitsbereich installiert wird. Dies ist nützlich für Validierungsaufgaben, Überprüfungen von Voraussetzungen oder die Vorbereitung des Status des Arbeitsbereichs, bevor die Hauptinstallation fortgesetzt wird.
|
||||
|
||||
Wenn du mit `create-twenty-app` eine neue App erstellst, wird für dich eine Pre-Installationsfunktion unter `src/logic-functions/pre-install.ts` erzeugt:
|
||||
|
||||
```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,
|
||||
});
|
||||
```
|
||||
|
||||
Du kannst die Pre-Installationsfunktion auch jederzeit manuell über die CLI ausführen:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty function:execute --preInstall
|
||||
```
|
||||
|
||||
Hauptpunkte:
|
||||
|
||||
* Pre-Installationsfunktionen verwenden `definePreInstallLogicFunction()` — eine spezialisierte Variante, die Trigger-Einstellungen (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `isTool`) weglässt.
|
||||
* Der Handler erhält ein `InstallLogicFunctionPayload` mit `{ previousVersion: string }` — die Version der App, die zuvor installiert war (oder eine leere Zeichenkette bei Neuinstallationen).
|
||||
* Pro Anwendung ist nur eine Pre-Installationsfunktion zulässig. Der Manifest-Build schlägt fehl, wenn mehr als eine erkannt wird.
|
||||
* Der `universalIdentifier` der Funktion wird während des Builds im Anwendungsmanifest automatisch als `preInstallLogicFunctionUniversalIdentifier` gesetzt — du musst ihn nicht in `defineApplication()` referenzieren.
|
||||
* Das standardmäßige Timeout ist auf 300 Sekunden (5 Minuten) festgelegt, um längere Vorbereitungsvorgänge zu ermöglichen.
|
||||
* Pre-Installationsfunktionen benötigen keine Trigger — sie werden von der Plattform vor der Installation oder manuell über `function:execute --preInstall` aufgerufen.
|
||||
|
||||
### Post-Installationsfunktionen
|
||||
|
||||
Eine Post-Installationsfunktion ist eine Logikfunktion, die automatisch ausgeführt wird, nachdem Ihre App in einem Arbeitsbereich installiert wurde. Dies ist nützlich für einmalige Einrichtungsvorgänge wie das Befüllen mit Standarddaten, das Erstellen erster Datensätze oder das Konfigurieren von Arbeitsbereichseinstellungen.
|
||||
@@ -499,16 +544,14 @@ Wenn du mit `create-twenty-app` eine neue App erstellst, wird für dich eine Pos
|
||||
|
||||
```typescript
|
||||
// src/logic-functions/post-install.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
|
||||
|
||||
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
|
||||
|
||||
const handler = async (): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!');
|
||||
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!', payload.previousVersion);
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
export default definePostInstallLogicFunction({
|
||||
universalIdentifier: '<generated-uuid>',
|
||||
name: 'post-install',
|
||||
description: 'Runs after installation to set up the application.',
|
||||
timeoutSeconds: 300,
|
||||
@@ -516,17 +559,6 @@ export default defineLogicFunction({
|
||||
});
|
||||
```
|
||||
|
||||
Die Funktion wird in deine App eingebunden, indem ihr universeller Bezeichner in `application-config.ts` referenziert wird:
|
||||
|
||||
```typescript
|
||||
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
|
||||
|
||||
export default defineApplication({
|
||||
// ...
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
Du kannst die Post-Installationsfunktion auch jederzeit manuell über die CLI ausführen:
|
||||
|
||||
```bash filename="Terminal"
|
||||
@@ -535,8 +567,10 @@ yarn twenty function:execute --postInstall
|
||||
|
||||
Hauptpunkte:
|
||||
|
||||
* Post-Installationsfunktionen sind Standard-Logikfunktionen — sie verwenden `defineLogicFunction()` wie jede andere Funktion.
|
||||
* Das Feld `postInstallLogicFunctionUniversalIdentifier` in `defineApplication()` ist optional. Wenn es weggelassen wird, wird nach der Installation keine Funktion ausgeführt.
|
||||
* Post-Installationsfunktionen verwenden `definePostInstallLogicFunction()` — eine spezialisierte Variante, die Trigger-Einstellungen (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `isTool`) weglässt.
|
||||
* Der Handler erhält ein `InstallLogicFunctionPayload` mit `{ previousVersion: string }` — die Version der App, die zuvor installiert war (oder eine leere Zeichenkette bei Neuinstallationen).
|
||||
* Pro Anwendung ist nur eine Post-Installationsfunktion zulässig. Der Manifest-Build schlägt fehl, wenn mehr als eine erkannt wird.
|
||||
* Der `universalIdentifier` der Funktion wird während des Builds im Anwendungsmanifest automatisch als `postInstallLogicFunctionUniversalIdentifier` gesetzt — du musst ihn nicht in `defineApplication()` referenzieren.
|
||||
* Das standardmäßige Timeout ist auf 300 Sekunden (5 Minuten) festgelegt, um längere Einrichtungsvorgänge wie Daten-Seeding zu ermöglichen.
|
||||
* Post-Installationsfunktionen benötigen keine Trigger — sie werden von der Plattform während der Installation oder manuell über `function:execute --postInstall` aufgerufen.
|
||||
|
||||
@@ -644,10 +678,10 @@ Um eine Logikfunktion als Tool zu markieren, setzen Sie `isTool: true` und geben
|
||||
```typescript
|
||||
// src/logic-functions/enrich-company.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
import Twenty from '~/generated';
|
||||
import { CoreApiClient } from 'twenty-sdk/generated';
|
||||
|
||||
const handler = async (params: { companyName: string; domain?: string }) => {
|
||||
const client = new Twenty();
|
||||
const client = new CoreApiClient();
|
||||
|
||||
const result = await client.mutation({
|
||||
createTask: {
|
||||
@@ -705,7 +739,7 @@ Hauptpunkte:
|
||||
Frontend-Komponenten ermöglichen es Ihnen, benutzerdefinierte React-Komponenten zu erstellen, die innerhalb der Twenty-UI gerendert werden. Verwenden Sie `defineFrontComponent()`, um Komponenten mit eingebauter Validierung zu definieren:
|
||||
|
||||
```typescript
|
||||
// src/my-widget.front-component.tsx
|
||||
// src/front-components/my-widget.tsx
|
||||
import { defineFrontComponent } from 'twenty-sdk';
|
||||
|
||||
const MyWidget = () => {
|
||||
@@ -728,14 +762,13 @@ export default defineFrontComponent({
|
||||
Hauptpunkte:
|
||||
|
||||
* Frontend-Komponenten sind React-Komponenten, die in isolierten Kontexten innerhalb von Twenty gerendert werden.
|
||||
* Verwenden Sie die Dateiendung `*.front-component.tsx` für die automatische Erkennung.
|
||||
* Das Feld `component` verweist auf Ihre React-Komponente.
|
||||
* Komponenten werden während `yarn twenty app:dev` automatisch gebaut und synchronisiert.
|
||||
|
||||
Sie können neue Frontend-Komponenten auf zwei Arten erstellen:
|
||||
|
||||
* **Generiert**: Führen Sie `yarn twenty entity:add` aus und wählen Sie die Option zum Hinzufügen einer neuen Frontend-Komponente.
|
||||
* **Manuell**: Erstellen Sie eine neue `*.front-component.tsx`-Datei und verwenden Sie `defineFrontComponent()`.
|
||||
* **Manuell**: Erstellen Sie eine neue `.tsx`-Datei und verwenden Sie `defineFrontComponent()` nach demselben Muster.
|
||||
|
||||
### Fähigkeiten
|
||||
|
||||
@@ -772,18 +805,24 @@ Sie können neue Skills auf zwei Arten erstellen:
|
||||
* **Generiert**: Führen Sie `yarn twenty entity:add` aus und wählen Sie die Option zum Hinzufügen eines neuen Skills.
|
||||
* **Manuell**: Erstellen Sie eine neue Datei und verwenden Sie `defineSkill()` nach demselben Muster.
|
||||
|
||||
### Generierter typisierter Client
|
||||
### Generierte typisierte Clients
|
||||
|
||||
Der typisierte Client wird von `yarn twenty app:dev` automatisch generiert und basierend auf Ihrem Arbeitsbereichs-Schema in `node_modules/twenty-sdk/generated` gespeichert. Verwenden Sie ihn in Ihren Funktionen:
|
||||
Zwei typisierte Clients werden von `yarn twenty app:dev` automatisch generiert und basierend auf Ihrem Arbeitsbereichs-Schema in `node_modules/twenty-sdk/generated` gespeichert:
|
||||
|
||||
* **`CoreApiClient`** — fragt den `/graphql`-Endpunkt nach Arbeitsbereichsdaten ab
|
||||
* **`MetadataApiClient`** — ruft über den Endpunkt `/metadata` die Arbeitsbereichskonfiguration und Datei-Uploads ab.
|
||||
|
||||
```typescript
|
||||
import Twenty from '~/generated';
|
||||
import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/generated';
|
||||
|
||||
const client = new Twenty();
|
||||
const client = new CoreApiClient();
|
||||
const { me } = await client.query({ me: { id: true, displayName: true } });
|
||||
|
||||
const metadataClient = new MetadataApiClient();
|
||||
const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id: true } });
|
||||
```
|
||||
|
||||
Der Client wird von `yarn twenty app:dev` automatisch neu generiert, sobald sich Ihre Objekte oder Felder ändern.
|
||||
Beide Clients werden von `yarn twenty app:dev` automatisch neu generiert, sobald sich Ihre Objekte oder Felder ändern.
|
||||
|
||||
#### Laufzeit-Anmeldedaten in Logikfunktionen
|
||||
|
||||
@@ -800,17 +839,17 @@ Notizen:
|
||||
|
||||
#### Dateien hochladen
|
||||
|
||||
Der generierte `Twenty`-Client enthält eine Methode `uploadFile`, um Dateien an Felder des Typs Datei in Ihren Arbeitsbereichsobjekten anzuhängen. Da Standard-GraphQL-Clients Multipart-Datei-Uploads nicht nativ unterstützen, stellt der Client diese dedizierte Methode bereit, die unter der Haube die [GraphQL-Multipart-Anfragespezifikation](https://github.com/jaydenseric/graphql-multipart-request-spec) implementiert.
|
||||
Der generierte `MetadataApiClient` enthält eine Methode `uploadFile`, um Dateien an Felder des Typs Datei in Ihren Arbeitsbereichsobjekten anzuhängen. Da Standard-GraphQL-Clients Multipart-Datei-Uploads nicht nativ unterstützen, stellt der Client diese dedizierte Methode bereit, die unter der Haube die [GraphQL-Multipart-Anfragespezifikation](https://github.com/jaydenseric/graphql-multipart-request-spec) implementiert.
|
||||
|
||||
```typescript
|
||||
import Twenty from '~/generated';
|
||||
import { MetadataApiClient } from 'twenty-sdk/generated';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const client = new Twenty();
|
||||
const metadataClient = new MetadataApiClient();
|
||||
|
||||
const fileBuffer = fs.readFileSync('./invoice.pdf');
|
||||
|
||||
const uploadedFile = await client.uploadFile(
|
||||
const uploadedFile = await metadataClient.uploadFile(
|
||||
fileBuffer, // file contents as a Buffer
|
||||
'invoice.pdf', // filename
|
||||
'application/pdf', // MIME type (defaults to 'application/octet-stream')
|
||||
@@ -841,7 +880,7 @@ uploadFile(
|
||||
|
||||
Hauptpunkte:
|
||||
|
||||
* Die Methode sendet die Datei an den **metadata endpoint** (nicht den main GraphQL endpoint), wo die Upload-Mutation ausgeführt wird.
|
||||
* Die Methode `uploadFile` ist auf dem `MetadataApiClient` verfügbar, weil die Upload-Mutation vom Endpunkt `/metadata` aufgelöst wird.
|
||||
* Sie verwendet den `universalIdentifier` des Feldes (nicht dessen arbeitsbereichsspezifische ID), sodass Ihr Upload-Code in jedem Arbeitsbereich funktioniert, in dem Ihre App installiert ist — im Einklang damit, wie Apps Felder überall sonst referenzieren.
|
||||
* Die zurückgegebene `url` ist eine signierte URL, mit der Sie auf die hochgeladene Datei zugreifen können.
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -13,7 +13,6 @@ Ermöglicht es Benutzern, einen Wert aus einer Liste vordefinierter Optionen aus
|
||||
<Tab title=""Verwendung"">
|
||||
|
||||
```jsx
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { IconTwentyStar } from 'twenty-ui/display';
|
||||
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
@@ -21,18 +20,16 @@ import { Select } from '@/ui/input/components/Select';
|
||||
export const MyComponent = () => {
|
||||
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<Select
|
||||
className
|
||||
disabled={false}
|
||||
label="Select an option"
|
||||
options={[
|
||||
{ value: 'option1', label: 'Option A', Icon: IconTwentyStar },
|
||||
{ value: 'option2', label: 'Option B', Icon: IconTwentyStar },
|
||||
]}
|
||||
value="option1"
|
||||
/>
|
||||
</RecoilRoot>
|
||||
<Select
|
||||
className
|
||||
disabled={false}
|
||||
label="Option auswählen"
|
||||
options={[
|
||||
{ value: 'option1', label: 'Option A', Icon: IconTwentyStar },
|
||||
{ value: 'option2', label: 'Option B', Icon: IconTwentyStar },
|
||||
]}
|
||||
value="option1"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -16,34 +16,31 @@ Ermöglicht es den Benutzern, Text einzugeben und zu bearbeiten.
|
||||
<Tab title="Verwendung">
|
||||
|
||||
```jsx
|
||||
import { RecoilRoot } from "recoil";
|
||||
import { TextInput } from "@/ui/input/components/TextInput";
|
||||
|
||||
export const MyComponent = () => {
|
||||
const handleChange = (text) => {
|
||||
console.log("Input changed:", text);
|
||||
console.log("Eingabe geändert:", text);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
console.log("Key pressed:", event.key);
|
||||
console.log("Taste gedrückt:", event.key);
|
||||
};
|
||||
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<TextInput
|
||||
className
|
||||
label="Username"
|
||||
onChange={handleChange}
|
||||
fullWidth={false}
|
||||
disableHotkeys={false}
|
||||
error="Invalid username"
|
||||
onKeyDown={handleKeyDown}
|
||||
RightIcon={null}
|
||||
/>
|
||||
</RecoilRoot>
|
||||
<TextInput
|
||||
className
|
||||
label="Benutzername"
|
||||
onChange={handleChange}
|
||||
fullWidth={false}
|
||||
disableHotkeys={false}
|
||||
error="Ungültiger Benutzername"
|
||||
onKeyDown={handleKeyDown}
|
||||
RightIcon={null}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
},{
|
||||
```
|
||||
|
||||
|
||||
@@ -81,22 +78,19 @@ Textkomponente, die ihre Höhe automatisch anhand des Inhalts anpasst.
|
||||
<Tab title="Verwendung">
|
||||
|
||||
```jsx
|
||||
import { RecoilRoot } from "recoil";
|
||||
import { AutosizeTextInput } from "@/ui/input/components/AutosizeTextInput";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<AutosizeTextInput
|
||||
onValidate={() => console.log("onValidate function fired")}
|
||||
minRows={1}
|
||||
placeholder="Write a comment"
|
||||
onFocus={() => console.log("onFocus function fired")}
|
||||
variant="icon"
|
||||
buttonTitle
|
||||
value="Task: "
|
||||
/>
|
||||
</RecoilRoot>
|
||||
<AutosizeTextInput
|
||||
onValidate={() => console.log("onValidate-Funktion ausgelöst")}
|
||||
minRows={1}
|
||||
placeholder="Kommentar schreiben"
|
||||
onFocus={() => console.log("onFocus-Funktion ausgelöst")}
|
||||
variant="icon"
|
||||
buttonTitle
|
||||
value="Aufgabe: "
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
+19
-18
@@ -6,9 +6,9 @@ Este documento describe las mejores prácticas que debes seguir al trabajar en e
|
||||
|
||||
## Gestión de estado
|
||||
|
||||
React y Recoil manejan la gestión de estado en la base de código.
|
||||
React y Jotai manejan la gestión de estado en la base de código.
|
||||
|
||||
### Usa `useRecoilState` para almacenar el estado
|
||||
### Usa `useAtomState` para almacenar el estado
|
||||
|
||||
Es buena práctica crear tantos átomos como necesites para almacenar tu estado.
|
||||
|
||||
@@ -17,13 +17,16 @@ Es buena práctica crear tantos átomos como necesites para almacenar tu estado.
|
||||
</Warning>
|
||||
|
||||
```tsx
|
||||
export const myAtomState = atom({
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
|
||||
export const myAtomState = createAtomState<string>({
|
||||
key: 'myAtomState',
|
||||
default: 'default value',
|
||||
defaultValue: 'default value',
|
||||
});
|
||||
|
||||
export const MyComponent = () => {
|
||||
const [myAtom, setMyAtom] = useRecoilState(myAtomState);
|
||||
const [myAtom, setMyAtom] = useAtomState(myAtomState);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -40,7 +43,7 @@ export const MyComponent = () => {
|
||||
|
||||
Evita usar `useRef` para almacenar el estado.
|
||||
|
||||
If you want to store state, you should use `useState` or `useRecoilState`.
|
||||
If you want to store state, you should use `useState` or `useAtomState`.
|
||||
|
||||
Consulta [cómo gestionar las re-renderizaciones](#managing-re-renders) si sientes que necesitas `useRef` para evitar algunas re-renderizaciones.
|
||||
|
||||
@@ -80,8 +83,8 @@ Puedes aplicar lo mismo para la lógica de obtención de datos, con hooks de Apo
|
||||
// ❌ Malo, provocará re-renderizados incluso si los datos no cambian,
|
||||
// porque useEffect necesita volver a evaluarse
|
||||
export const PageComponent = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [someDependency] = useRecoilState(someDependencyState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
const [someDependency] = useAtomState(someDependencyState);
|
||||
|
||||
useEffect(() => {
|
||||
if(someDependency !== data) {
|
||||
@@ -93,9 +96,7 @@ export const PageComponent = () => {
|
||||
};
|
||||
|
||||
export const App = () => (
|
||||
<RecoilRoot>
|
||||
<PageComponent />
|
||||
</RecoilRoot>
|
||||
<PageComponent />
|
||||
);
|
||||
```
|
||||
|
||||
@@ -103,14 +104,14 @@ export const App = () => (
|
||||
// ✅ Bueno, no provocará re-renderizados si los datos no cambian,
|
||||
// porque useEffect se vuelve a evaluar en otro componente hermano
|
||||
export const PageComponent = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
|
||||
return <div>{data}</div>;
|
||||
};
|
||||
|
||||
export const PageData = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [someDependency] = useRecoilState(someDependencyState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
const [someDependency] = useAtomState(someDependencyState);
|
||||
|
||||
useEffect(() => {
|
||||
if(someDependency !== data) {
|
||||
@@ -122,16 +123,16 @@ export const PageData = () => {
|
||||
};
|
||||
|
||||
export const App = () => (
|
||||
<RecoilRoot>
|
||||
<>
|
||||
<PageData />
|
||||
<PageComponent />
|
||||
</RecoilRoot>
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
### Usa estados de familia de recoil y selectores de familia de recoil
|
||||
### Usa estados de familia de jotai y selectores de familia de jotai
|
||||
|
||||
Los estados y selectores de familia de recoil son una gran manera de evitar re-renderizaciones.
|
||||
Los estados y selectores de familia de jotai son una gran manera de evitar re-renderizaciones.
|
||||
|
||||
Son útiles cuando necesitas almacenar una lista de elementos.
|
||||
|
||||
|
||||
+2
-2
@@ -82,9 +82,9 @@ Ver [Hooks](https://react.dev/learn/reusing-logic-with-custom-hooks) para más d
|
||||
|
||||
### Estados
|
||||
|
||||
Contiene la lógica de gestión de estado. [RecoilJS](https://recoiljs.org) maneja esto.
|
||||
Contiene la lógica de gestión de estado. [Jotai](https://jotai.org) maneja esto.
|
||||
|
||||
* Selectores: Ver [RecoilJS Selectors](https://recoiljs.org/docs/basic-tutorial/selectors) para más detalles.
|
||||
* Selectores: Los átomos derivados (usando `createAtomSelector`) calculan valores a partir de otros átomos y se memorizan automáticamente.
|
||||
|
||||
La gestión de estado incorporada de React todavía maneja el estado dentro de un componente.
|
||||
|
||||
|
||||
+2
-2
@@ -53,7 +53,7 @@ El proyecto tiene un stack limpio y sencillo, con un código boilerplate mínimo
|
||||
* [React](https://react.dev/)
|
||||
* [Apollo](https://www.apollographql.com/docs/)
|
||||
* [GraphQL Codegen](https://the-guild.dev/graphql/codegen)
|
||||
* [Recoil](https://recoiljs.org/docs/introduction/core-concepts)
|
||||
* [Jotai](https://jotai.org/)
|
||||
* [TypeScript](https://www.typescriptlang.org/)
|
||||
|
||||
**Pruebas**
|
||||
@@ -77,7 +77,7 @@ To avoid unnecessary [re-renders](/l/es/developers/contribute/capabilities/front
|
||||
|
||||
### Gestión del Estado
|
||||
|
||||
[Recoil](https://recoiljs.org/docs/introduction/core-concepts) maneja la gestión del estado.
|
||||
[Jotai](https://jotai.org/) maneja la gestión del estado.
|
||||
|
||||
Ver [mejores prácticas](/l/es/developers/contribute/capabilities/frontend-development/best-practices-front#state-management) para más información sobre la gestión del estado.
|
||||
|
||||
|
||||
+3
-3
@@ -160,7 +160,7 @@ export enum PageHotkeyScope {
|
||||
}
|
||||
```
|
||||
|
||||
Internamente, el ámbito seleccionado se almacena en un estado de Recoil que se comparte en toda la aplicación:
|
||||
Internamente, el ámbito seleccionado se almacena en un estado de Jotai que se comparte en toda la aplicación:
|
||||
|
||||
```tsx
|
||||
export const currentHotkeyScopeState = createState<HotkeyScope>({
|
||||
@@ -169,10 +169,10 @@ export const currentHotkeyScopeState = createState<HotkeyScope>({
|
||||
});
|
||||
```
|
||||
|
||||
¡Pero este estado de Recoil nunca debe manejarse manualmente! Veremos cómo usarlo en la siguiente sección.
|
||||
¡Pero este estado de Jotai nunca debe manejarse manualmente! Veremos cómo usarlo en la siguiente sección.
|
||||
|
||||
## ¿Cómo funciona internamente?
|
||||
|
||||
Hicimos un contenedor delgado sobre [react-hotkeys-hook](https://react-hotkeys-hook.vercel.app/docs/intro) que lo hace más eficiente y evita renders innecesarios.
|
||||
|
||||
También creamos un estado de Recoil para manejar el estado del ámbito de atajos de teclado y hacerlo disponible en toda la aplicación.
|
||||
También creamos un estado de Jotai para manejar el estado del ámbito de atajos de teclado y hacerlo disponible en toda la aplicación.
|
||||
|
||||
@@ -12,7 +12,6 @@ Un selector de íconos basado en un menú desplegable que permite a los usuarios
|
||||
<Tabs>
|
||||
<Tab title="Uso">
|
||||
```jsx
|
||||
import { RecoilRoot } from "recoil";
|
||||
import React, { useState } from "react";
|
||||
import { IconPicker } from "@/ui/input/components/IconPicker";
|
||||
|
||||
@@ -25,14 +24,12 @@ Un selector de íconos basado en un menú desplegable que permite a los usuarios
|
||||
};
|
||||
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<IconPicker
|
||||
disabled={false}
|
||||
onChange={handleIconChange}
|
||||
selectedIconKey={selectedIcon}
|
||||
variant="primary"
|
||||
/>
|
||||
</RecoilRoot>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
@@ -12,7 +12,6 @@ Permite a los usuarios seleccionar un valor de una lista de opciones predefinida
|
||||
<Tabs>
|
||||
<Tab title="Uso">
|
||||
```jsx
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { IconTwentyStar } from 'twenty-ui/display';
|
||||
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
@@ -20,7 +19,6 @@ Permite a los usuarios seleccionar un valor de una lista de opciones predefinida
|
||||
export const MyComponent = () => {
|
||||
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<Select
|
||||
className
|
||||
disabled={false}
|
||||
@@ -31,7 +29,6 @@ Permite a los usuarios seleccionar un valor de una lista de opciones predefinida
|
||||
]}
|
||||
value="option1"
|
||||
/>
|
||||
</RecoilRoot>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ image: '"/images/user-guide/notes/notes_header.png"'
|
||||
<Tabs>
|
||||
<Tab title=""Uso"">
|
||||
```jsx
|
||||
import { RecoilRoot } from "recoil";
|
||||
import { TextInput } from "@/ui/input/components/TextInput";
|
||||
|
||||
export const MyComponent = () => {
|
||||
@@ -27,7 +26,6 @@ image: '"/images/user-guide/notes/notes_header.png"'
|
||||
};
|
||||
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<TextInput
|
||||
className
|
||||
label="Nombre de usuario"
|
||||
@@ -38,7 +36,6 @@ image: '"/images/user-guide/notes/notes_header.png"'
|
||||
onKeyDown={handleKeyDown}
|
||||
RightIcon={null}
|
||||
/>
|
||||
</RecoilRoot>
|
||||
);
|
||||
};
|
||||
},{
|
||||
@@ -68,12 +65,10 @@ image: '"/images/user-guide/notes/notes_header.png"'
|
||||
<Tabs>
|
||||
<Tab title=""Uso"">
|
||||
```jsx
|
||||
import { RecoilRoot } from "recoil";
|
||||
import { AutosizeTextInput } from "@/ui/input/components/AutosizeTextInput";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<AutosizeTextInput
|
||||
onValidate={() => console.log("Función onValidate ejecutada")}
|
||||
minRows={1}
|
||||
@@ -83,7 +78,6 @@ image: '"/images/user-guide/notes/notes_header.png"'
|
||||
buttonTitle
|
||||
value="Tarea: "
|
||||
/>
|
||||
</RecoilRoot>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
+19
-18
@@ -6,9 +6,9 @@ Ce document décrit les meilleures pratiques à suivre lors de votre travail sur
|
||||
|
||||
## Gestion de l'état
|
||||
|
||||
React et Recoil gèrent la gestion de l'état dans la base de code.
|
||||
React et Jotai gèrent la gestion de l'état dans la base de code.
|
||||
|
||||
### Utilisez `useRecoilState` pour stocker l'état
|
||||
### Utilisez `useAtomState` pour stocker l'état
|
||||
|
||||
C'est une bonne pratique de créer autant d'atomes que nécessaire pour stocker votre état.
|
||||
|
||||
@@ -17,13 +17,16 @@ C'est une bonne pratique de créer autant d'atomes que nécessaire pour stocker
|
||||
</Warning>
|
||||
|
||||
```tsx
|
||||
export const myAtomState = atom({
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
|
||||
export const myAtomState = createAtomState<string>({
|
||||
key: 'myAtomState',
|
||||
default: 'default value',
|
||||
defaultValue: 'default value',
|
||||
});
|
||||
|
||||
export const MyComponent = () => {
|
||||
const [myAtom, setMyAtom] = useRecoilState(myAtomState);
|
||||
const [myAtom, setMyAtom] = useAtomState(myAtomState);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -40,7 +43,7 @@ export const MyComponent = () => {
|
||||
|
||||
Évitez d'utiliser `useRef` pour stocker l'état.
|
||||
|
||||
If you want to store state, you should use `useState` or `useRecoilState`.
|
||||
If you want to store state, you should use `useState` or `useAtomState`.
|
||||
|
||||
Consultez [comment gérer les re-rendus](#managing-re-renders) si vous avez l'impression d'avoir besoin de `useRef` pour empêcher certains re-rendus.
|
||||
|
||||
@@ -80,8 +83,8 @@ Vous pouvez appliquer la même chose à la logique de récupération de données
|
||||
// ❌ Mauvais, provoquera de nouveaux rendus même si les données ne changent pas,
|
||||
// car useEffect doit être réévalué
|
||||
export const PageComponent = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [someDependency] = useRecoilState(someDependencyState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
const [someDependency] = useAtomState(someDependencyState);
|
||||
|
||||
useEffect(() => {
|
||||
if(someDependency !== data) {
|
||||
@@ -93,9 +96,7 @@ export const PageComponent = () => {
|
||||
};
|
||||
|
||||
export const App = () => (
|
||||
<RecoilRoot>
|
||||
<PageComponent />
|
||||
</RecoilRoot>
|
||||
<PageComponent />
|
||||
);
|
||||
```
|
||||
|
||||
@@ -103,14 +104,14 @@ export const App = () => (
|
||||
// ✅ Bon, ne provoquera pas de nouveaux rendus si les données ne changent pas,
|
||||
// car useEffect est réévalué dans un autre composant frère
|
||||
export const PageComponent = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
|
||||
return <div>{data}</div>;
|
||||
};
|
||||
|
||||
export const PageData = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [someDependency] = useRecoilState(someDependencyState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
const [someDependency] = useAtomState(someDependencyState);
|
||||
|
||||
useEffect(() => {
|
||||
if(someDependency !== data) {
|
||||
@@ -122,16 +123,16 @@ export const PageData = () => {
|
||||
};
|
||||
|
||||
export const App = () => (
|
||||
<RecoilRoot>
|
||||
<>
|
||||
<PageData />
|
||||
<PageComponent />
|
||||
</RecoilRoot>
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
### Utilisez les états de famille de recoil et les sélecteurs de famille de recoil
|
||||
### Utilisez les états de famille de jotai et les sélecteurs de famille de jotai
|
||||
|
||||
Les états et sélecteurs de famille recoil sont un excellent moyen d'éviter les re-rendus.
|
||||
Les états et sélecteurs de famille jotai sont un excellent moyen d'éviter les re-rendus.
|
||||
|
||||
Ils sont utiles lorsque vous devez stocker une liste d'articles.
|
||||
|
||||
|
||||
+2
-2
@@ -82,9 +82,9 @@ Voir [Hooks](https://react.dev/learn/reusing-logic-with-custom-hooks) pour plus
|
||||
|
||||
### États
|
||||
|
||||
Contient la logique de gestion des états. [RecoilJS](https://recoiljs.org) gère cela.
|
||||
Contient la logique de gestion des états. [Jotai](https://jotai.org) gère cela.
|
||||
|
||||
* Sélecteurs : Voir [RecoilJS Selectors](https://recoiljs.org/docs/basic-tutorial/selectors) pour plus de détails.
|
||||
* Sélecteurs : Les atomes dérivés (via `createAtomSelector`) calculent des valeurs à partir d'autres atomes et sont automatiquement mémoïsés.
|
||||
|
||||
La gestion de l'état intégrée de React gère toujours l'état au sein d'un composant.
|
||||
|
||||
|
||||
+2
-2
@@ -53,7 +53,7 @@ Le projet a une stack simple et propre, avec un code boilerplate minimal.
|
||||
* [React](https://react.dev/)
|
||||
* [Apollo](https://www.apollographql.com/docs/)
|
||||
* [GraphQL Codegen](https://the-guild.dev/graphql/codegen)
|
||||
* [Recoil](https://recoiljs.org/docs/introduction/core-concepts)
|
||||
* [Jotai](https://jotai.org/)
|
||||
* [TypeScript](https://www.typescriptlang.org/)
|
||||
|
||||
**Tests**
|
||||
@@ -77,7 +77,7 @@ To avoid unnecessary [re-renders](/l/fr/developers/contribute/capabilities/front
|
||||
|
||||
### Gestion de l'État
|
||||
|
||||
[Recoil](https://recoiljs.org/docs/introduction/core-concepts) gère la gestion de l'état.
|
||||
[Jotai](https://jotai.org/) gère la gestion de l'état.
|
||||
|
||||
Voir [les meilleures pratiques](/l/fr/developers/contribute/capabilities/frontend-development/best-practices-front#state-management) pour plus d'informations sur la gestion de l'état.
|
||||
|
||||
|
||||
+3
-3
@@ -160,7 +160,7 @@ export enum PageHotkeyScope {
|
||||
}
|
||||
```
|
||||
|
||||
En interne, le périmètre sélectionné est stocké dans un état Recoil qui est partagé dans toute l'application :
|
||||
En interne, le périmètre sélectionné est stocké dans un état Jotai qui est partagé dans toute l'application :
|
||||
|
||||
```tsx
|
||||
export const currentHotkeyScopeState = createState<HotkeyScope>({
|
||||
@@ -169,10 +169,10 @@ export const currentHotkeyScopeState = createState<HotkeyScope>({
|
||||
});
|
||||
```
|
||||
|
||||
Mais cet état Recoil ne doit jamais être manipulé manuellement ! Nous verrons comment l'utiliser dans la prochaine section.
|
||||
Mais cet état Jotai ne doit jamais être manipulé manuellement ! Nous verrons comment l'utiliser dans la prochaine section.
|
||||
|
||||
## Comment cela fonctionne-t-il en interne ?
|
||||
|
||||
Nous avons créé un léger emballage au-dessus de [react-hotkeys-hook](https://react-hotkeys-hook.vercel.app/docs/intro) qui le rend plus performant et évite les rendus inutiles.
|
||||
|
||||
Nous créons également un état Recoil pour gérer l'état du périmètre des raccourcis et le rendre disponible partout dans l'application.
|
||||
Nous créons également un état Jotai pour gérer l'état du périmètre des raccourcis et le rendre disponible partout dans l'application.
|
||||
|
||||
@@ -12,7 +12,6 @@ Un sélecteur d'icônes basé sur un menu déroulant qui permet aux utilisateurs
|
||||
<Tabs>
|
||||
<Tab title="Utilisation">
|
||||
```jsx
|
||||
import { RecoilRoot } from "recoil";
|
||||
import React, { useState } from "react";
|
||||
import { IconPicker } from "@/ui/input/components/IconPicker";
|
||||
|
||||
@@ -25,14 +24,12 @@ Un sélecteur d'icônes basé sur un menu déroulant qui permet aux utilisateurs
|
||||
};
|
||||
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<IconPicker
|
||||
disabled={false}
|
||||
onChange={handleIconChange}
|
||||
selectedIconKey={selectedIcon}
|
||||
variant="primary"
|
||||
/>
|
||||
</RecoilRoot>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
@@ -12,7 +12,6 @@ Permet aux utilisateurs de choisir une valeur parmi une liste d'options prédéf
|
||||
<Tabs>
|
||||
<Tab title="Utilisation">
|
||||
```jsx
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { IconTwentyStar } from 'twenty-ui/display';
|
||||
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
@@ -20,7 +19,6 @@ Permet aux utilisateurs de choisir une valeur parmi une liste d'options prédéf
|
||||
export const MyComponent = () => {
|
||||
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<Select
|
||||
className
|
||||
disabled={false}
|
||||
@@ -31,7 +29,6 @@ Permet aux utilisateurs de choisir une valeur parmi une liste d'options prédéf
|
||||
]}
|
||||
value="option1"
|
||||
/>
|
||||
</RecoilRoot>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ Permet aux utilisateurs de saisir et de modifier du texte.
|
||||
<Tabs>
|
||||
<Tab title="Utilisation">
|
||||
```jsx
|
||||
import { RecoilRoot } from "recoil";
|
||||
import { TextInput } from "@/ui/input/components/TextInput";
|
||||
|
||||
export const MyComponent = () => {
|
||||
@@ -27,7 +26,6 @@ Permet aux utilisateurs de saisir et de modifier du texte.
|
||||
};
|
||||
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<TextInput
|
||||
className
|
||||
label="Username"
|
||||
@@ -38,7 +36,6 @@ Permet aux utilisateurs de saisir et de modifier du texte.
|
||||
onKeyDown={handleKeyDown}
|
||||
RightIcon={null}
|
||||
/>
|
||||
</RecoilRoot>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -68,12 +65,10 @@ Composant d'entrée de texte qui ajuste automatiquement sa hauteur en fonction d
|
||||
<Tabs>
|
||||
<Tab title="Utilisation">
|
||||
```jsx
|
||||
import { RecoilRoot } from "recoil";
|
||||
import { AutosizeTextInput } from "@/ui/input/components/AutosizeTextInput";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<AutosizeTextInput
|
||||
onValidate={() => console.log("onValidate function fired")}
|
||||
minRows={1}
|
||||
@@ -83,7 +78,6 @@ Composant d'entrée de texte qui ajuste automatiquement sa hauteur en fonction d
|
||||
buttonTitle
|
||||
value="Task: "
|
||||
/>
|
||||
</RecoilRoot>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
+19
-18
@@ -6,9 +6,9 @@ Questo documento descrive le migliori pratiche da seguire quando si lavora sul f
|
||||
|
||||
## Gestione dello Stato
|
||||
|
||||
React e Recoil gestiscono la gestione dello stato nella base di codice.
|
||||
React e Jotai gestiscono lo stato nella base di codice.
|
||||
|
||||
### Usa `useRecoilState` per memorizzare lo stato
|
||||
### Usa gli atomi di Jotai per memorizzare lo stato
|
||||
|
||||
È buona pratica creare tanti atomi quanti servono per memorizzare il tuo stato.
|
||||
|
||||
@@ -19,13 +19,16 @@ React e Recoil gestiscono la gestione dello stato nella base di codice.
|
||||
</Warning>
|
||||
|
||||
```tsx
|
||||
export const myAtomState = atom({
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
|
||||
export const myAtomState = createAtomState<string>({
|
||||
key: 'myAtomState',
|
||||
default: 'default value',
|
||||
defaultValue: 'default value',
|
||||
});
|
||||
|
||||
export const MyComponent = () => {
|
||||
const [myAtom, setMyAtom] = useRecoilState(myAtomState);
|
||||
const [myAtom, setMyAtom] = useAtomState(myAtomState);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -42,7 +45,7 @@ export const MyComponent = () => {
|
||||
|
||||
Evita di usare `useRef` per memorizzare lo stato.
|
||||
|
||||
Se vuoi memorizzare lo stato, dovresti usare `useState` o `useRecoilState`.
|
||||
Se vuoi memorizzare lo stato, dovresti usare `useState` o gli atomi di Jotai con `useAtomState`.
|
||||
|
||||
Consulta [come gestire i re-render](#managing-re-renders) se senti che hai bisogno di `useRef` per evitare alcuni re-render.
|
||||
|
||||
@@ -82,8 +85,8 @@ Puoi applicare lo stesso per la logica di recupero dati, con i hook di Apollo.
|
||||
// ❌ Sconsigliato, causerà re-render anche se i dati non cambiano,
|
||||
// perché useEffect deve essere ri-eseguito
|
||||
export const PageComponent = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [someDependency] = useRecoilState(someDependencyState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
const [someDependency] = useAtomState(someDependencyState);
|
||||
|
||||
useEffect(() => {
|
||||
if(someDependency !== data) {
|
||||
@@ -95,9 +98,7 @@ export const PageComponent = () => {
|
||||
};
|
||||
|
||||
export const App = () => (
|
||||
<RecoilRoot>
|
||||
<PageComponent />
|
||||
</RecoilRoot>
|
||||
<PageComponent />
|
||||
);
|
||||
```
|
||||
|
||||
@@ -105,14 +106,14 @@ export const App = () => (
|
||||
// ✅ Consigliato, non causerà re-render se i dati non cambiano,
|
||||
// perché useEffect viene ri-eseguito in un altro componente fratello
|
||||
export const PageComponent = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
|
||||
return <div>{data}</div>;
|
||||
};
|
||||
|
||||
export const PageData = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [someDependency] = useRecoilState(someDependencyState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
const [someDependency] = useAtomState(someDependencyState);
|
||||
|
||||
useEffect(() => {
|
||||
if(someDependency !== data) {
|
||||
@@ -124,16 +125,16 @@ export const PageData = () => {
|
||||
};
|
||||
|
||||
export const App = () => (
|
||||
<RecoilRoot>
|
||||
<>
|
||||
<PageData />
|
||||
<PageComponent />
|
||||
</RecoilRoot>
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
### Usa stati di famiglia recoil e selettori di famiglia recoil
|
||||
### Usa gli stati e i selettori della famiglia di atomi
|
||||
|
||||
Gli stati e i selettori di famiglia Recoil sono un ottimo modo per evitare re-render.
|
||||
Gli stati e i selettori della famiglia di atomi sono un ottimo modo per evitare re-render.
|
||||
|
||||
Sono utili quando hai bisogno di memorizzare una lista di elementi.
|
||||
|
||||
|
||||
+2
-2
@@ -82,9 +82,9 @@ Vedi [Hooks](https://react.dev/learn/reusing-logic-with-custom-hooks) per ulteri
|
||||
|
||||
### Stati
|
||||
|
||||
Contiene la logica di gestione degli stati. [RecoilJS](https://recoiljs.org) se ne occupa.
|
||||
Contiene la logica di gestione degli stati. [Jotai](https://jotai.org) se ne occupa.
|
||||
|
||||
* Selettori: Vedi [RecoilJS Selectors](https://recoiljs.org/docs/basic-tutorial/selectors) per ulteriori dettagli.
|
||||
* Selettori: Gli atomi derivati (utilizzando `createAtomSelector`) calcolano valori da altri atomi e vengono memoizzati automaticamente.
|
||||
|
||||
La gestione degli stati integrata di React si occupa ancora dello stato all'interno di un componente.
|
||||
|
||||
|
||||
+2
-2
@@ -53,7 +53,7 @@ Il progetto ha una struttura chiara e semplice, con un codice boilerplate minimo
|
||||
* [React](https://react.dev/)
|
||||
* [Apollo](https://www.apollographql.com/docs/)
|
||||
* [GraphQL Codegen](https://the-guild.dev/graphql/codegen)
|
||||
* [Recoil](https://recoiljs.org/docs/introduction/core-concepts)
|
||||
* [Jotai](https://jotai.org/)
|
||||
* [TypeScript](https://www.typescriptlang.org/)
|
||||
|
||||
**Testing**
|
||||
@@ -77,7 +77,7 @@ Per evitare [re-render](/l/it/developers/contribute/capabilities/frontend-develo
|
||||
|
||||
### Gestione dello stato
|
||||
|
||||
[Recoil](https://recoiljs.org/docs/introduction/core-concepts) gestisce lo stato.
|
||||
[Jotai](https://jotai.org/) gestisce lo stato.
|
||||
|
||||
Vedi [best practices](/l/it/developers/contribute/capabilities/frontend-development/best-practices-front#state-management) per ulteriori informazioni sulla gestione dello stato.
|
||||
|
||||
|
||||
+3
-3
@@ -160,7 +160,7 @@ export enum PageHotkeyScope {
|
||||
}
|
||||
```
|
||||
|
||||
Internamente, l'ambito selezionato attualmente viene memorizzato in uno stato Recoil condiviso in tutta l'applicazione:
|
||||
Internamente, l'ambito selezionato attualmente viene memorizzato in un atomo Jotai condiviso in tutta l'applicazione:
|
||||
|
||||
```tsx
|
||||
export const currentHotkeyScopeState = createState<HotkeyScope>({
|
||||
@@ -169,10 +169,10 @@ export const currentHotkeyScopeState = createState<HotkeyScope>({
|
||||
});
|
||||
```
|
||||
|
||||
Ma questo stato Recoil non dovrebbe mai essere gestito manualmente! Vedremo come usarlo nella sezione successiva.
|
||||
Ma questo atomo non dovrebbe mai essere gestito manualmente! Vedremo come usarlo nella sezione successiva.
|
||||
|
||||
## Come funziona internamente?
|
||||
|
||||
Abbiamo creato un sottile wrapper su [react-hotkeys-hook](https://react-hotkeys-hook.vercel.app/docs/intro) che lo rende più performante ed evita rendering non necessari.
|
||||
|
||||
Abbiamo anche creato uno stato Recoil per gestire lo stato dell'ambito del tasto di scelta rapida e renderlo disponibile ovunque nell'applicazione.
|
||||
Creiamo anche un atomo Jotai per gestire lo stato dell'ambito del tasto di scelta rapida e renderlo disponibile ovunque nell'applicazione.
|
||||
|
||||
@@ -61,6 +61,9 @@ yarn twenty function:logs
|
||||
# Esegui una funzione per nome
|
||||
yarn twenty function:execute -n my-function -p '{"name": "test"}'
|
||||
|
||||
# Esegui la funzione di pre-installazione
|
||||
yarn twenty function:execute --preInstall
|
||||
|
||||
# Esegui la funzione post-installazione
|
||||
yarn twenty function:execute --postInstall
|
||||
|
||||
@@ -80,7 +83,7 @@ Quando esegui `npx create-twenty-app@latest my-twenty-app`, lo scaffolder:
|
||||
* Copia un'applicazione base minimale in `my-twenty-app/`
|
||||
* Aggiunge una dipendenza locale `twenty-sdk` e la configurazione di Yarn 4
|
||||
* Crea file di configurazione e script collegati alla CLI `twenty`
|
||||
* Genera i file principali (configurazione dell'applicazione, ruolo predefinito per le funzioni logiche, funzione di post-installazione) più i file di esempio in base alla modalità di scaffolding
|
||||
* Genera i file principali (configurazione dell'applicazione, ruolo predefinito per le funzioni logiche, funzioni di pre-installazione e post-installazione) più i file di esempio in base alla modalità di scaffolding
|
||||
|
||||
Un'app appena creata con la modalità predefinita `--exhaustive` si presenta così:
|
||||
|
||||
@@ -96,29 +99,30 @@ my-twenty-app/
|
||||
eslint.config.mjs
|
||||
tsconfig.json
|
||||
README.md
|
||||
public/ # Public assets folder (images, fonts, etc.)
|
||||
public/ # Cartella delle risorse pubbliche (immagini, font, ecc.)
|
||||
src/
|
||||
├── application-config.ts # Required - main application configuration
|
||||
├── application-config.ts # Obbligatorio - configurazione principale dell'applicazione
|
||||
├── roles/
|
||||
│ └── default-role.ts # Default role for logic functions
|
||||
│ └── default-role.ts # Ruolo predefinito per le funzioni logiche
|
||||
├── objects/
|
||||
│ └── example-object.ts # Example custom object definition
|
||||
│ └── example-object.ts # Definizione di oggetto personalizzato di esempio
|
||||
├── fields/
|
||||
│ └── example-field.ts # Example standalone field definition
|
||||
│ └── example-field.ts # Definizione di campo autonomo di esempio
|
||||
├── logic-functions/
|
||||
│ ├── hello-world.ts # Example logic function
|
||||
│ └── post-install.ts # Post-install logic function
|
||||
│ ├── hello-world.ts # Funzione logica di esempio
|
||||
│ ├── pre-install.ts # Funzione logica di pre-installazione
|
||||
│ └── post-install.ts # Funzione logica di post-installazione
|
||||
├── front-components/
|
||||
│ └── hello-world.tsx # Example front component
|
||||
│ └── hello-world.tsx # Componente front-end di esempio
|
||||
├── views/
|
||||
│ └── example-view.ts # Example saved view definition
|
||||
│ └── example-view.ts # Definizione di vista salvata di esempio
|
||||
├── navigation-menu-items/
|
||||
│ └── example-navigation-menu-item.ts # Example sidebar navigation link
|
||||
│ └── example-navigation-menu-item.ts # Link di navigazione della barra laterale di esempio
|
||||
└── skills/
|
||||
└── example-skill.ts # Example AI agent skill definition
|
||||
└── example-skill.ts # Definizione di skill per agente IA di esempio
|
||||
```
|
||||
|
||||
Con `--minimal`, vengono creati solo i file principali (`application-config.ts`, `roles/default-role.ts` e `logic-functions/post-install.ts`). Con `--interactive`, scegli quali file di esempio includere.
|
||||
Con `--minimal`, vengono creati solo i file principali (`application-config.ts`, `roles/default-role.ts`, `logic-functions/pre-install.ts` e `logic-functions/post-install.ts`). Con `--interactive`, scegli quali file di esempio includere.
|
||||
|
||||
A livello generale:
|
||||
|
||||
@@ -135,16 +139,18 @@ A livello generale:
|
||||
|
||||
L'SDK rileva le entità analizzando i tuoi file TypeScript alla ricerca di chiamate **`export default define<Entity>({...})`**. Ogni tipo di entità ha una corrispondente funzione helper esportata da `twenty-sdk`:
|
||||
|
||||
| Funzione helper | Tipo di entità |
|
||||
| ---------------------------- | ---------------------------------------------- |
|
||||
| `defineObject()` | Definizioni di oggetti personalizzati |
|
||||
| `defineLogicFunction()` | Definizioni di funzioni logiche |
|
||||
| `defineFrontComponent()` | Definizioni dei componenti front-end |
|
||||
| `defineRole()` | Definizioni di ruoli |
|
||||
| `defineField()` | Estensioni di campo per oggetti esistenti |
|
||||
| `defineView()` | Definizioni di viste salvate |
|
||||
| `defineNavigationMenuItem()` | Definizioni delle voci del menu di navigazione |
|
||||
| `defineSkill()` | AI agent skill definitions |
|
||||
| Funzione helper | Tipo di entità |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| `defineObject()` | Definizioni di oggetti personalizzati |
|
||||
| `defineLogicFunction()` | Definizioni di funzioni logiche |
|
||||
| `definePreInstallLogicFunction()` | Funzione logica di pre-installazione (viene eseguita prima dell'installazione) |
|
||||
| `definePostInstallLogicFunction()` | Funzione logica di post-installazione (viene eseguita dopo l'installazione) |
|
||||
| `defineFrontComponent()` | Definizioni dei componenti front-end |
|
||||
| `defineRole()` | Definizioni di ruoli |
|
||||
| `defineField()` | Estensioni di campo per oggetti esistenti |
|
||||
| `defineView()` | Definizioni di viste salvate |
|
||||
| `defineNavigationMenuItem()` | Definizioni delle voci del menu di navigazione |
|
||||
| `defineSkill()` | AI agent skill definitions |
|
||||
|
||||
<Note>
|
||||
**La denominazione dei file è flessibile.** Il rilevamento delle entità è basato sull'AST — l'SDK esegue la scansione dei file sorgente alla ricerca del pattern `export default define<Entity>({...})`. Puoi organizzare file e cartelle come preferisci. Raggruppare per tipo di entità (ad es., `logic-functions/`, `roles/`) è solo una convenzione per l'organizzazione del codice, non un requisito.
|
||||
@@ -165,7 +171,7 @@ export default defineObject({
|
||||
|
||||
Comandi successivi aggiungeranno altri file e cartelle:
|
||||
|
||||
* `yarn twenty app:dev` genererà automaticamente un client API tipizzato in `node_modules/twenty-sdk/generated` (client Twenty tipizzato + tipi dell'area di lavoro).
|
||||
* `yarn twenty app:dev` genererà automaticamente due client API tipizzati in `node_modules/twenty-sdk/generated`: `CoreApiClient` (per i dati dell'area di lavoro tramite `/graphql`) e `MetadataApiClient` (per la configurazione dell'area di lavoro e il caricamento di file tramite `/metadata`).
|
||||
* `yarn twenty entity:add` will add entity definition files under `src/` for your custom objects, functions, front components, roles, skills, and more.
|
||||
|
||||
## Autenticazione
|
||||
@@ -209,17 +215,19 @@ Il pacchetto twenty-sdk fornisce blocchi tipizzati e funzioni helper da usare ne
|
||||
|
||||
L'SDK fornisce funzioni helper per definire le entità della tua app. Come descritto in [Rilevamento delle entità](#entity-detection), devi usare `export default define<Entity>({...})` affinché le tue entità vengano rilevate:
|
||||
|
||||
| Funzione | Scopo |
|
||||
| ---------------------------- | ----------------------------------------------------------------------- |
|
||||
| `defineApplication()` | Configura i metadati dell'applicazione (obbligatorio, uno per app) |
|
||||
| `defineObject()` | Definisci oggetti personalizzati con campi |
|
||||
| `defineLogicFunction()` | Definisci funzioni logiche con handler |
|
||||
| `defineFrontComponent()` | Definisci componenti front-end per un'interfaccia utente personalizzata |
|
||||
| `defineRole()` | Configura i permessi dei ruoli e l'accesso agli oggetti |
|
||||
| `defineField()` | Estendi gli oggetti esistenti con campi aggiuntivi |
|
||||
| `defineView()` | Definisce viste salvate per gli oggetti |
|
||||
| `defineNavigationMenuItem()` | Definisce i link di navigazione della barra laterale |
|
||||
| `defineSkill()` | Define AI agent skills |
|
||||
| Funzione | Scopo |
|
||||
| ---------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `defineApplication()` | Configura i metadati dell'applicazione (obbligatorio, uno per app) |
|
||||
| `defineObject()` | Definisci oggetti personalizzati con campi |
|
||||
| `defineLogicFunction()` | Definisci funzioni logiche con handler |
|
||||
| `definePreInstallLogicFunction()` | Definisce una funzione logica di pre-installazione (una per applicazione) |
|
||||
| `definePostInstallLogicFunction()` | Definisce una funzione logica di post-installazione (una per applicazione) |
|
||||
| `defineFrontComponent()` | Definisci componenti front-end per un'interfaccia utente personalizzata |
|
||||
| `defineRole()` | Configura i permessi dei ruoli e l'accesso agli oggetti |
|
||||
| `defineField()` | Estendi gli oggetti esistenti con campi aggiuntivi |
|
||||
| `defineView()` | Definisce viste salvate per gli oggetti |
|
||||
| `defineNavigationMenuItem()` | Definisce i link di navigazione della barra laterale |
|
||||
| `defineSkill()` | Define AI agent skills |
|
||||
|
||||
Queste funzioni convalidano la configurazione in fase di build e offrono il completamento automatico nell'IDE e la sicurezza dei tipi.
|
||||
|
||||
@@ -319,6 +327,7 @@ Ogni app ha un singolo file `application-config.ts` che descrive:
|
||||
* **Identità dell'app**: identificatori, nome visualizzato e descrizione.
|
||||
* **Come vengono eseguite le sue funzioni**: quale ruolo usano per i permessi.
|
||||
* **Variabili (opzionali)**: coppie chiave–valore esposte alle funzioni come variabili d'ambiente.
|
||||
* **(Opzionale) funzione di pre-installazione**: una funzione logica che viene eseguita prima che l'app venga installata.
|
||||
* **(Opzionale) funzione post-installazione**: una funzione logica che viene eseguita dopo l'installazione dell'app.
|
||||
|
||||
Usa `defineApplication()` per definire la configurazione della tua applicazione:
|
||||
@@ -327,7 +336,6 @@ Usa `defineApplication()` per definire la configurazione della tua applicazione:
|
||||
// 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',
|
||||
@@ -343,7 +351,6 @@ export default defineApplication({
|
||||
},
|
||||
},
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -352,7 +359,7 @@ Note:
|
||||
* I campi `universalIdentifier` sono ID deterministici sotto il tuo controllo; generali una volta e mantienili stabili tra le sincronizzazioni.
|
||||
* `applicationVariables` diventano variabili d'ambiente per le tue funzioni (ad esempio, `DEFAULT_RECIPIENT_NAME` è disponibile come `process.env.DEFAULT_RECIPIENT_NAME`).
|
||||
* `defaultRoleUniversalIdentifier` deve corrispondere al file del ruolo (vedi sotto).
|
||||
* `postInstallLogicFunctionUniversalIdentifier` (opzionale) fa riferimento a una funzione logica che viene eseguita automaticamente dopo l'installazione dell'app. Vedi [Funzioni post-installazione](#post-install-functions).
|
||||
* Le funzioni di pre-installazione e post-installazione vengono rilevate automaticamente durante la build del manifesto. Vedi [Funzioni di pre-installazione](#pre-install-functions) e [Funzioni di post-installazione](#post-install-functions).
|
||||
|
||||
#### Ruoli e permessi
|
||||
|
||||
@@ -426,10 +433,10 @@ Ogni file di funzione usa `defineLogicFunction()` per esportare una configurazio
|
||||
// src/app/createPostCard.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk';
|
||||
import Twenty, { type Person } from '~/generated';
|
||||
import { CoreApiClient, type Person } from 'twenty-sdk/generated';
|
||||
|
||||
const handler = async (params: RoutePayload) => {
|
||||
const client = new Twenty(); // generated typed client
|
||||
const client = new CoreApiClient();
|
||||
const name = 'name' in params.queryStringParameters
|
||||
? params.queryStringParameters.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
|
||||
: 'Hello world';
|
||||
@@ -491,6 +498,44 @@ Note:
|
||||
* L'array `triggers` è facoltativo. Le funzioni senza trigger possono essere utilizzate come funzioni di utilità richiamate da altre funzioni.
|
||||
* Puoi combinare più tipi di trigger in un'unica funzione.
|
||||
|
||||
### Funzioni di pre-installazione
|
||||
|
||||
Una funzione di pre-installazione è una funzione logica che viene eseguita automaticamente prima che la tua app venga installata in uno spazio di lavoro. È utile per attività di convalida, controlli dei prerequisiti o per preparare lo stato dello spazio di lavoro prima che proceda l'installazione principale.
|
||||
|
||||
Quando esegui lo scaffolding di una nuova app con `create-twenty-app`, viene generata una funzione di pre-installazione in `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,
|
||||
});
|
||||
```
|
||||
|
||||
Puoi anche eseguire manualmente la funzione di pre-installazione in qualsiasi momento utilizzando la CLI:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty function:execute --preInstall
|
||||
```
|
||||
|
||||
Punti chiave:
|
||||
|
||||
* Le funzioni di pre-installazione utilizzano `definePreInstallLogicFunction()` — una variante specializzata che omette le impostazioni dei trigger (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `isTool`).
|
||||
* L'handler riceve un `InstallLogicFunctionPayload` con `{ previousVersion: string }` — la versione dell'app precedentemente installata (oppure una stringa vuota per nuove installazioni).
|
||||
* È consentita una sola funzione di pre-installazione per applicazione. La build del manifesto genererà un errore se ne viene rilevata più di una.
|
||||
* L'`universalIdentifier` della funzione viene impostato automaticamente come `preInstallLogicFunctionUniversalIdentifier` nel manifesto dell'applicazione durante la build — non è necessario farvi riferimento in `defineApplication()`.
|
||||
* Il timeout predefinito è impostato a 300 secondi (5 minuti) per consentire attività di preparazione più lunghe.
|
||||
* Le funzioni di pre-installazione non necessitano di trigger — vengono invocate dalla piattaforma prima dell'installazione o manualmente tramite `function:execute --preInstall`.
|
||||
|
||||
### Funzioni post-installazione
|
||||
|
||||
Una funzione post-installazione è una funzione logica che viene eseguita automaticamente dopo che la tua app è stata installata in uno spazio di lavoro. Questo è utile per attività di configurazione una tantum come il popolamento di dati predefiniti, la creazione di record iniziali o la configurazione delle impostazioni dello spazio di lavoro.
|
||||
@@ -499,16 +544,14 @@ Quando esegui lo scaffolding di una nuova app con `create-twenty-app`, viene gen
|
||||
|
||||
```typescript
|
||||
// src/logic-functions/post-install.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
|
||||
|
||||
export const POST_INSTALL_UNIVERSAL_IDENTIFIER = '<generated-uuid>';
|
||||
|
||||
const handler = async (): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!');
|
||||
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!', payload.previousVersion);
|
||||
};
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
export default definePostInstallLogicFunction({
|
||||
universalIdentifier: '<generated-uuid>',
|
||||
name: 'post-install',
|
||||
description: 'Runs after installation to set up the application.',
|
||||
timeoutSeconds: 300,
|
||||
@@ -516,17 +559,6 @@ export default defineLogicFunction({
|
||||
});
|
||||
```
|
||||
|
||||
La funzione viene collegata alla tua app facendo riferimento al suo identificatore universale in `application-config.ts`:
|
||||
|
||||
```typescript
|
||||
import { POST_INSTALL_UNIVERSAL_IDENTIFIER } from 'src/logic-functions/post-install';
|
||||
|
||||
export default defineApplication({
|
||||
// ...
|
||||
postInstallLogicFunctionUniversalIdentifier: POST_INSTALL_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
Puoi anche eseguire manualmente la funzione di post-installazione in qualsiasi momento utilizzando la CLI:
|
||||
|
||||
```bash filename="Terminal"
|
||||
@@ -535,8 +567,10 @@ yarn twenty function:execute --postInstall
|
||||
|
||||
Punti chiave:
|
||||
|
||||
* Le funzioni di post-installazione sono funzioni logiche standard — usano `defineLogicFunction()` come qualsiasi altra funzione.
|
||||
* Il campo `postInstallLogicFunctionUniversalIdentifier` in `defineApplication()` è facoltativo. Se omesso, nessuna funzione viene eseguita dopo l'installazione.
|
||||
* Le funzioni di post-installazione utilizzano `definePostInstallLogicFunction()` — una variante specializzata che omette le impostazioni dei trigger (`cronTriggerSettings`, `databaseEventTriggerSettings`, `httpRouteTriggerSettings`, `isTool`).
|
||||
* L'handler riceve un `InstallLogicFunctionPayload` con `{ previousVersion: string }` — la versione dell'app precedentemente installata (oppure una stringa vuota per nuove installazioni).
|
||||
* È consentita una sola funzione di post-installazione per applicazione. La build del manifesto genererà un errore se ne viene rilevata più di una.
|
||||
* L'`universalIdentifier` della funzione viene impostato automaticamente come `postInstallLogicFunctionUniversalIdentifier` nel manifesto dell'applicazione durante la build — non è necessario farvi riferimento in `defineApplication()`.
|
||||
* Il timeout predefinito è impostato a 300 secondi (5 minuti) per consentire attività di configurazione più lunghe, come il popolamento dei dati.
|
||||
* Le funzioni di post-installazione non necessitano di trigger — vengono invocate dalla piattaforma durante l'installazione o manualmente tramite `function:execute --postInstall`.
|
||||
|
||||
@@ -644,10 +678,10 @@ Per contrassegnare una funzione logica come strumento, imposta `isTool: true` e
|
||||
```typescript
|
||||
// src/logic-functions/enrich-company.logic-function.ts
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
import Twenty from '~/generated';
|
||||
import { CoreApiClient } from 'twenty-sdk/generated';
|
||||
|
||||
const handler = async (params: { companyName: string; domain?: string }) => {
|
||||
const client = new Twenty();
|
||||
const client = new CoreApiClient();
|
||||
|
||||
const result = await client.mutation({
|
||||
createTask: {
|
||||
@@ -705,7 +739,7 @@ Punti chiave:
|
||||
I componenti front-end ti consentono di creare componenti React personalizzati che vengono renderizzati all'interno dell'interfaccia di Twenty. Usa `defineFrontComponent()` per definire componenti con convalida integrata:
|
||||
|
||||
```typescript
|
||||
// src/my-widget.front-component.tsx
|
||||
// src/front-components/my-widget.tsx
|
||||
import { defineFrontComponent } from 'twenty-sdk';
|
||||
|
||||
const MyWidget = () => {
|
||||
@@ -728,14 +762,13 @@ export default defineFrontComponent({
|
||||
Punti chiave:
|
||||
|
||||
* I componenti front-end sono componenti React che eseguono il rendering in contesti isolati all'interno di Twenty.
|
||||
* Usa il suffisso di file `*.front-component.tsx` per il rilevamento automatico.
|
||||
* Il campo `component` fa riferimento al tuo componente React.
|
||||
* I componenti vengono compilati e sincronizzati automaticamente durante `yarn twenty app:dev`.
|
||||
|
||||
Puoi creare nuovi componenti front-end in due modi:
|
||||
|
||||
* **Generata dallo scaffolder**: Esegui `yarn twenty entity:add` e scegli l'opzione per aggiungere un nuovo componente front-end.
|
||||
* **Manuale**: Crea un nuovo file `*.front-component.tsx` e usa `defineFrontComponent()`.
|
||||
* **Manuale**: Crea un nuovo file `.tsx` e usa `defineFrontComponent()`, seguendo lo stesso schema.
|
||||
|
||||
### Abilità
|
||||
|
||||
@@ -772,18 +805,24 @@ 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.
|
||||
|
||||
### Client tipizzato generato
|
||||
### Client tipizzati generati
|
||||
|
||||
Il client tipizzato è generato automaticamente da `yarn twenty app:dev` e salvato in `node_modules/twenty-sdk/generated` in base allo schema della tua area di lavoro. Usalo nelle tue funzioni:
|
||||
Due client tipizzati sono generati automaticamente da `yarn twenty app:dev` e salvati in `node_modules/twenty-sdk/generated` in base allo schema della tua area di lavoro:
|
||||
|
||||
* **`CoreApiClient`** — interroga l'endpoint `/graphql` per i dati dell'area di lavoro
|
||||
* **`MetadataApiClient`** — interroga l'endpoint `/metadata` per la configurazione dello spazio di lavoro e il caricamento dei file
|
||||
|
||||
```typescript
|
||||
import Twenty from '~/generated';
|
||||
import { CoreApiClient, MetadataApiClient } from 'twenty-sdk/generated';
|
||||
|
||||
const client = new Twenty();
|
||||
const client = new CoreApiClient();
|
||||
const { me } = await client.query({ me: { id: true, displayName: true } });
|
||||
|
||||
const metadataClient = new MetadataApiClient();
|
||||
const { currentWorkspace } = await metadataClient.query({ currentWorkspace: { id: true } });
|
||||
```
|
||||
|
||||
Il client viene rigenerato automaticamente da `yarn twenty app:dev` ogni volta che i tuoi oggetti o campi cambiano.
|
||||
Entrambi i client vengono rigenerati automaticamente da `yarn twenty app:dev` ogni volta che i tuoi oggetti o campi cambiano.
|
||||
|
||||
#### Credenziali di runtime nelle funzioni logiche
|
||||
|
||||
@@ -800,17 +839,17 @@ Note:
|
||||
|
||||
#### Caricamento dei file
|
||||
|
||||
Il client `Twenty` generato include un metodo `uploadFile` per allegare file ai campi di tipo file sugli oggetti del tuo spazio di lavoro. Poiché i client GraphQL standard non supportano nativamente il caricamento di file multipart, il client fornisce questo metodo dedicato che implementa la [specifica della richiesta GraphQL multipart](https://github.com/jaydenseric/graphql-multipart-request-spec) dietro le quinte.
|
||||
Il `MetadataApiClient` generato include un metodo `uploadFile` per allegare file ai campi di tipo file sugli oggetti del tuo spazio di lavoro. Poiché i client GraphQL standard non supportano nativamente il caricamento di file multipart, il client fornisce questo metodo dedicato che implementa la [specifica della richiesta GraphQL multipart](https://github.com/jaydenseric/graphql-multipart-request-spec) dietro le quinte.
|
||||
|
||||
```typescript
|
||||
import Twenty from '~/generated';
|
||||
import { MetadataApiClient } from 'twenty-sdk/generated';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const client = new Twenty();
|
||||
const metadataClient = new MetadataApiClient();
|
||||
|
||||
const fileBuffer = fs.readFileSync('./invoice.pdf');
|
||||
|
||||
const uploadedFile = await client.uploadFile(
|
||||
const uploadedFile = await metadataClient.uploadFile(
|
||||
fileBuffer, // file contents as a Buffer
|
||||
'invoice.pdf', // filename
|
||||
'application/pdf', // MIME type (defaults to 'application/octet-stream')
|
||||
@@ -841,7 +880,7 @@ uploadFile(
|
||||
|
||||
Punti chiave:
|
||||
|
||||
* Il metodo invia il file al **metadata endpoint** (non all'endpoint GraphQL principale), dove la mutation di upload viene risolta.
|
||||
* Il metodo `uploadFile` è disponibile su `MetadataApiClient` perché la mutazione di upload viene risolta dall'endpoint `/metadata`.
|
||||
* Usa l'`universalIdentifier` del campo (non il suo ID specifico dello spazio di lavoro), quindi il tuo codice di upload funziona in qualsiasi spazio di lavoro in cui la tua app è installata — coerentemente con il modo in cui le app fanno riferimento ai campi altrove.
|
||||
* L'`url` restituito è un URL firmato che puoi usare per accedere al file caricato.
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ Un selettore di icone basato su menu a tendina che consente agli utenti di scegl
|
||||
<Tab title="Utilizzo">
|
||||
|
||||
```jsx
|
||||
import { RecoilRoot } from "recoil";
|
||||
import React, { useState } from "react";
|
||||
import { IconPicker } from "@/ui/input/components/IconPicker";
|
||||
|
||||
@@ -27,14 +26,12 @@ export const MyComponent = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<IconPicker
|
||||
disabled={false}
|
||||
onChange={handleIconChange}
|
||||
selectedIconKey={selectedIcon}
|
||||
variant="primary"
|
||||
/>
|
||||
</RecoilRoot>
|
||||
<IconPicker
|
||||
disabled={false}
|
||||
onChange={handleIconChange}
|
||||
selectedIconKey={selectedIcon}
|
||||
variant="primary"
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
@@ -13,7 +13,6 @@ Permette agli utenti di scegliere un valore da un elenco di opzioni predefinite.
|
||||
<Tab title="Utilizzo">
|
||||
|
||||
```jsx
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { IconTwentyStar } from 'twenty-ui/display';
|
||||
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
@@ -21,18 +20,16 @@ import { Select } from '@/ui/input/components/Select';
|
||||
export const MyComponent = () => {
|
||||
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<Select
|
||||
className
|
||||
disabled={false}
|
||||
label="Seleziona un'opzione"
|
||||
options={[
|
||||
{ value: 'option1', label: 'Opzione A', Icon: IconTwentyStar },
|
||||
{ value: 'option2', label: 'Opzione B', Icon: IconTwentyStar },
|
||||
]}
|
||||
value="option1"
|
||||
/>
|
||||
</RecoilRoot>
|
||||
<Select
|
||||
className
|
||||
disabled={false}
|
||||
label="Seleziona un'opzione"
|
||||
options={[
|
||||
{ value: 'option1', label: 'Opzione A', Icon: IconTwentyStar },
|
||||
{ value: 'option2', label: 'Opzione B', Icon: IconTwentyStar },
|
||||
]}
|
||||
value="option1"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ Consente agli utenti di inserire e modificare il testo.
|
||||
<Tab title="Utilizzo">
|
||||
|
||||
```jsx
|
||||
import { RecoilRoot } from "recoil";
|
||||
import { TextInput } from "@/ui/input/components/TextInput";
|
||||
|
||||
export const MyComponent = () => {
|
||||
@@ -29,18 +28,16 @@ export const MyComponent = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<TextInput
|
||||
className
|
||||
label="Username"
|
||||
onChange={handleChange}
|
||||
fullWidth={false}
|
||||
disableHotkeys={false}
|
||||
error="Invalid username"
|
||||
onKeyDown={handleKeyDown}
|
||||
RightIcon={null}
|
||||
/>
|
||||
</RecoilRoot>
|
||||
<TextInput
|
||||
className
|
||||
label="Username"
|
||||
onChange={handleChange}
|
||||
fullWidth={false}
|
||||
disableHotkeys={false}
|
||||
error="Invalid username"
|
||||
onKeyDown={handleKeyDown}
|
||||
RightIcon={null}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -81,22 +78,19 @@ Componente di input testo che regola automaticamente la sua altezza in base al c
|
||||
<Tab title="Utilizzo">
|
||||
|
||||
```jsx
|
||||
import { RecoilRoot } from "recoil";
|
||||
import { AutosizeTextInput } from "@/ui/input/components/AutosizeTextInput";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<AutosizeTextInput
|
||||
onValidate={() => console.log("onValidate function fired")}
|
||||
minRows={1}
|
||||
placeholder="Write a comment"
|
||||
onFocus={() => console.log("onFocus function fired")}
|
||||
variant="icon"
|
||||
buttonTitle
|
||||
value="Task: "
|
||||
/>
|
||||
</RecoilRoot>
|
||||
<AutosizeTextInput
|
||||
onValidate={() => console.log("onValidate function fired")}
|
||||
minRows={1}
|
||||
placeholder="Write a comment"
|
||||
onFocus={() => console.log("onFocus function fired")}
|
||||
variant="icon"
|
||||
buttonTitle
|
||||
value="Task: "
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
+19
-18
@@ -6,9 +6,9 @@ title: ベストプラクティス',
|
||||
|
||||
## 状態管理
|
||||
|
||||
React と Recoil はコードベース内の状態管理を行います。
|
||||
React と Jotai はコードベース内の状態管理を行います。
|
||||
|
||||
### 状態を保存するために `useRecoilState` を使用する
|
||||
### 状態を保存するために `useAtomState` を使用する
|
||||
|
||||
状態を保存するために必要なだけ多くのアトムを作成するのがよいです。
|
||||
|
||||
@@ -17,13 +17,16 @@ React と Recoil はコードベース内の状態管理を行います。
|
||||
</Warning>
|
||||
|
||||
```tsx
|
||||
export const myAtomState = atom({
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
|
||||
export const myAtomState = createAtomState<string>({
|
||||
key: 'myAtomState',
|
||||
default: 'default value',
|
||||
defaultValue: 'default value',
|
||||
});
|
||||
|
||||
export const MyComponent = () => {
|
||||
const [myAtom, setMyAtom] = useRecoilState(myAtomState);
|
||||
const [myAtom, setMyAtom] = useAtomState(myAtomState);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -40,7 +43,7 @@ export const MyComponent = () => {
|
||||
|
||||
状態の保存に `useRef` を使用するのは避けてください。
|
||||
|
||||
If you want to store state, you should use `useState` or `useRecoilState`.
|
||||
If you want to store state, you should use `useState` or `useAtomState`.
|
||||
|
||||
いくつかの再レンダリングを防ぐために `useRef` が必要だと感じた場合は、[再レンダリングの管理方法](#managing-re-renders)を参照してください。
|
||||
|
||||
@@ -80,8 +83,8 @@ Apollo フックを使用してデータ取得ロジックにも同じことを
|
||||
// ❌ 悪い例: データが変化していなくても再レンダーを引き起こす
|
||||
// useEffect を再評価する必要があるため
|
||||
export const PageComponent = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [someDependency] = useRecoilState(someDependencyState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
const [someDependency] = useAtomState(someDependencyState);
|
||||
|
||||
useEffect(() => {
|
||||
if(someDependency !== data) {
|
||||
@@ -93,9 +96,7 @@ export const PageComponent = () => {
|
||||
};
|
||||
|
||||
export const App = () => (
|
||||
<RecoilRoot>
|
||||
<PageComponent />
|
||||
</RecoilRoot>
|
||||
<PageComponent />
|
||||
);
|
||||
```
|
||||
|
||||
@@ -103,14 +104,14 @@ export const App = () => (
|
||||
// ✅ 良い例: データが変化していなければ再レンダーは発生しない
|
||||
// useEffect が別の兄弟コンポーネントで再評価されるため
|
||||
export const PageComponent = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
|
||||
return <div>{data}</div>;
|
||||
};
|
||||
|
||||
export const PageData = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [someDependency] = useRecoilState(someDependencyState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
const [someDependency] = useAtomState(someDependencyState);
|
||||
|
||||
useEffect(() => {
|
||||
if(someDependency !== data) {
|
||||
@@ -122,16 +123,16 @@ export const PageData = () => {
|
||||
};
|
||||
|
||||
export const App = () => (
|
||||
<RecoilRoot>
|
||||
<>
|
||||
<PageData />
|
||||
<PageComponent />
|
||||
</RecoilRoot>
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
### Recoilファミリー状態とファミリーセレクターを使用する
|
||||
### Jotaiファミリー状態とファミリーセレクターを使用する
|
||||
|
||||
Recoil ファミリー状態とセレクターは、再レンダリングを回避するための優れた方法です。
|
||||
Jotai ファミリー状態とセレクターは、再レンダリングを回避するための優れた方法です。
|
||||
|
||||
アイテムのリストを保存する必要があるときに有用です。
|
||||
|
||||
|
||||
+2
-2
@@ -82,9 +82,9 @@ module1
|
||||
|
||||
### ステート
|
||||
|
||||
ステート管理のロジックを含みます。 [RecoilJS](https://recoiljs.org) がこれを管理します。
|
||||
ステート管理のロジックを含みます。 [Jotai](https://jotai.org) がこれを管理します。
|
||||
|
||||
* セレクター: 詳細は[RecoilJSセレクター](https://recoiljs.org/docs/basic-tutorial/selectors)を参照してください。
|
||||
* セレクター: 派生アトム(`createAtomSelector`を使用)は他のアトムから値を計算し、自動的にメモ化されます。
|
||||
|
||||
Reactの組み込みステート管理は依然としてコンポーネント内のステートを処理します。
|
||||
|
||||
|
||||
+2
-2
@@ -53,7 +53,7 @@ npx nx run twenty-front:storybook:coverage # (needs yarn storybook:serve:dev to
|
||||
* [React](https://react.dev/)
|
||||
* [Apollo](https://www.apollographql.com/docs/)
|
||||
* [GraphQL Codegen](https://the-guild.dev/graphql/codegen)
|
||||
* [Recoil](https://recoiljs.org/docs/introduction/core-concepts)
|
||||
* [Jotai](https://jotai.org/)
|
||||
* [TypeScript](https://www.typescriptlang.org/)
|
||||
|
||||
**テスト**
|
||||
@@ -77,7 +77,7 @@ To avoid unnecessary [re-renders](/l/ja/developers/contribute/capabilities/front
|
||||
|
||||
### 状態管理
|
||||
|
||||
[Recoil](https://recoiljs.org/docs/introduction/core-concepts)は状態管理を処理します。
|
||||
[Jotai](https://jotai.org/)は状態管理を処理します。
|
||||
|
||||
状態管理に関する詳細な情報は[ベストプラクティス](/l/ja/developers/contribute/capabilities/frontend-development/best-practices-front#state-management)を参照してください。
|
||||
|
||||
|
||||
+3
-3
@@ -160,7 +160,7 @@ export enum PageHotkeyScope {
|
||||
}
|
||||
```
|
||||
|
||||
内部的には、現在選択されているスコープはアプリケーション全体で共有されるRecoilステートに格納されています:
|
||||
内部的には、現在選択されているスコープはアプリケーション全体で共有されるJotaiステートに格納されています:
|
||||
|
||||
```tsx
|
||||
export const currentHotkeyScopeState = createState<HotkeyScope>({
|
||||
@@ -169,10 +169,10 @@ export const currentHotkeyScopeState = createState<HotkeyScope>({
|
||||
});
|
||||
```
|
||||
|
||||
しかし、このRecoilステートは手動で処理しないでください! 次のセクションでその使用方法を見ていきます。 次のセクションでその使用方法を見ていきます。 次のセクションでその使用方法を見ていきます。
|
||||
しかし、このJotaiステートは手動で処理しないでください! 次のセクションでその使用方法を見ていきます。 次のセクションでその使用方法を見ていきます。 次のセクションでその使用方法を見ていきます。
|
||||
|
||||
## 内部的にはどう機能しているのか?
|
||||
|
||||
[react-hotkeys-hook](https://react-hotkeys-hook.vercel.app/docs/intro)の上に薄いラッパーを作成し、より効率的にし、不必要な再レンダリングを避けます。
|
||||
|
||||
また、ホットキースコープの状態を処理し、アプリケーション全体で利用できるRecoilステートを作成しました。
|
||||
また、ホットキースコープの状態を処理し、アプリケーション全体で利用できるJotaiステートを作成しました。
|
||||
|
||||
@@ -12,7 +12,6 @@ image: /images/user-guide/github/github-header.png
|
||||
<Tabs>
|
||||
<Tab title="使用方法">
|
||||
```jsx
|
||||
import { RecoilRoot } from "recoil";
|
||||
import React, { useState } from "react";
|
||||
import { IconPicker } from "@/ui/input/components/IconPicker";
|
||||
|
||||
@@ -25,14 +24,12 @@ image: /images/user-guide/github/github-header.png
|
||||
};
|
||||
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<IconPicker
|
||||
disabled={false}
|
||||
onChange={handleIconChange}
|
||||
selectedIconKey={selectedIcon}
|
||||
variant="primary"
|
||||
/>
|
||||
</RecoilRoot>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
@@ -12,7 +12,6 @@ image: /images/user-guide/what-is-twenty/20.png
|
||||
<Tabs>
|
||||
<Tab title="使用方法">
|
||||
```jsx
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { IconTwentyStar } from 'twenty-ui/display';
|
||||
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
@@ -20,7 +19,6 @@ image: /images/user-guide/what-is-twenty/20.png
|
||||
export const MyComponent = () => {
|
||||
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<Select
|
||||
className
|
||||
disabled={false}
|
||||
@@ -31,7 +29,6 @@ image: /images/user-guide/what-is-twenty/20.png
|
||||
]}
|
||||
value="option1"
|
||||
/>
|
||||
</RecoilRoot>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ image: /images/user-guide/notes/notes_header.png
|
||||
<Tabs>
|
||||
<Tab title="使用方法">
|
||||
```jsx
|
||||
import { RecoilRoot } from "recoil";
|
||||
import { TextInput } from "@/ui/input/components/TextInput";
|
||||
|
||||
export const MyComponent = () => {
|
||||
@@ -27,7 +26,6 @@ image: /images/user-guide/notes/notes_header.png
|
||||
};
|
||||
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<TextInput
|
||||
className
|
||||
label="ユーザー名"
|
||||
@@ -38,7 +36,6 @@ image: /images/user-guide/notes/notes_header.png
|
||||
onKeyDown={handleKeyDown}
|
||||
RightIcon={null}
|
||||
/>
|
||||
</RecoilRoot>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -68,12 +65,10 @@ image: /images/user-guide/notes/notes_header.png
|
||||
<Tabs>
|
||||
<Tab title="使用方法">
|
||||
```jsx
|
||||
import { RecoilRoot } from "recoil";
|
||||
import { AutosizeTextInput } from "@/ui/input/components/AutosizeTextInput";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<AutosizeTextInput
|
||||
onValidate={() => console.log("onValidate function fired")}
|
||||
minRows={1}
|
||||
@@ -83,7 +78,6 @@ image: /images/user-guide/notes/notes_header.png
|
||||
buttonTitle
|
||||
value="Task: "
|
||||
/>
|
||||
</RecoilRoot>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
+19
-18
@@ -6,9 +6,9 @@ title: 모범 사례
|
||||
|
||||
## 상태 관리
|
||||
|
||||
React와 Recoil은 코드베이스에서 상태 관리를 처리합니다.
|
||||
React와 Jotai는 코드베이스에서 상태 관리를 처리합니다.
|
||||
|
||||
### `useRecoilState`로 상태 저장하기
|
||||
### `useAtomState`로 상태 저장하기
|
||||
|
||||
상태를 저장하는 데 필요한 만큼의 atom을 만드는 것이 좋은 습관입니다.
|
||||
|
||||
@@ -17,13 +17,16 @@ React와 Recoil은 코드베이스에서 상태 관리를 처리합니다.
|
||||
</Warning>
|
||||
|
||||
```tsx
|
||||
export const myAtomState = atom({
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
|
||||
export const myAtomState = createAtomState<string>({
|
||||
key: 'myAtomState',
|
||||
default: 'default value',
|
||||
defaultValue: 'default value',
|
||||
});
|
||||
|
||||
export const MyComponent = () => {
|
||||
const [myAtom, setMyAtom] = useRecoilState(myAtomState);
|
||||
const [myAtom, setMyAtom] = useAtomState(myAtomState);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -40,7 +43,7 @@ export const MyComponent = () => {
|
||||
|
||||
상태 저장에 `useRef`를 사용하지 않도록 주의하십시오.
|
||||
|
||||
If you want to store state, you should use `useState` or `useRecoilState`.
|
||||
If you want to store state, you should use `useState` or `useAtomState`.
|
||||
|
||||
일부 리렌더링을 방지하기 위해 `useRef`가 필요하다고 느낄 경우 [리렌더링 관리 방법](#managing-re-renders)을 참조하십시오.
|
||||
|
||||
@@ -80,8 +83,8 @@ Apollo 훅을 사용하여 데이터 페칭 로직에 동일한 규칙을 적용
|
||||
// ❌ Bad, will cause re-renders even if data is not changing,
|
||||
// because useEffect needs to be re-evaluated
|
||||
export const PageComponent = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [someDependency] = useRecoilState(someDependencyState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
const [someDependency] = useAtomState(someDependencyState);
|
||||
|
||||
useEffect(() => {
|
||||
if(someDependency !== data) {
|
||||
@@ -93,9 +96,7 @@ export const PageComponent = () => {
|
||||
};
|
||||
|
||||
export const App = () => (
|
||||
<RecoilRoot>
|
||||
<PageComponent />
|
||||
</RecoilRoot>
|
||||
<PageComponent />
|
||||
);
|
||||
```
|
||||
|
||||
@@ -103,14 +104,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] = useRecoilState(dataState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
|
||||
return <div>{data}</div>;
|
||||
};
|
||||
|
||||
export const PageData = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [someDependency] = useRecoilState(someDependencyState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
const [someDependency] = useAtomState(someDependencyState);
|
||||
|
||||
useEffect(() => {
|
||||
if(someDependency !== data) {
|
||||
@@ -122,16 +123,16 @@ export const PageData = () => {
|
||||
};
|
||||
|
||||
export const App = () => (
|
||||
<RecoilRoot>
|
||||
<>
|
||||
<PageData />
|
||||
<PageComponent />
|
||||
</RecoilRoot>
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
### Recoil 가족 상태 및 Recoil 가족 선택자 사용하기
|
||||
### Jotai 가족 상태 및 Jotai 가족 선택자 사용하기
|
||||
|
||||
Recoil 가족 상태와 선택자는 리렌더링을 피하기 위한 훌륭한 방법입니다.
|
||||
Jotai 가족 상태와 선택자는 리렌더링을 피하기 위한 훌륭한 방법입니다.
|
||||
|
||||
항목 목록을 저장해야 할 때 유용합니다.
|
||||
|
||||
|
||||
+2
-2
@@ -82,9 +82,9 @@ module1
|
||||
|
||||
### 상태
|
||||
|
||||
상태 관리 로직이 포함되어 있습니다. [RecoilJS](https://recoiljs.org)가 이것을 처리합니다.
|
||||
상태 관리 로직이 포함되어 있습니다. [Jotai](https://jotai.org)가 이것을 처리합니다.
|
||||
|
||||
* 셀렉터: 자세한 내용은 [RecoilJS 셀렉터](https://recoiljs.org/docs/basic-tutorial/selectors)를 참조하세요.
|
||||
* 셀렉터: 파생 아톰(`createAtomSelector` 사용)은 다른 아톰에서 값을 계산하며 자동으로 메모이제이션됩니다.
|
||||
|
||||
React의 내장 상태 관리는 구성 요소 내에서의 상태를 여전히 처리합니다.
|
||||
|
||||
|
||||
+2
-2
@@ -53,7 +53,7 @@ npx nx run twenty-front:storybook:coverage # (needs yarn storybook:serve:dev to
|
||||
* [React](https://react.dev/)
|
||||
* [Apollo](https://www.apollographql.com/docs/)
|
||||
* [GraphQL Codegen](https://the-guild.dev/graphql/codegen)
|
||||
* [Recoil](https://recoiljs.org/docs/introduction/core-concepts)
|
||||
* [Jotai](https://jotai.org/)
|
||||
* [TypeScript](https://www.typescriptlang.org/)
|
||||
|
||||
**테스트**
|
||||
@@ -77,7 +77,7 @@ To avoid unnecessary [re-renders](/l/ko/developers/contribute/capabilities/front
|
||||
|
||||
### 상태 관리
|
||||
|
||||
[Recoil](https://recoiljs.org/docs/introduction/core-concepts)이 상태 관리를 처리합니다.
|
||||
[Jotai](https://jotai.org/)이 상태 관리를 처리합니다.
|
||||
|
||||
상태 관리에 대한 자세한 정보는 [최고의 관례](/l/ko/developers/contribute/capabilities/frontend-development/best-practices-front#state-management)를 참조하십시오.
|
||||
|
||||
|
||||
+3
-3
@@ -160,7 +160,7 @@ export enum PageHotkeyScope {
|
||||
}
|
||||
```
|
||||
|
||||
내부적으로, 현재 선택한 스코프는 애플리케이션 전반에 걸쳐 공유되는 Recoil 상태에 저장됩니다:
|
||||
내부적으로, 현재 선택한 스코프는 애플리케이션 전반에 걸쳐 공유되는 Jotai 상태에 저장됩니다:
|
||||
|
||||
```tsx
|
||||
export const currentHotkeyScopeState = createState<HotkeyScope>({
|
||||
@@ -169,10 +169,10 @@ export const currentHotkeyScopeState = createState<HotkeyScope>({
|
||||
});
|
||||
```
|
||||
|
||||
하지만 이 Recoil 상태는 수동으로 처리해서는 안 됩니다! 다음 섹션에서 사용하는 방법을 배웁니다.
|
||||
하지만 이 Jotai 상태는 수동으로 처리해서는 안 됩니다! 다음 섹션에서 사용하는 방법을 배웁니다.
|
||||
|
||||
## 내부적으로 어떻게 작동합니까?
|
||||
|
||||
[react-hotkeys-hook](https://react-hotkeys-hook.vercel.app/docs/intro) 위에 얇은 래퍼를 만들어 성능을 높이고 불필요한 재랜더링을 방지합니다.
|
||||
|
||||
또한 핫키 스코프 상태를 처리하고 애플리케이션 전반에서 사용할 수 있도록 한 Recoil 상태를 만듭니다.
|
||||
또한 핫키 스코프 상태를 처리하고 애플리케이션 전반에서 사용할 수 있도록 한 Jotai 상태를 만듭니다.
|
||||
|
||||
@@ -12,7 +12,6 @@ image: /images/user-guide/github/github-header.png
|
||||
<Tabs>
|
||||
<Tab title="사용법">
|
||||
```jsx
|
||||
import { RecoilRoot } from "recoil";
|
||||
import React, { useState } from "react";
|
||||
import { IconPicker } from "@/ui/input/components/IconPicker";
|
||||
|
||||
@@ -25,14 +24,12 @@ image: /images/user-guide/github/github-header.png
|
||||
};
|
||||
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<IconPicker
|
||||
disabled={false}
|
||||
onChange={handleIconChange}
|
||||
selectedIconKey={selectedIcon}
|
||||
variant="primary"
|
||||
/>
|
||||
</RecoilRoot>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
@@ -12,7 +12,6 @@ image: /images/user-guide/what-is-twenty/20.png
|
||||
<Tabs>
|
||||
<Tab title="사용법">
|
||||
```jsx
|
||||
import { RecoilRoot } from 'recoil';
|
||||
import { IconTwentyStar } from 'twenty-ui/display';
|
||||
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
@@ -20,7 +19,6 @@ image: /images/user-guide/what-is-twenty/20.png
|
||||
export const MyComponent = () => {
|
||||
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<Select
|
||||
className
|
||||
disabled={false}
|
||||
@@ -31,7 +29,6 @@ image: /images/user-guide/what-is-twenty/20.png
|
||||
]}
|
||||
value="option1"
|
||||
/>
|
||||
</RecoilRoot>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ image: /images/user-guide/notes/notes_header.png
|
||||
<Tabs>
|
||||
<Tab title="사용법">
|
||||
```jsx
|
||||
import { RecoilRoot } from "recoil";
|
||||
import { TextInput } from "@/ui/input/components/TextInput";
|
||||
|
||||
export const MyComponent = () => {
|
||||
@@ -27,7 +26,6 @@ image: /images/user-guide/notes/notes_header.png
|
||||
};
|
||||
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<TextInput
|
||||
className
|
||||
label="사용자 이름"
|
||||
@@ -38,7 +36,6 @@ image: /images/user-guide/notes/notes_header.png
|
||||
onKeyDown={handleKeyDown}
|
||||
RightIcon={null}
|
||||
/>
|
||||
</RecoilRoot>
|
||||
);
|
||||
};
|
||||
```
|
||||
@@ -67,12 +64,10 @@ image: /images/user-guide/notes/notes_header.png
|
||||
<Tabs>
|
||||
<Tab title="사용법">
|
||||
```jsx
|
||||
import { RecoilRoot } from "recoil";
|
||||
import { AutosizeTextInput } from "@/ui/input/components/AutosizeTextInput";
|
||||
|
||||
export const MyComponent = () => {
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<AutosizeTextInput
|
||||
onValidate={() => console.log("onValidate 함수 실행됨")}
|
||||
minRows={1}
|
||||
@@ -82,7 +77,6 @@ image: /images/user-guide/notes/notes_header.png
|
||||
buttonTitle
|
||||
value="작업: "
|
||||
/>
|
||||
</RecoilRoot>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
+23
-22
@@ -6,9 +6,9 @@ Este documento descreve as melhores práticas que você deve seguir ao trabalhar
|
||||
|
||||
## Gerenciamento de Estado
|
||||
|
||||
React e Recoil lidam com o gerenciamento de estado na base de código.
|
||||
React e Jotai lidam com o gerenciamento de estado na base de código.
|
||||
|
||||
### Use `useRecoilState` para armazenar o estado
|
||||
### Use átomos do Jotai para armazenar o estado
|
||||
|
||||
É uma boa prática criar tantos átomos quanto necessário para armazenar seu estado.
|
||||
|
||||
@@ -19,13 +19,16 @@ React e Recoil lidam com o gerenciamento de estado na base de código.
|
||||
</Warning>
|
||||
|
||||
```tsx
|
||||
export const myAtomState = atom({
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
|
||||
export const myAtomState = createAtomState<string>({
|
||||
key: 'myAtomState',
|
||||
default: 'default value',
|
||||
defaultValue: 'default value',
|
||||
});
|
||||
|
||||
export const MyComponent = () => {
|
||||
const [myAtom, setMyAtom] = useRecoilState(myAtomState);
|
||||
const [myAtom, setMyAtom] = useAtomState(myAtomState);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -42,7 +45,7 @@ export const MyComponent = () => {
|
||||
|
||||
Evite usar `useRef` para armazenar estado.
|
||||
|
||||
If you want to store state, you should use `useState` or `useRecoilState`.
|
||||
Se quiser armazenar o estado, você deve usar `useState` ou átomos do Jotai com `useAtomState`.
|
||||
|
||||
Veja [como gerenciar re-renderizações](#managing-re-renders) se você sentir que precisa de `useRef` para evitar que algumas re-renderizações aconteçam.
|
||||
|
||||
@@ -79,11 +82,11 @@ If you feel like you need to add a `useEffect` in your root component, you shoul
|
||||
Você pode aplicar o mesmo para lógica de busca de dados, com hooks do Apollo.
|
||||
|
||||
```tsx
|
||||
// ❌ Bad, will cause re-renders even if data is not changing,
|
||||
// because useEffect needs to be re-evaluated
|
||||
// ❌ Ruim, causará re-renderizações mesmo se os dados não estiverem mudando,
|
||||
// porque o useEffect precisa ser reavaliado
|
||||
export const PageComponent = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [someDependency] = useRecoilState(someDependencyState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
const [someDependency] = useAtomState(someDependencyState);
|
||||
|
||||
useEffect(() => {
|
||||
if(someDependency !== data) {
|
||||
@@ -95,24 +98,22 @@ export const PageComponent = () => {
|
||||
};
|
||||
|
||||
export const App = () => (
|
||||
<RecoilRoot>
|
||||
<PageComponent />
|
||||
</RecoilRoot>
|
||||
<PageComponent />
|
||||
);
|
||||
```
|
||||
|
||||
```tsx
|
||||
// ✅ Good, will not cause re-renders if data is not changing,
|
||||
// because useEffect is re-evaluated in another sibling component
|
||||
// ✅ Bom, não causará re-renderizações se os dados não estiverem mudando,
|
||||
// porque o useEffect é reavaliado em outro componente irmão
|
||||
export const PageComponent = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
|
||||
return <div>{data}</div>;
|
||||
};
|
||||
|
||||
export const PageData = () => {
|
||||
const [data, setData] = useRecoilState(dataState);
|
||||
const [someDependency] = useRecoilState(someDependencyState);
|
||||
const [data, setData] = useAtomState(dataState);
|
||||
const [someDependency] = useAtomState(someDependencyState);
|
||||
|
||||
useEffect(() => {
|
||||
if(someDependency !== data) {
|
||||
@@ -124,16 +125,16 @@ export const PageData = () => {
|
||||
};
|
||||
|
||||
export const App = () => (
|
||||
<RecoilRoot>
|
||||
<>
|
||||
<PageData />
|
||||
<PageComponent />
|
||||
</RecoilRoot>
|
||||
</>
|
||||
);
|
||||
```
|
||||
|
||||
### Use estados de família Recoil e seletores de família Recoil
|
||||
### Use famílias de átomos e seletores de família
|
||||
|
||||
Estados de família Recoil e seletores são uma ótima maneira de evitar re-renderizações.
|
||||
Famílias de átomos e seletores são uma ótima maneira de evitar re-renderizações.
|
||||
|
||||
Eles são úteis quando você precisa armazenar uma lista de itens.
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user