Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85ca3faa47 |
@@ -144,9 +144,6 @@ jobs:
|
||||
|
||||
npx nx run twenty-server:database:init:prod
|
||||
|
||||
- name: Flush cache before seeding current branch
|
||||
run: npx nx command-no-deps twenty-server -- cache:flush
|
||||
|
||||
- name: Seed current branch database with test data
|
||||
run: |
|
||||
npx nx command-no-deps twenty-server -- workspace:seed:dev
|
||||
@@ -163,7 +160,7 @@ jobs:
|
||||
- name: Wait for current branch server to be ready
|
||||
run: |
|
||||
echo "Waiting for current branch server to start..."
|
||||
timeout=60
|
||||
timeout=300
|
||||
interval=5
|
||||
elapsed=0
|
||||
|
||||
@@ -188,10 +185,9 @@ jobs:
|
||||
done
|
||||
|
||||
if [ $elapsed -ge $timeout ]; then
|
||||
echo "❌ Timed out waiting for current branch server to serve a valid schema."
|
||||
echo "::warning::Timed out waiting for current branch server to serve a valid schema. Validation will skip the API diff."
|
||||
echo "Current server log:"
|
||||
cat /tmp/current-server.log || echo "No current server log found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Download GraphQL and REST responses from current branch
|
||||
@@ -315,9 +311,6 @@ jobs:
|
||||
|
||||
npx nx run twenty-server:database:init:prod
|
||||
|
||||
- name: Flush cache before seeding main branch
|
||||
run: npx nx command-no-deps twenty-server -- cache:flush
|
||||
|
||||
- name: Seed main branch database with test data
|
||||
run: |
|
||||
npx nx command-no-deps twenty-server -- workspace:seed:dev
|
||||
@@ -359,10 +352,9 @@ jobs:
|
||||
done
|
||||
|
||||
if [ $elapsed -ge $timeout ]; then
|
||||
echo "❌ Timed out waiting for main branch server to serve a valid schema."
|
||||
echo "::warning::Timed out waiting for main branch server to serve a valid schema. Validation will skip the API diff."
|
||||
echo "Main server log:"
|
||||
cat /tmp/main-server.log || echo "No main server log found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Download GraphQL and REST responses from main branch
|
||||
@@ -458,7 +450,6 @@ jobs:
|
||||
echo "Using OpenAPITools/openapi-diff via Docker"
|
||||
|
||||
- name: Generate GraphQL Schema Diff Reports
|
||||
id: graphql-diff
|
||||
if: steps.validate-schemas.outputs.valid == 'true'
|
||||
run: |
|
||||
echo "=== INSTALLING GRAPHQL INSPECTOR CLI ==="
|
||||
@@ -472,7 +463,6 @@ jobs:
|
||||
echo "✅ No changes in GraphQL schema"
|
||||
else
|
||||
echo "⚠️ Changes detected in GraphQL schema, generating report..."
|
||||
echo "core_breaking=true" >> $GITHUB_OUTPUT
|
||||
echo "# GraphQL Schema Changes" > graphql-schema-diff.md
|
||||
echo "" >> graphql-schema-diff.md
|
||||
graphql-inspector diff main-schema-introspection.json current-schema-introspection.json >> graphql-schema-diff.md 2>&1 || {
|
||||
@@ -490,7 +480,6 @@ jobs:
|
||||
echo "✅ No changes in GraphQL metadata schema"
|
||||
else
|
||||
echo "⚠️ Changes detected in GraphQL metadata schema, generating report..."
|
||||
echo "metadata_breaking=true" >> $GITHUB_OUTPUT
|
||||
echo "# GraphQL Metadata Schema Changes" > graphql-metadata-diff.md
|
||||
echo "" >> graphql-metadata-diff.md
|
||||
graphql-inspector diff main-metadata-schema-introspection.json current-metadata-schema-introspection.json >> graphql-metadata-diff.md 2>&1 || {
|
||||
@@ -507,7 +496,6 @@ jobs:
|
||||
ls -la *-diff.md 2>/dev/null || echo "No diff files generated (no changes detected)"
|
||||
|
||||
- name: Check REST API Breaking Changes
|
||||
id: rest-diff
|
||||
if: steps.validate-schemas.outputs.valid == 'true'
|
||||
run: |
|
||||
echo "=== CHECKING REST API FOR BREAKING CHANGES ==="
|
||||
@@ -530,7 +518,6 @@ jobs:
|
||||
|
||||
if [ "$incompatible" = "true" ]; then
|
||||
echo "❌ Breaking changes detected in REST API"
|
||||
echo "breaking=true" >> $GITHUB_OUTPUT
|
||||
|
||||
# Generate breaking changes report
|
||||
echo "# REST API Breaking Changes" > rest-api-diff.md
|
||||
@@ -578,7 +565,6 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Check REST Metadata API Breaking Changes
|
||||
id: rest-metadata-diff
|
||||
if: steps.validate-schemas.outputs.valid == 'true'
|
||||
run: |
|
||||
echo "=== CHECKING REST METADATA API FOR BREAKING CHANGES ==="
|
||||
@@ -601,7 +587,6 @@ jobs:
|
||||
|
||||
if [ "$incompatible" = "true" ]; then
|
||||
echo "❌ Breaking changes detected in REST Metadata API"
|
||||
echo "breaking=true" >> $GITHUB_OUTPUT
|
||||
|
||||
# Generate breaking changes report (only for breaking changes)
|
||||
echo "# REST Metadata API Breaking Changes" > rest-metadata-api-diff.md
|
||||
@@ -647,79 +632,6 @@ jobs:
|
||||
echo "::warning::REST Metadata API analysis tool error - continuing workflow"
|
||||
fi
|
||||
|
||||
- name: Fail on breaking changes
|
||||
if: steps.validate-schemas.outputs.valid == 'true'
|
||||
run: |
|
||||
breaking=false
|
||||
|
||||
if [ "${{ steps.graphql-diff.outputs.core_breaking }}" = "true" ]; then
|
||||
echo "❌ GraphQL core schema has breaking changes"
|
||||
breaking=true
|
||||
if [ -f graphql-schema-diff.md ]; then
|
||||
echo ""
|
||||
cat graphql-schema-diff.md
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${{ steps.graphql-diff.outputs.metadata_breaking }}" = "true" ]; then
|
||||
echo "❌ GraphQL metadata schema has breaking changes"
|
||||
breaking=true
|
||||
if [ -f graphql-metadata-diff.md ]; then
|
||||
echo ""
|
||||
cat graphql-metadata-diff.md
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${{ steps.rest-diff.outputs.breaking }}" = "true" ]; then
|
||||
echo "❌ REST core API has breaking changes"
|
||||
breaking=true
|
||||
if [ -f rest-api-diff.json ]; then
|
||||
echo ""
|
||||
jq -r '
|
||||
(if (.missingEndpoints | length) > 0 then
|
||||
" Removed endpoints:\n" +
|
||||
(.missingEndpoints | map(" - " + (.method // "?") + " " + (.pathUrl // "?")) | join("\n"))
|
||||
else "" end),
|
||||
(if (.changedOperations | length) > 0 then
|
||||
" Changed operations:\n" +
|
||||
(.changedOperations | map(" - " + (.method // "?") + " " + (.pathUrl // "?")) | join("\n"))
|
||||
else "" end)
|
||||
' rest-api-diff.json | sed '/^$/d'
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${{ steps.rest-metadata-diff.outputs.breaking }}" = "true" ]; then
|
||||
echo "❌ REST metadata API has breaking changes"
|
||||
breaking=true
|
||||
if [ -f rest-metadata-api-diff.json ]; then
|
||||
echo ""
|
||||
jq -r '
|
||||
(if (.missingEndpoints | length) > 0 then
|
||||
" Removed endpoints:\n" +
|
||||
(.missingEndpoints | map(" - " + (.method // "?") + " " + (.pathUrl // "?")) | join("\n"))
|
||||
else "" end),
|
||||
(if (.changedOperations | length) > 0 then
|
||||
" Changed operations:\n" +
|
||||
(.changedOperations | map(" - " + (.method // "?") + " " + (.pathUrl // "?")) | join("\n"))
|
||||
else "" end)
|
||||
' rest-metadata-api-diff.json | sed '/^$/d'
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$breaking" = "true" ]; then
|
||||
echo ""
|
||||
echo "This PR introduces breaking changes to the public API."
|
||||
echo "If intentional, deprecate the old endpoint and introduce a new one."
|
||||
echo "See the breaking changes report artifact and PR comment for details."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ No breaking API changes detected"
|
||||
|
||||
- name: Upload breaking changes report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
@@ -740,3 +652,5 @@ jobs:
|
||||
if [ -f /tmp/main-server.pid ]; then
|
||||
kill $(cat /tmp/main-server.pid) || true
|
||||
fi
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: 'Preview Environment Dispatch'
|
||||
|
||||
permissions: {}
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
@@ -10,6 +11,7 @@ on:
|
||||
- packages/twenty-server/**
|
||||
- packages/twenty-front/**
|
||||
- .github/workflows/preview-env-dispatch.yaml
|
||||
- .github/workflows/preview-env-keepalive.yaml
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -33,15 +35,28 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Dispatch preview-env to ci-privileged
|
||||
- name: Trigger preview environment workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
gh api repos/twentyhq/ci-privileged/dispatches \
|
||||
gh api repos/"$REPOSITORY"/dispatches \
|
||||
-f event_type=preview-environment \
|
||||
-f "client_payload[pr_number]=$PR_NUMBER" \
|
||||
-f "client_payload[pr_head_sha]=$PR_HEAD_SHA" \
|
||||
-f "client_payload[repo_full_name]=$REPOSITORY"
|
||||
|
||||
- name: Dispatch to ci-privileged for PR comment
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_PRIVILEGED_DISPATCH_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
KEEPALIVE_DISPATCH_TIME: ${{ github.event.pull_request.updated_at }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
gh api repos/twentyhq/ci-privileged/dispatches \
|
||||
-f event_type=preview-env-url \
|
||||
-f "client_payload[pr_number]=$PR_NUMBER" \
|
||||
-f "client_payload[keepalive_dispatch_time]=$KEEPALIVE_DISPATCH_TIME" \
|
||||
-f "client_payload[repo]=$REPOSITORY"
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
name: 'Preview Environment Keep Alive'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
repository_dispatch:
|
||||
types: [preview-environment]
|
||||
|
||||
jobs:
|
||||
preview-environment:
|
||||
timeout-minutes: 310
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
ref: ${{ github.event.client_payload.pr_head_sha }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Run compose setup
|
||||
run: |
|
||||
echo "Patching docker-compose.yml..."
|
||||
# change image to localbuild using yq
|
||||
yq eval 'del(.services.server.image)' -i packages/twenty-docker/docker-compose.yml
|
||||
yq eval '.services.server.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
|
||||
yq eval '.services.server.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
|
||||
yq eval '.services.server.build.target = "twenty"' -i packages/twenty-docker/docker-compose.yml
|
||||
|
||||
yq eval 'del(.services.worker.image)' -i packages/twenty-docker/docker-compose.yml
|
||||
yq eval '.services.worker.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
|
||||
yq eval '.services.worker.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
|
||||
yq eval '.services.worker.build.target = "twenty"' -i packages/twenty-docker/docker-compose.yml
|
||||
|
||||
echo "Adding SIGN_IN_PREFILLED environment variable to server service..."
|
||||
yq eval '.services.server.environment.SIGN_IN_PREFILLED = "${SIGN_IN_PREFILLED}"' -i packages/twenty-docker/docker-compose.yml
|
||||
|
||||
echo "Setting up .env file..."
|
||||
cp packages/twenty-docker/.env.example packages/twenty-docker/.env
|
||||
|
||||
echo "Generating secrets..."
|
||||
echo "" >> packages/twenty-docker/.env
|
||||
echo "# === Randomly generated secrets ===" >> packages/twenty-docker/.env
|
||||
echo "APP_SECRET=$(openssl rand -base64 32)" >> packages/twenty-docker/.env
|
||||
echo "PG_DATABASE_PASSWORD=$(openssl rand -hex 16)" >> packages/twenty-docker/.env
|
||||
echo "SIGN_IN_PREFILLED=true" >> packages/twenty-docker/.env
|
||||
echo "Docker compose build..."
|
||||
cd packages/twenty-docker/
|
||||
docker compose build
|
||||
working-directory: ./
|
||||
|
||||
- name: Create Tunnel
|
||||
id: expose-tunnel
|
||||
env:
|
||||
CLOUDFLARED_VERSION: '2026.3.0'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Install cloudflared (pinned for reproducibility)
|
||||
sudo curl -fsSL -o /usr/local/bin/cloudflared \
|
||||
"https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-amd64"
|
||||
sudo chmod +x /usr/local/bin/cloudflared
|
||||
cloudflared --version
|
||||
|
||||
# Start an account-less "quick tunnel" pointing at the server container.
|
||||
# Cloudflare prints the assigned https://*.trycloudflare.com URL into the log.
|
||||
log_file="$RUNNER_TEMP/cloudflared.log"
|
||||
: > "$log_file"
|
||||
|
||||
cloudflared tunnel \
|
||||
--url http://localhost:3000 \
|
||||
--no-autoupdate \
|
||||
--logfile "$log_file" \
|
||||
--loglevel info \
|
||||
> "$RUNNER_TEMP/cloudflared.stdout" 2>&1 &
|
||||
|
||||
pid=$!
|
||||
echo "$pid" > "$RUNNER_TEMP/cloudflared.pid"
|
||||
echo "cloudflared PID: $pid"
|
||||
|
||||
# Wait up to 2 minutes for the URL to appear; fail fast if cloudflared exits.
|
||||
url=''
|
||||
for _ in $(seq 1 60); do
|
||||
url=$(grep -oE 'https://[a-zA-Z0-9-]+\.trycloudflare\.com' "$log_file" 2>/dev/null | head -n1 || true)
|
||||
[ -n "$url" ] && break
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
echo "cloudflared exited before producing a URL"
|
||||
cat "$log_file" || true
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ -z "$url" ]; then
|
||||
echo "Timed out waiting for tunnel URL"
|
||||
cat "$log_file" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Tunnel URL: $url"
|
||||
echo "tunnel-url=$url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Start services with correct SERVER_URL
|
||||
env:
|
||||
TUNNEL_URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}
|
||||
run: |
|
||||
cd packages/twenty-docker/
|
||||
|
||||
echo "Setting SERVER_URL to $TUNNEL_URL"
|
||||
sed -i '/SERVER_URL=/d' .env
|
||||
echo "" >> .env
|
||||
echo "SERVER_URL=$TUNNEL_URL" >> .env
|
||||
|
||||
# Start the services
|
||||
echo "Docker compose up..."
|
||||
docker compose up -d || {
|
||||
echo "Docker compose failed to start"
|
||||
docker compose logs
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "Waiting for services to be ready..."
|
||||
count=0
|
||||
while [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-db-1) = "healthy" ] || [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-server-1) = "healthy" ]; do
|
||||
sleep 5
|
||||
count=$((count+1))
|
||||
if [ $count -gt 60 ]; then
|
||||
echo "Timeout waiting for services to be ready"
|
||||
docker compose logs
|
||||
exit 1
|
||||
fi
|
||||
echo "Still waiting for services... ($count/60)"
|
||||
done
|
||||
|
||||
echo "All services are up and running!"
|
||||
working-directory: ./
|
||||
|
||||
- name: Seed Dev Workspace
|
||||
run: |
|
||||
cd packages/twenty-docker/
|
||||
echo "Seeding light dev workspace (Apple only)..."
|
||||
if ! docker compose exec -T server yarn command:prod workspace:seed:dev --light; then
|
||||
echo "❌ Seeding light dev workspace failed. Dumping server logs..."
|
||||
docker compose logs server
|
||||
exit 1
|
||||
fi
|
||||
working-directory: ./
|
||||
|
||||
- name: Output tunnel URL
|
||||
env:
|
||||
TUNNEL_URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}
|
||||
run: |
|
||||
echo "✅ Preview Environment Ready!"
|
||||
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: Upload tunnel URL artifact
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
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)
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f "$RUNNER_TEMP/cloudflared.pid" ]; then
|
||||
kill "$(cat "$RUNNER_TEMP/cloudflared.pid")" 2>/dev/null || true
|
||||
fi
|
||||
cd packages/twenty-docker/
|
||||
docker compose down -v
|
||||
working-directory: ./
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "2.9.0",
|
||||
"version": "2.7.0",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
|
||||
@@ -80,8 +80,8 @@ export async function teardown() {
|
||||
const uninstallResult = await appUninstall({ appPath: APP_PATH });
|
||||
|
||||
if (!uninstallResult.success) {
|
||||
throw new Error(
|
||||
`App uninstall failed during teardown: ${JSON.stringify(uninstallResult.error, null, 2)}`,
|
||||
console.warn(
|
||||
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SystemPermissionFlag, defineRole } from 'twenty-sdk/define';
|
||||
import { PermissionFlag, defineRole } from 'twenty-sdk/define';
|
||||
import {
|
||||
CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
@@ -36,5 +36,5 @@ export default defineRole({
|
||||
canUpdateFieldValue: true,
|
||||
},
|
||||
],
|
||||
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.APPLICATIONS],
|
||||
permissionFlags: [PermissionFlag.APPLICATIONS],
|
||||
});
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { defineIndex } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
} from '../objects/post-card.object';
|
||||
|
||||
export default defineIndex({
|
||||
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff0',
|
||||
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff1',
|
||||
fieldUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SystemPermissionFlag, defineRole } from 'twenty-sdk/define';
|
||||
import { PermissionFlag, defineRole } from 'twenty-sdk/define';
|
||||
import {
|
||||
CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
@@ -36,5 +36,5 @@ export default defineRole({
|
||||
canUpdateFieldValue: false,
|
||||
},
|
||||
],
|
||||
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.APPLICATIONS],
|
||||
permissionFlags: [PermissionFlag.APPLICATIONS],
|
||||
});
|
||||
|
||||
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-partners",
|
||||
"version": "0.3.1",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
@@ -16,13 +16,8 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"seed": "tsx src/scripts/seed.ts",
|
||||
"seed:prod": "ENV_FILE=.env.prod tsx src/scripts/seed.ts",
|
||||
"purge": "tsx src/scripts/purge-soft-deleted.ts",
|
||||
"purge:prod": "ENV_FILE=.env.prod tsx src/scripts/purge-soft-deleted.ts",
|
||||
"import:dryrun": "tsx src/scripts/import-from-tft.ts",
|
||||
"import:dryrun:prod": "ENV_FILE=.env.prod tsx src/scripts/import-from-tft.ts",
|
||||
"import:apply": "IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts",
|
||||
"import:apply:prod": "ENV_FILE=.env.prod IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts"
|
||||
"import:apply": "IMPORT_APPLY=1 tsx src/scripts/import-from-tft.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"twenty-client-sdk": "2.4.0",
|
||||
|
||||
+3
-3
@@ -3,7 +3,7 @@ export const APP_DESCRIPTION = '';
|
||||
export const APPLICATION_UNIVERSAL_IDENTIFIER = 'e662fc1f-02c1-41ff-b8ba-c95a447b3965';
|
||||
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = 'ee18c3f3-ebe7-4c56-ad6d-aad555cc32db';
|
||||
export const PARTNER_OBJECT_UNIVERSAL_IDENTIFIER = '39101b39-1c16-4148-9e82-45dc271bb90d';
|
||||
export const PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER = '65172140-d377-41c1-a2ae-190e96fb79dd';
|
||||
export const PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER = '65172140-d377-41c1-a2ae-190e96fb79dd';
|
||||
export const ALL_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER = '379b11d5-44d5-476b-ba7d-31d5f515c9b4';
|
||||
export const ALL_MATCHED_DEALS_VIEW_UNIVERSAL_IDENTIFIER = '7a34da39-a8e1-44c7-88b4-91ceaa064920';
|
||||
export const PARTNERS_NAV_UNIVERSAL_IDENTIFIER = '3fe15ab5-e38b-4914-af17-2270b210aeb2';
|
||||
@@ -35,7 +35,7 @@ export const ALL_OPPORTUNITIES_NAV_UNIVERSAL_IDENTIFIER = '37944f52-cbe5-4814-a1
|
||||
// Partner views + nav (harmonization)
|
||||
export const PARTNER_APPLICATIONS_VIEW_UNIVERSAL_IDENTIFIER = 'b57a84ed-d7c1-420d-b0eb-348db0dac612';
|
||||
export const VALIDATED_PARTNERS_VIEW_UNIVERSAL_IDENTIFIER = '13cca6a7-b9f1-4103-b011-ea2e39430899';
|
||||
export const PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER = 'd9db705c-795a-4a14-b891-6201149510b3';
|
||||
export const PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER = 'd9db705c-795a-4a14-b891-6201149510b3';
|
||||
export const PARTNER_APPLICATIONS_NAV_UNIVERSAL_IDENTIFIER = '13e2334a-6b1e-4080-8c74-d11109990cc1';
|
||||
export const VALIDATED_PARTNERS_NAV_UNIVERSAL_IDENTIFIER = '6aed30c6-d80f-4ac6-aab0-db5bc59e5c4b';
|
||||
export const PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER = '3543723d-80c1-466a-ac35-86f7b284917b';
|
||||
export const PARTNER_QUOTES_NAV_UNIVERSAL_IDENTIFIER = '3543723d-80c1-466a-ac35-86f7b284917b';
|
||||
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
|
||||
|
||||
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export const PARTNER_CONTENT_CUSTOMER_COMPANY_FIELD_ID = 'd448f6be-533f-4c43-a70f-55dc361dcdd7';
|
||||
export const PARTNER_CONTENTS_ON_COMPANY_FIELD_ID = '941e7707-1b3d-47d4-b13a-45c6628c1b3d';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PARTNER_CONTENT_CUSTOMER_COMPANY_FIELD_ID,
|
||||
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'customerCompany',
|
||||
label: 'Customer Company',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
|
||||
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENTS_ON_COMPANY_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'customerCompanyId',
|
||||
},
|
||||
});
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
|
||||
|
||||
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export const PARTNER_CONTENT_CUSTOMER_PERSON_FIELD_ID = 'f17547be-76aa-4231-bd1a-3f57bb1ae323';
|
||||
export const PARTNER_CONTENTS_ON_PERSON_FIELD_ID = 'fae42f4e-b054-4267-a761-81a6792f7c12';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PARTNER_CONTENT_CUSTOMER_PERSON_FIELD_ID,
|
||||
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'customerPerson',
|
||||
label: 'Customer Person',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
|
||||
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENTS_ON_PERSON_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'customerPersonId',
|
||||
},
|
||||
});
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
import { FieldType, OnDeleteAction, RelationType, defineField } from 'twenty-sdk/define';
|
||||
|
||||
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export const PARTNER_CONTENT_PARTNER_FIELD_ID = '1774738e-96a1-43e2-aca4-d3c7cc794c50';
|
||||
export const PARTNER_CONTENTS_ON_PARTNER_FIELD_ID = 'ac7995f1-7ccf-4607-827b-0788eeacaee0';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PARTNER_CONTENT_PARTNER_FIELD_ID,
|
||||
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'partner',
|
||||
label: 'Partner',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENTS_ON_PARTNER_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'partnerId',
|
||||
},
|
||||
});
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
|
||||
|
||||
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { PARTNER_CONTENTS_ON_COMPANY_FIELD_ID, PARTNER_CONTENT_CUSTOMER_COMPANY_FIELD_ID } from './partner-content-customer-company.field';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PARTNER_CONTENTS_ON_COMPANY_FIELD_ID,
|
||||
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.company.universalIdentifier,
|
||||
type: FieldType.RELATION,
|
||||
name: 'partnerContents',
|
||||
label: 'Partner Content',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENT_CUSTOMER_COMPANY_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
});
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
import { FieldType, RelationType, defineField } from 'twenty-sdk/define';
|
||||
|
||||
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { PARTNER_CONTENTS_ON_PARTNER_FIELD_ID, PARTNER_CONTENT_PARTNER_FIELD_ID } from './partner-content-partner.field';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PARTNER_CONTENTS_ON_PARTNER_FIELD_ID,
|
||||
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'partnerContents',
|
||||
label: 'Partner Content',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENT_PARTNER_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
});
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
|
||||
|
||||
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { PARTNER_CONTENTS_ON_PERSON_FIELD_ID, PARTNER_CONTENT_CUSTOMER_PERSON_FIELD_ID } from './partner-content-customer-person.field';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PARTNER_CONTENTS_ON_PERSON_FIELD_ID,
|
||||
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
|
||||
type: FieldType.RELATION,
|
||||
name: 'partnerContents',
|
||||
label: 'Partner Content',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier: PARTNER_CONTENT_CUSTOMER_PERSON_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { FieldType, OnDeleteAction, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
|
||||
|
||||
import { PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export const PARTNER_QUOTE_OPPORTUNITY_FIELD_ID = '36684f3e-f01a-4270-8f34-78966747dd64';
|
||||
export const PARTNER_QUOTES_ON_OPPORTUNITY_FIELD_ID = '4857f339-a051-4cdb-bd8a-6219b933d1ce';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PARTNER_QUOTE_OPPORTUNITY_FIELD_ID,
|
||||
objectUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'opportunity',
|
||||
label: 'Opportunity',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
|
||||
relationTargetFieldMetadataUniversalIdentifier: PARTNER_QUOTES_ON_OPPORTUNITY_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'opportunityId',
|
||||
},
|
||||
});
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { FieldType, OnDeleteAction, RelationType, defineField } from 'twenty-sdk/define';
|
||||
|
||||
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export const PARTNER_QUOTE_PARTNER_FIELD_ID = '1774738e-96a1-43e2-aca4-d3c7cc794c50';
|
||||
export const PARTNER_QUOTES_ON_PARTNER_FIELD_ID = 'ac7995f1-7ccf-4607-827b-0788eeacaee0';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PARTNER_QUOTE_PARTNER_FIELD_ID,
|
||||
objectUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'partner',
|
||||
label: 'Partner',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier: PARTNER_QUOTES_ON_PARTNER_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'partnerId',
|
||||
},
|
||||
});
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { FieldType, RelationType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, defineField } from 'twenty-sdk/define';
|
||||
|
||||
import { PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { PARTNER_QUOTES_ON_OPPORTUNITY_FIELD_ID, PARTNER_QUOTE_OPPORTUNITY_FIELD_ID } from './partner-quote-opportunity.field';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PARTNER_QUOTES_ON_OPPORTUNITY_FIELD_ID,
|
||||
objectUniversalIdentifier: STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.opportunity.universalIdentifier,
|
||||
type: FieldType.RELATION,
|
||||
name: 'partnerQuotes',
|
||||
label: 'Partner Quotes',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier: PARTNER_QUOTE_OPPORTUNITY_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
});
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { FieldType, RelationType, defineField } from 'twenty-sdk/define';
|
||||
|
||||
import { PARTNER_OBJECT_UNIVERSAL_IDENTIFIER, PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { PARTNER_QUOTES_ON_PARTNER_FIELD_ID, PARTNER_QUOTE_PARTNER_FIELD_ID } from './partner-quote-partner.field';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: PARTNER_QUOTES_ON_PARTNER_FIELD_ID,
|
||||
objectUniversalIdentifier: PARTNER_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'partnerQuotes',
|
||||
label: 'Partner Quotes',
|
||||
isNullable: true,
|
||||
relationTargetObjectMetadataUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier: PARTNER_QUOTE_PARTNER_FIELD_ID,
|
||||
universalSettings: {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
},
|
||||
});
|
||||
+5
-5
@@ -1,15 +1,15 @@
|
||||
import { NavigationMenuItemType, defineNavigationMenuItem } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER,
|
||||
PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
PARTNER_QUOTES_NAV_UNIVERSAL_IDENTIFIER,
|
||||
PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineNavigationMenuItem({
|
||||
universalIdentifier: PARTNER_CONTENT_NAV_UNIVERSAL_IDENTIFIER,
|
||||
universalIdentifier: PARTNER_QUOTES_NAV_UNIVERSAL_IDENTIFIER,
|
||||
type: NavigationMenuItemType.VIEW,
|
||||
icon: 'IconQuote',
|
||||
icon: 'IconFileDollar',
|
||||
position: 3,
|
||||
folderUniversalIdentifier: '857be3b5-82c6-45f7-b546-e20a8a97be8d',
|
||||
viewUniversalIdentifier: PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
viewUniversalIdentifier: PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
+16
-30
@@ -1,15 +1,15 @@
|
||||
import { FieldType, defineObject } from 'twenty-sdk/define';
|
||||
|
||||
import { PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineObject({
|
||||
universalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: 'partnerContent',
|
||||
namePlural: 'partnerContents',
|
||||
labelSingular: 'Partner Content',
|
||||
labelPlural: 'Partner Content',
|
||||
description: 'Marketing content involving a partner: quotes, case studies, logos',
|
||||
icon: 'IconQuote',
|
||||
universalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: 'partnerQuote',
|
||||
namePlural: 'partnerQuotes',
|
||||
labelSingular: 'Partner Quote',
|
||||
labelPlural: 'Partner Quotes',
|
||||
description: 'A quote a partner submitted for a customer deal',
|
||||
icon: 'IconFileDollar',
|
||||
isSearchable: true,
|
||||
labelIdentifierFieldMetadataUniversalIdentifier: '9e688624-83d2-4715-8b18-80492a6de2b6',
|
||||
fields: [
|
||||
@@ -21,20 +21,6 @@ export default defineObject({
|
||||
icon: 'IconTag',
|
||||
defaultValue: "''",
|
||||
},
|
||||
{
|
||||
universalIdentifier: '1d926e6e-6ac1-4d60-ab3d-a73114005692',
|
||||
type: FieldType.MULTI_SELECT,
|
||||
name: 'contentType',
|
||||
label: 'Content Type',
|
||||
icon: 'IconCategory',
|
||||
isNullable: true,
|
||||
options: [
|
||||
{ id: '07f85e5b-0d70-416b-9884-256e469ed532', value: 'CUSTOMER_QUOTE', label: 'Customer quote', position: 0, color: 'blue' },
|
||||
{ id: '108b2358-d04d-4fdc-83df-a5978d39f66f', value: 'CASE_STUDY', label: 'Case study', position: 1, color: 'green' },
|
||||
{ id: 'eb45f371-f93c-4c45-9c8a-f29e1a58b7e4', value: 'PARTNER_QUOTE', label: 'Partner quote', position: 2, color: 'orange' },
|
||||
{ id: '3356c8a0-41cd-47c3-a293-5862138abc1a', value: 'LOGO', label: 'Logo', position: 3, color: 'purple' },
|
||||
],
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'a0fe09c4-c1f4-4b96-93c6-d7ec38f1166a',
|
||||
type: FieldType.SELECT,
|
||||
@@ -59,21 +45,21 @@ export default defineObject({
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'da7e9094-e2c3-47d3-924f-a1d4d3c717ed',
|
||||
type: FieldType.LINKS,
|
||||
name: 'interview',
|
||||
label: 'Interview',
|
||||
icon: 'IconMicrophone',
|
||||
universalIdentifier: '2cbe67e3-24ec-421b-bab5-50f3306c2391',
|
||||
type: FieldType.CURRENCY,
|
||||
name: 'amount',
|
||||
label: 'Amount',
|
||||
icon: 'IconCoin',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'f303369e-288c-4a48-9920-c1de0ad9a159',
|
||||
type: FieldType.FILES,
|
||||
name: 'documents',
|
||||
label: 'Documents',
|
||||
name: 'quoteFile',
|
||||
label: 'Quote File',
|
||||
icon: 'IconPaperclip',
|
||||
isNullable: true,
|
||||
universalSettings: { maxNumberOfValues: 10 },
|
||||
universalSettings: { maxNumberOfValues: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -23,7 +23,7 @@
|
||||
// tsx src/scripts/import-from-tft.ts
|
||||
|
||||
import { config } from 'dotenv';
|
||||
config({ path: process.env.ENV_FILE ?? '.env.local' });
|
||||
config({ path: '.env.local' });
|
||||
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
@@ -76,17 +76,6 @@ const PARTNER_STAGE_TO_VALIDATION: Record<string, string> = {
|
||||
REJECTED: 'REJECTED',
|
||||
};
|
||||
|
||||
// TFT person.partnerTimezone -> our Partner.region (MULTI_SELECT). TFT's value is a
|
||||
// coarse timezone band, not a geography, so each maps to every region it plausibly
|
||||
// covers. OTHER carries no signal -> no region. Region stays empty if unmapped.
|
||||
const TIMEZONE_TO_REGION: Record<string, string[]> = {
|
||||
AMERICAS: ['US', 'LATAM'],
|
||||
EMEA: ['EUROPE', 'MENA', 'AFRICA'],
|
||||
WEST_ASIA: ['MENA', 'APAC'],
|
||||
EAST_ASIA_OCEANIA: ['APAC'],
|
||||
OTHER: [],
|
||||
};
|
||||
|
||||
// Local SELECT option sets, for preflight coverage checks (a TFT value not in
|
||||
// these would fail a real write). Keep in sync with src/objects + src/fields.
|
||||
const LOCAL_OPTIONS: Record<string, Set<string>> = {
|
||||
@@ -112,30 +101,6 @@ const edges = (result: any, key: string): any[] =>
|
||||
const uniq = (values: (string | undefined | null)[]): string[] =>
|
||||
[...new Set(values.filter((v): v is string => !!v))];
|
||||
|
||||
// Normalize a domain for dedup. Twenty's company domain is a unique key but is
|
||||
// stored with an https:// prefix, while TFT values vary, so compare on a canonical
|
||||
// form (no protocol, no www, no trailing slash, lowercased).
|
||||
const normDomain = (d?: string | null): string | undefined =>
|
||||
d
|
||||
? d.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/+$/, '') || undefined
|
||||
: undefined;
|
||||
|
||||
// Distinct non-empty values across rows, for the preflight report. Flattens array
|
||||
// fields (e.g. partnerScope, typeCustom) and stringifies, so scalar and
|
||||
// multi-select fields share one path. Derived from the already-fetched source
|
||||
// rows — no per-loop bookkeeping needed.
|
||||
const distinct = <TRow>(rows: TRow[], pick: (row: TRow) => unknown): string[] =>
|
||||
[
|
||||
...new Set(
|
||||
rows.flatMap((row) => {
|
||||
const value = pick(row);
|
||||
return Array.isArray(value) ? value : value != null ? [value] : [];
|
||||
}),
|
||||
),
|
||||
]
|
||||
.map(String)
|
||||
.sort();
|
||||
|
||||
async function main() {
|
||||
console.log(`[import] mode: ${APPLY ? 'APPLY (writing to local)' : 'DRY-RUN (no writes)'}`);
|
||||
const local = new CoreApiClient({
|
||||
@@ -149,10 +114,26 @@ async function main() {
|
||||
if (APPLY && writes++ > 0) await new Promise((r) => setTimeout(r, 750));
|
||||
};
|
||||
|
||||
// Distinct TFT values seen, for the preflight coverage report.
|
||||
const seen: Record<string, Set<string>> = {
|
||||
partnerStage: new Set(),
|
||||
partnerTier: new Set(),
|
||||
partnerScope: new Set(),
|
||||
typeOfTeam: new Set(),
|
||||
oppStage: new Set(),
|
||||
hostingType: new Set(),
|
||||
subscriptionType: new Set(),
|
||||
subscriptionFrequency: new Set(),
|
||||
quoteStatus: new Set(),
|
||||
typeCustom: new Set(),
|
||||
};
|
||||
const note = (bucket: string, value?: string | null) => {
|
||||
if (value) seen[bucket].add(value);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 1. Read all three TFT datasets (raw fetch; not rate-limited by local).
|
||||
// ---------------------------------------------------------------------
|
||||
console.log('[import] fetching TFT people...');
|
||||
const tftPeople = edges(
|
||||
await tftQuery(`query {
|
||||
people(first: 500, filter: { partnerStage: { in: ["APPLICATION","POTENTIAL_PARTNER","PARTNER","FORMER_PARTNER","REJECTED"] } }) {
|
||||
@@ -162,7 +143,7 @@ async function main() {
|
||||
emails { primaryEmail }
|
||||
city jobTitle
|
||||
linkedinLink { primaryLinkUrl }
|
||||
partnerStage partnerTier partnerScope partnerTypeOfTeam partnerTimezone partnerIsAvailable partnerSkills
|
||||
partnerStage partnerTier partnerScope partnerTypeOfTeam partnerIsAvailable partnerSkills
|
||||
partnerBudgetMinimum { amountMicros currencyCode }
|
||||
partnerBudgetAverage { amountMicros currencyCode }
|
||||
company { id name domainName { primaryLinkUrl } }
|
||||
@@ -171,8 +152,7 @@ async function main() {
|
||||
}`),
|
||||
'people',
|
||||
);
|
||||
console.log('[import] fetching TFT opportunities...');
|
||||
const tftOppsAll = edges(
|
||||
const tftOpps = edges(
|
||||
await tftQuery(`query {
|
||||
opportunities(first: 500) {
|
||||
edges { node {
|
||||
@@ -186,32 +166,16 @@ async function main() {
|
||||
}`),
|
||||
'opportunities',
|
||||
);
|
||||
// Only import opportunities linked to a partner. The rest is TFT's general sales
|
||||
// pipeline (mostly LOST/IDENTIFIED) — noise for a partners app. Every partner
|
||||
// stage (INTRODUCED/WORKING) only ever appears on partner-linked opps anyway.
|
||||
const tftOpps = tftOppsAll.filter((o: any) => o.partner?.id);
|
||||
console.log(`[import] opportunities: ${tftOpps.length} partner-linked of ${tftOppsAll.length} total (skipping ${tftOppsAll.length - tftOpps.length} unlinked)`);
|
||||
console.log('[import] fetching TFT partner content...');
|
||||
const tftContent = edges(
|
||||
await tftQuery(`query {
|
||||
customerContents(first: 500) {
|
||||
edges { node {
|
||||
id name status approvalDate typeCustom
|
||||
interview { primaryLinkUrl }
|
||||
partnerPerson { id }
|
||||
customerCompany { id name domainName { primaryLinkUrl } }
|
||||
customerPerson { id }
|
||||
} }
|
||||
edges { node { id name status approvalDate typeCustom partnerPerson { id } } }
|
||||
}
|
||||
}`),
|
||||
'customerContents',
|
||||
);
|
||||
// Import all content TYPES (quotes, case studies, logos) but only records that
|
||||
// involve a partner. Customer-only content (no partnerPerson) is noise for the
|
||||
// partners app; a partner-linked case study/quote should show on the partner.
|
||||
const contentRecords = tftContent.filter((c: any) => c.partnerPerson?.id);
|
||||
console.log(`[import] TFT: ${tftPeople.length} partner-people, ${tftOpps.length} opportunities, ${tftContent.length} content records`);
|
||||
console.log(`[import] partner content: ${contentRecords.length} partner-linked of ${tftContent.length} total (skipping ${tftContent.length - contentRecords.length} customer-only)`);
|
||||
const quotes = tftContent.filter((c: any) => Array.isArray(c.typeCustom) && c.typeCustom.includes('PARTNER_QUOTE'));
|
||||
console.log(`[import] TFT: ${tftPeople.length} partner-people, ${tftOpps.length} opportunities, ${quotes.length} partner quotes`);
|
||||
|
||||
const personSlug = (p: any): string =>
|
||||
slugify([p.name?.firstName, p.name?.lastName].filter(Boolean).join(' ').trim() || 'Unknown partner') || p.id;
|
||||
@@ -219,7 +183,6 @@ async function main() {
|
||||
// ---------------------------------------------------------------------
|
||||
// 2. Batched existence lookups against local (one `in` query per object).
|
||||
// ---------------------------------------------------------------------
|
||||
console.log('[import] checking existing records in target workspace...');
|
||||
const partnerSlugs = uniq(tftPeople.map(personSlug));
|
||||
const partnerIdBySlug = new Map<string, string>(
|
||||
partnerSlugs.length
|
||||
@@ -232,33 +195,17 @@ async function main() {
|
||||
: [],
|
||||
);
|
||||
|
||||
// Fetch existing companies and index by BOTH name and normalized domain. Twenty
|
||||
// enforces uniqueness on domain, so dedup must be domain-aware: the same company
|
||||
// can arrive under different names (e.g. "Acme" vs "acme.com") across TFT people,
|
||||
// opps and content, and creating a second one collides on the domain constraint.
|
||||
// Page through ALL companies (not just the first 500): these dedupe maps must
|
||||
// be complete, or upsertCompany would create domain-colliding duplicates for
|
||||
// companies that live beyond the first page in a larger workspace.
|
||||
const existingCompanies: any[] = [];
|
||||
let companiesCursor: string | undefined;
|
||||
for (;;) {
|
||||
const page: any = await local.query({
|
||||
companies: {
|
||||
__args: { filter: {}, first: 200, ...(companiesCursor ? { after: companiesCursor } : {}) },
|
||||
edges: { node: { id: true, name: true, domainName: { primaryLinkUrl: true } } },
|
||||
pageInfo: { hasNextPage: true, endCursor: true },
|
||||
},
|
||||
} as any);
|
||||
existingCompanies.push(...edges(page, 'companies'));
|
||||
if (!page?.companies?.pageInfo?.hasNextPage) break;
|
||||
companiesCursor = page.companies.pageInfo.endCursor;
|
||||
}
|
||||
const companyIdByName = new Map<string, string>(existingCompanies.map((n: any) => [n.name, n.id]));
|
||||
const companyIdByDomain = new Map<string, string>();
|
||||
for (const c of existingCompanies) {
|
||||
const nd = normDomain(c.domainName?.primaryLinkUrl);
|
||||
if (nd && !companyIdByDomain.has(nd)) companyIdByDomain.set(nd, c.id);
|
||||
}
|
||||
const companyNames = uniq([...tftPeople.map((p: any) => p.company?.name), ...tftOpps.map((o: any) => o.company?.name)]);
|
||||
const companyIdByName = new Map<string, string>(
|
||||
companyNames.length
|
||||
? edges(
|
||||
await local.query({
|
||||
companies: { __args: { filter: { name: { in: companyNames } }, first: 500 }, edges: { node: { id: true, name: true } } },
|
||||
} as any),
|
||||
'companies',
|
||||
).map((n: any) => [n.name, n.id])
|
||||
: [],
|
||||
);
|
||||
|
||||
const oppTftIds = uniq(tftOpps.filter((o: any) => o.name).map((o: any) => o.id));
|
||||
const oppIdByTftId = new Map<string, string>(
|
||||
@@ -272,18 +219,14 @@ async function main() {
|
||||
: [],
|
||||
);
|
||||
|
||||
// Unnamed content upserts by name, so a constant fallback would make every
|
||||
// unnamed record collide on one key (collapsing them on re-run). Key the
|
||||
// fallback on the TFT id so each unnamed record stays distinct.
|
||||
const contentName = (c: any): string => c.name || `Partner content ${c.id}`;
|
||||
const contentNames = uniq(contentRecords.map(contentName));
|
||||
const contentIdByName = new Map<string, string>(
|
||||
contentNames.length
|
||||
const quoteNames = uniq(quotes.map((q: any) => q.name || 'Partner quote'));
|
||||
const quoteIdByName = new Map<string, string>(
|
||||
quoteNames.length
|
||||
? edges(
|
||||
await local.query({
|
||||
partnerContents: { __args: { filter: { name: { in: contentNames } }, first: 500 }, edges: { node: { id: true, name: true } } },
|
||||
partnerQuotes: { __args: { filter: { name: { in: quoteNames } }, first: 500 }, edges: { node: { id: true, name: true } } },
|
||||
} as any),
|
||||
'partnerContents',
|
||||
'partnerQuotes',
|
||||
).map((n: any) => [n.name, n.id])
|
||||
: [],
|
||||
);
|
||||
@@ -295,102 +238,43 @@ async function main() {
|
||||
const upsertCompany = async (name?: string, domain?: string): Promise<string | undefined> => {
|
||||
if (!name) return undefined;
|
||||
if (companyIdByName.has(name)) return companyIdByName.get(name);
|
||||
const nd = normDomain(domain);
|
||||
// Same company under a different name but same domain — reuse it.
|
||||
if (nd && companyIdByDomain.has(nd)) {
|
||||
const existingId = companyIdByDomain.get(nd) as string;
|
||||
companyIdByName.set(name, existingId);
|
||||
return existingId;
|
||||
}
|
||||
let id = `dry:company:${name}`;
|
||||
if (APPLY) {
|
||||
await pace();
|
||||
try {
|
||||
const created: any = await local.mutation({
|
||||
createCompany: {
|
||||
__args: { data: { name, ...(domain ? { domainName: { primaryLinkUrl: domain } } : {}) } },
|
||||
id: true,
|
||||
},
|
||||
} as any);
|
||||
id = created.createCompany.id;
|
||||
} catch (err) {
|
||||
// Fallback: createCompany failed, almost certainly because the domain
|
||||
// already exists (Twenty enforces a unique domain) on a company we
|
||||
// didn't index. Re-find it and reuse instead of failing the import.
|
||||
// `ilike` is a substring match — and the stored value carries an
|
||||
// https:// prefix so we can't `eq` the normalized form — so it can
|
||||
// return the wrong company ("acme.com" also matches "notacme.com" or
|
||||
// "acme.com.br"). Confirm an exact normalized-domain match before reuse.
|
||||
if (!nd) throw err;
|
||||
await pace();
|
||||
const candidates = edges(
|
||||
await local.query({
|
||||
companies: { __args: { filter: { domainName: { primaryLinkUrl: { ilike: `%${nd}%` } } }, first: 20 }, edges: { node: { id: true, domainName: { primaryLinkUrl: true } } } },
|
||||
} as any),
|
||||
'companies',
|
||||
);
|
||||
const match = candidates.find((c: any) => normDomain(c.domainName?.primaryLinkUrl) === nd);
|
||||
if (!match?.id) throw err;
|
||||
id = match.id;
|
||||
console.log(`[import] company "${name}" reused existing by domain ${nd}`);
|
||||
}
|
||||
const created: any = await local.mutation({
|
||||
createCompany: {
|
||||
__args: { data: { name, ...(domain ? { domainName: { primaryLinkUrl: domain } } : {}) } },
|
||||
id: true,
|
||||
},
|
||||
} as any);
|
||||
id = created.createCompany.id;
|
||||
}
|
||||
companyIdByName.set(name, id);
|
||||
if (nd) companyIdByDomain.set(nd, id);
|
||||
return id;
|
||||
};
|
||||
|
||||
const budgetCurrency = (amount: any) =>
|
||||
amount?.amountMicros != null ? { amountMicros: amount.amountMicros, currencyCode: amount.currencyCode ?? 'USD' } : undefined;
|
||||
|
||||
// Generic create/update dispatch shared by partners, opportunities and content.
|
||||
// Owns pacing + APPLY-gating + the create-vs-update branch, so each loop below
|
||||
// only builds its `data`. genql keys a mutation by the object name, so the
|
||||
// create<Object>/update<Object> names are derived from one argument. Companies
|
||||
// keep their own upsert (above) because of the domain-collision fallback.
|
||||
// Returns the row id: the real id on APPLY, a synthetic dry id otherwise so the
|
||||
// relation mapping in dry-run still resolves to a stable placeholder.
|
||||
const upsert = async (
|
||||
object: 'Partner' | 'Opportunity' | 'PartnerContent',
|
||||
existingId: string | undefined,
|
||||
data: Record<string, unknown>,
|
||||
dryKey: string,
|
||||
): Promise<string> => {
|
||||
if (!APPLY) return existingId ?? `dry:${object}:${dryKey}`;
|
||||
await pace();
|
||||
if (existingId) {
|
||||
await local.mutation({ [`update${object}`]: { __args: { id: existingId, data }, id: true } } as any);
|
||||
return existingId;
|
||||
}
|
||||
const created: any = await local.mutation({ [`create${object}`]: { __args: { data }, id: true } } as any);
|
||||
return created[`create${object}`].id;
|
||||
};
|
||||
|
||||
// -- Partners (upsert by slug) --
|
||||
console.log(`[import] upserting ${tftPeople.length} partners...`);
|
||||
const localPartnerIdByTftPersonId = new Map<string, string>();
|
||||
let partnersCreated = 0;
|
||||
let partnersUpdated = 0;
|
||||
let partnersDone = 0;
|
||||
for (const p of tftPeople) {
|
||||
note('partnerStage', p.partnerStage);
|
||||
note('partnerTier', p.partnerTier);
|
||||
(Array.isArray(p.partnerScope) ? p.partnerScope : []).forEach((s: string) => note('partnerScope', s));
|
||||
note('typeOfTeam', p.partnerTypeOfTeam);
|
||||
|
||||
const slug = personSlug(p);
|
||||
const companyId = await upsertCompany(p.company?.name, p.company?.domainName?.primaryLinkUrl);
|
||||
// Timezone band -> geographic region(s). Unmapped/OTHER -> no region.
|
||||
const region = TIMEZONE_TO_REGION[p.partnerTimezone] ?? [];
|
||||
// A partner scoped for hosting is, by definition, a self-host expert.
|
||||
const scope = Array.isArray(p.partnerScope) ? p.partnerScope : [];
|
||||
const deploymentExpertise = scope.includes('HOSTING_ENVIRONMENT') ? ['SELF_HOST'] : [];
|
||||
const data: Record<string, unknown> = {
|
||||
name: [p.name?.firstName, p.name?.lastName].filter(Boolean).join(' ').trim() || 'Unknown partner',
|
||||
slug,
|
||||
validationStage: PARTNER_STAGE_TO_VALIDATION[p.partnerStage] ?? 'APPLICATION',
|
||||
availability: p.partnerIsAvailable ? 'AVAILABLE' : 'UNAVAILABLE',
|
||||
// TFT has no language data; default everyone to English.
|
||||
languagesSpoken: ['ENGLISH'],
|
||||
...(p.partnerTier ? { partnerTier: p.partnerTier } : {}),
|
||||
...(scope.length ? { partnerScope: scope } : {}),
|
||||
...(region.length ? { region } : {}),
|
||||
...(deploymentExpertise.length ? { deploymentExpertise } : {}),
|
||||
...(Array.isArray(p.partnerScope) && p.partnerScope.length ? { partnerScope: p.partnerScope } : {}),
|
||||
...(p.partnerTypeOfTeam ? { typeOfTeam: p.partnerTypeOfTeam } : {}),
|
||||
...(Array.isArray(p.partnerSkills) && p.partnerSkills.length ? { skills: p.partnerSkills } : {}),
|
||||
...(p.city ? { city: p.city } : {}),
|
||||
@@ -400,29 +284,39 @@ async function main() {
|
||||
...(companyId && APPLY ? { companyId } : {}),
|
||||
};
|
||||
|
||||
const existingId = partnerIdBySlug.get(slug);
|
||||
const partnerId = await upsert('Partner', existingId, data, slug);
|
||||
if (existingId) {
|
||||
let partnerId = partnerIdBySlug.get(slug);
|
||||
if (partnerId) {
|
||||
if (APPLY) {
|
||||
await pace();
|
||||
await local.mutation({ updatePartner: { __args: { id: partnerId, data }, id: true } } as any);
|
||||
}
|
||||
partnersUpdated++;
|
||||
} else {
|
||||
partnerIdBySlug.set(slug, partnerId);
|
||||
if (APPLY) {
|
||||
await pace();
|
||||
const created: any = await local.mutation({ createPartner: { __args: { data }, id: true } } as any);
|
||||
partnerId = created.createPartner.id;
|
||||
} else {
|
||||
partnerId = `dry:partner:${slug}`;
|
||||
}
|
||||
partnerIdBySlug.set(slug, partnerId as string);
|
||||
partnersCreated++;
|
||||
}
|
||||
localPartnerIdByTftPersonId.set(p.id, partnerId);
|
||||
partnersDone++;
|
||||
if (partnersDone % 10 === 0 || partnersDone === tftPeople.length)
|
||||
console.log(`[import] partners ${partnersDone}/${tftPeople.length} (created=${partnersCreated} updated=${partnersUpdated})`);
|
||||
localPartnerIdByTftPersonId.set(p.id, partnerId as string);
|
||||
}
|
||||
console.log(`[import] partners done: created=${partnersCreated} updated=${partnersUpdated}`);
|
||||
console.log(`[import] partners created=${partnersCreated} updated=${partnersUpdated}`);
|
||||
|
||||
// -- Opportunities (upsert by tftOpportunityId) --
|
||||
console.log(`[import] upserting ${tftOpps.length} opportunities...`);
|
||||
let oppsCreated = 0;
|
||||
let oppsUpdated = 0;
|
||||
let oppsPartnerLinked = 0;
|
||||
let oppsDone = 0;
|
||||
for (const o of tftOpps) {
|
||||
if (!o.name) continue;
|
||||
note('oppStage', o.stage);
|
||||
note('hostingType', o.hostingType);
|
||||
note('subscriptionType', o.subscriptionType);
|
||||
note('subscriptionFrequency', o.subscriptionFrequence);
|
||||
|
||||
const companyId = await upsertCompany(o.company?.name, o.company?.domainName?.primaryLinkUrl);
|
||||
const partnerId = o.partner?.id ? localPartnerIdByTftPersonId.get(o.partner.id) : undefined;
|
||||
if (partnerId) oppsPartnerLinked++;
|
||||
@@ -444,44 +338,58 @@ async function main() {
|
||||
};
|
||||
|
||||
const existingId = oppIdByTftId.get(o.id);
|
||||
await upsert('Opportunity', existingId, data, o.id);
|
||||
if (existingId) oppsUpdated++;
|
||||
else oppsCreated++;
|
||||
oppsDone++;
|
||||
if (oppsDone % 20 === 0 || oppsDone === tftOpps.length)
|
||||
console.log(`[import] opportunities ${oppsDone}/${tftOpps.length} (created=${oppsCreated} updated=${oppsUpdated})`);
|
||||
if (existingId) {
|
||||
if (APPLY) {
|
||||
await pace();
|
||||
await local.mutation({ updateOpportunity: { __args: { id: existingId, data }, id: true } } as any);
|
||||
}
|
||||
oppsUpdated++;
|
||||
} else {
|
||||
if (APPLY) {
|
||||
await pace();
|
||||
await local.mutation({ createOpportunity: { __args: { data }, id: true } } as any);
|
||||
}
|
||||
oppsCreated++;
|
||||
}
|
||||
}
|
||||
console.log(`[import] opportunities done: created=${oppsCreated} updated=${oppsUpdated} (partner-linked=${oppsPartnerLinked})`);
|
||||
console.log(`[import] opportunities created=${oppsCreated} updated=${oppsUpdated} (partner-linked=${oppsPartnerLinked})`);
|
||||
|
||||
// -- Partner content (upsert by name) --
|
||||
console.log(`[import] upserting ${contentRecords.length} content records...`);
|
||||
let contentCreated = 0;
|
||||
let contentUpdated = 0;
|
||||
for (const c of contentRecords) {
|
||||
const name = contentName(c);
|
||||
const partnerId = c.partnerPerson?.id ? localPartnerIdByTftPersonId.get(c.partnerPerson.id) : undefined;
|
||||
const customerCompanyId = await upsertCompany(c.customerCompany?.name, c.customerCompany?.domainName?.primaryLinkUrl);
|
||||
// -- Partner quotes (upsert by name) --
|
||||
let quotesCreated = 0;
|
||||
let quotesUpdated = 0;
|
||||
tftContent.forEach((c: any) => (Array.isArray(c.typeCustom) ? c.typeCustom : []).forEach((t: string) => note('typeCustom', t)));
|
||||
for (const q of quotes) {
|
||||
note('quoteStatus', q.status);
|
||||
const name = q.name || 'Partner quote';
|
||||
const partnerId = q.partnerPerson?.id ? localPartnerIdByTftPersonId.get(q.partnerPerson.id) : undefined;
|
||||
const data: Record<string, unknown> = {
|
||||
name,
|
||||
...(Array.isArray(c.typeCustom) && c.typeCustom.length ? { contentType: c.typeCustom } : {}),
|
||||
...(c.status ? { status: c.status } : {}),
|
||||
...(c.approvalDate ? { approvalDate: c.approvalDate } : {}),
|
||||
...(c.interview?.primaryLinkUrl ? { interview: { primaryLinkUrl: c.interview.primaryLinkUrl } } : {}),
|
||||
...(q.status ? { status: q.status } : {}),
|
||||
...(q.approvalDate ? { approvalDate: q.approvalDate } : {}),
|
||||
...(partnerId && APPLY ? { partnerId } : {}),
|
||||
...(customerCompanyId && APPLY ? { customerCompanyId } : {}),
|
||||
};
|
||||
const existingId = contentIdByName.get(name);
|
||||
await upsert('PartnerContent', existingId, data, name);
|
||||
if (existingId) contentUpdated++;
|
||||
else contentCreated++;
|
||||
const existingId = quoteIdByName.get(name);
|
||||
if (existingId) {
|
||||
if (APPLY) {
|
||||
await pace();
|
||||
await local.mutation({ updatePartnerQuote: { __args: { id: existingId, data }, id: true } } as any);
|
||||
}
|
||||
quotesUpdated++;
|
||||
} else {
|
||||
if (APPLY) {
|
||||
await pace();
|
||||
await local.mutation({ createPartnerQuote: { __args: { data }, id: true } } as any);
|
||||
}
|
||||
quotesCreated++;
|
||||
}
|
||||
}
|
||||
console.log(`[import] partner content created=${contentCreated} updated=${contentUpdated}`);
|
||||
console.log(`[import] partner quotes created=${quotesCreated} updated=${quotesUpdated}`);
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// 4. Preflight: distinct TFT values vs local option coverage. Derived
|
||||
// directly from the fetched source rows (no per-loop bookkeeping).
|
||||
// 4. Preflight: distinct TFT values vs local option coverage.
|
||||
// ---------------------------------------------------------------------
|
||||
const report = (label: string, values: string[], optionKey?: string) => {
|
||||
const report = (label: string, bucket: string, optionKey?: string) => {
|
||||
const values = [...seen[bucket]].sort();
|
||||
const allowed = optionKey ? LOCAL_OPTIONS[optionKey] : undefined;
|
||||
const uncovered = allowed ? values.filter((v) => !allowed.has(v)) : [];
|
||||
console.log(
|
||||
@@ -489,27 +397,21 @@ async function main() {
|
||||
(uncovered.length ? ` ⚠️ NOT IN LOCAL OPTIONS: ${uncovered.join(', ')}` : ''),
|
||||
);
|
||||
};
|
||||
const partnerStages = distinct(tftPeople, (p: any) => p.partnerStage);
|
||||
const oppStages = distinct(tftOpps, (o: any) => o.stage);
|
||||
const timezones = distinct(tftPeople, (p: any) => p.partnerTimezone);
|
||||
console.log('--- preflight: distinct TFT values seen ---');
|
||||
report('partnerStage (-> validationStage map)', partnerStages);
|
||||
report('partnerTier', distinct(tftPeople, (p: any) => p.partnerTier), 'partnerTier');
|
||||
report('partnerScope', distinct(tftPeople, (p: any) => p.partnerScope), 'partnerScope');
|
||||
report('typeOfTeam', distinct(tftPeople, (p: any) => p.partnerTypeOfTeam), 'typeOfTeam');
|
||||
report('partnerTimezone (-> region map)', timezones);
|
||||
report('opp stage (-> matchStatus map)', oppStages);
|
||||
report('hostingType', distinct(tftOpps, (o: any) => o.hostingType), 'hostingType');
|
||||
report('subscriptionType', distinct(tftOpps, (o: any) => o.subscriptionType), 'subscriptionType');
|
||||
report('subscriptionFrequency', distinct(tftOpps, (o: any) => o.subscriptionFrequence), 'subscriptionFrequency');
|
||||
report('quote status', distinct(contentRecords, (c: any) => c.status), 'quoteStatus');
|
||||
report('customerContent typeCustom', distinct(contentRecords, (c: any) => c.typeCustom));
|
||||
const unmappedStages = partnerStages.filter((s) => !(s in PARTNER_STAGE_TO_VALIDATION));
|
||||
const unmappedOpps = oppStages.filter((s) => !(s in STAGE_TO_MATCH_STATUS));
|
||||
const unmappedTz = timezones.filter((t) => !(t in TIMEZONE_TO_REGION));
|
||||
report('partnerStage (-> validationStage map)', 'partnerStage');
|
||||
report('partnerTier', 'partnerTier', 'partnerTier');
|
||||
report('partnerScope', 'partnerScope', 'partnerScope');
|
||||
report('typeOfTeam', 'typeOfTeam', 'typeOfTeam');
|
||||
report('opp stage (-> matchStatus map)', 'oppStage');
|
||||
report('hostingType', 'hostingType', 'hostingType');
|
||||
report('subscriptionType', 'subscriptionType', 'subscriptionType');
|
||||
report('subscriptionFrequency', 'subscriptionFrequency', 'subscriptionFrequency');
|
||||
report('quote status', 'quoteStatus', 'quoteStatus');
|
||||
report('customerContent typeCustom', 'typeCustom');
|
||||
const unmappedStages = [...seen.partnerStage].filter((s) => !(s in PARTNER_STAGE_TO_VALIDATION));
|
||||
const unmappedOpps = [...seen.oppStage].filter((s) => !(s in STAGE_TO_MATCH_STATUS));
|
||||
if (unmappedStages.length) console.log(`[preflight] ⚠️ partnerStage not mapped: ${unmappedStages.join(', ')}`);
|
||||
if (unmappedOpps.length) console.log(`[preflight] ⚠️ opp stage not mapped: ${unmappedOpps.join(', ')}`);
|
||||
if (unmappedTz.length) console.log(`[preflight] ⚠️ partnerTimezone not mapped: ${unmappedTz.join(', ')}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
// Hard-destroy soft-deleted records that block re-imports.
|
||||
//
|
||||
// Twenty SOFT-deletes (sets deletedAt); the row stays in the DB and keeps holding
|
||||
// unique constraints (e.g. company domain, partner slug). But normal queries —
|
||||
// including the import's existence checks — exclude soft-deleted rows. So after a
|
||||
// UI "delete" or a partial import that got rolled back, re-running the import hits
|
||||
// "A duplicate entry was detected" on records it cannot see. This purges those
|
||||
// ghosts permanently so idempotent upserts work again.
|
||||
//
|
||||
// Only touches soft-deleted rows (deletedAt IS NOT NULL); active/default data is
|
||||
// left untouched. One bulk destroy per object, so it is not rate-limited.
|
||||
//
|
||||
// yarn purge # against .env.local
|
||||
// yarn purge:prod # against .env.prod
|
||||
//
|
||||
import { config } from 'dotenv';
|
||||
config({ path: process.env.ENV_FILE ?? '.env.local' });
|
||||
|
||||
const requireEnv = (name: string): string => {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`Missing ${name} env var`);
|
||||
return value;
|
||||
};
|
||||
|
||||
// Objects the import writes to. partners + partnerContents are app custom objects;
|
||||
// companies + opportunities are standard but populated by the import.
|
||||
const OBJECTS = ['companies', 'partners', 'opportunities', 'partnerContents'] as const;
|
||||
|
||||
const gql = async (url: string, key: string, query: string): Promise<any> => {
|
||||
const response = await fetch(`${url.replace(/\/$/, '')}/graphql`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
const json: any = await response.json();
|
||||
if (json.errors?.length) throw new Error(JSON.stringify(json.errors));
|
||||
return json.data;
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const url = requireEnv('TWENTY_PARTNERS_API_URL');
|
||||
const key = requireEnv('TWENTY_PARTNERS_API_KEY');
|
||||
console.log(`[purge] target: ${url} — destroying soft-deleted rows only`);
|
||||
|
||||
for (const obj of OBJECTS) {
|
||||
// destroy<Object>s is the bulk variant; capitalise + already-plural names.
|
||||
const mutationName = `destroy${obj.charAt(0).toUpperCase()}${obj.slice(1)}`;
|
||||
const data = await gql(url, key, `mutation { ${mutationName}(filter: { deletedAt: { is: NOT_NULL } }) { id } }`);
|
||||
const destroyed = data[mutationName]?.length ?? 0;
|
||||
console.log(`[purge] ${obj}: destroyed ${destroyed} soft-deleted`);
|
||||
}
|
||||
|
||||
console.log('[purge] done');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -11,7 +11,7 @@
|
||||
// tsx src/scripts/seed.ts
|
||||
|
||||
import { config } from 'dotenv';
|
||||
config({ path: process.env.ENV_FILE ?? '.env.local' });
|
||||
config({ path: '.env.local' });
|
||||
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
|
||||
@@ -89,13 +89,13 @@ const OPPORTUNITIES: Opp[] = [
|
||||
{ name: 'Sunrise — vendor onboarding', companyName: 'Sunrise Logistics', matchStatus: 'LOST', numberOfSeats: 10, hostingType: 'CLOUD', subscriptionType: 'PRO', subscriptionFrequency: 'MONTHLY' },
|
||||
];
|
||||
|
||||
type Quote = { name: string; status: string; partnerSlug: string; contentType: string[] };
|
||||
type Quote = { name: string; status: string; partnerSlug: string; oppName: string };
|
||||
const QUOTES: Quote[] = [
|
||||
{ name: 'Sunrise APAC fleet — Nine Dots quote', status: 'WIP', partnerSlug: 'nine-dots-ventures', contentType: ['PARTNER_QUOTE'] },
|
||||
{ name: 'Helix clinical — NetZero quote', status: 'INTERVIEW_SCHEDULED', partnerSlug: 'netzero-systems', contentType: ['PARTNER_QUOTE'] },
|
||||
{ name: 'Acme rollout — Elevate quote', status: 'UNDER_CUSTOMER_PARTNER_REVIEW', partnerSlug: 'elevate-consulting', contentType: ['PARTNER_QUOTE'] },
|
||||
{ name: 'Sunrise LATAM — Nine Dots quote', status: 'APPROVED', partnerSlug: 'nine-dots-ventures', contentType: ['PARTNER_QUOTE'] },
|
||||
{ name: 'Helix self-host — Meridian quote', status: 'REJECTED', partnerSlug: 'meridian-craft', contentType: ['CASE_STUDY'] },
|
||||
{ name: 'Sunrise APAC fleet — Nine Dots quote', status: 'WIP', partnerSlug: 'nine-dots-ventures', oppName: 'Sunrise — APAC fleet CRM' },
|
||||
{ name: 'Helix clinical — NetZero quote', status: 'INTERVIEW_SCHEDULED', partnerSlug: 'netzero-systems', oppName: 'Helix Bio — clinical trials CRM' },
|
||||
{ name: 'Acme rollout — Elevate quote', status: 'UNDER_CUSTOMER_PARTNER_REVIEW', partnerSlug: 'elevate-consulting', oppName: 'Acme RE — CRM rollout' },
|
||||
{ name: 'Sunrise LATAM — Nine Dots quote', status: 'APPROVED', partnerSlug: 'nine-dots-ventures', oppName: 'Sunrise — LATAM expansion' },
|
||||
{ name: 'Helix self-host — Meridian quote', status: 'REJECTED', partnerSlug: 'meridian-craft', oppName: 'Helix Bio — self-host evaluation' },
|
||||
];
|
||||
|
||||
const nodes = (r: any, key: string): any[] => (r?.[key]?.edges ?? []).map((e: any) => e.node);
|
||||
@@ -177,12 +177,12 @@ async function main() {
|
||||
// -- Partner quotes (upsert by name) --
|
||||
let quoteCount = 0;
|
||||
for (const q of QUOTES) {
|
||||
const data = { name: q.name, status: q.status, contentType: q.contentType, partnerId: partnerIdBySlug.get(q.partnerSlug) };
|
||||
const existing = nodes(await client.query({ partnerContents: { __args: { filter: { name: { eq: q.name } }, first: 1 }, edges: { node: { id: true } } } } as any), 'partnerContents');
|
||||
const data = { name: q.name, status: q.status, partnerId: partnerIdBySlug.get(q.partnerSlug), opportunityId: oppIdByName.get(q.oppName) };
|
||||
const existing = nodes(await client.query({ partnerQuotes: { __args: { filter: { name: { eq: q.name } }, first: 1 }, edges: { node: { id: true } } } } as any), 'partnerQuotes');
|
||||
if (existing[0]?.id) {
|
||||
await client.mutation({ updatePartnerContent: { __args: { id: existing[0].id, data }, id: true } } as any);
|
||||
await client.mutation({ updatePartnerQuote: { __args: { id: existing[0].id, data }, id: true } } as any);
|
||||
} else {
|
||||
await client.mutation({ createPartnerContent: { __args: { data }, id: true } } as any);
|
||||
await client.mutation({ createPartnerQuote: { __args: { data }, id: true } } as any);
|
||||
}
|
||||
quoteCount++;
|
||||
}
|
||||
|
||||
+9
-9
@@ -1,21 +1,21 @@
|
||||
import { ViewType, defineView } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
// Index view for partner content.
|
||||
// Index view for partner quotes.
|
||||
export default defineView({
|
||||
universalIdentifier: PARTNER_CONTENT_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Partner content',
|
||||
icon: 'IconQuote',
|
||||
objectUniversalIdentifier: PARTNER_CONTENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
universalIdentifier: PARTNER_QUOTES_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Partner quotes',
|
||||
icon: 'IconFileDollar',
|
||||
objectUniversalIdentifier: PARTNER_QUOTE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: ViewType.TABLE,
|
||||
fields: [
|
||||
{ universalIdentifier: 'ad7b3702-b552-4355-afec-1e1e96d9f3df', fieldMetadataUniversalIdentifier: '9e688624-83d2-4715-8b18-80492a6de2b6', position: 0, isVisible: true },
|
||||
{ universalIdentifier: 'a9bf3eaa-ec27-4a0a-8df2-e18c8f4239a7', fieldMetadataUniversalIdentifier: '1d926e6e-6ac1-4d60-ab3d-a73114005692', position: 1, isVisible: true },
|
||||
{ universalIdentifier: '426e0c2d-449d-4a06-860b-0cfe0ed501e6', fieldMetadataUniversalIdentifier: 'a0fe09c4-c1f4-4b96-93c6-d7ec38f1166a', position: 2, isVisible: true },
|
||||
{ universalIdentifier: '426e0c2d-449d-4a06-860b-0cfe0ed501e6', fieldMetadataUniversalIdentifier: 'a0fe09c4-c1f4-4b96-93c6-d7ec38f1166a', position: 1, isVisible: true },
|
||||
{ universalIdentifier: '62bb1536-539c-4fb0-95bf-440c5c5da89f', fieldMetadataUniversalIdentifier: '2cbe67e3-24ec-421b-bab5-50f3306c2391', position: 2, isVisible: true },
|
||||
{ universalIdentifier: 'fbd1f953-1dd2-4d0f-a239-148a0688fbff', fieldMetadataUniversalIdentifier: 'b52d263e-423e-40b0-b82c-29214597c005', position: 3, isVisible: true },
|
||||
],
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twenty-client-sdk",
|
||||
"version": "2.9.0",
|
||||
"version": "2.7.0",
|
||||
"sideEffects": false,
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@@ -42,7 +42,7 @@
|
||||
"dependencies": {
|
||||
"@genql/cli": "^3.0.3",
|
||||
"@genql/runtime": "^2.10.0",
|
||||
"esbuild": "^0.28.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"graphql": "^16.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -247,7 +247,7 @@ type FieldPermission {
|
||||
type RolePermissionFlag {
|
||||
id: UUID!
|
||||
roleId: UUID!
|
||||
flag: String!
|
||||
flag: PermissionFlagType!
|
||||
}
|
||||
|
||||
type ApiKeyForRole {
|
||||
@@ -523,7 +523,6 @@ type IndexField {
|
||||
id: UUID!
|
||||
fieldMetadataId: UUID!
|
||||
order: Float!
|
||||
subFieldName: String
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
@@ -1315,7 +1314,6 @@ type FieldConfiguration {
|
||||
configurationType: WidgetConfigurationType!
|
||||
fieldMetadataId: String!
|
||||
fieldDisplayMode: FieldDisplayMode!
|
||||
viewId: String
|
||||
}
|
||||
|
||||
"""Display mode for field configuration widgets"""
|
||||
@@ -1324,7 +1322,6 @@ enum FieldDisplayMode {
|
||||
EDITOR
|
||||
FIELD
|
||||
VIEW
|
||||
TABLE
|
||||
}
|
||||
|
||||
type FieldRichTextConfiguration {
|
||||
@@ -2479,18 +2476,6 @@ type ImapSmtpCaldavConnectionSuccess {
|
||||
connectedAccountId: String!
|
||||
}
|
||||
|
||||
type Webhook {
|
||||
id: UUID!
|
||||
targetUrl: String!
|
||||
operations: [String!]!
|
||||
description: String
|
||||
secret: String!
|
||||
applicationId: UUID!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
deletedAt: DateTime
|
||||
}
|
||||
|
||||
type ToolIndexEntry {
|
||||
name: String!
|
||||
description: String!
|
||||
@@ -2917,6 +2902,18 @@ type MinimalMetadata {
|
||||
collectionHashes: [CollectionHash!]!
|
||||
}
|
||||
|
||||
type Webhook {
|
||||
id: UUID!
|
||||
targetUrl: String!
|
||||
operations: [String!]!
|
||||
description: String
|
||||
secret: String!
|
||||
applicationId: UUID!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
deletedAt: DateTime
|
||||
}
|
||||
|
||||
type Query {
|
||||
navigationMenuItems: [NavigationMenuItem!]!
|
||||
navigationMenuItem(id: UUID!): NavigationMenuItem
|
||||
@@ -2985,8 +2982,6 @@ type Query {
|
||||
getRoles: [Role!]!
|
||||
getToolIndex: [ToolIndexEntry!]!
|
||||
getToolInputSchema(toolName: String!): JSON
|
||||
webhooks: [Webhook!]!
|
||||
webhook(id: UUID!): Webhook
|
||||
field(
|
||||
"""The id of the record to find."""
|
||||
id: UUID!
|
||||
@@ -3004,6 +2999,8 @@ type Query {
|
||||
myMessageChannels(connectedAccountId: UUID): [MessageChannel!]!
|
||||
myConnectedAccounts: [ConnectedAccountPublicDTO!]!
|
||||
myCalendarChannels(connectedAccountId: UUID): [CalendarChannel!]!
|
||||
webhooks: [Webhook!]!
|
||||
webhook(id: UUID!): Webhook
|
||||
minimalMetadata: MinimalMetadata!
|
||||
chatThreads: [AgentChatThread!]!
|
||||
chatThread(id: UUID!): AgentChatThread!
|
||||
@@ -3212,8 +3209,6 @@ type Mutation {
|
||||
createOneObject(input: CreateOneObjectInput!): Object!
|
||||
deleteOneObject(input: DeleteOneObjectInput!): Object!
|
||||
updateOneObject(input: UpdateOneObjectInput!): Object!
|
||||
createOneIndex(input: CreateOneIndexInput!): Index!
|
||||
deleteOneIndex(input: DeleteOneIndexInput!): Index!
|
||||
createOneAgent(input: CreateAgentInput!): Agent!
|
||||
updateOneAgent(input: UpdateAgentInput!): Agent!
|
||||
deleteOneAgent(input: AgentIdInput!): Agent!
|
||||
@@ -3227,9 +3222,6 @@ type Mutation {
|
||||
upsertRowLevelPermissionPredicates(input: UpsertRowLevelPermissionPredicatesInput!): UpsertRowLevelPermissionPredicatesResult!
|
||||
assignRoleToAgent(agentId: UUID!, roleId: UUID!): Boolean!
|
||||
removeRoleFromAgent(agentId: UUID!): Boolean!
|
||||
createWebhook(input: CreateWebhookInput!): Webhook!
|
||||
updateWebhook(input: UpdateWebhookInput!): Webhook!
|
||||
deleteWebhook(id: UUID!): Webhook!
|
||||
createOneField(input: CreateOneFieldMetadataInput!): Field!
|
||||
updateOneField(input: UpdateOneFieldMetadataInput!): Field!
|
||||
deleteOneField(input: DeleteOneFieldInput!): Field!
|
||||
@@ -3246,6 +3238,9 @@ type Mutation {
|
||||
deleteEmailGroupChannel(id: UUID!): MessageChannel!
|
||||
deleteConnectedAccount(id: UUID!): ConnectedAccountPublicDTO!
|
||||
updateCalendarChannel(input: UpdateCalendarChannelInput!): CalendarChannel!
|
||||
createWebhook(input: CreateWebhookInput!): Webhook!
|
||||
updateWebhook(input: UpdateWebhookInput!): Webhook!
|
||||
deleteWebhook(id: UUID!): Webhook!
|
||||
createChatThread: AgentChatThread!
|
||||
sendChatMessage(threadId: UUID!, text: String!, messageId: UUID!, browsingContext: JSON, modelId: String, fileAttachments: [FileAttachmentInput!]): SendChatMessageResult!
|
||||
stopAgentChatStream(threadId: UUID!): Boolean!
|
||||
@@ -3930,27 +3925,6 @@ input UpdateObjectPayload {
|
||||
isSearchable: Boolean
|
||||
}
|
||||
|
||||
input CreateOneIndexInput {
|
||||
"""The custom index to create"""
|
||||
index: CreateIndexInput!
|
||||
}
|
||||
|
||||
input CreateIndexInput {
|
||||
objectMetadataId: UUID!
|
||||
fields: [CreateIndexFieldInput!]!
|
||||
indexType: IndexType! = BTREE
|
||||
}
|
||||
|
||||
input CreateIndexFieldInput {
|
||||
fieldMetadataId: UUID!
|
||||
subFieldName: String
|
||||
}
|
||||
|
||||
input DeleteOneIndexInput {
|
||||
"""The id of the custom index to delete."""
|
||||
id: UUID!
|
||||
}
|
||||
|
||||
input CreateAgentInput {
|
||||
name: String
|
||||
label: String!
|
||||
@@ -4031,7 +4005,7 @@ input ObjectPermissionInput {
|
||||
|
||||
input UpsertPermissionFlagsInput {
|
||||
roleId: UUID!
|
||||
permissionFlagKeys: [String!]!
|
||||
permissionFlagKeys: [PermissionFlagType!]!
|
||||
}
|
||||
|
||||
input UpsertFieldPermissionsInput {
|
||||
@@ -4073,29 +4047,6 @@ input RowLevelPermissionPredicateGroupInput {
|
||||
positionInRowLevelPermissionPredicateGroup: Float
|
||||
}
|
||||
|
||||
input CreateWebhookInput {
|
||||
id: UUID
|
||||
targetUrl: String!
|
||||
operations: [String!]!
|
||||
description: String
|
||||
secret: String
|
||||
}
|
||||
|
||||
input UpdateWebhookInput {
|
||||
"""The id of the webhook to update"""
|
||||
id: UUID!
|
||||
|
||||
"""The webhook fields to update"""
|
||||
update: UpdateWebhookInputUpdates!
|
||||
}
|
||||
|
||||
input UpdateWebhookInputUpdates {
|
||||
targetUrl: String
|
||||
operations: [String!]
|
||||
description: String
|
||||
secret: String
|
||||
}
|
||||
|
||||
input CreateOneFieldMetadataInput {
|
||||
"""The record to create"""
|
||||
field: CreateFieldInput!
|
||||
@@ -4233,6 +4184,29 @@ input UpdateCalendarChannelInputUpdates {
|
||||
isSyncEnabled: Boolean
|
||||
}
|
||||
|
||||
input CreateWebhookInput {
|
||||
id: UUID
|
||||
targetUrl: String!
|
||||
operations: [String!]!
|
||||
description: String
|
||||
secret: String
|
||||
}
|
||||
|
||||
input UpdateWebhookInput {
|
||||
"""The id of the webhook to update"""
|
||||
id: UUID!
|
||||
|
||||
"""The webhook fields to update"""
|
||||
update: UpdateWebhookInputUpdates!
|
||||
}
|
||||
|
||||
input UpdateWebhookInputUpdates {
|
||||
targetUrl: String
|
||||
operations: [String!]
|
||||
description: String
|
||||
secret: String
|
||||
}
|
||||
|
||||
input FileAttachmentInput {
|
||||
id: UUID!
|
||||
filename: String!
|
||||
|
||||
@@ -193,7 +193,7 @@ export interface FieldPermission {
|
||||
export interface RolePermissionFlag {
|
||||
id: Scalars['UUID']
|
||||
roleId: Scalars['UUID']
|
||||
flag: Scalars['String']
|
||||
flag: PermissionFlagType
|
||||
__typename: 'RolePermissionFlag'
|
||||
}
|
||||
|
||||
@@ -386,7 +386,6 @@ export interface IndexField {
|
||||
id: Scalars['UUID']
|
||||
fieldMetadataId: Scalars['UUID']
|
||||
order: Scalars['Float']
|
||||
subFieldName?: Scalars['String']
|
||||
createdAt: Scalars['DateTime']
|
||||
updatedAt: Scalars['DateTime']
|
||||
__typename: 'IndexField'
|
||||
@@ -966,13 +965,12 @@ export interface FieldConfiguration {
|
||||
configurationType: WidgetConfigurationType
|
||||
fieldMetadataId: Scalars['String']
|
||||
fieldDisplayMode: FieldDisplayMode
|
||||
viewId?: Scalars['String']
|
||||
__typename: 'FieldConfiguration'
|
||||
}
|
||||
|
||||
|
||||
/** Display mode for field configuration widgets */
|
||||
export type FieldDisplayMode = 'CARD' | 'EDITOR' | 'FIELD' | 'VIEW' | 'TABLE'
|
||||
export type FieldDisplayMode = 'CARD' | 'EDITOR' | 'FIELD' | 'VIEW'
|
||||
|
||||
export interface FieldRichTextConfiguration {
|
||||
configurationType: WidgetConfigurationType
|
||||
@@ -2162,19 +2160,6 @@ export interface ImapSmtpCaldavConnectionSuccess {
|
||||
__typename: 'ImapSmtpCaldavConnectionSuccess'
|
||||
}
|
||||
|
||||
export interface Webhook {
|
||||
id: Scalars['UUID']
|
||||
targetUrl: Scalars['String']
|
||||
operations: Scalars['String'][]
|
||||
description?: Scalars['String']
|
||||
secret: Scalars['String']
|
||||
applicationId: Scalars['UUID']
|
||||
createdAt: Scalars['DateTime']
|
||||
updatedAt: Scalars['DateTime']
|
||||
deletedAt?: Scalars['DateTime']
|
||||
__typename: 'Webhook'
|
||||
}
|
||||
|
||||
export interface ToolIndexEntry {
|
||||
name: Scalars['String']
|
||||
description: Scalars['String']
|
||||
@@ -2543,6 +2528,19 @@ export interface MinimalMetadata {
|
||||
__typename: 'MinimalMetadata'
|
||||
}
|
||||
|
||||
export interface Webhook {
|
||||
id: Scalars['UUID']
|
||||
targetUrl: Scalars['String']
|
||||
operations: Scalars['String'][]
|
||||
description?: Scalars['String']
|
||||
secret: Scalars['String']
|
||||
applicationId: Scalars['UUID']
|
||||
createdAt: Scalars['DateTime']
|
||||
updatedAt: Scalars['DateTime']
|
||||
deletedAt?: Scalars['DateTime']
|
||||
__typename: 'Webhook'
|
||||
}
|
||||
|
||||
export interface Query {
|
||||
navigationMenuItems: NavigationMenuItem[]
|
||||
navigationMenuItem?: NavigationMenuItem
|
||||
@@ -2593,8 +2591,6 @@ export interface Query {
|
||||
getRoles: Role[]
|
||||
getToolIndex: ToolIndexEntry[]
|
||||
getToolInputSchema?: Scalars['JSON']
|
||||
webhooks: Webhook[]
|
||||
webhook?: Webhook
|
||||
field: Field
|
||||
fields: FieldConnection
|
||||
getViewGroups: ViewGroup[]
|
||||
@@ -2603,6 +2599,8 @@ export interface Query {
|
||||
myMessageChannels: MessageChannel[]
|
||||
myConnectedAccounts: ConnectedAccountPublicDTO[]
|
||||
myCalendarChannels: CalendarChannel[]
|
||||
webhooks: Webhook[]
|
||||
webhook?: Webhook
|
||||
minimalMetadata: MinimalMetadata
|
||||
chatThreads: AgentChatThread[]
|
||||
chatThread: AgentChatThread
|
||||
@@ -2744,8 +2742,6 @@ export interface Mutation {
|
||||
createOneObject: Object
|
||||
deleteOneObject: Object
|
||||
updateOneObject: Object
|
||||
createOneIndex: Index
|
||||
deleteOneIndex: Index
|
||||
createOneAgent: Agent
|
||||
updateOneAgent: Agent
|
||||
deleteOneAgent: Agent
|
||||
@@ -2759,9 +2755,6 @@ export interface Mutation {
|
||||
upsertRowLevelPermissionPredicates: UpsertRowLevelPermissionPredicatesResult
|
||||
assignRoleToAgent: Scalars['Boolean']
|
||||
removeRoleFromAgent: Scalars['Boolean']
|
||||
createWebhook: Webhook
|
||||
updateWebhook: Webhook
|
||||
deleteWebhook: Webhook
|
||||
createOneField: Field
|
||||
updateOneField: Field
|
||||
deleteOneField: Field
|
||||
@@ -2778,6 +2771,9 @@ export interface Mutation {
|
||||
deleteEmailGroupChannel: MessageChannel
|
||||
deleteConnectedAccount: ConnectedAccountPublicDTO
|
||||
updateCalendarChannel: CalendarChannel
|
||||
createWebhook: Webhook
|
||||
updateWebhook: Webhook
|
||||
deleteWebhook: Webhook
|
||||
createChatThread: AgentChatThread
|
||||
sendChatMessage: SendChatMessageResult
|
||||
stopAgentChatStream: Scalars['Boolean']
|
||||
@@ -3261,7 +3257,6 @@ export interface IndexFieldGenqlSelection{
|
||||
id?: boolean | number
|
||||
fieldMetadataId?: boolean | number
|
||||
order?: boolean | number
|
||||
subFieldName?: boolean | number
|
||||
createdAt?: boolean | number
|
||||
updatedAt?: boolean | number
|
||||
__typename?: boolean | number
|
||||
@@ -3888,7 +3883,6 @@ export interface FieldConfigurationGenqlSelection{
|
||||
configurationType?: boolean | number
|
||||
fieldMetadataId?: boolean | number
|
||||
fieldDisplayMode?: boolean | number
|
||||
viewId?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
@@ -5172,20 +5166,6 @@ export interface ImapSmtpCaldavConnectionSuccessGenqlSelection{
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface WebhookGenqlSelection{
|
||||
id?: boolean | number
|
||||
targetUrl?: boolean | number
|
||||
operations?: boolean | number
|
||||
description?: boolean | number
|
||||
secret?: boolean | number
|
||||
applicationId?: boolean | number
|
||||
createdAt?: boolean | number
|
||||
updatedAt?: boolean | number
|
||||
deletedAt?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface ToolIndexEntryGenqlSelection{
|
||||
name?: boolean | number
|
||||
description?: boolean | number
|
||||
@@ -5561,6 +5541,20 @@ export interface MinimalMetadataGenqlSelection{
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface WebhookGenqlSelection{
|
||||
id?: boolean | number
|
||||
targetUrl?: boolean | number
|
||||
operations?: boolean | number
|
||||
description?: boolean | number
|
||||
secret?: boolean | number
|
||||
applicationId?: boolean | number
|
||||
createdAt?: boolean | number
|
||||
updatedAt?: boolean | number
|
||||
deletedAt?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface QueryGenqlSelection{
|
||||
navigationMenuItems?: NavigationMenuItemGenqlSelection
|
||||
navigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
@@ -5623,8 +5617,6 @@ export interface QueryGenqlSelection{
|
||||
getRoles?: RoleGenqlSelection
|
||||
getToolIndex?: ToolIndexEntryGenqlSelection
|
||||
getToolInputSchema?: { __args: {toolName: Scalars['String']} }
|
||||
webhooks?: WebhookGenqlSelection
|
||||
webhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
field?: (FieldGenqlSelection & { __args: {
|
||||
/** The id of the record to find. */
|
||||
id: Scalars['UUID']} })
|
||||
@@ -5639,6 +5631,8 @@ export interface QueryGenqlSelection{
|
||||
myMessageChannels?: (MessageChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
|
||||
myConnectedAccounts?: ConnectedAccountPublicDTOGenqlSelection
|
||||
myCalendarChannels?: (CalendarChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
|
||||
webhooks?: WebhookGenqlSelection
|
||||
webhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
minimalMetadata?: MinimalMetadataGenqlSelection
|
||||
chatThreads?: AgentChatThreadGenqlSelection
|
||||
chatThread?: (AgentChatThreadGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
@@ -5801,8 +5795,6 @@ export interface MutationGenqlSelection{
|
||||
createOneObject?: (ObjectGenqlSelection & { __args: {input: CreateOneObjectInput} })
|
||||
deleteOneObject?: (ObjectGenqlSelection & { __args: {input: DeleteOneObjectInput} })
|
||||
updateOneObject?: (ObjectGenqlSelection & { __args: {input: UpdateOneObjectInput} })
|
||||
createOneIndex?: (IndexGenqlSelection & { __args: {input: CreateOneIndexInput} })
|
||||
deleteOneIndex?: (IndexGenqlSelection & { __args: {input: DeleteOneIndexInput} })
|
||||
createOneAgent?: (AgentGenqlSelection & { __args: {input: CreateAgentInput} })
|
||||
updateOneAgent?: (AgentGenqlSelection & { __args: {input: UpdateAgentInput} })
|
||||
deleteOneAgent?: (AgentGenqlSelection & { __args: {input: AgentIdInput} })
|
||||
@@ -5816,9 +5808,6 @@ export interface MutationGenqlSelection{
|
||||
upsertRowLevelPermissionPredicates?: (UpsertRowLevelPermissionPredicatesResultGenqlSelection & { __args: {input: UpsertRowLevelPermissionPredicatesInput} })
|
||||
assignRoleToAgent?: { __args: {agentId: Scalars['UUID'], roleId: Scalars['UUID']} }
|
||||
removeRoleFromAgent?: { __args: {agentId: Scalars['UUID']} }
|
||||
createWebhook?: (WebhookGenqlSelection & { __args: {input: CreateWebhookInput} })
|
||||
updateWebhook?: (WebhookGenqlSelection & { __args: {input: UpdateWebhookInput} })
|
||||
deleteWebhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
createOneField?: (FieldGenqlSelection & { __args: {input: CreateOneFieldMetadataInput} })
|
||||
updateOneField?: (FieldGenqlSelection & { __args: {input: UpdateOneFieldMetadataInput} })
|
||||
deleteOneField?: (FieldGenqlSelection & { __args: {input: DeleteOneFieldInput} })
|
||||
@@ -5835,6 +5824,9 @@ export interface MutationGenqlSelection{
|
||||
deleteEmailGroupChannel?: (MessageChannelGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
deleteConnectedAccount?: (ConnectedAccountPublicDTOGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
updateCalendarChannel?: (CalendarChannelGenqlSelection & { __args: {input: UpdateCalendarChannelInput} })
|
||||
createWebhook?: (WebhookGenqlSelection & { __args: {input: CreateWebhookInput} })
|
||||
updateWebhook?: (WebhookGenqlSelection & { __args: {input: UpdateWebhookInput} })
|
||||
deleteWebhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
createChatThread?: AgentChatThreadGenqlSelection
|
||||
sendChatMessage?: (SendChatMessageResultGenqlSelection & { __args: {threadId: Scalars['UUID'], text: Scalars['String'], messageId: Scalars['UUID'], browsingContext?: (Scalars['JSON'] | null), modelId?: (Scalars['String'] | null), fileAttachments?: (FileAttachmentInput[] | null)} })
|
||||
stopAgentChatStream?: { __args: {threadId: Scalars['UUID']} }
|
||||
@@ -6134,18 +6126,6 @@ id: Scalars['UUID']}
|
||||
|
||||
export interface UpdateObjectPayload {labelSingular?: (Scalars['String'] | null),labelPlural?: (Scalars['String'] | null),nameSingular?: (Scalars['String'] | null),namePlural?: (Scalars['String'] | null),description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortcut?: (Scalars['String'] | null),color?: (Scalars['String'] | null),isActive?: (Scalars['Boolean'] | null),labelIdentifierFieldMetadataId?: (Scalars['UUID'] | null),imageIdentifierFieldMetadataId?: (Scalars['UUID'] | null),isLabelSyncedWithName?: (Scalars['Boolean'] | null),isSearchable?: (Scalars['Boolean'] | null)}
|
||||
|
||||
export interface CreateOneIndexInput {
|
||||
/** The custom index to create */
|
||||
index: CreateIndexInput}
|
||||
|
||||
export interface CreateIndexInput {objectMetadataId: Scalars['UUID'],fields: CreateIndexFieldInput[],indexType: IndexType}
|
||||
|
||||
export interface CreateIndexFieldInput {fieldMetadataId: Scalars['UUID'],subFieldName?: (Scalars['String'] | null)}
|
||||
|
||||
export interface DeleteOneIndexInput {
|
||||
/** The id of the custom index to delete. */
|
||||
id: Scalars['UUID']}
|
||||
|
||||
export interface CreateAgentInput {name?: (Scalars['String'] | null),label: Scalars['String'],icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),prompt: Scalars['String'],modelId: Scalars['String'],roleId?: (Scalars['UUID'] | null),responseFormat?: (Scalars['JSON'] | null),modelConfiguration?: (Scalars['JSON'] | null),evaluationInputs?: (Scalars['String'][] | null)}
|
||||
|
||||
export interface UpdateAgentInput {id: Scalars['UUID'],name?: (Scalars['String'] | null),label?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),prompt?: (Scalars['String'] | null),modelId?: (Scalars['String'] | null),roleId?: (Scalars['UUID'] | null),responseFormat?: (Scalars['JSON'] | null),modelConfiguration?: (Scalars['JSON'] | null),evaluationInputs?: (Scalars['String'][] | null)}
|
||||
@@ -6162,7 +6142,7 @@ export interface UpsertObjectPermissionsInput {roleId: Scalars['UUID'],objectPer
|
||||
|
||||
export interface ObjectPermissionInput {objectMetadataId: Scalars['UUID'],canReadObjectRecords?: (Scalars['Boolean'] | null),canUpdateObjectRecords?: (Scalars['Boolean'] | null),canSoftDeleteObjectRecords?: (Scalars['Boolean'] | null),canDestroyObjectRecords?: (Scalars['Boolean'] | null)}
|
||||
|
||||
export interface UpsertPermissionFlagsInput {roleId: Scalars['UUID'],permissionFlagKeys: Scalars['String'][]}
|
||||
export interface UpsertPermissionFlagsInput {roleId: Scalars['UUID'],permissionFlagKeys: PermissionFlagType[]}
|
||||
|
||||
export interface UpsertFieldPermissionsInput {roleId: Scalars['UUID'],fieldPermissions: FieldPermissionInput[]}
|
||||
|
||||
@@ -6174,16 +6154,6 @@ export interface RowLevelPermissionPredicateInput {id?: (Scalars['UUID'] | null)
|
||||
|
||||
export interface RowLevelPermissionPredicateGroupInput {id?: (Scalars['UUID'] | null),objectMetadataId: Scalars['UUID'],parentRowLevelPermissionPredicateGroupId?: (Scalars['UUID'] | null),logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator,positionInRowLevelPermissionPredicateGroup?: (Scalars['Float'] | null)}
|
||||
|
||||
export interface CreateWebhookInput {id?: (Scalars['UUID'] | null),targetUrl: Scalars['String'],operations: Scalars['String'][],description?: (Scalars['String'] | null),secret?: (Scalars['String'] | null)}
|
||||
|
||||
export interface UpdateWebhookInput {
|
||||
/** The id of the webhook to update */
|
||||
id: Scalars['UUID'],
|
||||
/** The webhook fields to update */
|
||||
update: UpdateWebhookInputUpdates}
|
||||
|
||||
export interface UpdateWebhookInputUpdates {targetUrl?: (Scalars['String'] | null),operations?: (Scalars['String'][] | null),description?: (Scalars['String'] | null),secret?: (Scalars['String'] | null)}
|
||||
|
||||
export interface CreateOneFieldMetadataInput {
|
||||
/** The record to create */
|
||||
field: CreateFieldInput}
|
||||
@@ -6236,6 +6206,16 @@ export interface UpdateCalendarChannelInput {id: Scalars['UUID'],update: UpdateC
|
||||
|
||||
export interface UpdateCalendarChannelInputUpdates {visibility?: (CalendarChannelVisibility | null),isContactAutoCreationEnabled?: (Scalars['Boolean'] | null),contactAutoCreationPolicy?: (CalendarChannelContactAutoCreationPolicy | null),isSyncEnabled?: (Scalars['Boolean'] | null)}
|
||||
|
||||
export interface CreateWebhookInput {id?: (Scalars['UUID'] | null),targetUrl: Scalars['String'],operations: Scalars['String'][],description?: (Scalars['String'] | null),secret?: (Scalars['String'] | null)}
|
||||
|
||||
export interface UpdateWebhookInput {
|
||||
/** The id of the webhook to update */
|
||||
id: Scalars['UUID'],
|
||||
/** The webhook fields to update */
|
||||
update: UpdateWebhookInputUpdates}
|
||||
|
||||
export interface UpdateWebhookInputUpdates {targetUrl?: (Scalars['String'] | null),operations?: (Scalars['String'][] | null),description?: (Scalars['String'] | null),secret?: (Scalars['String'] | null)}
|
||||
|
||||
export interface FileAttachmentInput {id: Scalars['UUID'],filename: Scalars['String']}
|
||||
|
||||
export interface CreateSkillInput {id?: (Scalars['UUID'] | null),name: Scalars['String'],label: Scalars['String'],icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),content: Scalars['String']}
|
||||
@@ -7957,14 +7937,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
|
||||
|
||||
|
||||
|
||||
const Webhook_possibleTypes: string[] = ['Webhook']
|
||||
export const isWebhook = (obj?: { __typename?: any } | null): obj is Webhook => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isWebhook"')
|
||||
return Webhook_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const ToolIndexEntry_possibleTypes: string[] = ['ToolIndexEntry']
|
||||
export const isToolIndexEntry = (obj?: { __typename?: any } | null): obj is ToolIndexEntry => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isToolIndexEntry"')
|
||||
@@ -8229,6 +8201,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
|
||||
|
||||
|
||||
|
||||
const Webhook_possibleTypes: string[] = ['Webhook']
|
||||
export const isWebhook = (obj?: { __typename?: any } | null): obj is Webhook => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isWebhook"')
|
||||
return Webhook_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const Query_possibleTypes: string[] = ['Query']
|
||||
export const isQuery = (obj?: { __typename?: any } | null): obj is Query => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isQuery"')
|
||||
@@ -8629,8 +8609,7 @@ export const enumFieldDisplayMode = {
|
||||
CARD: 'CARD' as const,
|
||||
EDITOR: 'EDITOR' as const,
|
||||
FIELD: 'FIELD' as const,
|
||||
VIEW: 'VIEW' as const,
|
||||
TABLE: 'TABLE' as const
|
||||
VIEW: 'VIEW' as const
|
||||
}
|
||||
|
||||
export const enumPageLayoutType = {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -44,53 +44,8 @@ A Twenty app's **data layer** is the data your app *adds* to a workspace — the
|
||||
| **Object** | A new custom record type (e.g. PostCard, Invoice) with its own fields | `defineObject()` |
|
||||
| **Field** | A column on an object. Standalone fields can extend objects you didn't create (e.g. add `loyaltyTier` to Company) | `defineField()` |
|
||||
| **Relation** | A bidirectional link between two objects — both sides declared as fields | `defineField()` with `FieldType.RELATION` |
|
||||
| **Index** | A database index to speed up a recurring query on one of your objects | `defineIndex()` |
|
||||
|
||||
The SDK detects these via AST analysis at build time, so file organization is up to you — the convention is `src/objects/`, `src/fields/`, and `src/indexes/`. Stable `universalIdentifier` UUIDs tie everything together across deploys.
|
||||
|
||||
## Indexes (optional)
|
||||
|
||||
Apps can ship indexes alongside their objects to keep recurring queries fast. The most common case is a status or foreign-key column that you read frequently.
|
||||
|
||||
```ts src/indexes/post-card-status.index.ts
|
||||
import { defineIndex } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
} from '../objects/post-card.object';
|
||||
|
||||
export default defineIndex({
|
||||
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff0',
|
||||
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff1',
|
||||
fieldUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Unique indexes
|
||||
|
||||
`defineIndex` accepts `isUnique: true` for both single- and multi-column uniqueness. This is the recommended primitive — `defineField({ isUnique: true })` is deprecated and will be removed in a future release.
|
||||
|
||||
```ts
|
||||
defineIndex({
|
||||
universalIdentifier: '…',
|
||||
objectUniversalIdentifier: PERSON_UNIVERSAL_IDENTIFIER,
|
||||
isUnique: true,
|
||||
fields: [{ universalIdentifier: '…', fieldUniversalIdentifier: EMAIL_FIELD_UNIVERSAL_IDENTIFIER }],
|
||||
});
|
||||
```
|
||||
|
||||
### Other constraints
|
||||
|
||||
- Partial `WHERE` clauses stay under admin control — apps can't declare them.
|
||||
- Each object is capped at 10 custom indexes (the framework's own indexes don't count).
|
||||
|
||||
Order the `fields` array the way Postgres should use it — leftmost column first, like a phone book. Indexes are not free: every write to the table updates them. Add one only when you have a query that needs it.
|
||||
The SDK detects these via AST analysis at build time, so file organization is up to you — the convention is `src/objects/` and `src/fields/`. Stable `universalIdentifier` UUIDs tie everything together across deploys.
|
||||
|
||||
<Note>
|
||||
Looking for **Application Config** or **Roles & Permissions**? Those describe the app itself rather than the data it adds — they live under [Config](/developers/extend/apps/config/overview). Looking for **Connections** (Linear, GitHub, Slack OAuth)? Those exist to be called *from* logic functions and live under [Logic](/developers/extend/apps/logic/connections).
|
||||
|
||||
@@ -70,7 +70,7 @@ filters: [
|
||||
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `BOOLEAN` | `IS` |
|
||||
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `UUID` | `IS` |
|
||||
| `TS_VECTOR` | `VECTOR_SEARCH` |
|
||||
|
||||
> Field types with similar names can use entirely different operands — `SELECT` and `MULTI_SELECT` being a common case.
|
||||
|
||||
@@ -16,9 +16,10 @@ Each key carries a `publicKey` (kept indefinitely so it can verify previously is
|
||||
|
||||
### Rotate the current key
|
||||
|
||||
Set `SIGNING_KEY_ROTATION_DAYS` to opt in: a daily cron then issues a new current key once the existing one is older than that threshold. Previous keys are *not* revoked, so tokens signed under them keep verifying. Leave the variable unset to disable auto-rotation.
|
||||
- **Manual** — **Settings → Admin Panel → Signing keys → Revoke** on the current row. Revoking wipes its encrypted private material and demotes it; the next sign call automatically mints a fresh ES256 keypair as the new current. Tokens signed under any other (non-revoked) `kid` keep verifying until they expire.
|
||||
- **Enterprise (automatic)** — a daily cron (`'15 3 * * *'` UTC) issues a new current key once the existing one has been current for `SIGNING_KEY_ROTATION_DAYS` (default `90`). The previous key is *not* revoked, so tokens signed under it keep verifying. Register it once with `yarn command:prod cron:register:all`.
|
||||
|
||||
<Note>Auto-rotation ships in v2.6+.</Note>
|
||||
<Note>The Enterprise cron and `SIGNING_KEY_ROTATION_DAYS` ship in v2.6+.</Note>
|
||||
|
||||
### Revoke a key (leak / emergency only)
|
||||
|
||||
|
||||
@@ -44,53 +44,8 @@ Die **Datenebene** einer Twenty-App umfasst die Daten, die Ihre App zu einem Wor
|
||||
| **Objekt** | Ein neuer benutzerdefinierter Datensatztyp (z. B. PostCard, Invoice) mit eigenen Feldern | `defineObject()` |
|
||||
| **Feld** | Eine Spalte in einem Objekt. Eigenständige Felder können Objekte erweitern, die Sie nicht erstellt haben (z. B. `loyaltyTier` zu Company hinzufügen) | `defineField()` |
|
||||
| **Beziehung** | Eine bidirektionale Verknüpfung zwischen zwei Objekten – beide Seiten werden als Felder deklariert | `defineField()` mit `FieldType.RELATION` |
|
||||
| **Indizes** | Ein Datenbankindex, um eine wiederkehrende Abfrage für eines Ihrer Objekte zu beschleunigen | `defineIndex()` |
|
||||
|
||||
Das SDK erkennt diese zur Build-Zeit über eine AST-Analyse, sodass die Dateiorganisation Ihnen überlassen ist – die Konvention ist `src/objects/`, `src/fields/` und `src/indexes/`. Stabile `universalIdentifier`-UUIDs verknüpfen alles über Deploys hinweg.
|
||||
|
||||
## Indizes (optional)
|
||||
|
||||
Apps können Indizes gemeinsam mit ihren Objekten ausliefern, um wiederkehrende Abfragen schnell zu halten. Der häufigste Fall ist eine Status- oder Fremdschlüsselspalte, die Sie häufig lesen.
|
||||
|
||||
```ts src/indexes/post-card-status.index.ts
|
||||
import { defineIndex } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
} from '../objects/post-card.object';
|
||||
|
||||
export default defineIndex({
|
||||
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff0',
|
||||
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff1',
|
||||
fieldUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Eindeutige Indizes
|
||||
|
||||
`defineIndex` akzeptiert `isUnique: true` sowohl für Einspalten- als auch Mehrspalteneindeutigkeit. Dies ist das empfohlene Primitive – `defineField({ isUnique: true })` ist veraltet und wird in einer zukünftigen Version entfernt.
|
||||
|
||||
```ts
|
||||
defineIndex({
|
||||
universalIdentifier: '…',
|
||||
objectUniversalIdentifier: PERSON_UNIVERSAL_IDENTIFIER,
|
||||
isUnique: true,
|
||||
fields: [{ universalIdentifier: '…', fieldUniversalIdentifier: EMAIL_FIELD_UNIVERSAL_IDENTIFIER }],
|
||||
});
|
||||
```
|
||||
|
||||
### Andere Einschränkungen
|
||||
|
||||
* Partielle `WHERE`-Klauseln bleiben unter Kontrolle der Administratoren – Apps können sie nicht deklarieren.
|
||||
* Jedes Objekt ist auf 10 benutzerdefinierte Indizes begrenzt (die Indizes des Frameworks selbst werden nicht mitgezählt).
|
||||
|
||||
Ordnen Sie das `fields`-Array so an, wie Postgres es verwenden soll – die ganz linke Spalte zuerst, wie in einem Telefonbuch. Indizes sind nicht kostenlos: Jeder Schreibvorgang in die Tabelle aktualisiert sie. Fügen Sie einen nur dann hinzu, wenn Sie eine Abfrage haben, die ihn benötigt.
|
||||
Das SDK erkennt diese zur Build-Zeit über eine AST-Analyse, sodass die Dateiorganisation Ihnen überlassen ist – die Konvention ist `src/objects/` und `src/fields/`. Stabile `universalIdentifier`-UUIDs verknüpfen alles über Deploys hinweg.
|
||||
|
||||
<Note>
|
||||
Suchen Sie nach **Application Config** oder **Roles & Permissions**? Diese beschreiben die App selbst und nicht die Daten, die sie hinzufügt – sie befinden sich unter [Config](/l/de/developers/extend/apps/config/overview). Suchen Sie nach **Connections** (Linear, GitHub, Slack OAuth)? Diese existieren, um *von* Logikfunktionen aufgerufen zu werden, und befinden sich unter [Logic](/l/de/developers/extend/apps/logic/connections).
|
||||
|
||||
@@ -70,7 +70,7 @@ filters: [
|
||||
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `BOOLEAN` | `IS` |
|
||||
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `UUID` | `IS` |
|
||||
| `TS_VECTOR` | `VECTOR_SEARCH` |
|
||||
|
||||
> Feldtypen mit ähnlichen Namen können völlig unterschiedliche Operanden verwenden – `SELECT` und `MULTI_SELECT` sind ein häufiges Beispiel.
|
||||
|
||||
@@ -16,9 +16,10 @@ Jeder Schlüssel enthält einen `publicKey` (unbefristet aufbewahrt, damit er zu
|
||||
|
||||
### Aktuellen Schlüssel rotieren
|
||||
|
||||
Setzen Sie `SIGNING_KEY_ROTATION_DAYS`, um die Funktion zu aktivieren: Ein täglicher Cronjob erstellt dann einen neuen aktuellen Schlüssel, sobald der bestehende Schlüssel älter als dieser Schwellenwert ist. Frühere Schlüssel werden *nicht* widerrufen, daher werden Tokens, die darunter signiert wurden, weiterhin verifiziert. Lassen Sie die Variable ungesetzt, um die automatische Rotation zu deaktivieren.
|
||||
* **Manuell** — **Settings → Admin Panel → Signing keys → Revoke** in der aktuellen Zeile. Das Widerrufen löscht das verschlüsselte private Material und stuft es herab; der nächste Signieraufruf erstellt automatisch ein neues ES256-Schlüsselpaar als neuen aktuellen Schlüssel. Tokens, die unter einem anderen (nicht widerrufenen) `kid` signiert wurden, werden weiterhin verifiziert, bis sie ablaufen.
|
||||
* **Enterprise (automatisch)** — ein täglicher Cron-Job (`'15 3 * * *'` UTC) erstellt einen neuen aktuellen Schlüssel, sobald der vorhandene Schlüssel seit `SIGNING_KEY_ROTATION_DAYS` (Standard `90`) aktuell ist. Der vorherige Schlüssel wird *nicht* widerrufen, daher werden Tokens, die darunter signiert wurden, weiterhin verifiziert. Registriere ihn einmalig mit `yarn command:prod cron:register:all`.
|
||||
|
||||
<Note>Die automatische Rotation ist ab Version v2.6 verfügbar.</Note>
|
||||
<Note>Der Enterprise-Cron-Job und `SIGNING_KEY_ROTATION_DAYS` sind ab v2.6+ enthalten.</Note>
|
||||
|
||||
### Einen Schlüssel widerrufen (nur bei Leak / Notfall)
|
||||
|
||||
|
||||
@@ -101,10 +101,6 @@ Machen Sie ein Feld einzigartig, um sicherzustellen, dass sich keine verschieden
|
||||
|
||||
Wenn beim Einstellen der Einzigartigkeit ein Fehler auftritt, überprüfen Sie auf doppelte Werte in Ihren Daten (einschließlich gelöschter Datensätze).
|
||||
|
||||
## Indizes (Erweitert)
|
||||
|
||||
Datenbankindizes werden automatisch verwaltet – eigene hinzuzufügen ist selten erforderlich und kann leicht schiefgehen. Wenn der Erweiterte Modus aktiviert ist, hat jedes Objekt unter `Einstellungen → Datenmodell → <object>` einen Abschnitt **Indizes** für die Fälle, in denen du weißt, dass du einen brauchst.
|
||||
|
||||
## Beste Praktiken zur Feldkonfiguration
|
||||
|
||||
### Benennungskonventionen und Einschränkungen
|
||||
|
||||
@@ -44,53 +44,8 @@ A **camada de dados** de um app Twenty é o conjunto de dados que seu app *adici
|
||||
| **Objeto** | Um novo tipo de registro personalizado (por exemplo, PostCard, Invoice) com seus próprios campos | `defineObject()` |
|
||||
| **Campo** | Uma coluna em um objeto. Campos independentes podem estender objetos que você não criou (por exemplo, adicionar `loyaltyTier` ao objeto Company) | `defineField()` |
|
||||
| **Relação** | Um vínculo bidirecional entre dois objetos — ambos os lados declarados como campos | `defineField()` com `FieldType.RELATION` |
|
||||
| **Índice** | Um índice de banco de dados para acelerar uma consulta recorrente em um dos seus objetos | `defineIndex()` |
|
||||
|
||||
O SDK detecta esses elementos por meio de análise de AST em tempo de build, então a organização dos arquivos fica a seu critério — a convenção é `src/objects/`, `src/fields/` e `src/indexes/`. UUIDs `universalIdentifier` estáveis conectam tudo em implantações diferentes.
|
||||
|
||||
## Índices (Opcional)
|
||||
|
||||
Os apps podem incluir índices junto com seus objetos para manter rápidas as consultas recorrentes. O caso mais comum é uma coluna de status ou de chave estrangeira que você lê com frequência.
|
||||
|
||||
```ts src/indexes/post-card-status.index.ts
|
||||
import { defineIndex } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
} from '../objects/post-card.object';
|
||||
|
||||
export default defineIndex({
|
||||
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff0',
|
||||
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff1',
|
||||
fieldUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Índices únicos
|
||||
|
||||
`defineIndex` aceita `isUnique: true` tanto para unicidade de uma única coluna quanto de múltiplas colunas. Este é o recurso recomendado — `defineField({ isUnique: true })` está obsoleto e será removido em uma versão futura.
|
||||
|
||||
```ts
|
||||
defineIndex({
|
||||
universalIdentifier: '…',
|
||||
objectUniversalIdentifier: PERSON_UNIVERSAL_IDENTIFIER,
|
||||
isUnique: true,
|
||||
fields: [{ universalIdentifier: '…', fieldUniversalIdentifier: EMAIL_FIELD_UNIVERSAL_IDENTIFIER }],
|
||||
});
|
||||
```
|
||||
|
||||
### Outras restrições
|
||||
|
||||
* Cláusulas `WHERE` parciais permanecem sob controle do administrador — os apps não podem declará-las.
|
||||
* Cada objeto é limitado a 10 índices personalizados (os índices do próprio framework não contam).
|
||||
|
||||
Ordene o array `fields` da forma como o Postgres deve usá-lo — coluna mais à esquerda primeiro, como em uma lista telefônica. Índices não são gratuitos: cada gravação na tabela os atualiza. Adicione um apenas quando você tiver uma consulta que precise dele.
|
||||
O SDK detecta esses elementos por meio de análise de AST em tempo de build, então a organização dos arquivos fica a seu critério — a convenção é `src/objects/` e `src/fields/`. UUIDs `universalIdentifier` estáveis conectam tudo em implantações diferentes.
|
||||
|
||||
<Note>
|
||||
Procurando por **Application Config** ou **Roles & Permissions**? Esses descrevem o próprio app em vez dos dados que ele adiciona — eles ficam em [Config](/l/pt/developers/extend/apps/config/overview). Procurando por **Connections** (Linear, GitHub, Slack OAuth)? Essas existem para serem chamadas *a partir de* funções de lógica e ficam em [Logic](/l/pt/developers/extend/apps/logic/connections).
|
||||
|
||||
@@ -70,7 +70,7 @@ filters: [
|
||||
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `BOOLEAN` | `IS` |
|
||||
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `UUID` | `IS` |
|
||||
| `TS_VECTOR` | `VECTOR_SEARCH` |
|
||||
|
||||
> Tipos de campos com nomes semelhantes podem usar operandos completamente diferentes — `SELECT` e `MULTI_SELECT` sendo um caso comum.
|
||||
|
||||
@@ -16,9 +16,10 @@ Cada chave carrega uma `publicKey` (mantida indefinidamente para que possa verif
|
||||
|
||||
### Rotacionar a chave atual
|
||||
|
||||
Defina `SIGNING_KEY_ROTATION_DAYS` para ativar: um cron diário então gera uma nova chave e a define como atual quando a existente for mais antiga do que esse limite. Chaves anteriores *não* são revogadas, então tokens assinados sob elas continuam sendo verificados. Deixe a variável não definida para desativar a rotação automática.
|
||||
* **Manual** — **Settings → Admin Panel → Signing keys → Revoke** na linha atual. A revogação apaga o material privado criptografado e o rebaixa; a próxima chamada de assinatura gera automaticamente um novo par de chaves ES256 como o novo atual. Tokens assinados sob qualquer outro `kid` (não revogado) continuam sendo verificados até expirarem.
|
||||
* **Enterprise (automático)** — um cron diário (`'15 3 * * *'` UTC) emite uma nova chave atual assim que a existente tiver sido atual por `SIGNING_KEY_ROTATION_DAYS` (padrão `90`). A chave anterior *não* é revogada, então tokens assinados sob ela continuam sendo verificados. Registre-o uma vez com `yarn command:prod cron:register:all`.
|
||||
|
||||
<Note>A rotação automática está disponível a partir da v2.6+.</Note>
|
||||
<Note>O cron do Enterprise e `SIGNING_KEY_ROTATION_DAYS` são disponibilizados a partir da v2.6+.</Note>
|
||||
|
||||
### Revogar uma chave (apenas vazamento / emergência)
|
||||
|
||||
|
||||
@@ -101,10 +101,6 @@ Torne um campo único para garantir que registros distintos não possam ter o me
|
||||
|
||||
Se você receber um erro ao definir exclusividade, verifique se há valores duplicados nos seus dados (incluindo registros excluídos).
|
||||
|
||||
## Índices (Avançado)
|
||||
|
||||
Os índices do banco de dados são gerenciados automaticamente — adicionar os seus próprios raramente é necessário e é fácil cometer erros. Com o modo Avançado ativado, cada objeto tem uma seção **Índices** em `Settings → Data Model → <object>` para os casos em que você sabe que precisa de um índice.
|
||||
|
||||
## Melhores Práticas de Configuração de Campos
|
||||
|
||||
### Convenções de Nomeação e Limitações
|
||||
|
||||
@@ -44,53 +44,8 @@ Stratul de **date** al unei aplicații Twenty reprezintă datele pe care aplica
|
||||
| **Obiect** | Un nou tip de înregistrare personalizat (de ex. PostCard, Invoice) cu propriile sale câmpuri | `defineObject()` |
|
||||
| **Câmp** | O coloană pe un obiect. Câmpurile independente pot extinde obiecte pe care nu le-ați creat (de ex. adăugați `loyaltyTier` la Company) | `defineField()` |
|
||||
| **Relație** | O legătură bidirecțională între două obiecte — ambele părți declarate ca câmpuri | `defineField()` cu `FieldType.RELATION` |
|
||||
| **Indice** | Un indice de bază de date pentru a accelera o interogare recurentă asupra unuia dintre obiectele tale | `defineIndex()` |
|
||||
|
||||
SDK-ul detectează acestea prin analiza AST la momentul build-ului, astfel încât organizarea fișierelor ține de tine — convenția este `src/objects/`, `src/fields/` și `src/indexes/`. UUID-urile stabile `universalIdentifier` leagă totul în toate implementările.
|
||||
|
||||
## Indici (opțional)
|
||||
|
||||
Aplicațiile pot livra indici împreună cu obiectele lor pentru a menține rapide interogările recurente. Cel mai comun caz este o coloană de status sau o coloană cu cheie străină pe care o citești frecvent.
|
||||
|
||||
```ts src/indexes/post-card-status.index.ts
|
||||
import { defineIndex } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
} from '../objects/post-card.object';
|
||||
|
||||
export default defineIndex({
|
||||
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff0',
|
||||
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff1',
|
||||
fieldUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Indici unici
|
||||
|
||||
`defineIndex` acceptă `isUnique: true` atât pentru unicitatea pe o singură coloană, cât și pe mai multe coloane. Aceasta este primitiva recomandată — `defineField({ isUnique: true })` este învechită (deprecated) și va fi eliminată într-o versiune viitoare.
|
||||
|
||||
```ts
|
||||
defineIndex({
|
||||
universalIdentifier: '…',
|
||||
objectUniversalIdentifier: PERSON_UNIVERSAL_IDENTIFIER,
|
||||
isUnique: true,
|
||||
fields: [{ universalIdentifier: '…', fieldUniversalIdentifier: EMAIL_FIELD_UNIVERSAL_IDENTIFIER }],
|
||||
});
|
||||
```
|
||||
|
||||
### Alte constrângeri
|
||||
|
||||
* Clauzele `WHERE` parțiale rămân sub controlul administratorului — aplicațiile nu le pot declara.
|
||||
* Fiecare obiect este limitat la 10 indici personalizați (indicii proprii ai framework-ului nu se pun la socoteală).
|
||||
|
||||
Ordonează array-ul `fields` în modul în care Postgres ar trebui să îl folosească — coloana din stânga prima, ca într-o agendă telefonică. Indicii nu sunt gratuiți: fiecare scriere în tabel îi actualizează. Adaugă unul doar atunci când ai o interogare care are nevoie de el.
|
||||
SDK-ul detectează acestea prin analiza AST la momentul build-ului, astfel încât organizarea fișierelor ține de dumneavoastră — convenția este `src/objects/` și `src/fields/`. UUID-urile stabile `universalIdentifier` leagă totul în toate implementările.
|
||||
|
||||
<Note>
|
||||
Căutați **Application Config** sau **Roles & Permissions**? Acestea descriu aplicația în sine, mai degrabă decât datele pe care le adaugă — se află la [Config](/l/ro/developers/extend/apps/config/overview). Căutați **Connections** (Linear, GitHub, Slack OAuth)? Acestea există pentru a fi apelate *din* funcții de logică și se află la [Logic](/l/ro/developers/extend/apps/logic/connections).
|
||||
|
||||
@@ -70,7 +70,7 @@ filters: [
|
||||
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `BOOLEAN` | `IS` |
|
||||
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `UUID` | `IS` |
|
||||
| `TS_VECTOR` | `VECTOR_SEARCH` |
|
||||
|
||||
> Tipurile de câmp cu nume similare pot folosi operanzi complet diferiți — `SELECT` și `MULTI_SELECT` fiind un caz comun.
|
||||
|
||||
@@ -16,9 +16,10 @@ Fiecare cheie are un `publicKey` (păstrat pe termen nelimitat pentru a putea ve
|
||||
|
||||
### Rotește cheia curentă
|
||||
|
||||
Setați `SIGNING_KEY_ROTATION_DAYS` pentru a activa rotația automată: un job cron zilnic va emite o nouă cheie curentă odată ce cea existentă este mai veche decât acest prag. Cheile anterioare *nu* sunt revocate, astfel încât token-urile semnate cu ele continuă să fie verificate. Lăsați variabila nesetată pentru a dezactiva rotația automată.
|
||||
* **Manual** — **Settings → Admin Panel → Signing keys → Revoke** pe rândul curent. Revocarea șterge materialul privat criptat și o retrogradează; următorul apel de semnare generează automat o nouă pereche de chei ES256 ca noua cheie curentă. Token-urile semnate sub orice alt `kid` (nerevocat) continuă să fie verificate până la expirare.
|
||||
* **Enterprise (automat)** — un cron zilnic (`'15 3 * * *'` UTC) emite o nouă cheie curentă după ce cea existentă a fost curentă timp de `SIGNING_KEY_ROTATION_DAYS` (implicit `90`). Cheia anterioară *nu* este revocată, astfel încât token-urile semnate cu ea continuă să fie verificate. Înregistrează-l o singură dată cu `yarn command:prod cron:register:all`.
|
||||
|
||||
<Note>Rotația automată este disponibilă începând cu versiunea v2.6+.</Note>
|
||||
<Note>Cron-ul Enterprise și `SIGNING_KEY_ROTATION_DAYS` sunt disponibile în v2.6+.</Note>
|
||||
|
||||
### Revocă o cheie (numai în caz de scurgere / urgență)
|
||||
|
||||
|
||||
@@ -101,10 +101,6 @@ Faceți un câmp unic pentru a vă asigura că înregistrările distincte nu pot
|
||||
|
||||
Dacă primiți o eroare când setați unicitatea, verificați prezența valorilor duplicate în datele dumneavoastră (inclusiv în cele șterse).
|
||||
|
||||
## Indexuri (Avansat)
|
||||
|
||||
Indexurile bazei de date sunt gestionate automat — adăugarea unor indexuri proprii este rareori necesară și se greșește ușor. Cu modul Avansat activat, fiecare obiect are o secțiune **Indexuri** sub `Settings → Data Model → <object>` pentru cazurile în care știi că ai nevoie de unul.
|
||||
|
||||
## Cele mai bune practici pentru configurarea câmpurilor
|
||||
|
||||
### Convenții de denumire și limitări
|
||||
|
||||
@@ -44,53 +44,8 @@ icon: database
|
||||
| **Объект** | Новый пользовательский тип записей (например, PostCard, Invoice) с собственными полями | `defineObject()` |
|
||||
| **Поле** | Столбец в объекте. Отдельные поля могут расширять объекты, которые вы не создавали (например, добавьте `loyaltyTier` к объекту Company) | `defineField()` |
|
||||
| **Связь** | Двусторонняя связь между двумя объектами — обе стороны объявлены как поля | `defineField()` с `FieldType.RELATION` |
|
||||
| **Индекс** | Индекс базы данных для ускорения повторяющегося запроса к одному из ваших объектов | `defineIndex()` |
|
||||
|
||||
SDK обнаруживает их с помощью анализа AST во время сборки, поэтому организация файлов остается на ваше усмотрение — по соглашению используются `src/objects/`, `src/fields/` и `src/indexes/`. Стабильные UUID `universalIdentifier` связывают все воедино между развертываниями.
|
||||
|
||||
## Индексы (необязательно)
|
||||
|
||||
Приложения могут поставлять индексы вместе со своими объектами, чтобы повторяющиеся запросы выполнялись быстро. Наиболее распространенный случай — столбец статуса или внешнего ключа, к которому вы часто обращаетесь при чтении.
|
||||
|
||||
```ts src/indexes/post-card-status.index.ts
|
||||
import { defineIndex } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
} from '../objects/post-card.object';
|
||||
|
||||
export default defineIndex({
|
||||
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff0',
|
||||
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff1',
|
||||
fieldUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Уникальные индексы
|
||||
|
||||
`defineIndex` принимает `isUnique: true` как для уникальности по одному столбцу, так и по нескольким столбцам. Это рекомендуемый примитив — `defineField({ isUnique: true })` устарел и будет удален в одном из будущих релизов.
|
||||
|
||||
```ts
|
||||
defineIndex({
|
||||
universalIdentifier: '…',
|
||||
objectUniversalIdentifier: PERSON_UNIVERSAL_IDENTIFIER,
|
||||
isUnique: true,
|
||||
fields: [{ universalIdentifier: '…', fieldUniversalIdentifier: EMAIL_FIELD_UNIVERSAL_IDENTIFIER }],
|
||||
});
|
||||
```
|
||||
|
||||
### Другие ограничения
|
||||
|
||||
* Частичные предложения `WHERE` остаются под контролем администратора — приложения не могут объявлять их.
|
||||
* Для каждого объекта допускается не более 10 пользовательских индексов (индексы самого фреймворка не учитываются).
|
||||
|
||||
Упорядочьте массив `fields` в том порядке, в котором Postgres должен его использовать — сначала самый левый столбец, как в телефонной книге. Индексы не бесплатны: при каждой записи в таблицу они обновляются. Добавляйте индекс только тогда, когда у вас есть запрос, которому он действительно нужен.
|
||||
SDK обнаруживает их с помощью анализа AST во время сборки, поэтому организация файлов остается на ваше усмотрение — по соглашению используются `src/objects/` и `src/fields/`. Стабильные UUID `universalIdentifier` связывают все воедино между развертываниями.
|
||||
|
||||
<Note>
|
||||
Ищете **Application Config** или **Roles & Permissions**? Они описывают само приложение, а не данные, которые оно добавляет, — их можно найти в разделе [Config](/l/ru/developers/extend/apps/config/overview). Ищете **Connections** (Linear, GitHub, Slack OAuth)? Они существуют для вызова *из* логических функций и находятся в разделе [Logic](/l/ru/developers/extend/apps/logic/connections).
|
||||
|
||||
@@ -70,7 +70,7 @@ filters: [
|
||||
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `BOOLEAN` | `IS` |
|
||||
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `UUID` | `IS` |
|
||||
| `TS_VECTOR` | `VECTOR_SEARCH` |
|
||||
|
||||
> Типы полей с похожими названиями могут использовать совершенно разные операнды — типичный пример: `SELECT` и `MULTI_SELECT`.
|
||||
|
||||
@@ -16,9 +16,10 @@ icon: rotate
|
||||
|
||||
### Выполнить ротацию текущего ключа
|
||||
|
||||
Установите `SIGNING_KEY_ROTATION_DAYS`, чтобы включить эту опцию: ежедневный cron затем будет выпускать новый текущий ключ, как только существующий ключ станет старше этого порогового значения. Предыдущие ключи *не* отзываются, поэтому токены, подписанные ими, продолжают успешно проходить проверку. Оставьте переменную не заданной, чтобы отключить автоматическую ротацию.
|
||||
* **Вручную** — **Settings → Admin Panel → Signing keys → Revoke** в текущей строке. Отзыв стирает его зашифрованный закрытый материал и понижает его; следующий вызов подписи автоматически создает новую пару ключей ES256 как новый текущий ключ. Токены, подписанные любым другим (не отозванным) `kid`, продолжают успешно проходить проверку до истечения срока действия.
|
||||
* **Enterprise (автоматически)** — ежедневный cron (`'15 3 * * *'` UTC) выпускает новый текущий ключ, как только существующий ключ находится в статусе текущего в течение `SIGNING_KEY_ROTATION_DAYS` (по умолчанию `90`). Предыдущий ключ *не* отзывается, поэтому токены, подписанные им, продолжают успешно проходить проверку. Зарегистрируйте его один раз с помощью `yarn command:prod cron:register:all`.
|
||||
|
||||
<Note>Автоматическая ротация доступна, начиная с версии v2.6+.</Note>
|
||||
<Note>Enterprise-cron и `SIGNING_KEY_ROTATION_DAYS` поставляются начиная с v2.6+.</Note>
|
||||
|
||||
### Отозвать ключ (только при утечке / в экстренных случаях)
|
||||
|
||||
|
||||
@@ -101,10 +101,6 @@ Twenty поддерживает различные типы полей:
|
||||
|
||||
Если вы получаете ошибку при установке уникальности, проверьте дублирующиеся значения в ваших данных (включая удаленные записи).
|
||||
|
||||
## Индексы (расширенный режим)
|
||||
|
||||
Индексы базы данных управляются автоматически — добавлять собственные почти никогда не требуется и при этом легко допустить ошибку. При включенном расширенном режиме у каждого объекта есть раздел **Индексы** в `Settings → Data Model → <object>` для случаев, когда вы знаете, что вам нужен индекс.
|
||||
|
||||
## Лучшие практики в настройке полей
|
||||
|
||||
### Именование и ограничения
|
||||
|
||||
@@ -44,53 +44,8 @@ Bir Twenty uygulamasının **veri katmanı**, uygulamanızın bir çalışma ala
|
||||
| **Nesne** | Kendi alanlarına sahip yeni bir özel kayıt türü (ör. PostCard, Invoice) | `defineObject()` |
|
||||
| **Alan** | Bir nesne üzerindeki sütun. Bağımsız alanlar, oluşturmadığınız nesneleri genişletebilir (ör. Company nesnesine `loyaltyTier` ekleyin) | `defineField()` |
|
||||
| **İlişki** | İki nesne arasında, her iki tarafı da alan olarak bildirilmiş çift yönlü bir bağlantı | `defineField()` ile `FieldType.RELATION` |
|
||||
| **Dizin** | Nesnelerinizden biri üzerinde yinelenen bir sorguyu hızlandırmak için bir veritabanı dizini | `defineIndex()` |
|
||||
|
||||
SDK bunları derleme zamanında AST analiziyle algılar, bu yüzden dosya organizasyonu size kalmıştır — kullanılan gelenek `src/objects/`, `src/fields/` ve `src/indexes/` dizinleridir. Kararlı `universalIdentifier` UUID’leri, dağıtımlar arasında her şeyi birbirine bağlar.
|
||||
|
||||
## Dizinler (İsteğe bağlı)
|
||||
|
||||
Uygulamalar, yinelenen sorguları hızlı tutmak için nesneleriyle birlikte dizinler sunabilir. En yaygın durum, sık okuduğunuz bir durum ya da yabancı anahtar sütunudur.
|
||||
|
||||
```ts src/indexes/post-card-status.index.ts
|
||||
import { defineIndex } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
} from '../objects/post-card.object';
|
||||
|
||||
export default defineIndex({
|
||||
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff0',
|
||||
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff1',
|
||||
fieldUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Benzersiz dizinler
|
||||
|
||||
`defineIndex`, hem tek sütunlu hem çok sütunlu benzersizlik için `isUnique: true` kabul eder. Önerilen yöntem budur — `defineField({ isUnique: true })` kullanımdan kaldırılmıştır ve gelecekteki bir sürümde kaldırılacaktır.
|
||||
|
||||
```ts
|
||||
defineIndex({
|
||||
universalIdentifier: '…',
|
||||
objectUniversalIdentifier: PERSON_UNIVERSAL_IDENTIFIER,
|
||||
isUnique: true,
|
||||
fields: [{ universalIdentifier: '…', fieldUniversalIdentifier: EMAIL_FIELD_UNIVERSAL_IDENTIFIER }],
|
||||
});
|
||||
```
|
||||
|
||||
### Diğer kısıtlamalar
|
||||
|
||||
* Kısmi `WHERE` koşulları yönetici kontrolü altında kalır — uygulamalar bunları tanımlayamaz.
|
||||
* Her nesne, 10 özel dizin ile sınırlandırılmıştır (framework'ün kendi dizinleri buna dahil değildir).
|
||||
|
||||
`fields` dizisini, Postgres'in kullanması gereken şekilde sıralayın — en soldaki sütun ilk, bir telefon rehberinde olduğu gibi. Dizinler bedava değildir: tabloya yapılan her yazma işlemi bunları günceller. Bir dizini yalnızca ona ihtiyaç duyan bir sorgunuz olduğunda ekleyin.
|
||||
SDK bunları derleme zamanında AST analiziyle algılar, bu yüzden dosya organizasyonu size kalmıştır — kullanılan gelenek `src/objects/` ve `src/fields/` dizinleridir. Kararlı `universalIdentifier` UUID’leri, dağıtımlar arasında her şeyi birbirine bağlar.
|
||||
|
||||
<Note>
|
||||
**Application Config** veya **Roles & Permissions** mı arıyorsunuz? Bunlar, ekledikleri verilerden çok uygulamanın kendisini tanımlar — [Config](/l/tr/developers/extend/apps/config/overview) altında bulunurlar. **Connections** (Linear, GitHub, Slack OAuth) mı arıyorsunuz? Bunlar, mantık fonksiyonları *içinden* çağrılmak için vardır ve [Logic](/l/tr/developers/extend/apps/logic/connections) altında bulunurlar.
|
||||
|
||||
@@ -70,7 +70,7 @@ filters: [
|
||||
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `BOOLEAN` | `IS` |
|
||||
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `UUID` | `IS` |
|
||||
| `TS_VECTOR` | `VECTOR_SEARCH` |
|
||||
|
||||
> Benzer adlara sahip alan türleri tamamen farklı işleçler kullanabilir — buna `SELECT` ve `MULTI_SELECT` yaygın bir örnektir.
|
||||
|
||||
@@ -16,9 +16,10 @@ Her anahtar bir `publicKey` (önceden verilmiş belirteçleri doğrulayabilmesi
|
||||
|
||||
### Geçerli anahtarı döndür
|
||||
|
||||
Etkinleştirmek için `SIGNING_KEY_ROTATION_DAYS` değerini ayarlayın: günlük bir cron görevi, mevcut anahtar bu eşikten daha eski olduğunda yeni bir güncel anahtar oluşturur. Önceki anahtarlar *iptal edilmez*, bu nedenle onlarla imzalanmış belirteçler doğrulanmaya devam eder. Otomatik döndürmeyi devre dışı bırakmak için değişkeni ayarlamadan bırakın.
|
||||
* **El ile** — geçerli satırda **Settings → Admin Panel → Signing keys → Revoke**. İptal etmek, şifrelenmiş özel materyalini siler ve onu geçerli olmaktan çıkarır; bir sonraki imzalama çağrısı, yeni geçerli olarak otomatik olarak yeni bir ES256 anahtar çifti oluşturur. Herhangi başka bir (iptal edilmemiş) `kid` ile imzalanan belirteçler, süreleri dolana kadar doğrulanmaya devam eder.
|
||||
* **Kurumsal (otomatik)** — günlük bir cron (`'15 3 * * *'` UTC), mevcut anahtar `SIGNING_KEY_ROTATION_DAYS` (varsayılan `90`) kadar süredir geçerli olduğunda yeni bir geçerli anahtar verir. Önceki anahtar *iptal edilmez*, bu nedenle onunla imzalanan belirteçler doğrulanmaya devam eder. `yarn command:prod cron:register:all` ile bir kez kaydedin.
|
||||
|
||||
<Note>Otomatik döndürme v2.6+ ile birlikte gelir.</Note>
|
||||
<Note>Kurumsal cron ve `SIGNING_KEY_ROTATION_DAYS` v2.6+ ile birlikte gelir.</Note>
|
||||
|
||||
### Bir anahtarı iptal et (yalnızca sızıntı / acil durum için)
|
||||
|
||||
|
||||
@@ -101,10 +101,6 @@ Farklı kayıtların aynı değere sahip olamaması için bir alanı benzersiz y
|
||||
|
||||
Benzersizliği ayarlarken bir hata alırsanız, verilerinizde (silinen kayıtlar dahil) yinelenen değerleri kontrol edin.
|
||||
|
||||
## Dizinler (Gelişmiş)
|
||||
|
||||
Veritabanı dizinleri otomatik olarak yönetilir — kendi dizinlerinizi eklemeniz nadiren gereklidir ve yanlış yapmak da kolaydır. Gelişmiş mod açıkken, bir dizine ihtiyacınız olduğunu bildiğiniz durumlar için her nesnenin `Ayarlar → Veri Modeli → <object>` altında bir **Dizinler** bölümü bulunur.
|
||||
|
||||
## Alan Yapılandırma En İyi Uygulamaları
|
||||
|
||||
### Adlandırma Kuralları ve Kısıtlamaları
|
||||
|
||||
@@ -44,53 +44,8 @@ Twenty 应用的 **数据层(data layer)** 是你的应用*添加*到工作
|
||||
| **对象** | 具有自有字段的新自定义记录类型(例如 PostCard、Invoice) | `defineObject()` |
|
||||
| **字段** | 对象上的一列。 独立字段可以扩展你未创建的对象(例如向 Company 添加 `loyaltyTier`) | `defineField()` |
|
||||
| **关系** | 两个对象之间的双向链接——双方都声明为字段 | 使用 `defineField()` 并指定 `FieldType.RELATION` |
|
||||
| **索引** | 用于加速在某个对象上经常执行的查询的数据库索引 | `defineIndex()` |
|
||||
|
||||
SDK 会在构建时通过 AST 分析检测这些内容,因此文件组织方式由你决定——约定是使用 `src/objects/`、`src/fields/` 和 `src/indexes/`。 稳定的 `universalIdentifier` UUID 在不同部署之间将一切关联在一起。
|
||||
|
||||
## 索引(可选)
|
||||
|
||||
应用可以随对象一同提供索引,以确保经常执行的查询保持快速。 最常见的情况是某个你经常读取的状态列或外键列。
|
||||
|
||||
```ts src/indexes/post-card-status.index.ts
|
||||
import { defineIndex } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
} from '../objects/post-card.object';
|
||||
|
||||
export default defineIndex({
|
||||
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff0',
|
||||
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: 'b6e9d2a1-5a4c-46ca-9d52-42c8f02d1ff1',
|
||||
fieldUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### 唯一索引
|
||||
|
||||
`defineIndex` 接受 `isUnique: true`,可同时用于单列和多列表达唯一性约束。 这是推荐的基础方式——`defineField({ isUnique: true })` 已被弃用,并将在未来的版本中移除。
|
||||
|
||||
```ts
|
||||
defineIndex({
|
||||
universalIdentifier: '…',
|
||||
objectUniversalIdentifier: PERSON_UNIVERSAL_IDENTIFIER,
|
||||
isUnique: true,
|
||||
fields: [{ universalIdentifier: '…', fieldUniversalIdentifier: EMAIL_FIELD_UNIVERSAL_IDENTIFIER }],
|
||||
});
|
||||
```
|
||||
|
||||
### 其他约束
|
||||
|
||||
* 部分 `WHERE` 子句由管理员控制——应用无法声明它们。
|
||||
* 每个对象最多只能有 10 个自定义索引(框架自身的索引不计入其中)。
|
||||
|
||||
按照 Postgres 使用索引的方式来排列 `fields` 数组——最左边的列放在最前面,就像电话簿一样。 索引不是免费的:对表的每一次写入都会更新索引。 只有当你确实有查询需要某个索引时才添加它。
|
||||
SDK 会在构建时通过 AST 分析检测这些内容,因此文件组织方式由你决定——约定是使用 `src/objects/` 和 `src/fields/`。 稳定的 `universalIdentifier` UUID 在不同部署之间将一切关联在一起。
|
||||
|
||||
<Note>
|
||||
在找 **Application Config** 或 **Roles & Permissions** 吗? 这些描述的是应用本身而不是它添加的数据——相关内容位于 [Config](/l/zh/developers/extend/apps/config/overview) 下。 在找 **Connections**(Linear、GitHub、Slack OAuth)吗? 这些用于*从*逻辑函数中调用,并位于 [Logic](/l/zh/developers/extend/apps/logic/connections) 下。
|
||||
|
||||
@@ -70,7 +70,7 @@ filters: [
|
||||
| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `BOOLEAN` | `IS` |
|
||||
| `UUID` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` |
|
||||
| `UUID` | `IS` |
|
||||
| `TS_VECTOR` | `VECTOR_SEARCH` |
|
||||
|
||||
> 名称相似的字段类型可以使用完全不同的运算符 —— `SELECT` 和 `MULTI_SELECT` 就是常见情况。
|
||||
|
||||
@@ -16,9 +16,10 @@ Twenty 具有两个相互独立的密钥族:
|
||||
|
||||
### 轮换当前密钥
|
||||
|
||||
将 `SIGNING_KEY_ROTATION_DAYS` 设为启用该功能:然后每日的 cron 任务会在现有密钥超过该阈值后签发新的当前密钥。 先前的密钥*不会*被吊销,因此在其下签名的令牌仍然可以通过验证。 将变量保持未设置以禁用自动轮换。
|
||||
* **手动** — 在当前行上执行 **Settings → Admin Panel → Signing keys → Revoke**。 吊销操作会清除其加密的私有材料并将其降级;下一次签名调用会自动生成一个新的 ES256 密钥对,作为新的当前密钥。 在任何其他(未被吊销的)`kid` 下签名的令牌会一直验证,直到过期。
|
||||
* **Enterprise(自动)** — 每日 cron(`'15 3 * * *'` UTC)会在现有密钥已作为当前密钥使用 `SIGNING_KEY_ROTATION_DAYS`(默认 `90`)后签发一个新的当前密钥。 先前的密钥*不会*被吊销,因此在其下签名的令牌仍然可以通过验证。 使用 `yarn command:prod cron:register:all` 注册一次。
|
||||
|
||||
<Note>自动轮换在 v2.6+ 中提供。</Note>
|
||||
<Note>Enterprise 的 cron 和 `SIGNING_KEY_ROTATION_DAYS` 从 v2.6+ 版本开始提供。</Note>
|
||||
|
||||
### 吊销密钥(仅限泄露 / 紧急情况)
|
||||
|
||||
|
||||
@@ -101,10 +101,6 @@ Twenty 支持多种字段类型:
|
||||
|
||||
如果在设置唯一性时出现错误,请检查数据中是否有重复值(包括删除的记录)。
|
||||
|
||||
## 索引(高级)
|
||||
|
||||
数据库索引由系统自动管理——自己添加索引通常没必要,而且也很容易出错。 在开启高级模式后,每个对象在 `Settings → Data Model → <object>` 下都会有一个 **Indexes** 部分,供你在确实需要索引时使用。
|
||||
|
||||
## 字段配置最佳实践
|
||||
|
||||
### 命名约定和限制
|
||||
|
||||
@@ -101,10 +101,6 @@ Make a field unique to ensure distinct records cannot have the same value. For e
|
||||
|
||||
If you get an error when setting uniqueness, check for duplicate values in your data (including deleted records).
|
||||
|
||||
## Indexes (Advanced)
|
||||
|
||||
Database indexes are managed automatically — adding your own is rarely necessary and easy to get wrong. With Advanced mode on, each object has an **Indexes** section under `Settings → Data Model → <object>` for the cases where you know you need one.
|
||||
|
||||
## Field Configuration Best Practices
|
||||
|
||||
### Naming Conventions and Limitations
|
||||
|
||||
@@ -66,16 +66,10 @@
|
||||
],
|
||||
"inputs": [
|
||||
"{projectRoot}/scripts/front-component-stories/**/*",
|
||||
"{projectRoot}/src/__stories__/html-tag/**/*",
|
||||
"{projectRoot}/src/__stories__/host-api/**/*",
|
||||
"{projectRoot}/src/__stories__/showcase/**/*",
|
||||
"{projectRoot}/src/__stories__/shared/front-components/**/*",
|
||||
"{projectRoot}/src/__stories__/example-sources/*",
|
||||
"{workspaceRoot}/packages/twenty-sdk/src/cli/utilities/build/**/*"
|
||||
],
|
||||
"outputs": [
|
||||
"{projectRoot}/src/__stories__/example-sources-built/*",
|
||||
"{projectRoot}/src/__stories__/example-sources-built-preact/*"
|
||||
],
|
||||
"outputs": ["{projectRoot}/src/__stories__/example-sources-built/*"],
|
||||
"options": {
|
||||
"command": "tsx {projectRoot}/scripts/front-component-stories/build-source-examples.ts"
|
||||
}
|
||||
|
||||
+28
-52
@@ -6,7 +6,10 @@ import { fileURLToPath } from 'node:url';
|
||||
import { getFrontComponentBuildPlugins } from 'twenty-sdk/front-component-renderer/build';
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const storiesDir = path.resolve(dirname, '../../src/__stories__');
|
||||
const exampleSourcesDir = path.resolve(
|
||||
dirname,
|
||||
'../../src/__stories__/example-sources',
|
||||
);
|
||||
const exampleSourcesBuiltDir = path.resolve(
|
||||
dirname,
|
||||
'../../src/__stories__/example-sources-built',
|
||||
@@ -16,8 +19,6 @@ const exampleSourcesBuiltPreactDir = path.resolve(
|
||||
'../../src/__stories__/example-sources-built-preact',
|
||||
);
|
||||
|
||||
const SOURCE_SCAN_ROOTS = ['html-tag', 'host-api', 'showcase'];
|
||||
|
||||
const rootNodeModules = path.resolve(dirname, '../../../../node_modules');
|
||||
|
||||
const twentyUiIndividualIndex = path.resolve(
|
||||
@@ -70,69 +71,44 @@ const storyAlias = {
|
||||
...twentySharedAliases,
|
||||
};
|
||||
|
||||
const ENTRY_POINT_PATTERN = /\.front-component\.tsx$/;
|
||||
|
||||
const findEntryPointFiles = (directory: string): string[] => {
|
||||
const result: string[] = [];
|
||||
|
||||
if (!fs.existsSync(directory)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const dirent of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
const absolutePath = path.join(directory, dirent.name);
|
||||
|
||||
if (dirent.isDirectory()) {
|
||||
if (dirent.name === 'shared') {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push(...findEntryPointFiles(absolutePath));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!dirent.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ENTRY_POINT_PATTERN.test(dirent.name)) {
|
||||
result.push(absolutePath);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
const STORY_COMPONENTS = [
|
||||
'static.front-component',
|
||||
'interactive.front-component',
|
||||
'lifecycle.front-component',
|
||||
'chakra-example.front-component',
|
||||
'tailwind-example.front-component',
|
||||
'emotion-example.front-component',
|
||||
'styled-components-example.front-component',
|
||||
'shadcn-example.front-component',
|
||||
'mui-example.front-component',
|
||||
'twenty-ui-example.front-component',
|
||||
'sdk-context-example.front-component',
|
||||
'form-events.front-component',
|
||||
'keyboard-events.front-component',
|
||||
'host-api-calls.front-component',
|
||||
'caret-preservation.front-component',
|
||||
'file-input.front-component',
|
||||
];
|
||||
|
||||
const resolveEntryPoints = (): Record<string, string> => {
|
||||
const files = SOURCE_SCAN_ROOTS.flatMap((root) =>
|
||||
findEntryPointFiles(path.join(storiesDir, root)),
|
||||
);
|
||||
|
||||
const entryPoints: Record<string, string> = {};
|
||||
|
||||
for (const filePath of files) {
|
||||
const basename = path.basename(filePath).replace(/\.tsx$/, '');
|
||||
for (const name of STORY_COMPONENTS) {
|
||||
const filePath = path.join(exampleSourcesDir, `${name}.tsx`);
|
||||
|
||||
if (entryPoints[basename] !== undefined) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(
|
||||
`Duplicate front-component basename "${basename}" found at ${filePath} and ${entryPoints[basename]}`,
|
||||
`Story component source file not found: ${filePath}\n` +
|
||||
`Ensure the file exists in ${exampleSourcesDir} and the name in STORY_COMPONENTS is correct.`,
|
||||
);
|
||||
}
|
||||
|
||||
entryPoints[basename] = filePath;
|
||||
}
|
||||
|
||||
if (Object.keys(entryPoints).length === 0) {
|
||||
throw new Error(
|
||||
`No front-component source files found under ${storiesDir} (scanned: ${SOURCE_SCAN_ROOTS.join(', ')})`,
|
||||
);
|
||||
entryPoints[name] = filePath;
|
||||
}
|
||||
|
||||
return entryPoints;
|
||||
};
|
||||
|
||||
const STORY_COMPONENTS = Object.keys(resolveEntryPoints());
|
||||
|
||||
type BundleSizeEntry = {
|
||||
name: string;
|
||||
reactBytes: number;
|
||||
|
||||
+11
-78
@@ -107,66 +107,6 @@ const generateCommonEventsType = (
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
sourceFile.addVariableStatement({
|
||||
declarationKind: VariableDeclarationKind.Const,
|
||||
declarations: [
|
||||
{
|
||||
name: 'createSerializedEventConfig',
|
||||
initializer: (writer) => {
|
||||
writer.write(
|
||||
'(eventType: string): RemoteElementEventListenerDefinition => (',
|
||||
);
|
||||
writer.block(() => {
|
||||
writer.writeLine(
|
||||
'dispatchEvent(this: Element, eventData: SerializedEventData) {',
|
||||
);
|
||||
writer.indent(() => {
|
||||
writer.writeLine('applySerializedEventTargetProperties(');
|
||||
writer.indent(() => {
|
||||
writer.writeLine('this as unknown as Record<string, unknown>,');
|
||||
writer.writeLine('eventData,');
|
||||
});
|
||||
writer.writeLine(');');
|
||||
writer.blankLine();
|
||||
writer.writeLine('return new CustomEvent(eventType, {');
|
||||
writer.indent(() => {
|
||||
writer.writeLine('detail: eventData,');
|
||||
});
|
||||
writer.writeLine('}) as RemoteEvent<SerializedEventData>;');
|
||||
});
|
||||
writer.writeLine('},');
|
||||
});
|
||||
writer.write(')');
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
sourceFile.addVariableStatement({
|
||||
declarationKind: VariableDeclarationKind.Const,
|
||||
declarations: [
|
||||
{
|
||||
name: 'HTML_COMMON_EVENTS_CONFIG',
|
||||
initializer: (writer) => {
|
||||
writer.writeLine('Object.fromEntries(');
|
||||
writer.indent(() => {
|
||||
writer.writeLine(
|
||||
`${TYPE_NAMES.COMMON_EVENTS_ARRAY}.map((eventType) => [`,
|
||||
);
|
||||
writer.indent(() => {
|
||||
writer.writeLine('eventType,');
|
||||
writer.writeLine('createSerializedEventConfig(eventType),');
|
||||
});
|
||||
writer.writeLine(']),');
|
||||
});
|
||||
writer.write(
|
||||
`) as RemoteElementEventListenersDefinition<${TYPE_NAMES.COMMON_EVENTS}>`,
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const generateCommonPropertiesConfig = (
|
||||
@@ -306,19 +246,17 @@ const generateElementDefinition = (
|
||||
}
|
||||
}
|
||||
if (hasEvents) {
|
||||
writer.write('events: ');
|
||||
writer.block(() => {
|
||||
if (hasCommonHtmlEvents) {
|
||||
writer.writeLine('...HTML_COMMON_EVENTS_CONFIG,');
|
||||
}
|
||||
const formattedCustomEvents = customEvents
|
||||
.map((event) => `'${event}'`)
|
||||
.join(', ');
|
||||
|
||||
for (const event of customEvents) {
|
||||
writer.writeLine(
|
||||
`'${event}': createSerializedEventConfig('${event}'),`,
|
||||
);
|
||||
}
|
||||
});
|
||||
writer.write(',');
|
||||
writer.write(
|
||||
hasCommonHtmlEvents && customEvents.length > 0
|
||||
? `events: [...${TYPE_NAMES.COMMON_EVENTS_ARRAY}, ${formattedCustomEvents}],`
|
||||
: hasCommonHtmlEvents
|
||||
? `events: [...${TYPE_NAMES.COMMON_EVENTS_ARRAY}],`
|
||||
: `events: [${formattedCustomEvents}],`,
|
||||
);
|
||||
writer.newLine();
|
||||
}
|
||||
});
|
||||
@@ -394,17 +332,12 @@ export const generateRemoteElements = (
|
||||
INTERNAL_ELEMENT_CLASSES.ROOT,
|
||||
INTERNAL_ELEMENT_CLASSES.FRAGMENT,
|
||||
{ name: 'RemoteEvent', isTypeOnly: true },
|
||||
{ name: 'RemoteElementEventListenerDefinition', isTypeOnly: true },
|
||||
{ name: 'RemoteElementEventListenersDefinition', isTypeOnly: true },
|
||||
],
|
||||
});
|
||||
|
||||
sourceFile.addImportDeclaration({
|
||||
moduleSpecifier: '@/constants/SerializedEventData',
|
||||
namedImports: [
|
||||
'applySerializedEventTargetProperties',
|
||||
{ name: 'SerializedEventData', isTypeOnly: true },
|
||||
],
|
||||
namedImports: [{ name: 'SerializedEventData', isTypeOnly: true }],
|
||||
});
|
||||
|
||||
const commonPropertyNames = new Set(Object.keys(commonProperties));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import bundleSizes from '@/__stories__/example-sources-built/bundle-sizes.json';
|
||||
import bundleSizes from './example-sources-built/bundle-sizes.json';
|
||||
|
||||
type BundleSizeEntry = {
|
||||
name: string;
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
||||
import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { FrontComponentRenderer } from '../host/components/FrontComponentRenderer';
|
||||
|
||||
import { getBuiltStoryComponentPathForRender } from './utils/getBuiltStoryComponentPathForRender';
|
||||
|
||||
const errorHandler = fn();
|
||||
|
||||
const createHostApiMocks = () => ({
|
||||
navigate: fn().mockResolvedValue(undefined),
|
||||
enqueueSnackbar: fn().mockResolvedValue(undefined),
|
||||
openSidePanelPage: fn().mockResolvedValue(undefined),
|
||||
closeSidePanel: fn().mockResolvedValue(undefined),
|
||||
unmountFrontComponent: fn().mockResolvedValue(undefined),
|
||||
updateProgress: fn().mockResolvedValue(undefined),
|
||||
requestAccessTokenRefresh: fn().mockResolvedValue('refreshed-token'),
|
||||
openCommandConfirmationModal: fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
const meta: Meta<typeof FrontComponentRenderer> = {
|
||||
title: 'FrontComponent/EventForwarding',
|
||||
component: FrontComponentRenderer,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
args: {
|
||||
onError: errorHandler,
|
||||
applicationAccessToken: 'fake-token',
|
||||
executionContext: {
|
||||
frontComponentId: 'storybook-test',
|
||||
userId: null,
|
||||
recordId: null,
|
||||
selectedRecordIds: [],
|
||||
},
|
||||
colorScheme: 'light',
|
||||
frontComponentHostCommunicationApi: createHostApiMocks(),
|
||||
},
|
||||
beforeEach: () => {
|
||||
errorHandler.mockClear();
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof FrontComponentRenderer>;
|
||||
|
||||
const MOUNT_TIMEOUT = 30000;
|
||||
const INTERACTION_TIMEOUT = 5000;
|
||||
const HOST_API_TIMEOUT = 10000;
|
||||
|
||||
const createComponentStory = (
|
||||
name: string,
|
||||
options?: { play?: Story['play'] },
|
||||
): Story => ({
|
||||
args: {
|
||||
componentUrl: getBuiltStoryComponentPathForRender(
|
||||
`${name}.front-component`,
|
||||
),
|
||||
},
|
||||
...(options?.play ? { play: options.play } : {}),
|
||||
});
|
||||
|
||||
const createHostApiStory = (play: Story['play']): Story => ({
|
||||
...createComponentStory('host-api-calls'),
|
||||
args: {
|
||||
...createComponentStory('host-api-calls').args,
|
||||
frontComponentHostCommunicationApi: createHostApiMocks(),
|
||||
},
|
||||
play,
|
||||
});
|
||||
|
||||
export const FormTextInput: Story = createComponentStory('form-events', {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByTestId(
|
||||
'form-events-component',
|
||||
{},
|
||||
{ timeout: MOUNT_TIMEOUT },
|
||||
);
|
||||
|
||||
const textInput = await canvas.findByTestId('text-input');
|
||||
await userEvent.type(textInput, 'hello');
|
||||
|
||||
expect(
|
||||
await canvas.findByText('hello', {}, { timeout: INTERACTION_TIMEOUT }),
|
||||
).toBeVisible();
|
||||
},
|
||||
});
|
||||
|
||||
export const FormCheckbox: Story = createComponentStory('form-events', {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByTestId(
|
||||
'form-events-component',
|
||||
{},
|
||||
{ timeout: MOUNT_TIMEOUT },
|
||||
);
|
||||
|
||||
const checkbox = await canvas.findByTestId('checkbox-input');
|
||||
await userEvent.click(checkbox);
|
||||
|
||||
expect(
|
||||
await canvas.findByText('true', {}, { timeout: INTERACTION_TIMEOUT }),
|
||||
).toBeVisible();
|
||||
},
|
||||
});
|
||||
|
||||
export const FormFocusAndBlur: Story = createComponentStory('form-events', {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByTestId(
|
||||
'form-events-component',
|
||||
{},
|
||||
{ timeout: MOUNT_TIMEOUT },
|
||||
);
|
||||
|
||||
const textInput = await canvas.findByTestId('text-input');
|
||||
await userEvent.click(textInput);
|
||||
|
||||
expect(
|
||||
await canvas.findByText('focused', {}, { timeout: INTERACTION_TIMEOUT }),
|
||||
).toBeVisible();
|
||||
|
||||
await userEvent.click(await canvas.findByTestId('form-events-component'));
|
||||
|
||||
expect(
|
||||
await canvas.findByText('blurred', {}, { timeout: INTERACTION_TIMEOUT }),
|
||||
).toBeVisible();
|
||||
},
|
||||
});
|
||||
|
||||
export const FormSubmission: Story = createComponentStory('form-events', {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByTestId(
|
||||
'form-events-component',
|
||||
{},
|
||||
{ timeout: MOUNT_TIMEOUT },
|
||||
);
|
||||
|
||||
const textInput = await canvas.findByTestId('text-input');
|
||||
await userEvent.type(textInput, 'hello');
|
||||
|
||||
const checkbox = await canvas.findByTestId('checkbox-input');
|
||||
await userEvent.click(checkbox);
|
||||
|
||||
const submitButton = await canvas.findByTestId('submit-button');
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
expect(
|
||||
await canvas.findByText(
|
||||
'{"text":"hello","checkbox":true}',
|
||||
{},
|
||||
{ timeout: INTERACTION_TIMEOUT },
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
});
|
||||
|
||||
export const KeyboardBasicInput: Story = createComponentStory(
|
||||
'keyboard-events',
|
||||
{
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByTestId(
|
||||
'keyboard-events-component',
|
||||
{},
|
||||
{ timeout: MOUNT_TIMEOUT },
|
||||
);
|
||||
|
||||
const input = await canvas.findByTestId('keyboard-input');
|
||||
await userEvent.click(input);
|
||||
|
||||
await userEvent.keyboard('a');
|
||||
|
||||
expect(
|
||||
await canvas.findByText('a', {}, { timeout: INTERACTION_TIMEOUT }),
|
||||
).toBeVisible();
|
||||
|
||||
expect(
|
||||
await canvas.findByText('KeyA', {}, { timeout: INTERACTION_TIMEOUT }),
|
||||
).toBeVisible();
|
||||
|
||||
expect(
|
||||
await canvas.findByText(
|
||||
/^[1-9]\d*$/,
|
||||
{},
|
||||
{ timeout: INTERACTION_TIMEOUT },
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const KeyboardModifiers: Story = createComponentStory(
|
||||
'keyboard-events',
|
||||
{
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByTestId(
|
||||
'keyboard-events-component',
|
||||
{},
|
||||
{ timeout: MOUNT_TIMEOUT },
|
||||
);
|
||||
|
||||
const input = await canvas.findByTestId('keyboard-input');
|
||||
await userEvent.click(input);
|
||||
|
||||
await userEvent.keyboard('{Shift>}b{/Shift}');
|
||||
|
||||
expect(
|
||||
await canvas.findByText('shift', {}, { timeout: INTERACTION_TIMEOUT }),
|
||||
).toBeVisible();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const HostApiNavigate: Story = createHostApiStory(
|
||||
async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const api = args.frontComponentHostCommunicationApi!;
|
||||
|
||||
await canvas.findByTestId(
|
||||
'host-api-calls-component',
|
||||
{},
|
||||
{ timeout: MOUNT_TIMEOUT },
|
||||
);
|
||||
|
||||
const navigateBtn = await canvas.findByTestId('btn-navigate');
|
||||
await userEvent.click(navigateBtn);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(api.navigate).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: HOST_API_TIMEOUT },
|
||||
);
|
||||
|
||||
expect(
|
||||
await canvas.findByText(
|
||||
'navigate:success',
|
||||
{},
|
||||
{ timeout: INTERACTION_TIMEOUT },
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
export const HostApiSnackbar: Story = createHostApiStory(
|
||||
async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const api = args.frontComponentHostCommunicationApi!;
|
||||
|
||||
await canvas.findByTestId(
|
||||
'host-api-calls-component',
|
||||
{},
|
||||
{ timeout: MOUNT_TIMEOUT },
|
||||
);
|
||||
|
||||
const snackbarBtn = await canvas.findByTestId('btn-snackbar');
|
||||
await userEvent.click(snackbarBtn);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(api.enqueueSnackbar).toHaveBeenCalledWith({
|
||||
message: 'Test notification',
|
||||
variant: 'success',
|
||||
});
|
||||
},
|
||||
{ timeout: HOST_API_TIMEOUT },
|
||||
);
|
||||
|
||||
expect(
|
||||
await canvas.findByText(
|
||||
'snackbar:success',
|
||||
{},
|
||||
{ timeout: INTERACTION_TIMEOUT },
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
export const HostApiProgress: Story = createHostApiStory(
|
||||
async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const api = args.frontComponentHostCommunicationApi!;
|
||||
|
||||
await canvas.findByTestId(
|
||||
'host-api-calls-component',
|
||||
{},
|
||||
{ timeout: MOUNT_TIMEOUT },
|
||||
);
|
||||
|
||||
const progressBtn = await canvas.findByTestId('btn-progress');
|
||||
await userEvent.click(progressBtn);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(api.updateProgress).toHaveBeenCalledWith(50);
|
||||
},
|
||||
{ timeout: HOST_API_TIMEOUT },
|
||||
);
|
||||
|
||||
expect(
|
||||
await canvas.findByText(
|
||||
'progress:success',
|
||||
{},
|
||||
{ timeout: INTERACTION_TIMEOUT },
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
const TYPING_TIMEOUT = 10000;
|
||||
|
||||
const expectCaretAt = async (
|
||||
element: HTMLInputElement | HTMLTextAreaElement,
|
||||
position: number,
|
||||
): Promise<void> => {
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(element.selectionStart).toBe(position);
|
||||
expect(element.selectionEnd).toBe(position);
|
||||
},
|
||||
{ timeout: TYPING_TIMEOUT },
|
||||
);
|
||||
};
|
||||
|
||||
export const InputCaretPreservedMidString: Story = createComponentStory(
|
||||
'caret-preservation',
|
||||
{
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByTestId(
|
||||
'caret-preservation-component',
|
||||
{},
|
||||
{ timeout: MOUNT_TIMEOUT },
|
||||
);
|
||||
|
||||
const input = (await canvas.findByTestId(
|
||||
'caret-text-input',
|
||||
)) as HTMLInputElement;
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(input.value).toBe('Hello world');
|
||||
},
|
||||
{ timeout: INTERACTION_TIMEOUT },
|
||||
);
|
||||
|
||||
input.focus();
|
||||
input.setSelectionRange(4, 4);
|
||||
|
||||
await userEvent.keyboard('X');
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(input.value).toBe('HellXo world');
|
||||
expect(canvas.getByTestId('caret-text-value').textContent).toBe(
|
||||
'HellXo world',
|
||||
);
|
||||
},
|
||||
{ timeout: TYPING_TIMEOUT },
|
||||
);
|
||||
|
||||
await expectCaretAt(input, 5);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const TextareaCaretPreservedMidString: Story = createComponentStory(
|
||||
'caret-preservation',
|
||||
{
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByTestId(
|
||||
'caret-preservation-component',
|
||||
{},
|
||||
{ timeout: MOUNT_TIMEOUT },
|
||||
);
|
||||
|
||||
const textarea = (await canvas.findByTestId(
|
||||
'caret-textarea-input',
|
||||
)) as HTMLTextAreaElement;
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(textarea.value).toBe('Hello world');
|
||||
},
|
||||
{ timeout: INTERACTION_TIMEOUT },
|
||||
);
|
||||
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(4, 4);
|
||||
|
||||
await userEvent.keyboard('X');
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(textarea.value).toBe('HellXo world');
|
||||
expect(canvas.getByTestId('caret-textarea-value').textContent).toBe(
|
||||
'HellXo world',
|
||||
);
|
||||
},
|
||||
{ timeout: TYPING_TIMEOUT },
|
||||
);
|
||||
|
||||
await expectCaretAt(textarea, 5);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const FileInputSingle: Story = createComponentStory('file-input', {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByTestId(
|
||||
'file-input-component',
|
||||
{},
|
||||
{ timeout: MOUNT_TIMEOUT },
|
||||
);
|
||||
|
||||
const input = (await canvas.findByTestId(
|
||||
'single-file-input',
|
||||
)) as HTMLInputElement;
|
||||
|
||||
const file = new File(['hello world'], 'hello.txt', {
|
||||
type: 'text/plain',
|
||||
lastModified: 1700000000000,
|
||||
});
|
||||
|
||||
await userEvent.upload(input, file);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(canvas.getByTestId('single-file-count').textContent).toBe('1');
|
||||
expect(canvas.getByTestId('single-file-name').textContent).toContain(
|
||||
'hello.txt',
|
||||
);
|
||||
expect(canvas.getByTestId('single-file-name').textContent).toContain(
|
||||
'text/plain',
|
||||
);
|
||||
},
|
||||
{ timeout: INTERACTION_TIMEOUT },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const FileInputMultiple: Story = createComponentStory('file-input', {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByTestId(
|
||||
'file-input-component',
|
||||
{},
|
||||
{ timeout: MOUNT_TIMEOUT },
|
||||
);
|
||||
|
||||
const input = (await canvas.findByTestId(
|
||||
'multi-file-input',
|
||||
)) as HTMLInputElement;
|
||||
|
||||
const first = new File(['a'], 'one.png', { type: 'image/png' });
|
||||
const second = new File(['bb'], 'two.png', { type: 'image/png' });
|
||||
|
||||
await userEvent.upload(input, [first, second]);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(canvas.getByTestId('multi-file-count').textContent).toBe('2');
|
||||
const list = canvas.getByTestId('multi-file-list');
|
||||
expect(list.textContent).toContain('one.png');
|
||||
expect(list.textContent).toContain('two.png');
|
||||
},
|
||||
{ timeout: INTERACTION_TIMEOUT },
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const HostApiClosePanel: Story = createHostApiStory(
|
||||
async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const api = args.frontComponentHostCommunicationApi!;
|
||||
|
||||
await canvas.findByTestId(
|
||||
'host-api-calls-component',
|
||||
{},
|
||||
{ timeout: MOUNT_TIMEOUT },
|
||||
);
|
||||
|
||||
const closePanelBtn = await canvas.findByTestId('btn-close-panel');
|
||||
await userEvent.click(closePanelBtn);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(api.closeSidePanel).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: HOST_API_TIMEOUT },
|
||||
);
|
||||
|
||||
expect(
|
||||
await canvas.findByText(
|
||||
'closePanel:success',
|
||||
{},
|
||||
{ timeout: INTERACTION_TIMEOUT },
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
);
|
||||
+3
-2
@@ -1,8 +1,9 @@
|
||||
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
||||
import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
|
||||
import { getBuiltStoryComponentPathForRender } from '@/__stories__/utils/getBuiltStoryComponentPathForRender';
|
||||
import { FrontComponentRenderer } from '../host/components/FrontComponentRenderer';
|
||||
|
||||
import { getBuiltStoryComponentPathForRender } from './utils/getBuiltStoryComponentPathForRender';
|
||||
|
||||
const errorHandler = fn();
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
||||
import { expect, fn, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
|
||||
import { getBuiltStoryComponentPathForRender } from '@/__stories__/utils/getBuiltStoryComponentPathForRender';
|
||||
import { FrontComponentRenderer } from '../host/components/FrontComponentRenderer';
|
||||
|
||||
import { getBuiltStoryComponentPathForRender } from './utils/getBuiltStoryComponentPathForRender';
|
||||
|
||||
const errorHandler = fn();
|
||||
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { type ChangeEvent, useState } from 'react';
|
||||
|
||||
const CARD_STYLE = {
|
||||
padding: 24,
|
||||
backgroundColor: '#eff6ff',
|
||||
border: '2px solid #3b82f6',
|
||||
borderRadius: 12,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: 16,
|
||||
maxWidth: 400,
|
||||
};
|
||||
|
||||
const HEADING_STYLE = {
|
||||
color: '#1e3a8a',
|
||||
fontWeight: 700,
|
||||
fontSize: 18,
|
||||
margin: 0,
|
||||
};
|
||||
|
||||
const LABEL_STYLE = {
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: '#374151',
|
||||
};
|
||||
|
||||
const HINT_STYLE = {
|
||||
fontSize: 13,
|
||||
color: '#6b7280',
|
||||
fontFamily: 'monospace',
|
||||
};
|
||||
|
||||
const INPUT_STYLE = {
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
fontFamily: 'monospace',
|
||||
};
|
||||
|
||||
const INITIAL_VALUE = 'Hello world';
|
||||
|
||||
const CaretPreservationComponent = () => {
|
||||
const [text, setText] = useState(INITIAL_VALUE);
|
||||
const [textareaText, setTextareaText] = useState(INITIAL_VALUE);
|
||||
|
||||
return (
|
||||
<div data-testid="caret-preservation-component" style={CARD_STYLE}>
|
||||
<h2 style={HEADING_STYLE}>Caret Preservation</h2>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<label style={LABEL_STYLE}>Text input (pre-filled)</label>
|
||||
<input
|
||||
data-testid="caret-text-input"
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const detail = (event as unknown as { detail: { value?: string } })
|
||||
.detail;
|
||||
setText(detail?.value ?? '');
|
||||
}}
|
||||
style={INPUT_STYLE}
|
||||
/>
|
||||
<span data-testid="caret-text-value" style={HINT_STYLE}>
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<label style={LABEL_STYLE}>Textarea (pre-filled)</label>
|
||||
<textarea
|
||||
data-testid="caret-textarea-input"
|
||||
value={textareaText}
|
||||
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const detail = (event as unknown as { detail: { value?: string } })
|
||||
.detail;
|
||||
setTextareaText(detail?.value ?? '');
|
||||
}}
|
||||
style={INPUT_STYLE}
|
||||
rows={3}
|
||||
/>
|
||||
<span data-testid="caret-textarea-value" style={HINT_STYLE}>
|
||||
{textareaText}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'test-caret-00000000-0000-0000-0000-000000000021',
|
||||
name: 'caret-preservation-component',
|
||||
description:
|
||||
'Component verifying caret position is preserved during mid-string editing of <input>/<textarea>',
|
||||
component: CaretPreservationComponent,
|
||||
});
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { type ChangeEvent, useState } from 'react';
|
||||
|
||||
type SelectedFile = {
|
||||
name: string;
|
||||
size: number;
|
||||
type: string;
|
||||
lastModified: number;
|
||||
};
|
||||
|
||||
const CARD_STYLE = {
|
||||
padding: 24,
|
||||
backgroundColor: '#fef3c7',
|
||||
border: '2px solid #f59e0b',
|
||||
borderRadius: 12,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: 16,
|
||||
maxWidth: 480,
|
||||
};
|
||||
|
||||
const HEADING_STYLE = {
|
||||
color: '#92400e',
|
||||
fontWeight: 700,
|
||||
fontSize: 18,
|
||||
margin: 0,
|
||||
};
|
||||
|
||||
const LABEL_STYLE = {
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: '#374151',
|
||||
};
|
||||
|
||||
const HINT_STYLE = {
|
||||
fontSize: 13,
|
||||
color: '#6b7280',
|
||||
fontFamily: 'monospace',
|
||||
};
|
||||
|
||||
const LIST_STYLE = {
|
||||
margin: 0,
|
||||
paddingLeft: 16,
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
color: '#374151',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: 4,
|
||||
};
|
||||
|
||||
const FileInputComponent = () => {
|
||||
const [singleFile, setSingleFile] = useState<SelectedFile | null>(null);
|
||||
const [multiFiles, setMultiFiles] = useState<SelectedFile[]>([]);
|
||||
|
||||
return (
|
||||
<div data-testid="file-input-component" style={CARD_STYLE}>
|
||||
<h2 style={HEADING_STYLE}>File Input</h2>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<label style={LABEL_STYLE}>Single file</label>
|
||||
<input
|
||||
data-testid="single-file-input"
|
||||
type="file"
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const detail = (
|
||||
event as unknown as { detail: { files?: SelectedFile[] } }
|
||||
).detail;
|
||||
setSingleFile(detail?.files?.[0] ?? null);
|
||||
}}
|
||||
/>
|
||||
<span data-testid="single-file-count" style={HINT_STYLE}>
|
||||
{singleFile === null ? 'none' : '1'}
|
||||
</span>
|
||||
{singleFile !== null && (
|
||||
<span data-testid="single-file-name" style={HINT_STYLE}>
|
||||
{singleFile.name} ({singleFile.size}B, {singleFile.type})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<label style={LABEL_STYLE}>Multiple files (image/*)</label>
|
||||
<input
|
||||
data-testid="multi-file-input"
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const detail = (
|
||||
event as unknown as { detail: { files?: SelectedFile[] } }
|
||||
).detail;
|
||||
setMultiFiles(detail?.files ?? []);
|
||||
}}
|
||||
/>
|
||||
<span data-testid="multi-file-count" style={HINT_STYLE}>
|
||||
{multiFiles.length}
|
||||
</span>
|
||||
{multiFiles.length > 0 && (
|
||||
<ul data-testid="multi-file-list" style={LIST_STYLE}>
|
||||
{multiFiles.map((file) => (
|
||||
<li key={`${file.name}-${file.lastModified}`}>
|
||||
{file.name} ({file.size}B)
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'test-file-00000000-0000-0000-0000-000000000022',
|
||||
name: 'file-input-component',
|
||||
description:
|
||||
'Component verifying file input metadata is forwarded from host to worker',
|
||||
component: FileInputComponent,
|
||||
});
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { type ChangeEvent, useState } from 'react';
|
||||
|
||||
const CARD_STYLE = {
|
||||
padding: 24,
|
||||
backgroundColor: '#f0fdf4',
|
||||
border: '2px solid #22c55e',
|
||||
borderRadius: 12,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: 16,
|
||||
maxWidth: 400,
|
||||
};
|
||||
|
||||
const HEADING_STYLE = {
|
||||
color: '#166534',
|
||||
fontWeight: 700,
|
||||
fontSize: 18,
|
||||
margin: 0,
|
||||
};
|
||||
|
||||
const LABEL_STYLE = {
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: '#374151',
|
||||
};
|
||||
|
||||
const HINT_STYLE = {
|
||||
fontSize: 13,
|
||||
color: '#6b7280',
|
||||
};
|
||||
|
||||
const INPUT_STYLE = {
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
const SUBMIT_BUTTON_STYLE = {
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#16a34a',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
};
|
||||
|
||||
const FormEventsComponent = () => {
|
||||
const [textValue, setTextValue] = useState('');
|
||||
const [checkboxValue, setCheckboxValue] = useState(false);
|
||||
const [focusState, setFocusState] = useState('none');
|
||||
const [submittedData, setSubmittedData] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div data-testid="form-events-component" style={CARD_STYLE}>
|
||||
<h2 style={HEADING_STYLE}>Form Events</h2>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<label style={LABEL_STYLE}>Text Input</label>
|
||||
<input
|
||||
data-testid="text-input"
|
||||
type="text"
|
||||
placeholder="Type here..."
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const detail = (event as unknown as { detail: { value?: string } })
|
||||
.detail;
|
||||
setTextValue(detail?.value ?? '');
|
||||
}}
|
||||
onFocus={() => setFocusState('focused')}
|
||||
onBlur={() => setFocusState('blurred')}
|
||||
style={INPUT_STYLE}
|
||||
/>
|
||||
<span data-testid="text-value" style={HINT_STYLE}>
|
||||
{textValue}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
data-testid="checkbox-input"
|
||||
type="checkbox"
|
||||
checked={checkboxValue}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const detail = (
|
||||
event as unknown as { detail: { checked?: boolean } }
|
||||
).detail;
|
||||
setCheckboxValue(detail?.checked ?? false);
|
||||
}}
|
||||
/>
|
||||
<label style={LABEL_STYLE}>Check me</label>
|
||||
<span data-testid="checkbox-value" style={HINT_STYLE}>
|
||||
{String(checkboxValue)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span data-testid="focus-state" style={HINT_STYLE}>
|
||||
{focusState}
|
||||
</span>
|
||||
|
||||
<button
|
||||
data-testid="submit-button"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSubmittedData(
|
||||
JSON.stringify({ text: textValue, checkbox: checkboxValue }),
|
||||
)
|
||||
}
|
||||
style={SUBMIT_BUTTON_STYLE}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
|
||||
{submittedData !== null && (
|
||||
<pre
|
||||
data-testid="submitted-data"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
background: '#dcfce7',
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
margin: 0,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{submittedData}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'test-form-00000000-0000-0000-0000-000000000020',
|
||||
name: 'form-events-component',
|
||||
description:
|
||||
'Component testing form input events (onChange, onFocus, onBlur, submit)',
|
||||
component: FormEventsComponent,
|
||||
});
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import {
|
||||
AppPath,
|
||||
closeSidePanel,
|
||||
enqueueSnackbar,
|
||||
navigate,
|
||||
openSidePanelPage,
|
||||
SidePanelPages,
|
||||
unmountFrontComponent,
|
||||
updateProgress,
|
||||
} from 'twenty-sdk/front-component';
|
||||
import { useState } from 'react';
|
||||
|
||||
const CARD_STYLE = {
|
||||
padding: 24,
|
||||
backgroundColor: '#faf5ff',
|
||||
border: '2px solid #a78bfa',
|
||||
borderRadius: 12,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: 10,
|
||||
maxWidth: 400,
|
||||
};
|
||||
|
||||
const HEADING_STYLE = {
|
||||
color: '#5b21b6',
|
||||
fontWeight: 700,
|
||||
fontSize: 18,
|
||||
margin: 0,
|
||||
};
|
||||
|
||||
const BUTTON_STYLE = {
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#7c3aed',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
const STATUS_STYLE = {
|
||||
fontSize: 13,
|
||||
color: '#6b7280',
|
||||
fontFamily: 'monospace',
|
||||
};
|
||||
|
||||
const HostApiCallsComponent = () => {
|
||||
const [apiStatus, setApiStatus] = useState('idle');
|
||||
|
||||
const callApi = async (name: string, apiFunction: () => Promise<void>) => {
|
||||
try {
|
||||
await apiFunction();
|
||||
setApiStatus(`${name}:success`);
|
||||
} catch (error) {
|
||||
setApiStatus(
|
||||
`${name}:error:${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="host-api-calls-component" style={CARD_STYLE}>
|
||||
<h2 style={HEADING_STYLE}>Host API Calls</h2>
|
||||
|
||||
<button
|
||||
data-testid="btn-navigate"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
callApi('navigate', () =>
|
||||
navigate(AppPath.RecordIndexPage, {
|
||||
objectNamePlural: 'companies',
|
||||
}),
|
||||
)
|
||||
}
|
||||
style={BUTTON_STYLE}
|
||||
>
|
||||
Navigate
|
||||
</button>
|
||||
|
||||
<button
|
||||
data-testid="btn-snackbar"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
callApi('snackbar', () =>
|
||||
enqueueSnackbar({
|
||||
message: 'Test notification',
|
||||
variant: 'success',
|
||||
}),
|
||||
)
|
||||
}
|
||||
style={BUTTON_STYLE}
|
||||
>
|
||||
Snackbar
|
||||
</button>
|
||||
|
||||
<button
|
||||
data-testid="btn-side-panel"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
callApi('sidePanel', () =>
|
||||
openSidePanelPage({
|
||||
page: SidePanelPages.ViewRecord,
|
||||
pageTitle: 'Test Record',
|
||||
}),
|
||||
)
|
||||
}
|
||||
style={BUTTON_STYLE}
|
||||
>
|
||||
Open Side Panel
|
||||
</button>
|
||||
|
||||
<button
|
||||
data-testid="btn-close-panel"
|
||||
type="button"
|
||||
onClick={() => callApi('closePanel', () => closeSidePanel())}
|
||||
style={BUTTON_STYLE}
|
||||
>
|
||||
Close Side Panel
|
||||
</button>
|
||||
|
||||
<button
|
||||
data-testid="btn-unmount"
|
||||
type="button"
|
||||
onClick={() => callApi('unmount', () => unmountFrontComponent())}
|
||||
style={BUTTON_STYLE}
|
||||
>
|
||||
Unmount
|
||||
</button>
|
||||
|
||||
<button
|
||||
data-testid="btn-progress"
|
||||
type="button"
|
||||
onClick={() => callApi('progress', () => updateProgress(50))}
|
||||
style={BUTTON_STYLE}
|
||||
>
|
||||
Update Progress (50)
|
||||
</button>
|
||||
|
||||
<span data-testid="api-status" style={STATUS_STYLE}>
|
||||
{apiStatus}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'test-hapi-00000000-0000-0000-0000-000000000022',
|
||||
name: 'host-api-calls-component',
|
||||
description:
|
||||
'Component testing host communication API calls (navigate, snackbar, side panel, etc.)',
|
||||
component: HostApiCallsComponent,
|
||||
});
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { type KeyboardEvent, useState } from 'react';
|
||||
|
||||
type RemoteKeyboardEventDetail = {
|
||||
key?: string;
|
||||
code?: string;
|
||||
shiftKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
metaKey?: boolean;
|
||||
altKey?: boolean;
|
||||
};
|
||||
|
||||
const KeyboardEventsComponent = () => {
|
||||
const [lastKey, setLastKey] = useState('');
|
||||
const [lastCode, setLastCode] = useState('');
|
||||
const [modifiers, setModifiers] = useState('');
|
||||
const [keyCount, setKeyCount] = useState(0);
|
||||
|
||||
// remote-dom serializes keyboard events into CustomEvent.detail
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
const data = (event as unknown as { detail: RemoteKeyboardEventDetail })
|
||||
.detail;
|
||||
|
||||
setLastKey(data.key ?? '');
|
||||
setLastCode(data.code ?? '');
|
||||
setKeyCount((previousCount) => previousCount + 1);
|
||||
|
||||
const activeModifiers: string[] = [];
|
||||
if (data.shiftKey) activeModifiers.push('shift');
|
||||
if (data.ctrlKey) activeModifiers.push('ctrl');
|
||||
if (data.metaKey) activeModifiers.push('meta');
|
||||
if (data.altKey) activeModifiers.push('alt');
|
||||
setModifiers(activeModifiers.join(','));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="keyboard-events-component"
|
||||
style={{
|
||||
padding: 24,
|
||||
backgroundColor: '#fefce8',
|
||||
border: '2px solid #eab308',
|
||||
borderRadius: 12,
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
maxWidth: 400,
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
color: '#854d0e',
|
||||
fontWeight: 700,
|
||||
fontSize: 18,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Keyboard Events
|
||||
</h2>
|
||||
|
||||
<input
|
||||
data-testid="keyboard-input"
|
||||
type="text"
|
||||
placeholder="Press keys here..."
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #d1d5db',
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<span style={{ fontSize: 13, color: '#6b7280' }}>
|
||||
Key:{' '}
|
||||
<span
|
||||
data-testid="last-key"
|
||||
style={{ fontWeight: 700, color: '#854d0e' }}
|
||||
>
|
||||
{lastKey}
|
||||
</span>
|
||||
</span>
|
||||
<span style={{ fontSize: 13, color: '#6b7280' }}>
|
||||
Code:{' '}
|
||||
<span
|
||||
data-testid="last-code"
|
||||
style={{ fontWeight: 700, color: '#854d0e' }}
|
||||
>
|
||||
{lastCode}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 13, color: '#6b7280' }}>
|
||||
Modifiers:{' '}
|
||||
<span
|
||||
data-testid="modifiers"
|
||||
style={{ fontWeight: 700, color: '#854d0e' }}
|
||||
>
|
||||
{modifiers}
|
||||
</span>
|
||||
</span>
|
||||
<span style={{ fontSize: 13, color: '#6b7280' }}>
|
||||
Key count:{' '}
|
||||
<span
|
||||
data-testid="key-count"
|
||||
style={{ fontWeight: 700, color: '#854d0e' }}
|
||||
>
|
||||
{keyCount}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'test-kbd0-00000000-0000-0000-0000-000000000021',
|
||||
name: 'keyboard-events-component',
|
||||
description:
|
||||
'Component testing keyboard event serialization (key, code, modifiers)',
|
||||
component: KeyboardEventsComponent,
|
||||
});
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
||||
import { expect, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
|
||||
import {
|
||||
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
|
||||
resetFrontComponentStoryMocks,
|
||||
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
|
||||
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
|
||||
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
|
||||
import {
|
||||
HOST_API_TIMEOUT,
|
||||
INTERACTION_TIMEOUT,
|
||||
} from '@/__stories__/shared/test-utils/timeouts';
|
||||
|
||||
const meta: Meta<typeof FrontComponentRenderer> = {
|
||||
title: 'FrontComponent/HostApi/ClosePanel',
|
||||
component: FrontComponentRenderer,
|
||||
parameters: { layout: 'centered' },
|
||||
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
|
||||
beforeEach: resetFrontComponentStoryMocks,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof FrontComponentRenderer>;
|
||||
|
||||
export const ClosePanel: Story = runFrontComponentStory({
|
||||
frontComponentBundleName: 'host-api-side-panel-close',
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const api = args.frontComponentHostCommunicationApi;
|
||||
|
||||
if (!isDefined(api)) {
|
||||
throw new Error('frontComponentHostCommunicationApi is required');
|
||||
}
|
||||
|
||||
await expectFrontComponentMounted(canvas);
|
||||
|
||||
const subject = await canvas.findByTestId('subject');
|
||||
|
||||
await userEvent.click(subject);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(api.closeSidePanel).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: HOST_API_TIMEOUT },
|
||||
);
|
||||
|
||||
expect(
|
||||
await canvas.findByText(
|
||||
'closePanel:success',
|
||||
{},
|
||||
{ timeout: INTERACTION_TIMEOUT },
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
});
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
||||
import { expect, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
|
||||
import {
|
||||
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
|
||||
resetFrontComponentStoryMocks,
|
||||
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
|
||||
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
|
||||
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
|
||||
import {
|
||||
HOST_API_TIMEOUT,
|
||||
INTERACTION_TIMEOUT,
|
||||
} from '@/__stories__/shared/test-utils/timeouts';
|
||||
|
||||
const meta: Meta<typeof FrontComponentRenderer> = {
|
||||
title: 'FrontComponent/HostApi/CopyToClipboard',
|
||||
component: FrontComponentRenderer,
|
||||
parameters: { layout: 'centered' },
|
||||
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
|
||||
beforeEach: resetFrontComponentStoryMocks,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof FrontComponentRenderer>;
|
||||
|
||||
export const CopyToClipboard: Story = runFrontComponentStory({
|
||||
frontComponentBundleName: 'host-api-copy-to-clipboard',
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const api = args.frontComponentHostCommunicationApi;
|
||||
|
||||
if (!isDefined(api)) {
|
||||
throw new Error('frontComponentHostCommunicationApi is required');
|
||||
}
|
||||
|
||||
await expectFrontComponentMounted(canvas);
|
||||
|
||||
const subject = await canvas.findByTestId('subject');
|
||||
|
||||
await userEvent.click(subject);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(api.copyToClipboard).toHaveBeenCalledWith('Hello clipboard');
|
||||
},
|
||||
{ timeout: HOST_API_TIMEOUT },
|
||||
);
|
||||
|
||||
expect(
|
||||
await canvas.findByText(
|
||||
'clipboard:success',
|
||||
{},
|
||||
{ timeout: INTERACTION_TIMEOUT },
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
});
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { copyToClipboard } from 'twenty-sdk/front-component';
|
||||
|
||||
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
|
||||
import { BUTTON_STYLE } from '@/__stories__/shared/front-components/styles';
|
||||
|
||||
const STATUS_STYLE = {
|
||||
fontSize: 13,
|
||||
color: '#1f2937',
|
||||
fontFamily: 'monospace',
|
||||
};
|
||||
|
||||
const HostApiCopyToClipboardFrontComponent = () => {
|
||||
const [status, setStatus] = useState('idle');
|
||||
|
||||
const handleClick = async () => {
|
||||
try {
|
||||
await copyToClipboard('Hello clipboard');
|
||||
setStatus('clipboard:success');
|
||||
} catch (error) {
|
||||
setStatus(
|
||||
`clipboard:error:${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FrontComponentCard title="host-api:copy-to-clipboard">
|
||||
<button
|
||||
data-testid="subject"
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
style={BUTTON_STYLE}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<span data-testid="api-status" style={STATUS_STYLE}>
|
||||
{status}
|
||||
</span>
|
||||
</FrontComponentCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'fc-host-clipboard-00000000-0000-0000-0000-000000000020',
|
||||
name: 'host-api-copy-to-clipboard-front-component',
|
||||
description: 'Front component covering copyToClipboard host API',
|
||||
component: HostApiCopyToClipboardFrontComponent,
|
||||
});
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { AppPath, navigate } from 'twenty-sdk/front-component';
|
||||
|
||||
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
|
||||
import { BUTTON_STYLE } from '@/__stories__/shared/front-components/styles';
|
||||
|
||||
const STATUS_STYLE = {
|
||||
fontSize: 13,
|
||||
color: '#1f2937',
|
||||
fontFamily: 'monospace',
|
||||
};
|
||||
|
||||
const HostApiNavigateFrontComponent = () => {
|
||||
const [status, setStatus] = useState('idle');
|
||||
|
||||
const handleClick = async () => {
|
||||
try {
|
||||
await navigate(AppPath.RecordIndexPage, {
|
||||
objectNamePlural: 'companies',
|
||||
});
|
||||
setStatus('navigate:success');
|
||||
} catch (error) {
|
||||
setStatus(
|
||||
`navigate:error:${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FrontComponentCard title="host-api:navigate">
|
||||
<button
|
||||
data-testid="subject"
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
style={BUTTON_STYLE}
|
||||
>
|
||||
Navigate
|
||||
</button>
|
||||
<span data-testid="api-status" style={STATUS_STYLE}>
|
||||
{status}
|
||||
</span>
|
||||
</FrontComponentCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'fc-host-navigate-00000000-0000-0000-0000-000000000020',
|
||||
name: 'host-api-navigate-front-component',
|
||||
description: 'Front component covering navigate host API',
|
||||
component: HostApiNavigateFrontComponent,
|
||||
});
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { updateProgress } from 'twenty-sdk/front-component';
|
||||
|
||||
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
|
||||
import { BUTTON_STYLE } from '@/__stories__/shared/front-components/styles';
|
||||
|
||||
const STATUS_STYLE = {
|
||||
fontSize: 13,
|
||||
color: '#1f2937',
|
||||
fontFamily: 'monospace',
|
||||
};
|
||||
|
||||
const HostApiProgressFrontComponent = () => {
|
||||
const [status, setStatus] = useState('idle');
|
||||
|
||||
const handleClick = async () => {
|
||||
try {
|
||||
await updateProgress(50);
|
||||
setStatus('progress:success');
|
||||
} catch (error) {
|
||||
setStatus(
|
||||
`progress:error:${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FrontComponentCard title="host-api:progress">
|
||||
<button
|
||||
data-testid="subject"
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
style={BUTTON_STYLE}
|
||||
>
|
||||
Update Progress
|
||||
</button>
|
||||
<span data-testid="api-status" style={STATUS_STYLE}>
|
||||
{status}
|
||||
</span>
|
||||
</FrontComponentCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'fc-host-progress-00000000-0000-0000-0000-000000000020',
|
||||
name: 'host-api-progress-front-component',
|
||||
description: 'Front component covering updateProgress host API',
|
||||
component: HostApiProgressFrontComponent,
|
||||
});
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { closeSidePanel } from 'twenty-sdk/front-component';
|
||||
|
||||
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
|
||||
import { BUTTON_STYLE } from '@/__stories__/shared/front-components/styles';
|
||||
|
||||
const STATUS_STYLE = {
|
||||
fontSize: 13,
|
||||
color: '#1f2937',
|
||||
fontFamily: 'monospace',
|
||||
};
|
||||
|
||||
const HostApiSidePanelCloseFrontComponent = () => {
|
||||
const [status, setStatus] = useState('idle');
|
||||
|
||||
const handleClick = async () => {
|
||||
try {
|
||||
await closeSidePanel();
|
||||
setStatus('closePanel:success');
|
||||
} catch (error) {
|
||||
setStatus(
|
||||
`closePanel:error:${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FrontComponentCard title="host-api:side-panel:close">
|
||||
<button
|
||||
data-testid="subject"
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
style={BUTTON_STYLE}
|
||||
>
|
||||
Close Side Panel
|
||||
</button>
|
||||
<span data-testid="api-status" style={STATUS_STYLE}>
|
||||
{status}
|
||||
</span>
|
||||
</FrontComponentCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'fc-host-spc-00000000-0000-0000-0000-000000000020',
|
||||
name: 'host-api-side-panel-close-front-component',
|
||||
description: 'Front component covering closeSidePanel host API',
|
||||
component: HostApiSidePanelCloseFrontComponent,
|
||||
});
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { openSidePanelPage, SidePanelPages } from 'twenty-sdk/front-component';
|
||||
|
||||
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
|
||||
import { BUTTON_STYLE } from '@/__stories__/shared/front-components/styles';
|
||||
|
||||
const STATUS_STYLE = {
|
||||
fontSize: 13,
|
||||
color: '#1f2937',
|
||||
fontFamily: 'monospace',
|
||||
};
|
||||
|
||||
const HostApiSidePanelOpenFrontComponent = () => {
|
||||
const [status, setStatus] = useState('idle');
|
||||
|
||||
const handleClick = async () => {
|
||||
try {
|
||||
await openSidePanelPage({
|
||||
page: SidePanelPages.ViewRecord,
|
||||
pageTitle: 'Test Record',
|
||||
});
|
||||
setStatus('sidePanel:success');
|
||||
} catch (error) {
|
||||
setStatus(
|
||||
`sidePanel:error:${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FrontComponentCard title="host-api:side-panel:open">
|
||||
<button
|
||||
data-testid="subject"
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
style={BUTTON_STYLE}
|
||||
>
|
||||
Open Side Panel
|
||||
</button>
|
||||
<span data-testid="api-status" style={STATUS_STYLE}>
|
||||
{status}
|
||||
</span>
|
||||
</FrontComponentCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'fc-host-spo-00000000-0000-0000-0000-000000000020',
|
||||
name: 'host-api-side-panel-open-front-component',
|
||||
description: 'Front component covering openSidePanelPage host API',
|
||||
component: HostApiSidePanelOpenFrontComponent,
|
||||
});
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { enqueueSnackbar } from 'twenty-sdk/front-component';
|
||||
|
||||
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
|
||||
import { BUTTON_STYLE } from '@/__stories__/shared/front-components/styles';
|
||||
|
||||
const STATUS_STYLE = {
|
||||
fontSize: 13,
|
||||
color: '#1f2937',
|
||||
fontFamily: 'monospace',
|
||||
};
|
||||
|
||||
const HostApiSnackbarFrontComponent = () => {
|
||||
const [status, setStatus] = useState('idle');
|
||||
|
||||
const handleClick = async () => {
|
||||
try {
|
||||
await enqueueSnackbar({
|
||||
message: 'Test notification',
|
||||
variant: 'success',
|
||||
});
|
||||
setStatus('snackbar:success');
|
||||
} catch (error) {
|
||||
setStatus(
|
||||
`snackbar:error:${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FrontComponentCard title="host-api:snackbar">
|
||||
<button
|
||||
data-testid="subject"
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
style={BUTTON_STYLE}
|
||||
>
|
||||
Snackbar
|
||||
</button>
|
||||
<span data-testid="api-status" style={STATUS_STYLE}>
|
||||
{status}
|
||||
</span>
|
||||
</FrontComponentCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'fc-host-snackbar-00000000-0000-0000-0000-000000000020',
|
||||
name: 'host-api-snackbar-front-component',
|
||||
description: 'Front component covering enqueueSnackbar host API',
|
||||
component: HostApiSnackbarFrontComponent,
|
||||
});
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { unmountFrontComponent } from 'twenty-sdk/front-component';
|
||||
|
||||
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
|
||||
import { BUTTON_STYLE } from '@/__stories__/shared/front-components/styles';
|
||||
|
||||
const STATUS_STYLE = {
|
||||
fontSize: 13,
|
||||
color: '#1f2937',
|
||||
fontFamily: 'monospace',
|
||||
};
|
||||
|
||||
const HostApiUnmountFrontComponent = () => {
|
||||
const [status, setStatus] = useState('idle');
|
||||
|
||||
const handleClick = async () => {
|
||||
try {
|
||||
await unmountFrontComponent();
|
||||
setStatus('unmount:success');
|
||||
} catch (error) {
|
||||
setStatus(
|
||||
`unmount:error:${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FrontComponentCard title="host-api:unmount">
|
||||
<button
|
||||
data-testid="subject"
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
style={BUTTON_STYLE}
|
||||
>
|
||||
Unmount
|
||||
</button>
|
||||
<span data-testid="api-status" style={STATUS_STYLE}>
|
||||
{status}
|
||||
</span>
|
||||
</FrontComponentCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'fc-host-unmount-00000000-0000-0000-0000-000000000020',
|
||||
name: 'host-api-unmount-front-component',
|
||||
description: 'Front component covering unmountFrontComponent host API',
|
||||
component: HostApiUnmountFrontComponent,
|
||||
});
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
||||
import { expect, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
|
||||
import {
|
||||
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
|
||||
resetFrontComponentStoryMocks,
|
||||
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
|
||||
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
|
||||
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
|
||||
import {
|
||||
HOST_API_TIMEOUT,
|
||||
INTERACTION_TIMEOUT,
|
||||
} from '@/__stories__/shared/test-utils/timeouts';
|
||||
|
||||
const meta: Meta<typeof FrontComponentRenderer> = {
|
||||
title: 'FrontComponent/HostApi/Navigate',
|
||||
component: FrontComponentRenderer,
|
||||
parameters: { layout: 'centered' },
|
||||
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
|
||||
beforeEach: resetFrontComponentStoryMocks,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof FrontComponentRenderer>;
|
||||
|
||||
export const Navigate: Story = runFrontComponentStory({
|
||||
frontComponentBundleName: 'host-api-navigate',
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const api = args.frontComponentHostCommunicationApi;
|
||||
|
||||
if (!isDefined(api)) {
|
||||
throw new Error('frontComponentHostCommunicationApi is required');
|
||||
}
|
||||
|
||||
await expectFrontComponentMounted(canvas);
|
||||
|
||||
const subject = await canvas.findByTestId('subject');
|
||||
|
||||
await userEvent.click(subject);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(api.navigate).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: HOST_API_TIMEOUT },
|
||||
);
|
||||
|
||||
expect(
|
||||
await canvas.findByText(
|
||||
'navigate:success',
|
||||
{},
|
||||
{ timeout: INTERACTION_TIMEOUT },
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
});
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
||||
import { expect, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
|
||||
import {
|
||||
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
|
||||
resetFrontComponentStoryMocks,
|
||||
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
|
||||
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
|
||||
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
|
||||
import {
|
||||
HOST_API_TIMEOUT,
|
||||
INTERACTION_TIMEOUT,
|
||||
} from '@/__stories__/shared/test-utils/timeouts';
|
||||
|
||||
const meta: Meta<typeof FrontComponentRenderer> = {
|
||||
title: 'FrontComponent/HostApi/Progress',
|
||||
component: FrontComponentRenderer,
|
||||
parameters: { layout: 'centered' },
|
||||
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
|
||||
beforeEach: resetFrontComponentStoryMocks,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof FrontComponentRenderer>;
|
||||
|
||||
export const Progress: Story = runFrontComponentStory({
|
||||
frontComponentBundleName: 'host-api-progress',
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const api = args.frontComponentHostCommunicationApi;
|
||||
|
||||
if (!isDefined(api)) {
|
||||
throw new Error('frontComponentHostCommunicationApi is required');
|
||||
}
|
||||
|
||||
await expectFrontComponentMounted(canvas);
|
||||
|
||||
const subject = await canvas.findByTestId('subject');
|
||||
|
||||
await userEvent.click(subject);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(api.updateProgress).toHaveBeenCalledWith(50);
|
||||
},
|
||||
{ timeout: HOST_API_TIMEOUT },
|
||||
);
|
||||
|
||||
expect(
|
||||
await canvas.findByText(
|
||||
'progress:success',
|
||||
{},
|
||||
{ timeout: INTERACTION_TIMEOUT },
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
});
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
||||
import { expect, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
|
||||
import {
|
||||
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
|
||||
resetFrontComponentStoryMocks,
|
||||
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
|
||||
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
|
||||
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
|
||||
import {
|
||||
HOST_API_TIMEOUT,
|
||||
INTERACTION_TIMEOUT,
|
||||
} from '@/__stories__/shared/test-utils/timeouts';
|
||||
|
||||
const meta: Meta<typeof FrontComponentRenderer> = {
|
||||
title: 'FrontComponent/HostApi/Snackbar',
|
||||
component: FrontComponentRenderer,
|
||||
parameters: { layout: 'centered' },
|
||||
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
|
||||
beforeEach: resetFrontComponentStoryMocks,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof FrontComponentRenderer>;
|
||||
|
||||
export const Snackbar: Story = runFrontComponentStory({
|
||||
frontComponentBundleName: 'host-api-snackbar',
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const api = args.frontComponentHostCommunicationApi;
|
||||
|
||||
if (!isDefined(api)) {
|
||||
throw new Error('frontComponentHostCommunicationApi is required');
|
||||
}
|
||||
|
||||
await expectFrontComponentMounted(canvas);
|
||||
|
||||
const subject = await canvas.findByTestId('subject');
|
||||
|
||||
await userEvent.click(subject);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(api.enqueueSnackbar).toHaveBeenCalledWith({
|
||||
message: 'Test notification',
|
||||
variant: 'success',
|
||||
});
|
||||
},
|
||||
{ timeout: HOST_API_TIMEOUT },
|
||||
);
|
||||
|
||||
expect(
|
||||
await canvas.findByText(
|
||||
'snackbar:success',
|
||||
{},
|
||||
{ timeout: INTERACTION_TIMEOUT },
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
});
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import {
|
||||
EventLog,
|
||||
useEventLog,
|
||||
} from '@/__stories__/shared/front-components/event-log';
|
||||
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
|
||||
import { FILL_RECT_STYLE } from '@/__stories__/shared/front-components/styles';
|
||||
|
||||
const IframeClickFrontComponent = () => {
|
||||
const [interactionCount, setInteractionCount] = useState(0);
|
||||
const { entries, pushEvent } = useEventLog();
|
||||
|
||||
return (
|
||||
<FrontComponentCard title="iframe:click">
|
||||
<iframe
|
||||
data-testid="subject"
|
||||
title="probe"
|
||||
onClick={(event) => {
|
||||
setInteractionCount((previous) => previous + 1);
|
||||
pushEvent(event);
|
||||
}}
|
||||
tabIndex={0}
|
||||
style={{ ...FILL_RECT_STYLE, height: 80 }}
|
||||
/>
|
||||
<span data-testid="front-component-value">{interactionCount}</span>
|
||||
<EventLog entries={entries} />
|
||||
</FrontComponentCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'fc-iframe-c-click-00000000-0000-0000-0000-000000000020',
|
||||
name: 'iframe-click-front-component',
|
||||
description: 'Front component covering click on <iframe>',
|
||||
component: IframeClickFrontComponent,
|
||||
});
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
import { type Meta } from '@storybook/react-vite';
|
||||
|
||||
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
|
||||
import {
|
||||
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
|
||||
resetFrontComponentStoryMocks,
|
||||
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
|
||||
import {
|
||||
createHtmlTagClickStory,
|
||||
createHtmlTagFocusStory,
|
||||
} from '@/__stories__/shared/test-utils/createHtmlElementStory';
|
||||
|
||||
const meta: Meta<typeof FrontComponentRenderer> = {
|
||||
title: 'FrontComponent/HtmlTag/Embedded/Iframe/Events',
|
||||
component: FrontComponentRenderer,
|
||||
parameters: { layout: 'centered' },
|
||||
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
|
||||
beforeEach: resetFrontComponentStoryMocks,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Click = createHtmlTagClickStory({
|
||||
frontComponentBundleName: 'iframe-click',
|
||||
});
|
||||
|
||||
export const FocusBlur = createHtmlTagFocusStory({
|
||||
frontComponentBundleName: 'iframe-focus-blur',
|
||||
});
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import {
|
||||
EventLog,
|
||||
useEventLog,
|
||||
} from '@/__stories__/shared/front-components/event-log';
|
||||
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
|
||||
import { FILL_RECT_STYLE } from '@/__stories__/shared/front-components/styles';
|
||||
|
||||
const IframeFocusBlurFrontComponent = () => {
|
||||
const [interactionCount, setInteractionCount] = useState(0);
|
||||
const { entries, pushEvent } = useEventLog();
|
||||
|
||||
return (
|
||||
<FrontComponentCard title="iframe:focus-blur">
|
||||
<iframe
|
||||
data-testid="subject"
|
||||
title="probe"
|
||||
onFocus={(event) => {
|
||||
setInteractionCount((previous) => previous + 1);
|
||||
pushEvent(event);
|
||||
}}
|
||||
onBlur={(event) => pushEvent(event)}
|
||||
tabIndex={0}
|
||||
style={{ ...FILL_RECT_STYLE, height: 80 }}
|
||||
/>
|
||||
<span data-testid="front-component-value">{interactionCount}</span>
|
||||
<EventLog entries={entries} />
|
||||
</FrontComponentCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'fc-iframe-fb-fb-00000000-0000-0000-0000-000000000020',
|
||||
name: 'iframe-focus-blur-front-component',
|
||||
description: 'Front component covering focus-blur on <iframe>',
|
||||
component: IframeFocusBlurFrontComponent,
|
||||
});
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import {
|
||||
EventLog,
|
||||
useEventLog,
|
||||
} from '@/__stories__/shared/front-components/event-log';
|
||||
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
|
||||
import { FILL_RECT_STYLE } from '@/__stories__/shared/front-components/styles';
|
||||
|
||||
const ImgClickFrontComponent = () => {
|
||||
const [interactionCount, setInteractionCount] = useState(0);
|
||||
const { entries, pushEvent } = useEventLog();
|
||||
|
||||
return (
|
||||
<FrontComponentCard title="img:click">
|
||||
<img
|
||||
data-testid="subject"
|
||||
src="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='80' height='40'/>"
|
||||
alt="probe"
|
||||
onClick={(event) => {
|
||||
setInteractionCount((previous) => previous + 1);
|
||||
pushEvent(event);
|
||||
}}
|
||||
tabIndex={0}
|
||||
style={{ ...FILL_RECT_STYLE, height: 40 }}
|
||||
/>
|
||||
<span data-testid="front-component-value">{interactionCount}</span>
|
||||
<EventLog entries={entries} />
|
||||
</FrontComponentCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'fc-img-c-click-00000000-0000-0000-0000-000000000020',
|
||||
name: 'img-click-front-component',
|
||||
description: 'Front component covering click on <img>',
|
||||
component: ImgClickFrontComponent,
|
||||
});
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
import { type Meta } from '@storybook/react-vite';
|
||||
|
||||
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
|
||||
import {
|
||||
FRONT_COMPONENT_STORY_DEFAULT_ARGS,
|
||||
resetFrontComponentStoryMocks,
|
||||
} from '@/__stories__/shared/test-utils/createFrontComponentStoryMeta';
|
||||
import {
|
||||
createHtmlTagClickStory,
|
||||
createHtmlTagFocusStory,
|
||||
} from '@/__stories__/shared/test-utils/createHtmlElementStory';
|
||||
|
||||
const meta: Meta<typeof FrontComponentRenderer> = {
|
||||
title: 'FrontComponent/HtmlTag/Embedded/Img/Events',
|
||||
component: FrontComponentRenderer,
|
||||
parameters: { layout: 'centered' },
|
||||
args: FRONT_COMPONENT_STORY_DEFAULT_ARGS,
|
||||
beforeEach: resetFrontComponentStoryMocks,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Click = createHtmlTagClickStory({
|
||||
frontComponentBundleName: 'img-click',
|
||||
});
|
||||
|
||||
export const FocusBlur = createHtmlTagFocusStory({
|
||||
frontComponentBundleName: 'img-focus-blur',
|
||||
});
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import {
|
||||
EventLog,
|
||||
useEventLog,
|
||||
} from '@/__stories__/shared/front-components/event-log';
|
||||
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
|
||||
import { FILL_RECT_STYLE } from '@/__stories__/shared/front-components/styles';
|
||||
|
||||
const ImgFocusBlurFrontComponent = () => {
|
||||
const [interactionCount, setInteractionCount] = useState(0);
|
||||
const { entries, pushEvent } = useEventLog();
|
||||
|
||||
return (
|
||||
<FrontComponentCard title="img:focus-blur">
|
||||
<img
|
||||
data-testid="subject"
|
||||
src="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='80' height='40'/>"
|
||||
alt="probe"
|
||||
onFocus={(event) => {
|
||||
setInteractionCount((previous) => previous + 1);
|
||||
pushEvent(event);
|
||||
}}
|
||||
onBlur={(event) => pushEvent(event)}
|
||||
tabIndex={0}
|
||||
style={{ ...FILL_RECT_STYLE, height: 40 }}
|
||||
/>
|
||||
<span data-testid="front-component-value">{interactionCount}</span>
|
||||
<EventLog entries={entries} />
|
||||
</FrontComponentCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: 'fc-img-fb-fb-00000000-0000-0000-0000-000000000020',
|
||||
name: 'img-focus-blur-front-component',
|
||||
description: 'Front component covering focus-blur on <img>',
|
||||
component: ImgFocusBlurFrontComponent,
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user