Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code 78c4f9c73c fix: add defensive check for package.json in copyDependenciesInMemory
https://sonarly.com/issue/32080?type=bug

Logic function execution fails with "File not found" error when attempting to build Lambda dependency layers because the code unconditionally downloads package.json from S3 without checking if it exists first. Upgraded workspaces lack this file entirely, causing FileStorageException.

Fix: The fix implements a defensive check for the `package.json` file in `copyDependenciesInMemory()` method, preventing FileStorageException when the file is missing from S3.

**Changes made:**

1. Added import for `SEED_DEPENDENCIES_DIRNAME` constant which points to the seed-dependencies directory containing default dependency files
2. Added `path` to the path module import (previously only importing `dirname` and `join`)
3. Modified `copyDependenciesInMemory()` to check if both `package.json` and `yarn.lock` exist in parallel using `Promise.all()`
4. Added conditional logic for `package.json`: if it exists in S3, download it; otherwise, copy the seed package.json from the local seed-dependencies directory
5. Kept existing conditional logic for `yarn.lock` unchanged

**Why this fixes the issue:**

Workspaces created before the application-system feature, or upgraded from earlier versions, do not have dependency files in S3. When logic functions are executed, the system attempts to retrieve these files. Previously, the code unconditionally tried to download `package.json` without checking if it existed first, causing FileStorageException(FILE_NOT_FOUND) which prevented the entire logic function build process.

By checking file existence first and falling back to the seed package.json (which contains all necessary production dependencies), logic functions can now execute successfully on any workspace, regardless of when it was created or whether it was upgraded.

This follows the exact same defensive pattern already implemented for `yarn.lock` handling and matches the reference fix that was previously developed (commit a730cf3f7d).
2026-04-28 12:50:14 +00:00
Abdul RahmanandGitHub a710c105cf Fix orphan views by deferring record table widget view creation to dashboard save (#20006) 2026-04-28 10:06:26 +00:00
Sai Sathwik PandGitHub a5cd64daf5 refactor: standardize JsonStringified casing (#20101)
## Summary
- Rename safeParseRelativeDateFilterJSONStringified to
safeParseRelativeDateFilterJsonStringified
- Update the matching utility file, exports, tests, and workflow usages

Part of #19839.

## Validation
- CI passed
2026-04-28 09:01:26 +00:00
df3e217d64 fix(ai-billing): bill executeAgent in a finally block so failed runs don't leak (#20065)
## Summary

`AgentAsyncExecutorService.executeAgent` consumes Anthropic tokens at
two points (the main `generateText` and the optional structured-output
sub-call). Billing was previously the **caller's** responsibility,
executed only after `executeAgent` returned. If `executeAgent` threw —
e.g. when `structuredResult.output == null` for a schema-mismatched
response, or anything caught by the catch-and-rethrow — we paid
Anthropic but never recorded a `usageEvent`. Likely the dominant source
of the 716M-token-vs-3.27-credits discrepancy seen on the affected
workspace in the 2026-04-26 incident.

## What changed

- Inject `AiBillingService` into `AgentAsyncExecutorService`. Add
`workspaceId` (required), `userWorkspaceId`, and `operationType`
(default `AI_WORKFLOW_TOKEN`) to `executeAgent`'s args.
- Capture `accumulatedUsage`, `cacheCreationTokens`, and
`nativeWebSearchCallCount` into mutable locals as each `generateText`
resolves. A throw between the main and structured-output calls still
bills the first call's tokens; the schema-validation throw still bills
the merged usage.
- Wrap the body in `try { ... } finally { ... }`. The finally calls
`calculateAndBillUsage` and `billNativeWebSearchUsage`, each guarded by
its own `try/catch + logger.error` so a billing exception can't mask the
original execution error or block the second emit.
- `ai-agent.workflow-action.ts`: pass the new args; drop the
now-redundant billing calls and `AiBillingService` injection.
`AiBillingModule` removed from this action's module imports.
- `run-evaluation-input.job.ts`: pass `workspaceId` (already in `data`)
and `userWorkspaceId: null`. **As a side effect, the eval pipeline now
bills correctly** — closing an additional billing leak from the audit
(`RunEvaluationInputJob` previously called `executeAgent` and discarded
`executionResult.usage`).

## Behavior change worth calling out

Previously, failed agent executions were silently free. They will now be
billed for the tokens Anthropic charged us. This is intentional and
correct.

## Test plan
- [ ] Trigger a workflow agent action that succeeds — `usageEvent` count
should match what was previously emitted.
- [ ] Trigger a workflow agent with a JSON response schema and ambiguous
input that produces a non-schema-conforming output
(`structuredResult.output == null`) — verify a `usageEvent` row is now
written for the consumed tokens (was 0 rows previously).
- [ ] Trigger a `runEvaluationInput` GraphQL mutation — verify a
`usageEvent` row is written (was 0 rows previously).

## Notes for review
- Conflicts trivially with the Sentry-context PR on
`run-evaluation-input.job.ts`. Recommend merging Sentry-context first;
this PR's 2-line argument addition rebases inside that PR's
`aiCallContextService.run(...)` callback wrapper.
- A small follow-up after this lands: thread `billingContext` through
the `experimental_repairToolCall` callback in
`agent-async-executor.service.ts:198` (currently marked with a TODO from
the title-gen+repair-tool PR).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:43:53 +02:00
1c06506256 chore: sync AI model catalog from models.dev (#20106)
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).

This PR updates pricing, context windows, and model availability based
on the latest data.
New models meeting inclusion criteria (tool calling, pricing data,
context limits) are added automatically.
Deprecated models are detected based on cost-efficiency within the same
model family.

**Please review before merging** — verify no critical models were
incorrectly deprecated.

Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
2026-04-28 08:49:33 +02:00
Mohamed Houssein DouiciandGitHub d271ba06f1 chore: use navigation menu item type into the generated navigation menu layout (#20099)
This is a small PR to fix an issue that came up when I created an object
with the default navigation menu item.

<img width="811" height="461" alt="image"
src="https://github.com/user-attachments/assets/70c7f55d-3d41-48ed-8cf9-5f649ea41f49"
/>
2026-04-28 06:23:00 +00:00
7d35979fcf i18n - docs translations (#20100)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-28 00:31:22 +02:00
8d3047c1fc i18n - docs translations (#20097)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-27 22:44:15 +02:00
Félix MalfaitGitHubClaude Opus 4.7claude[bot] <41898282+claude[bot]@users.noreply.github.com>
e632b7dbb9 fix(ai-billing): bill POST /rest/ai/generate-text usage to ClickHouse (#20066)
## Summary

`POST /rest/ai/generate-text` calls `generateText` and returns `usage`
to the client without emitting a `usageEvent`. Authenticated, gated only
by `PermissionFlagType.AI` — any workspace user with that permission
could call it in a loop without billing. Identified during the
2026-04-26 incident audit.

## What changed

- Inject `AiBillingService` into `AiGenerateTextController`.
- Add `@AuthUserWorkspaceId() userWorkspaceId: string` to source the
user-workspace identifier.
- Wrap the `generateText` call in `try { ... return ... } finally { ...
}` so billing fires even if the controller throws after Anthropic was
paid.
- Bill with `UsageOperationType.AI_WORKFLOW_TOKEN` and
`cacheCreationTokens: result.usage.inputTokenDetails?.cacheWriteTokens
?? 0`.
- Inner `try/catch` around the billing emit so a billing error can't
break the response.
- One-line module change: `AiGenerateTextModule` imports
`AiBillingModule` (NestJS DI requirement).

## Test plan
- [ ] Call `POST /rest/ai/generate-text` with a small prompt; verify a
`usageEvent` row appears in ClickHouse for the workspace with the
correct token count and `operationType = AI_WORKFLOW_TOKEN`.
- [ ] Call with a malformed model id that throws after the API key is
validated — verify no spurious billing call occurs (no Anthropic call
was made).

## Notes for review
- Response shape unchanged.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-04-27 21:16:19 +02:00
5b3ee3f1a7 fix(ai-billing): bill thread title generation and tool-call repair (#20067)
## Summary

Two `generateText` call sites were unbilled — identified during the
2026-04-26 incident audit:

- **Thread title generation**
(`AgentTitleGenerationService.generateThreadTitle`) — fires once per new
chat thread; low-volume but completeness matters.
- **Tool-call repair** (`repairToolCall` util, used inside
`experimental_repairToolCall` callbacks) — can fire `MAX_STEPS` times
per agent turn if a model gets stuck producing malformed tool calls.

## What changed

- `AgentTitleGenerationService` — inject `AiBillingService`, expand
`generateThreadTitle` signature to accept `workspaceId` and
`userWorkspaceId`, bill in a `finally` block with
`UsageOperationType.AI_CHAT_TOKEN`. Restructured so the no-default-model
short-circuit happens before the `try`, avoiding a fake billing call.
- `repair-tool-call.util.ts` — added an optional `billingContext` arg
containing `aiBillingService`, `modelId`, `workspaceId`,
`userWorkspaceId`, `operationType`. Wraps the `generateText` call in
`try/finally`; bills in finally with the operation type provided by the
caller. Optional so existing callers keep compiling.
- `chat-execution.service.ts` — threads `billingContext`
(`AI_CHAT_TOKEN`) into the repair callback.
- `agent-chat.service.ts` — passes `workspaceId` and
`thread.userWorkspaceId` to `generateThreadTitle`.
- `agent-async-executor.service.ts` — TODO comment marking that the
repair callback should thread billing once `executeAgent` accepts
`workspaceId` (depends on the executeAgent-finally PR).

## Follow-up

After the executeAgent-finally PR lands, `workspaceId` is in scope at
the `experimental_repairToolCall` callback in
`agent-async-executor.service.ts:198`. Thread `billingContext` through
to fire repair-call billing for workflow agents too, and remove the
TODO. Tiny follow-up.

## Test plan
- [ ] Create a new chat thread; verify a `usageEvent` row is written for
the title generation call.
- [ ] Send a chat message that triggers tool-call repair (e.g. force a
malformed tool call); verify a `usageEvent` row for the repair sub-call.

## Notes for review
- `billingContext` arg made **optional** to allow incremental rollout —
the executeAgent-finally PR plus a small follow-up will close the
agent-async-executor side.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:54:40 +02:00
875795cc30 feat(sentry): propagate workspace context to all spans (#20064)
## Summary

After the 2026-04-26 token-usage incident, identifying the responsible
workspace from a Sentry trace required a Postgres scavenger hunt —
Vercel AI SDK auto-instrumentation captures token counts and model name
but no twenty-specific identifiers, and that same gap exists for every
other auto-instrumented span (HTTP outbound, Postgres queries, GraphQL
resolvers, Redis, etc.).

This PR plugs that gap globally, not just for AI:

- A small utility
(`packages/twenty-server/src/engine/core-modules/sentry/utils/sentry-workspace-context.util.ts`)
that writes workspace identifiers onto Sentry's active isolation scope
as a `twenty` context block plus filterable tags and a `Sentry.setUser`
call.
- Two hook points covering all server traffic:
- **`WorkspaceAuthContextMiddleware`** — already runs after token
hydration on the GraphQL, metadata, admin-panel, and REST routes. It now
calls the utility once per authenticated request, before delegating to
`withWorkspaceAuthContext`.
- **`BullMQDriver.work` and `SyncDriver.processJob`** — every queue job
now runs inside `Sentry.withIsolationScope` and applies workspace
context from `job.data.workspaceId` (skipping silently for system jobs
that don't carry one).
- A `beforeSendSpan` hook in `instrument.ts` that reads the scope's
`twenty` context block back and projects it onto every span as
`twenty.workspace.id` and (when available) `twenty.user_workspace.id` —
dotted-namespace naming consistent with OTel/Sentry conventions like
`user.id` and `http.response.status_code`. Spans without a workspace
context (unauthenticated traffic) pass through untouched.

## Why this shape

Sentry's docs position `beforeSendSpan` as a per-span hook. The previous
iteration set context only at AI-specific call sites, which left non-AI
spans (DB queries, outbound HTTP, regular GraphQL queries, workflow
steps not touching AI) entirely unenriched. Hooking the two existing
global boundaries — auth middleware for HTTP/GraphQL/REST, and the queue
driver `work()` callback for background jobs — covers every
authenticated span across the app with no per-handler instrumentation.

## What's not in this PR

AI-specific identifiers (`twenty.agent.id`, `twenty.thread.id`,
`twenty.turn.id`, `twenty.workflow_run.id`) are out of scope here.
They're useful additions but require either propagating the IDs through
the call stack or a more fine-grained scope (per-step, per-turn) than
the request/job boundary, which is best handled in follow-up PRs that
target those specific call sites.

## Test plan

- [ ] Make any authenticated GraphQL request locally and confirm the
resulting span(s) in Sentry carry `twenty.workspace.id` and (for
user-authenticated routes) `twenty.user_workspace.id`.
- [ ] Make any authenticated REST request and confirm the same.
- [ ] Trigger a queue job (chat stream, agent turn evaluation, workflow
run, etc.) and confirm spans produced inside the worker carry
`twenty.workspace.id`.
- [ ] Confirm that DB and outbound HTTP spans produced under the
request/job also carry the workspace tag — these previously had no
twenty-specific identifiers.
- [ ] In the Sentry UI, filter events by the `twenty.workspace.id` tag
and confirm matching events appear.

## Notes for review

- Sentry init lives in `instrument.ts`, loaded before Nest bootstraps,
so `beforeSendSpan` runs outside Nest DI and reads context off the
isolation scope rather than holding a service reference.
- The middleware change is three lines; the BullMQ wrap is a single
`Sentry.withIsolationScope` around the existing job handler body; the
SyncDriver wrap mirrors it for the dev/test path. No new modules or DI
providers.
- The previous iteration's `AiCallContextService` and per-handler
`setContext` / `withContext` calls have been removed in favor of these
two hooks.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:53:50 +02:00
350b835fbc i18n - docs translations (#20095)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-27 20:48:05 +02:00
ef49dad0bc i18n - docs translations (#20093)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-27 18:51:56 +02:00
Mohamed Houssein DouiciandGitHub e3a668a054 chore: export the enum view calendar layout to avoid typing errors on apps (#20090)
This is a small PR to fix an issue that came up when I created an app
with a custom object. I think the enum `ViewCalendarLayout` was not
caught as part of the exports.

<img width="1624" height="1114" alt="image"
src="https://github.com/user-attachments/assets/7bca3d0d-0e68-48ef-ac5f-6a3375733499"
/>
2026-04-27 16:35:22 +00:00
74536e6f98 Gate AI chat navigation entries by AI_SETTINGS permission (#20089)
## Summary

The AI chat history tab in the navigation drawer (and the corresponding
"New chat" icon in the mobile bottom bar) were rendered for every
authenticated user, regardless of whether they had `AI_SETTINGS`
permission. The actual chat content load is already gated — see
`AgentChatThreadInitializationEffect` — but the entry points were not,
so users without AI access saw a tab they could open and find empty.

This regressed when the `IS_AI_ENABLED` feature flag was removed in
#19916. The flag check was the only gate; nothing replaced it with a
permission check.

## Fix

Three small changes, all using
`useHasPermissionFlag(PermissionFlagType.AI_SETTINGS)`:

- **`MainNavigationDrawerTabsRow`** — return `null` when the user
  lacks AI access. This row only contains AI controls (the chat
  history tab pill + the "New chat" button), so without AI access
  there is nothing meaningful to render.
- **`MainNavigationDrawer`** — only render
`NavigationDrawerAiChatContent`
  when the user has AI access **and** the active tab is
  `AI_CHAT_HISTORY`. This is defensive: with the tabs row hidden the
  user can no longer switch to AI, but the active-tab atom could still
  carry `AI_CHAT_HISTORY` from a previous state (notably right after
  stopping impersonation of a user who had AI). Without this guard,
  the AI panel would briefly stay rendered.
- **`MobileNavigationBar`** — drop the `newAiChat` item when the user
  lacks AI access.

Surfaced by the same customer report that motivated #20088
(stop-impersonation
state cleanup), but this is a separate, pre-existing bug — admins
without AI permission see the tab regardless of impersonation.

## Test plan

- [ ] As a user **with** `AI_SETTINGS` permission, verify the AI chat
      tab and "New chat" button still appear and work in both the
      desktop sidebar and mobile bottom bar
- [ ] As a user **without** `AI_SETTINGS` permission, verify:
  - the tabs row at the top of the sidebar is gone (only the
    navigation menu shows below)
  - the "New chat" mobile bottom-bar icon is gone
  - the AI chat panel does not appear even after navigating away from
    a state where it was previously active

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 18:21:14 +02:00
b6ec2acb56 i18n - docs translations (#20087)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-27 16:59:00 +02:00
nitinandGitHub bb5f294c5a [AI] Collapse NativeToolBinder to a single bind() entry (#20051)
The binder doesnt need an agent or full tool context -- just a model and
options.
Single `bind(model, options)` entry!


Builds on #20022.
2026-04-27 16:35:59 +02:00
13aaea597e i18n - docs translations (#20083)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-27 14:56:00 +02:00
ec893c2767 i18n - translations (#20081)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-27 14:34:12 +02:00
Paul RastoinandGitHub f8c1ad5b3c Filtered upgrade logs when stopping before starting next instance segment (#20078)
# Introduction
In a nutshell, added a more readable logs that relates that when
performing a workspace fitlered workspace you can only browser the
workspace commands sequence

This PR is not a fix, but a log improvement
As before when cross-upgrading a single workspace you would be facing a
`instance` sync barrier error as not all your workspaces would have been
updated

Now logging and early returning instead of letting the guard hard throw

## Tests
Created a dedicated test for instance prevention on filtered upgrade
Standardized `migrationRecordToKey` usage across all upgrade integration
tests suites
2026-04-27 12:12:04 +00:00
Abdullah.andGitHub a90895e167 [Website] locale-segment routing and shared Lingui factory (#20079)
**1. Shared Lingui factory in `twenty-shared`**

- Extracted `createI18nInstanceFactory` into
`packages/twenty-shared/src/i18n/create-i18n-instance-factory.ts` so
every package gets the same per-render Lingui bootstrap with a
per-locale singleton cache and a `SOURCE_LOCALE` fallback.
- `twenty-emails/src/utils/i18n.utils.ts` now consumes the shared
factory.

**2. `twenty-website-new` Lingui bootstrap + Crowdin wiring**

- `lingui.config.ts`, `src/lib/i18n/*`, `nx run
twenty-website-new:lingui:{extract,compile}`.
- 31 locale PO files generated; minified compiled output kept out of
Prettier and Oxlint.
- `i18n-{push,pull}.yaml` workflows updated to include
`twenty-website-new` in Crowdin sync.

**3. `app/[locale]/...` segment routing with English at the root**

- All marketing routes moved under `src/app/[locale]/`; static
generation preserved (15 routes × 31 locales = 465 prerendered URLs).
- Middleware behavior:
  - `/{en}/...`         → 301 redirect to unprefixed canonical.
  - `/{non-en}/...`     → pass through, set `NEXT_LOCALE` cookie.

### What this PR explicitly does not do (deferred)

- Lingui-wrapping the actual marketing copy. Keys, build pipeline, and
  runtime are wired; copy migration is a separate, reviewer-friendlier
  PR.
2026-04-27 12:11:30 +00:00
Charles BochetandGitHub 9b7f7d059a docs: document rawBody on RoutePayload (#20080)
## Summary

Follow-up to #20061, which added `rawBody?: string` to
`LogicFunctionEvent` / `RoutePayload` so logic functions can verify
HMAC-style webhook signatures (GitHub's `X-Hub-Signature-256`, Stripe,
…) against the exact bytes that were received.

That PR did not update the public SDK docs, so the new field is
effectively invisible to developers consuming `RoutePayload` from
`twenty-sdk`. This PR adds a single row to the `RoutePayload` structure
table in `logic-functions.mdx` so the field is discoverable.

Addresses the review feedback on
https://github.com/twentyhq/twenty/pull/20061#discussion_r3147065014.
2026-04-27 12:06:55 +00:00
300 changed files with 4971 additions and 2267 deletions
+2
View File
@@ -58,6 +58,7 @@ jobs:
npx nx run twenty-server:lingui:compile --strict
npx nx run twenty-emails:lingui:compile --strict
npx nx run twenty-front:lingui:compile --strict
npx nx run twenty-website-new:lingui:compile --strict
continue-on-error: true
- name: Stash any changes before pulling translations
@@ -115,6 +116,7 @@ jobs:
npx nx run twenty-server:lingui:compile
npx nx run twenty-emails:lingui:compile
npx nx run twenty-front:lingui:compile
npx nx run twenty-website-new:lingui:compile
git status
git add .
if ! git diff --staged --quiet --exit-code; then
+2
View File
@@ -41,6 +41,7 @@ jobs:
npx nx run twenty-server:lingui:extract
npx nx run twenty-emails:lingui:extract
npx nx run twenty-front:lingui:extract
npx nx run twenty-website-new:lingui:extract
- name: Check and commit extracted files
id: check_extract_changes
@@ -60,6 +61,7 @@ jobs:
npx nx run twenty-server:lingui:compile
npx nx run twenty-emails:lingui:compile
npx nx run twenty-front:lingui:compile
npx nx run twenty-website-new:lingui:compile
- name: Check and commit compiled files
id: check_compile_changes
@@ -101,6 +101,7 @@ The `RoutePayload` type has the following structure:
| `queryStringParameters` | `Record<string, string \| undefined>` | Query string parameters (multiple values joined with commas) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }`|
| `pathParameters` | `Record<string, string \| undefined>` | Path parameters extracted from the route pattern | `/users/:id`, `/users/123` -> `{ id: '123' }` |
| `body` | `object \| null` | Parsed request body (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
| `rawBody` | `string \| undefined` | Original UTF-8 request body, before JSON parsing. Useful for verifying HMAC-style webhook signatures (e.g. GitHub's `X-Hub-Signature-256`, Stripe). `undefined` when the runtime did not preserve it. | |
| `isBase64Encoded` | `boolean` | Whether the body is base64 encoded | |
| `requestContext.http.method` | `string` | HTTP method (GET, POST, PUT, PATCH, DELETE) | |
| `requestContext.http.path` | `string` | Raw request path | |
@@ -94,15 +94,16 @@ const handler = async (event: RoutePayload) => {
يحتوي نوع `RoutePayload` على البنية التالية:
| الخاصية | النوع | الوصف | مثال |
| ---------------------------- | ------------------------------------------------------- | ------------------------------------------------------------ | -------------------------------------------------------------------------- |
| `headers` | `Record\<string, string \| undefined>` | رؤوس HTTP (فقط تلك المدرجة في `forwardedRequestHeaders`) | انظر القسم أدناه |
| `queryStringParameters` | `Record\<string, string \| undefined>` | معلمات سلسلة الاستعلام (تُضمّ القيم المتعددة باستخدام فواصل) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
| `pathParameters` | `Record\<string, string \| undefined>` | معلمات المسار المستخرجة من نمط المسار | `/users/:id`, `/users/123` -> `{ id: '123' }` |
| `body` | `object \| null` | جسم الطلب المُحلَّل (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
| `isBase64Encoded` | `boolean` | ما إذا كان جسم الطلب مُرمَّزًا بترميز base64 | |
| `requestContext.http.method` | `string` | طريقة HTTP (GET, POST, PUT, PATCH, DELETE) | |
| `requestContext.http.path` | `string` | المسار الخام للطلب | |
| الخاصية | النوع | الوصف | مثال |
| ---------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `headers` | `Record\<string, string \| undefined>` | رؤوس HTTP (فقط تلك المدرجة في `forwardedRequestHeaders`) | انظر القسم أدناه |
| `queryStringParameters` | `Record\<string, string \| undefined>` | معلمات سلسلة الاستعلام (تُضمّ القيم المتعددة باستخدام فواصل) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
| `pathParameters` | `Record\<string, string \| undefined>` | معلمات المسار المستخرجة من نمط المسار | `/users/:id`, `/users/123` -> `{ id: '123' }` |
| `body` | `object \| null` | جسم الطلب المُحلَّل (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
| `rawBody` | `string \| undefined` | Original UTF-8 request body, before JSON parsing. Useful for verifying HMAC-style webhook signatures (e.g. GitHub's `X-Hub-Signature-256`, Stripe). `undefined` when the runtime did not preserve it. | |
| `isBase64Encoded` | `boolean` | ما إذا كان جسم الطلب مُرمَّزًا بترميز base64 | |
| `requestContext.http.method` | `string` | طريقة HTTP (GET, POST, PUT, PATCH, DELETE) | |
| `requestContext.http.path` | `string` | المسار الخام للطلب | |
#### forwardedRequestHeaders
@@ -19,7 +19,7 @@ description: تعرّف على خطط تسعير Twenty وكيفية التبد
* دعم قياسي
<Note>
الميزات المتميزة (SSO وأذونات على مستوى الصف) غير مشمولة في خطة Pro.
Premium features (SSO, row-level permissions and AI usage data) are not included in the Pro plan.
</Note>
### المؤسسة (سحابي)
@@ -27,7 +27,7 @@ description: تعرّف على خطط تسعير Twenty وكيفية التبد
للفرق الأكبر ذات الاحتياجات المتقدّمة:
* كل ما في Pro
* **ميزات متميزة**: تكامل SSO وأذونات على مستوى الصف
* **Premium features**: SSO integration, row-level permissions and AI usage data
* دعم متميز
## خطط الاستضافة الذاتية
@@ -45,7 +45,7 @@ description: تعرّف على خطط تسعير Twenty وكيفية التبد
للفرق التي تحتاج إلى ميزات متميزة أثناء الاستضافة الذاتية:
* جميع ميزات Pro
* **ميزات متميزة**: تكامل SSO وأذونات على مستوى الصف
* **Premium features**: SSO integration, row-level permissions and AI usage data
* دعم فريق Twenty
* لا يُشترط نشر الشيفرة المخصّصة كمفتوح المصدر قبل التوزيع
@@ -55,6 +55,7 @@ description: تعرّف على خطط تسعير Twenty وكيفية التبد
* **تكامل SSO**: تسجيل دخول أحادي مع موفّر الهوية لديك
* **أذونات على مستوى الصف**: تحكّم دقيق في الوصول على مستوى السجل
* **AI usage data**: Track AI consumption across the workspace
## التبديل بين الخطط
@@ -77,3 +78,15 @@ description: تعرّف على خطط تسعير Twenty وكيفية التبد
### التبديل إلى الفوترة الشهرية
تواصل مع الدعم للعودة إلى الفوترة الشهرية.
## Obtain an Enterprise Key for Organization (Self-Hosted)
To use the Organization (Self-Hosted) plan, you need to obtain an Enterprise key:
1. Go to **Settings → Admin Panel → Enterprise**
<img src="/images/user-guide/billing/enterprise-key.png" alt="Enterprise key" />
2. Click **Get Enterprise Key**
3. When you are redirected to Stripe, enter your payment details and confirm
4. When your Enterprise key is displayed, paste it into the Enterprise settings page and activate the Organization license
@@ -16,6 +16,11 @@ description: الأسئلة الشائعة حول تسعير Twenty والفوت
لا تتوفر الميزات المتميزة إلا في خطط Organization (السحابة أو الاستضافة الذاتية):
* **تكامل SSO**: تسجيل الدخول الأحادي مع موفر الهوية لديك
* **أذونات على مستوى السجل**: تحكم دقيق في الوصول على مستوى السجل
* **بيانات استخدام الذكاء الاصطناعي**: تتبع استهلاك الذكاء الاصطناعي عبر مساحة العمل
</Accordion>
<Accordion title="هل خطة Organization على السحابة وخطة Organization ذاتية الاستضافة متماثلتان؟">
كلتاهما تقدمان الميزات المتميزة نفسها. ومع ذلك، لا يمكن استخدام اشتراك السحابة لعمليات النشر ذاتية الاستضافة. يتطلب النشر ذاتي الاستضافة مفتاح Enterprise.
</Accordion>
<Accordion title="هل تقدمون مقاعد مجانية للمستخدمين العارضين فقط؟">
@@ -53,38 +53,45 @@ People ←→ Project Assignments ←→ Projects
1. اذهب إلى **الإعدادات → نموذج البيانات**
2. انقر **+ كائن جديد**
3. سمِّه تسمية وصفية (مثلًا: "تعيين مشروع"، "عضو فريق"، "طلب منتج")
4. انقر على **حفظ**
4. فعِّل خيار "تخطي إنشاء حقل الاسم"
<img src="/images/user-guide/fields/new-pivot-object.png" alt="كائن ربط جديد" />
5. انقر على **حفظ**
<Tip>
**اتفاقية التسمية**: استخدم اسمًا يصف العلاقة، مثل "تعيين مشروع" أو "عضوية الفريق". هذا يجعل نموذج البيانات أسهل في الفهم.
</Tip>
## الخطوة 2: إنشاء علاقات من كائن الربط
## الخطوة 2: إنشاء علاقات بين الكائنات وكائن الربط
أضِف حقول علاقة من كائن الربط إلى كلا الكائنين اللذين تريد ربطهما.
أضف حقول العلاقة من كلٍ من الكائنين لديك إلى كائن الربط.
### العلاقة الأولى (كائن الربطالكائن A)
### العلاقة الأولى (الكائن A → كائن الربط)
1. حدِّد كائن الربط في **الإعدادات → نموذج البيانات**
2. انقر **+ إضافة حقل**
3. اختر **العلاقة** كنوع الحقل
4. اختر الكائن الأول (مثلًا، "الأشخاص")
5. عيِّن نوع العلاقة إلى **متعدد-إلى-واحد** (يمكن لعديد من التعيينات الارتباط بشخص واحد)
6. قم بتسمية الحقول:
* الحقل على كائن الربط: مثلًا، "شخص"
* الحقل على الأشخاص: مثلًا، "تعيينات المشروع"
7. انقر على **حفظ**
### العلاقة الثانية (كائن الربط → الكائن B)
1. وأنت ما زلت في كائن الربط، انقر **+ إضافة حقل**
2. اختر **العلاقة** كنوع الحقل
3. اختر الكائن الثاني (مثلًا، "المشاريع")
4. عيِّن نوع العلاقة إلى **متعدد-إلى-واحد**
1. حدِّد الكائن الأول لديك في **الإعدادات → نموذج البيانات**
2. انقر **+ إضافة علاقة**
3. اختر كائن الربط (مثلًا، "تعيينات المشروع")
4. عيِّن نوع العلاقة إلى **واحد-إلى-متعدد** (يمكن لشخص واحد الارتباط بالعديد من التعيينات)
5. قم بتسمية الحقول:
* الحقل على الأشخاص: مثلًا، "تعيينات المشروع"
* الحقل على كائن الربط: مثلًا، "شخص"
6. انقر على **حفظ**
### العلاقة الثانية (الكائن B → كائن الربط)
1. حدِّد الكائن الثاني لديك في **الإعدادات → نموذج البيانات**
2. انقر **+ إضافة علاقة**
3. اختر كائن الربط (مثلًا، "تعيينات المشروع")
4. عيِّن نوع العلاقة إلى **واحد-إلى-متعدد** (يمكن لمشروع واحد الارتباط بالعديد من التعيينات)
5. فعّل **"هذه علاقة بكائن ربط"**
<img src="/images/user-guide/fields/junction-relation-toggle.png" style={{width:'100%'}} />
6. قم بتسمية الحقول:
* الحقل على كائن الربط: مثلًا، "مشروع"
* الحقل على المشاريع: مثلًا، "أعضاء الفريق"
6. انقر على **حفظ**
7. انقر على **حفظ**
## الخطوة 3: ضبط عرض علاقة الربط
@@ -98,18 +105,6 @@ People ←→ Project Assignments ←→ Projects
6. حدِّد **العلاقة الهدف** (مثلًا، "مشروع" — الحقل على كائن الربط الذي يشير إلى الجانب الآخر)
7. انقر على **حفظ**
{/* TODO: Add image
<img src="/images/user-guide/fields/junction-relation-toggle.png" style={{width:'100%'}}/>
*/}
كرِّر على الكائن الآخر:
1. اختر "المشاريع" في نموذج البيانات
2. حرِّر حقل العلاقة "أعضاء الفريق"
3. فعّل مفتاح الربط
4. حدِّد "شخص" كالعلاقة الهدف
5. حفظ
## النتيجة
بعد التكوين:
@@ -130,15 +125,15 @@ People ←→ Project Assignments ←→ Projects
### إضافة علاقات
1. **تعيين مشروع → الأشخاص**
* النوع: متعدد-إلى-واحد
* الحقل على التعيين: "شخص"
1. **الأشخاص → تعيين مشروع**
* النوع: واحد-إلى-متعدد
* الحقل على الأشخاص: "تعيينات المشروع"
* الحقل على التعيين: "شخص"
2. **تعيين مشروع → المشاريع**
* النوع: متعدد-إلى-واحد
* الحقل على التعيين: "مشروع"
2. **المشاريع → تعيين مشروع**
* النوع: واحد-إلى-متعدد
* الحقل على المشاريع: "أعضاء الفريق"
* الحقل على التعيين: "مشروع"
### ضبط عرض علاقة الربط
@@ -30,3 +30,9 @@ description: خصص الشريط الجانبي الأيسر ليتوافق مع
## قائمة الأوامر
اضغط `Cmd+K` (أو `Ctrl+K`) لفتح قائمة الأوامر — شريط بحث للوصول السريع يتيح لك الانتقال إلى أي سجل أو طريقة عرض أو إجراء دون التنقل عبر الشريط الجانبي.
## تخصيص الشريط الجانبي
لتخصيص الشريط الجانبي، مرّر المؤشر فوق قسم "مساحة العمل" في الشريط الجانبي وانقر على أيقونة مفتاح الربط.
<img src="/images/user-guide/layout/navigation-edit-icon.png" alt="أيقونة تحرير التنقّل" />
@@ -3,6 +3,8 @@ title: صفحات السجل
description: خصص تخطيط صفحات تفاصيل السجلات باستخدام علامات تبويب وأدوات.
---
## نظرة عامة
عند فتح سجل في Twenty، تتكون صفحة التفاصيل من **علامات تبويب** و**أدوات**. كلاهما قابل للتخصيص بالكامل حسب نوع الكائن.
## علامات التبويب
@@ -38,12 +40,20 @@ description: خصص تخطيط صفحات تفاصيل السجلات باستخ
1. افتح أي سجل
2. اضغط على `Cmd+K` وابحث عن "تحرير تخطيط صفحة السجل"
أو
1. انتقل إلى الإعدادات > نموذج البيانات > الكائن الذي تختاره > التخطيط
2. انقر على الزر "تخصيص صفحة السجل" لذلك الكائن
3. أنت الآن في وضع التخصيص:
* **أضف أدوات** من منتقي الأدوات
* **اسحب الأدوات** لإعادة وضعها على الشبكة
* **غيّر حجم الأدوات** بسحب حوافها
* **اضبط الحقول** المعروضة داخل كل أداة
* **أدِر علامات التبويب** — أضف، أزل، أعد التسمية، وأعد الترتيب
4. احفظ تغييراتك — سيتم تطبيقها على جميع السجلات لذلك النوع من الكائنات
## ظهور الحقول
@@ -95,15 +95,16 @@ const handler = async (event: RoutePayload) => {
Typ `RoutePayload` má následující strukturu:
| Vlastnost | Typ | Popis | Příklad |
| ---------------------------- | ------------------------------------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------- |
| `headers` | `Record\<string, string \| undefined>` | Záhlaví HTTP (pouze ta uvedená v `forwardedRequestHeaders`) | viz sekci níže |
| `queryStringParameters` | `Record\<string, string \| undefined>` | Parametry query stringu (více hodnot spojených čárkami) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
| `pathParameters` | `Record\<string, string \| undefined>` | Parametry cesty extrahované ze vzoru trasy | `/users/:id`, `/users/123` -> `{ id: '123' }` |
| `body` | `object \| null` | Parsované tělo požadavku (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
| `isBase64Encoded` | `boolean` | Zda je tělo kódováno base64 | |
| `requestContext.http.method` | `string` | Metoda HTTP (GET, POST, PUT, PATCH, DELETE) | |
| `requestContext.http.path` | `string` | Nezpracovaná cesta požadavku | |
| Vlastnost | Typ | Popis | Příklad |
| ---------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `headers` | `Record\<string, string \| undefined>` | Záhlaví HTTP (pouze ta uvedená v `forwardedRequestHeaders`) | viz sekci níže |
| `queryStringParameters` | `Record\<string, string \| undefined>` | Parametry query stringu (více hodnot spojených čárkami) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
| `pathParameters` | `Record\<string, string \| undefined>` | Parametry cesty extrahované ze vzoru trasy | `/users/:id`, `/users/123` -> `{ id: '123' }` |
| `body` | `object \| null` | Parsované tělo požadavku (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
| `rawBody` | `string \| undefined` | Original UTF-8 request body, before JSON parsing. Useful for verifying HMAC-style webhook signatures (e.g. GitHub's `X-Hub-Signature-256`, Stripe). `undefined` when the runtime did not preserve it. | |
| `isBase64Encoded` | `boolean` | Zda je tělo kódováno base64 | |
| `requestContext.http.method` | `string` | Metoda HTTP (GET, POST, PUT, PATCH, DELETE) | |
| `requestContext.http.path` | `string` | Nezpracovaná cesta požadavku | |
#### forwardedRequestHeaders
@@ -19,7 +19,7 @@ Pro týmy připravené škálovat:
* Standardní podpora
<Note>
Prémiové funkce (SSO a oprávnění na úrovni řádků) nejsou součástí plánu Pro.
Premium features (SSO, row-level permissions and AI usage data) are not included in the Pro plan.
</Note>
### Organizace (Cloud)
@@ -27,7 +27,7 @@ Prémiové funkce (SSO a oprávnění na úrovni řádků) nejsou součástí pl
Pro větší týmy s pokročilými potřebami:
* Vše z plánu Pro
* **Prémiové funkce**: integrace SSO a oprávnění na úrovni řádků
* **Premium features**: SSO integration, row-level permissions and AI usage data
* Prioritní podpora
## Plány pro selfhosting
@@ -45,7 +45,7 @@ Hostujte Twenty na vlastní infrastruktuře bez poplatků:
Pro týmy, které při selfhostingu potřebují prémiové funkce:
* Všechny funkce plánu Pro
* **Prémiové funkce**: integrace SSO a oprávnění na úrovni řádků
* **Premium features**: SSO integration, row-level permissions and AI usage data
* Podpora týmu Twenty
* Není vyžadováno zveřejnit vlastní kód jako opensource před distribucí
@@ -55,6 +55,7 @@ Prémiové funkce jsou dostupné pouze v plánech Organizace (Cloud nebo self
* **Integrace SSO**: jednotné přihlášení s vaším poskytovatelem identity
* **Oprávnění na úrovni řádků**: jemně odstupňované řízení přístupu na úrovni záznamu
* **AI usage data**: Track AI consumption across the workspace
## Přepínání plánů
@@ -77,3 +78,15 @@ Chcete-li přejít na nižší plán, kontaktujte podporu.
### Přepnout na měsíční fakturaci
Chcete-li přepnout zpět na měsíční fakturaci, kontaktujte podporu.
## Obtain an Enterprise Key for Organization (Self-Hosted)
To use the Organization (Self-Hosted) plan, you need to obtain an Enterprise key:
1. Go to **Settings → Admin Panel → Enterprise**
<img src="/images/user-guide/billing/enterprise-key.png" alt="Enterprise key" />
2. Click **Get Enterprise Key**
3. When you are redirected to Stripe, enter your payment details and confirm
4. When your Enterprise key is displayed, paste it into the Enterprise settings page and activate the Organization license
@@ -16,6 +16,11 @@ Pokud chcete hostovat sami a potřebujete prémiové funkce (SSO a oprávnění
Prémiové funkce jsou dostupné pouze v plánech Organization (Cloud nebo Self-Hosted):
* **Integrace SSO**: Jednotné přihlášení s vaším poskytovatelem identity
* **Oprávnění na úrovni řádků**: Jemně granulované řízení přístupu na úrovni záznamu
* **AI usage data**: Track AI consumption across the workspace
</Accordion>
<Accordion title="Is Organization plan on cloud and Organization plan on self-hosted the same?">
They offer the same premium features. However, a cloud subscription cannot be used for self-hosted deployments. Self-hosted requires an Enterprise key.
</Accordion>
<Accordion title="Nabízíte volná místa pro uživatele pouze s přístupem ke čtení?">
@@ -53,38 +53,45 @@ Nejprve vytvořte mezilehlý objekt, který bude uchovávat propojení.
1. Přejděte do **Nastavení → Datový model**
2. Klikněte na **+ New object**
3. Pojmenujte jej výstižně (např. "Project Assignment", "Team Member", "Product Order")
4. Klikněte na **Uložit**
4. Přepněte "Přeskočit vytvoření pole Název" na zapnuto
<img src="/images/user-guide/fields/new-pivot-object.png" alt="Nový spojovací objekt" />
5. Klikněte na **Uložit**
<Tip>
**Konvence pojmenování**: Použijte název, který popisuje vztah, například "Project Assignment" nebo "Team Membership". Tím bude datový model srozumitelnější.
</Tip>
## Krok 2: Vytvořte vztahy ze spojovacího objektu
## Krok 2: Vytvořte vztahy mezi objekty a spojovacím objektem
Přidejte relační pole ze spojovacího objektu do obou objektů, které chcete propojit.
Přidejte vztahová pole z obou objektů do spojovacího objektu.
### První vztah (Junction → Objekt A)
### První vztah (Objekt A → Junction)
1. Vyberte svůj spojovací objekt v **Settings → Data Model**
2. Klikněte na **+ Add Field**
3. Zvolte **Relation** jako typ pole
4. Vyberte první objekt (např. "People")
5. Nastavte typ vztahu na **Many-to-One** (mnoho přiřazení může odkazovat na jednu osobu)
6. Pojmenujte pole:
* Pole na spojovacím objektu: např. "Person"
* Pole na People: např. "Project Assignments"
7. Klikněte na **Uložit**
### Druhý vztah (Junction → Objekt B)
1. Stále na spojovacím objektu klikněte na **+ Add Field**
2. Zvolte **Relation** jako typ pole
3. Vyberte druhý objekt (např. "Projects")
4. Nastavte typ vztahu na **Many-to-One**
1. Vyberte svůj první objekt v **Settings → Data Model**
2. Klikněte na **+ Přidat vztah**
3. Vyberte spojovací objekt (např. "Project Assignments")
4. Nastavte typ vztahu na **One-To-Many** (jedna osoba může být propojena s mnoha přiřazeními)
5. Pojmenujte pole:
* Pole na People: např. "Project Assignments"
* Pole na spojovacím objektu: např. "Person"
6. Klikněte na **Uložit**
### Druhý vztah (Objekt B → Junction)
1. Vyberte svůj druhý objekt v **Settings → Data Model**
2. Klikněte na **+ Přidat vztah**
3. Vyberte spojovací objekt (např. "Project Assignments")
4. Nastavte typ vztahu na **One-To-Many** (jeden projekt může být propojen s mnoha přiřazeními)
5. Povolte **"This is a relation to a Junction Object"**
<img src="/images/user-guide/fields/junction-relation-toggle.png" style={{width:'100%'}} />
6. Pojmenujte pole:
* Pole na spojovacím objektu: např. "Project"
* Pole na Projects: např. "Team Members"
6. Klikněte na **Uložit**
7. Klikněte na **Uložit**
## Krok 3: Nakonfigurujte zobrazení vztahu přes spojovací objekt
@@ -98,18 +105,6 @@ Nyní nakonfigurujte zdrojové objekty tak, aby zobrazovaly propojené záznamy
6. Vyberte **Target relation** (např. "Project" — pole na spojovacím objektu, které ukazuje na druhou stranu)
7. Klikněte na **Uložit**
{/* TODO: Add image
<img src="/images/user-guide/fields/junction-relation-toggle.png" style={{width:'100%'}}/>
*/}
Opakujte pro druhý objekt:
1. Vyberte "Projects" v Data Model
2. Upravte relační pole "Team Members"
3. Povolte přepínač pro vztah přes spojovací objekt
4. Vyberte "Person" jako cílový vztah
5. Uložit
## Výsledek
Po konfiguraci:
@@ -130,15 +125,15 @@ Zde je kompletní postup:
### Přidejte vztahy
1. **Project Assignment → People**
* Typ: Many-to-One
* Pole na Assignment: "Person"
1. **People → Project Assignment**
* Typ: One-to-Many
* Pole na People: "Project Assignments"
* Pole na Assignment: "Person"
2. **Project Assignment → Projects**
* Typ: Many-to-One
* Pole na Assignment: "Project"
2. **Projects → Project Assignment**
* Typ: One-to-Many
* Pole na Projects: "Team Members"
* Pole na Assignment: "Project"
### Nakonfigurujte zobrazení spojovacího objektu
@@ -30,3 +30,9 @@ Přidejte odkazy na externí nástroje přímo do postranního panelu. Užitečn
## Nabídka příkazů
Stiskněte `Cmd+K` (nebo `Ctrl+K`) pro otevření nabídky příkazů — rychlá vyhledávací lišta pro přechod na libovolný záznam, zobrazení nebo akci bez procházení postranního panelu.
## Přizpůsobení postranního panelu
Chcete-li přizpůsobit postranní panel, najeďte myší na sekci "Pracovní prostor" v postranním panelu a klikněte na ikonu klíče.
<img src="/images/user-guide/layout/navigation-edit-icon.png" alt="Ikona pro úpravu navigace" />
@@ -3,6 +3,8 @@ title: Stránky záznamů
description: Přizpůsobte rozvržení jednotlivých stránek detailu záznamu pomocí karet a widgetů.
---
## Přehled
Když v Twenty otevřete záznam, stránka detailu se skládá z **karet** a **widgetů**. Obojí lze pro každý typ objektu plně přizpůsobit.
## Karty
@@ -38,12 +40,20 @@ Widgety jsou stavební prvky uvnitř každé karty. Mezi dostupné typy widgetů
1. Otevřete libovolný záznam
2. Stiskněte `Cmd+K` a vyhledejte "Upravit rozložení stránky záznamu"
nebo
1. Přejděte do Nastavení > Datový model > objekt dle vašeho výběru > Rozložení
2. Klikněte na tlačítko "Přizpůsobit stránku záznamu" pro daný objekt
3. Nyní jste v režimu přizpůsobení:
* **Přidávejte widgety** z výběru widgetů
* **Přetahujte widgety** pro jejich přemístění v mřížce
* **Změňte velikost widgetů** tažením jejich okrajů
* **Nakonfigurujte pole** zobrazovaná v jednotlivých widgetech
* **Spravujte karty** — přidávejte, odebírejte, přejmenovávejte, měňte pořadí
4. Uložte změny — budou platit pro všechny záznamy daného typu objektu
## Viditelnost polí
@@ -94,15 +94,16 @@ const handler = async (event: RoutePayload) => {
Der Typ `RoutePayload` hat die folgende Struktur:
| Eigenschaft | Typ | Beschreibung | Beispiel |
| ---------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `headers` | `Record\<string, string \| undefined>` | HTTP-Header (nur die in `forwardedRequestHeaders` aufgelisteten) | siehe Abschnitt unten |
| `queryStringParameters` | `Record\<string, string \| undefined>` | Query-String-Parameter (mehrere Werte mit Kommas verbunden) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
| `pathParameters` | `Record\<string, string \| undefined>` | Aus dem Routenmuster extrahierte Pfadparameter | `/users/:id`, `/users/123` -> `{ id: '123' }` |
| `body` | `object \| null` | Geparster Request-Body (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
| `isBase64Encoded` | `boolean` | Gibt an, ob der Body Base64-codiert ist | |
| `requestContext.http.method` | `Zeichenkette` | HTTP-Methode (GET, POST, PUT, PATCH, DELETE) | |
| `requestContext.http.path` | `Zeichenkette` | Rohpfad der Anfrage | |
| Eigenschaft | Typ | Beschreibung | Beispiel |
| ---------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `headers` | `Record\<string, string \| undefined>` | HTTP-Header (nur die in `forwardedRequestHeaders` aufgelisteten) | siehe Abschnitt unten |
| `queryStringParameters` | `Record\<string, string \| undefined>` | Query-String-Parameter (mehrere Werte mit Kommas verbunden) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
| `pathParameters` | `Record\<string, string \| undefined>` | Aus dem Routenmuster extrahierte Pfadparameter | `/users/:id`, `/users/123` -> `{ id: '123' }` |
| `body` | `object \| null` | Geparster Request-Body (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
| `rawBody` | `string \| undefined` | Ursprünglicher UTF-8-Request-Body vor dem JSON-Parsing. Nützlich zur Verifizierung von Webhook-Signaturen im HMAC-Stil (z. B. GitHubs `X-Hub-Signature-256`, Stripe). `undefined`, wenn die Laufzeitumgebung es nicht beibehalten hat. | |
| `isBase64Encoded` | `boolean` | Gibt an, ob der Body Base64-codiert ist | |
| `requestContext.http.method` | `Zeichenkette` | HTTP-Methode (GET, POST, PUT, PATCH, DELETE) | |
| `requestContext.http.path` | `Zeichenkette` | Rohpfad der Anfrage | |
#### forwardedRequestHeaders
@@ -19,7 +19,7 @@ Für Teams, die bereit sind zu skalieren:
* Standard-Support
<Note>
Premiumfunktionen (SSO und Berechtigungen auf Zeilenebene) sind im Pro-Plan nicht enthalten.
Premiumfunktionen (SSO, Berechtigungen auf Zeilenebene und KI-Nutzungsdaten) sind im Pro-Tarif nicht enthalten.
</Note>
### Organisation (Cloud)
@@ -27,7 +27,7 @@ Premiumfunktionen (SSO und Berechtigungen auf Zeilenebene) sind im Pro-Plan nich
Für größere Teams mit erweiterten Anforderungen:
* Alles aus Pro
* **Premiumfunktionen**: SSO-Integration und Berechtigungen auf Zeilenebene
* **Premiumfunktionen**: SSO-Integration, Berechtigungen auf Zeilenebene und KI-Nutzungsdaten
* Vorrangiger Support
## Selbstgehostete Pläne
@@ -45,7 +45,7 @@ Hosten Sie Twenty auf Ihrer eigenen Infrastruktur kostenlos:
Für Teams, die beim Selbsthosting Premiumfunktionen benötigen:
* Alle Pro-Funktionen
* **Premiumfunktionen**: SSO-Integration und Berechtigungen auf Zeilenebene
* **Premiumfunktionen**: SSO-Integration, Berechtigungen auf Zeilenebene und KI-Nutzungsdaten
* Support durch das Twenty-Team
* Keine Verpflichtung, benutzerdefinierten Code vor der Weitergabe als Open Source zu veröffentlichen
@@ -55,6 +55,7 @@ Premiumfunktionen sind nur in den Organisation-Plänen (Cloud oder Selbstgehoste
* **SSO-Integration**: Single Sign-On mit Ihrem Identitätsanbieter
* **Berechtigungen auf Zeilenebene**: Feingranulare Zugriffskontrolle auf Datensatzebene
* **KI-Nutzungsdaten**: Verfolgen Sie den KI-Verbrauch in Ihrem Arbeitsbereich
## Pläne wechseln
@@ -77,3 +78,15 @@ Wenden Sie sich an den Support, um Ihren Plan herabzustufen.
### Zur monatlichen Abrechnung wechseln
Wenden Sie sich an den Support, um wieder zur monatlichen Abrechnung zu wechseln.
## Enterprise-Schlüssel für Organization (Self-Hosted) abrufen
Um den Tarif Organization (Self-Hosted) zu verwenden, müssen Sie einen Enterprise-Schlüssel abrufen:
1. Gehen Sie zu **Einstellungen → Admin-Panel → Enterprise**
<img src="/images/user-guide/billing/enterprise-key.png" alt="Enterprise-Schlüssel" />
2. Klicken Sie auf **Enterprise-Schlüssel abrufen**
3. Wenn Sie zu Stripe weitergeleitet werden, geben Sie Ihre Zahlungsdaten ein und bestätigen Sie
4. Wenn Ihr Enterprise-Schlüssel angezeigt wird, fügen Sie ihn auf der Seite Enterprise-Einstellungen ein und aktivieren Sie die Organization-Lizenz
@@ -16,6 +16,11 @@ Wenn Sie selbst hosten möchten und die Premium-Funktionen (SSO und Berechtigung
Premium-Funktionen sind nur in den Organization-Tarifen (Cloud oder Self-Hosted) verfügbar:
* **SSO-Integration**: Single Sign-On mit Ihrem Identitätsanbieter
* **Berechtigungen auf Zeilenebene**: feingranulare Zugriffskontrolle auf Datensatzebene
* **KI-Nutzungsdaten**: Verfolgen Sie den KI-Verbrauch in Ihrem Arbeitsbereich
</Accordion>
<Accordion title="Sind der Organisationsplan in der Cloud und der Organisationsplan bei selbst gehosteter Bereitstellung gleich?">
Sie bieten dieselben Premium-Funktionen. Ein Cloud-Abonnement kann jedoch nicht für selbst gehostete Bereitstellungen verwendet werden. Für selbst gehostete Bereitstellungen ist ein Enterprise-Schlüssel erforderlich.
</Accordion>
<Accordion title="Bieten Sie kostenlose Plätze für Nur-Ansicht-Benutzer an?">
@@ -53,38 +53,45 @@ Erstellen Sie zunächst das Zwischenobjekt, das die Verknüpfungen speichert.
1. Gehen Sie zu **Einstellungen → Datenmodell**
2. Klicken Sie auf **+ Neues Objekt**
3. Benennen Sie es aussagekräftig (z. B. "Projektzuweisung", "Teammitglied", "Produktbestellung")
4. Klicken Sie auf **Speichern**
4. Schalten Sie "Erstellen eines Namensfelds überspringen" ein
<img src="/images/user-guide/fields/new-pivot-object.png" alt="Neues Verknüpfungsobjekt" />
5. Klicken Sie auf **Speichern**
<Tip>
**Namenskonvention**: Verwenden Sie einen Namen, der die Beziehung beschreibt, z. B. "Projektzuweisung" oder "Teammitgliedschaft". Das macht das Datenmodell leichter verständlich.
</Tip>
## Schritt 2: Beziehungen vom Verknüpfungsobjekt erstellen
## Schritt 2: Beziehungen zwischen Objekten und dem Verknüpfungsobjekt erstellen
Fügen Sie vom Verknüpfungsobjekt aus Beziehungsfelder zu beiden Objekten hinzu, die Sie verbinden möchten.
Fügen Sie für jedes Ihrer beiden Objekte Beziehungsfelder zum Verknüpfungsobjekt hinzu.
### Erste Beziehung (Verknüpfung → Objekt A)
### Erste Beziehung (Objekt A → Verknüpfung)
1. Wählen Sie Ihr Verknüpfungsobjekt unter **Einstellungen → Datenmodell** aus
2. Klicken Sie auf **+ Feld hinzufügen**
3. Wählen Sie **Relation** als Feldtyp
4. Wählen Sie das erste Objekt aus (z. B. "Personen")
5. Legen Sie den Beziehungstyp auf **Viele-zu-Eins** fest (viele Zuweisungen können mit einer Person verknüpft werden)
6. Benennen Sie die Felder:
* Feld auf der Verknüpfung: z. B. "Person"
* Feld bei Personen: z. B. "Projektzuweisungen"
7. Klicken Sie auf **Speichern**
### Zweite Beziehung (Verknüpfung → Objekt B)
1. Bleiben Sie im Verknüpfungsobjekt und klicken Sie auf **+ Feld hinzufügen**
2. Wählen Sie **Relation** als Feldtyp
3. Wählen Sie das zweite Objekt aus (z. B. "Projekte")
4. Legen Sie den Beziehungstyp auf **Viele-zu-Eins** fest
1. Wählen Sie Ihr erstes Objekt unter **Einstellungen → Datenmodell** aus
2. Klicken Sie auf **+ Beziehung hinzufügen**
3. Wählen Sie das Verknüpfungsobjekt aus (z. B. "Projektzuweisungen")
4. Legen Sie den Beziehungstyp auf **Eins-zu-Viele** fest (eine Person kann mit vielen Zuweisungen verknüpft werden)
5. Benennen Sie die Felder:
* Feld bei Personen: z. B. "Projektzuweisungen"
* Feld auf der Verknüpfung: z. B. "Person"
6. Klicken Sie auf **Speichern**
### Zweite Beziehung (Objekt B → Verknüpfung)
1. Wählen Sie Ihr zweites Objekt unter **Einstellungen → Datenmodell** aus
2. Klicken Sie auf **+ Beziehung hinzufügen**
3. Wählen Sie das Verknüpfungsobjekt aus (z. B. "Projektzuweisungen")
4. Legen Sie den Beziehungstyp auf **Eins-zu-Viele** fest (ein Projekt kann mit vielen Zuweisungen verknüpft werden)
5. Aktivieren Sie **"Dies ist eine Beziehung zu einem Verknüpfungsobjekt"**
<img src="/images/user-guide/fields/junction-relation-toggle.png" style={{width:'100%'}} />
6. Benennen Sie die Felder:
* Feld auf der Verknüpfung: z. B. "Projekt"
* Feld bei Projekten: z. B. "Teammitglieder"
6. Klicken Sie auf **Speichern**
7. Klicken Sie auf **Speichern**
## Schritt 3: Anzeige der Verknüpfungsbeziehung konfigurieren
@@ -98,18 +105,6 @@ Konfigurieren Sie nun die Quellobjekte so, dass verknüpfte Datensätze direkt a
6. Wählen Sie die **Zielbeziehung** aus (z. B. "Projekt" — das Feld an der Verknüpfung, das auf die andere Seite zeigt)
7. Klicken Sie auf **Speichern**
{/* TODO: Add image
<img src="/images/user-guide/fields/junction-relation-toggle.png" style={{width:'100%'}}/>
*/}
Wiederholen Sie dies für das andere Objekt:
1. Wählen Sie "Projekte" im Datenmodell aus
2. Bearbeiten Sie das Beziehungsfeld "Teammitglieder"
3. Aktivieren Sie den Verknüpfungsschalter
4. Wählen Sie "Person" als Zielbeziehung aus
5. Speichern
## Ergebnis
Nach der Konfiguration:
@@ -130,15 +125,15 @@ Hier ist eine vollständige Schritt-für-Schritt-Anleitung:
### Beziehungen hinzufügen
1. **Projektzuweisung → Personen**
* Typ: Viele-zu-Eins
* Feld bei Zuweisung: "Person"
1. **Personen → Projektzuweisung**
* Typ: Eins-zu-Viele
* Feld bei Personen: "Projektzuweisungen"
* Feld bei Zuweisung: "Person"
2. **Projektzuweisung → Projekte**
* Typ: Viele-zu-Eins
* Feld bei Zuweisung: "Projekt"
2. **Projekte → Projektzuweisung**
* Typ: Eins-zu-Viele
* Feld bei Projekten: "Teammitglieder"
* Feld bei Zuweisung: "Projekt"
### Verknüpfungsanzeige konfigurieren
@@ -30,3 +30,9 @@ Fügen Sie Links zu externen Tools direkt in der Seitenleiste hinzu. Nützlich,
## Befehlsmenü
Drücken Sie `Cmd+K` (oder `Ctrl+K`), um das Befehlsmenü zu öffnen — eine Suchleiste für den Schnellzugriff, mit der Sie zu jedem Datensatz, jeder Ansicht oder Aktion springen können, ohne durch die Seitenleiste zu navigieren.
## Anpassung der Seitenleiste
Um die Seitenleiste anzupassen, bewegen Sie den Mauszeiger über den Abschnitt "Arbeitsbereich" in der Seitenleiste und klicken Sie auf das Schraubenschlüssel-Symbol.
<img src="/images/user-guide/layout/navigation-edit-icon.png" alt="Symbol zum Bearbeiten der Navigation" />
@@ -3,6 +3,8 @@ title: Datensatzseiten
description: Passen Sie das Layout einzelner Datensatz-Detailseiten mit Tabs und Widgets an.
---
## Übersicht
Wenn Sie in Twenty einen Datensatz öffnen, besteht die Detailseite aus **Tabs** und **Widgets**. Beide lassen sich je Objekttyp vollständig anpassen.
## Registerkarten
@@ -38,12 +40,20 @@ Widgets sind die Bausteine in jedem Tab. Zu den verfügbaren Widget-Typen gehör
1. Öffnen Sie einen beliebigen Datensatz
2. Drücken Sie `Cmd+K` und suchen Sie nach "Layout der Datensatzseite bearbeiten"
oder
1. Gehen Sie zu Einstellungen > Datenmodell > Objekt Ihrer Wahl > Layout
2. Klicken Sie für dieses Objekt auf die Schaltfläche "Datensatzseite anpassen"
3. Sie befinden sich jetzt im Anpassungsmodus:
* **Widgets hinzufügen** aus der Widget-Auswahl
* **Widgets ziehen**, um sie im Raster neu zu positionieren
* **Widgets in der Größe ändern**, indem Sie ihre Ränder ziehen
* **Felder konfigurieren**, die in jedem Widget angezeigt werden
* **Tabs verwalten** — hinzufügen, entfernen, umbenennen, neu anordnen
4. Speichern Sie Ihre Änderungen — sie gelten für alle Datensätze dieses Objekttyps
## Feldsichtbarkeit
@@ -94,15 +94,16 @@ const handler = async (event: RoutePayload) => {
Il tipo `RoutePayload` ha la seguente struttura:
| Proprietà | Tipo | Descrizione | Esempio |
| ---------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `headers` | `Record\<string, string \| undefined>` | Intestazioni HTTP (solo quelle elencate in `forwardedRequestHeaders`) | vedi la sezione sotto |
| `queryStringParameters` | `Record\<string, string \| undefined>` | Parametri della query string (valori multipli uniti da virgole) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
| `pathParameters` | `Record\<string, string \| undefined>` | Parametri di percorso estratti dal pattern della route | `/users/:id`, `/users/123` -> `{ id: '123' }` |
| `body` | `object \| null` | Corpo della richiesta analizzato (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
| `isBase64Encoded` | `boolean` | Indica se il corpo è codificato in base64 | |
| `requestContext.http.method` | `string` | Metodo HTTP (GET, POST, PUT, PATCH, DELETE) | |
| `requestContext.http.path` | `string` | Percorso della richiesta non elaborato | |
| Proprietà | Tipo | Descrizione | Esempio |
| ---------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `headers` | `Record\<string, string \| undefined>` | Intestazioni HTTP (solo quelle elencate in `forwardedRequestHeaders`) | vedi la sezione sotto |
| `queryStringParameters` | `Record\<string, string \| undefined>` | Parametri della query string (valori multipli uniti da virgole) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
| `pathParameters` | `Record\<string, string \| undefined>` | Parametri di percorso estratti dal pattern della route | `/users/:id`, `/users/123` -> `{ id: '123' }` |
| `body` | `object \| null` | Corpo della richiesta analizzato (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
| `rawBody` | `string \| undefined` | Original UTF-8 request body, before JSON parsing. Useful for verifying HMAC-style webhook signatures (e.g. GitHub's `X-Hub-Signature-256`, Stripe). `undefined` when the runtime did not preserve it. | |
| `isBase64Encoded` | `boolean` | Indica se il corpo è codificato in base64 | |
| `requestContext.http.method` | `string` | Metodo HTTP (GET, POST, PUT, PATCH, DELETE) | |
| `requestContext.http.path` | `string` | Percorso della richiesta non elaborato | |
#### forwardedRequestHeaders
@@ -19,7 +19,7 @@ Per i team pronti a scalare:
* Supporto standard
<Note>
Le funzionalità premium (SSO e autorizzazioni a livello di riga) non sono incluse nel piano Pro.
Premium features (SSO, row-level permissions and AI usage data) are not included in the Pro plan.
</Note>
### Organizzazione (Cloud)
@@ -27,7 +27,7 @@ Le funzionalità premium (SSO e autorizzazioni a livello di riga) non sono inclu
Per i team più numerosi con esigenze avanzate:
* Tutto ciò che è incluso in Pro
* **Funzionalità premium**: integrazione SSO e autorizzazioni a livello di riga
* **Premium features**: SSO integration, row-level permissions and AI usage data
* Supporto prioritario
## Piani selfhosted
@@ -45,7 +45,7 @@ Esegui Twenty sulla tua infrastruttura senza costi:
Per i team che necessitano di funzionalità premium con il selfhosting:
* Tutte le funzionalità di Pro
* **Funzionalità premium**: integrazione SSO e autorizzazioni a livello di riga
* **Premium features**: SSO integration, row-level permissions and AI usage data
* Supporto del team di Twenty
* Nessun requisito di pubblicare il codice personalizzato come open source prima della distribuzione
@@ -55,6 +55,7 @@ Le funzionalità premium sono disponibili solo nei piani Organizzazione (Cloud o
* **Integrazione SSO**: Single SignOn con il tuo provider di identità
* **Autorizzazioni a livello di riga**: controllo degli accessi granulare a livello di record
* **AI usage data**: Track AI consumption across the workspace
## Cambio piano
@@ -77,3 +78,15 @@ Contatta il supporto per eseguire il downgrade del tuo piano.
### Passa alla fatturazione mensile
Contatta il supporto per tornare alla fatturazione mensile.
## Obtain an Enterprise Key for Organization (Self-Hosted)
To use the Organization (Self-Hosted) plan, you need to obtain an Enterprise key:
1. Go to **Settings → Admin Panel → Enterprise**
<img src="/images/user-guide/billing/enterprise-key.png" alt="Enterprise key" />
2. Click **Get Enterprise Key**
3. When you are redirected to Stripe, enter your payment details and confirm
4. When your Enterprise key is displayed, paste it into the Enterprise settings page and activate the Organization license
@@ -16,6 +16,11 @@ Se vuoi effettuare il self-hosting e hai bisogno delle funzionalità Premium (SS
Le funzionalità Premium sono disponibili solo nei piani Organization (Cloud o Self-Hosted):
* **Integrazione SSO**: Single Sign-On con il tuo provider di identità
* **Autorizzazioni a livello di riga**: controllo degli accessi granulare a livello di record
* **Dati di utilizzo dell'IA**: Tieni traccia del consumo dell'IA nel tuo spazio di lavoro
</Accordion>
<Accordion title="Il piano Organization su cloud e il piano Organization in modalità self-hosted sono la stessa cosa?">
Offrono le stesse funzionalità Premium. Tuttavia, un abbonamento cloud non può essere utilizzato per distribuzioni self-hosted. La modalità self-hosted richiede una chiave Enterprise.
</Accordion>
<Accordion title="Offrite posti gratuiti per utenti solo visualizzazione?">
@@ -53,38 +53,45 @@ Per prima cosa, crea l'oggetto intermedio che conterrà i collegamenti.
1. Vai a **Impostazioni → Modello dati**
2. Fai clic su **+ Nuovo oggetto**
3. Assegnagli un nome descrittivo (ad es., "Assegnazione al progetto", "Membro del team", "Ordine del prodotto")
4. Clicca su **Salva**
4. Attiva l'opzione "Salta la creazione di un campo Nome"
<img src="/images/user-guide/fields/new-pivot-object.png" alt="Nuovo oggetto pivot" />
5. Clicca su **Salva**
<Tip>
**Convenzione di denominazione**: Usa un nome che descriva la relazione, come "Assegnazione al progetto" o "Appartenenza al team". Questo rende il modello di dati più facile da comprendere.
</Tip>
## Passaggio 2: Crea le relazioni dall'oggetto di giunzione
## Passaggio 2: Crea relazioni tra gli oggetti e l'oggetto di giunzione
Aggiungi campi di relazione dall'oggetto di giunzione a entrambi gli oggetti che desideri collegare.
Aggiungi campi di relazione da ciascuno dei due oggetti all'oggetto di giunzione.
### Prima relazione (Giunzione → Oggetto A)
### Prima relazione (Oggetto A → Oggetto di giunzione)
1. Seleziona il tuo oggetto di giunzione in **Impostazioni → Modello dati**
2. Fai clic su **+ Aggiungi campo**
3. Scegli **Relazione** come tipo di campo
4. Seleziona il primo oggetto (ad es., "Persone")
5. Imposta il tipo di relazione su **Molti-a-uno** (molte assegnazioni possono collegarsi a una persona)
6. Assegna un nome ai campi:
* Campo sull'oggetto di giunzione: ad es., "Persona"
* Campo su Persone: ad es., "Assegnazioni ai progetti"
7. Clicca su **Salva**
### Seconda relazione (Giunzione → Oggetto B)
1. Sempre sull'oggetto di giunzione, fai clic su **+ Aggiungi campo**
2. Scegli **Relazione** come tipo di campo
3. Seleziona il secondo oggetto (ad es., "Progetti")
4. Imposta il tipo di relazione su **Molti-a-uno**
1. Seleziona il tuo primo oggetto in **Impostazioni → Modello dati**
2. Fai clic su **+ Aggiungi relazione**
3. Seleziona l'oggetto di giunzione (ad es., "Assegnazioni al progetto")
4. Imposta il tipo di relazione su **Uno-a-molti** (una persona può collegarsi a molte assegnazioni)
5. Assegna un nome ai campi:
* Campo su Persone: ad es., "Assegnazioni ai progetti"
* Campo sull'oggetto di giunzione: ad es., "Persona"
6. Clicca su **Salva**
### Seconda relazione (Oggetto B → Oggetto di giunzione)
1. Seleziona il tuo secondo oggetto in **Impostazioni → Modello dati**
2. Fai clic su **+ Aggiungi relazione**
3. Seleziona l'oggetto di giunzione (ad es., "Assegnazioni al progetto")
4. Imposta il tipo di relazione su **Uno-a-molti** (un progetto può collegarsi a molte assegnazioni)
5. Abilita **"Questa è una relazione con un oggetto di giunzione"**
<img src="/images/user-guide/fields/junction-relation-toggle.png" style={{width:'100%'}} />
6. Assegna un nome ai campi:
* Campo sull'oggetto di giunzione: ad es., "Progetto"
* Campo su Progetti: ad es., "Membri del team"
6. Clicca su **Salva**
7. Clicca su **Salva**
## Passaggio 3: Configura la visualizzazione della relazione di giunzione
@@ -98,18 +105,6 @@ Ora configura gli oggetti sorgente per visualizzare direttamente i record colleg
6. Seleziona la **Relazione di destinazione** (ad es., "Progetto" — il campo sull'oggetto di giunzione che punta all'altro lato)
7. Clicca su **Salva**
{/* TODO: Add image
<img src="/images/user-guide/fields/junction-relation-toggle.png" style={{width:'100%'}}/>
*/}
Ripeti per l'altro oggetto:
1. Seleziona "Progetti" in Modello dati
2. Modifica il campo di relazione "Membri del team"
3. Abilita l'interruttore di giunzione
4. Seleziona "Persona" come relazione di destinazione
5. Salva
## Risultato
Dopo la configurazione:
@@ -130,15 +125,15 @@ Ecco una procedura completa:
### Aggiungi relazioni
1. **Assegnazione al progetto → Persone**
* Tipo: Molti-a-uno
* Campo su Assegnazione: "Persona"
1. **Persone → Assegnazione al progetto**
* Tipo: Uno-a-molti
* Campo su Persone: "Assegnazioni ai progetti"
* Campo su Assegnazione: "Persona"
2. **Assegnazione al progetto → Progetti**
* Tipo: Molti-a-uno
* Campo su Assegnazione: "Progetto"
2. **Progetti → Assegnazione al progetto**
* Tipo: Uno-a-molti
* Campo su Progetti: "Membri del team"
* Campo su Assegnazione: "Progetto"
### Configura la visualizzazione della giunzione
@@ -30,3 +30,9 @@ Aggiungi collegamenti a strumenti esterni direttamente nella barra laterale. Uti
## Menu Comandi
Premi `Cmd+K` (o `Ctrl+K`) per aprire il menu comandi — una barra di ricerca ad accesso rapido per passare a qualsiasi record, vista o azione senza usare la barra laterale.
## Personalizzare la barra laterale
Per personalizzare la barra laterale, passa il mouse sulla sezione "Workspace" nella barra laterale e fai clic sull'icona a forma di chiave inglese.
<img src="/images/user-guide/layout/navigation-edit-icon.png" alt="Icona di modifica della navigazione" />
@@ -3,6 +3,8 @@ title: Pagine dei record
description: Personalizza il layout delle singole pagine di dettaglio dei record con schede e widget.
---
## Panoramica
Quando apri un record in Twenty, la pagina di dettaglio è composta da **schede** e **widget**. Entrambi sono completamente personalizzabili per ciascun tipo di oggetto.
## Schede
@@ -38,12 +40,20 @@ I widget sono gli elementi costitutivi all'interno di ogni scheda. Tipi di widge
1. Apri un record qualsiasi
2. Premi `Cmd+K` e cerca "Modifica il layout della pagina del record"
o
1. Vai a Impostazioni > Modello dati > oggetto di tua scelta > Layout
2. Fai clic sul pulsante "Personalizza pagina del record" per quell'oggetto
3. Ora sei in modalità di personalizzazione:
* **Aggiungi widget** dal selettore di widget
* **Trascina i widget** per riposizionarli sulla griglia
* **Ridimensiona i widget** trascinando i bordi
* **Configura i campi** mostrati all'interno di ciascun widget
* **Gestisci le schede** — aggiungi, rimuovi, rinomina, riordina
4. Salva le modifiche — si applicano a tutti i record di quel tipo di oggetto
## Visibilità dei campi
@@ -94,15 +94,16 @@ const handler = async (event: RoutePayload) => {
O tipo `RoutePayload` tem a seguinte estrutura:
| Propriedade | Tipo | Descrição | Exemplo |
| ---------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `headers` | `Record\<string, string \| undefined>` | Cabeçalhos HTTP (apenas aqueles listados em `forwardedRequestHeaders`) | veja a seção abaixo |
| `queryStringParameters` | `Record\<string, string \| undefined>` | Parâmetros de query string (valores múltiplos unidos por vírgulas) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
| `pathParameters` | `Record\<string, string \| undefined>` | Parâmetros de caminho extraídos do padrão de rota | `/users/:id`, `/users/123` -> `{ id: '123' }` |
| `body` | `object \| null` | Corpo da requisição analisado (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
| `isBase64Encoded` | `boolean` | Se o corpo está codificado em base64 | |
| `requestContext.http.method` | `string` | Método HTTP (GET, POST, PUT, PATCH, DELETE) | |
| `requestContext.http.path` | `string` | Caminho bruto da requisição | |
| Propriedade | Tipo | Descrição | Exemplo |
| ---------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `headers` | `Record\<string, string \| undefined>` | Cabeçalhos HTTP (apenas aqueles listados em `forwardedRequestHeaders`) | veja a seção abaixo |
| `queryStringParameters` | `Record\<string, string \| undefined>` | Parâmetros de query string (valores múltiplos unidos por vírgulas) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
| `pathParameters` | `Record\<string, string \| undefined>` | Parâmetros de caminho extraídos do padrão de rota | `/users/:id`, `/users/123` -> `{ id: '123' }` |
| `body` | `object \| null` | Corpo da requisição analisado (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
| `rawBody` | `string \| undefined` | Corpo da solicitação UTF-8 original, antes da análise de JSON. Útil para verificar assinaturas de webhook no estilo HMAC (por exemplo, `X-Hub-Signature-256` do GitHub, Stripe). `undefined` quando o ambiente de execução não o preservou. | |
| `isBase64Encoded` | `boolean` | Se o corpo está codificado em base64 | |
| `requestContext.http.method` | `string` | Método HTTP (GET, POST, PUT, PATCH, DELETE) | |
| `requestContext.http.path` | `string` | Caminho bruto da requisição | |
#### forwardedRequestHeaders
@@ -19,7 +19,7 @@ Para equipes prontas para escalar:
* Suporte padrão
<Note>
Recursos premium (SSO e permissões em nível de linha) não estão incluídos no plano Pro.
Os recursos Premium (SSO, permissões em nível de linha e dados de uso de IA) não estão incluídos no plano Pro.
</Note>
### Organização (Nuvem)
@@ -27,7 +27,7 @@ Recursos premium (SSO e permissões em nível de linha) não estão incluídos n
Para equipes maiores com necessidades avançadas:
* Tudo do Pro
* **Recursos premium**: integração SSO e permissões em nível de linha
* **Recursos Premium**: integração com SSO, permissões em nível de linha e dados de uso de IA
* Suporte prioritário
## Planos auto-hospedados
@@ -45,7 +45,7 @@ Hospede o Twenty na sua própria infraestrutura sem custo:
Para equipes que precisam de recursos premium enquanto se auto-hospedam:
* Todos os recursos do Pro
* **Recursos premium**: integração SSO e permissões em nível de linha
* **Recursos Premium**: integração com SSO, permissões em nível de linha e dados de uso de IA
* Suporte da equipe Twenty
* Sem necessidade de publicar código personalizado como código aberto antes de distribuir
@@ -55,6 +55,7 @@ Os recursos premium estão disponíveis apenas nos planos Organização (Nuvem o
* **Integração SSO**: Single Sign-On com seu provedor de identidade
* **Permissões em nível de linha**: controle de acesso granular no nível de registro
* **Dados de uso de IA**: Acompanhe o consumo de IA em todo o espaço de trabalho
## Mudança de planos
@@ -77,3 +78,15 @@ Entre em contato com o suporte para fazer downgrade do seu plano.
### Mudar para faturamento mensal
Entre em contato com o suporte para voltar ao faturamento mensal.
## Obtenha uma chave Enterprise para Organization (Self-Hosted)
Para usar o plano Organization (Self-Hosted), você precisa obter uma chave Enterprise:
1. Vá para **Configurações → Painel de Administração → Enterprise**
<img src="/images/user-guide/billing/enterprise-key.png" alt="Chave Enterprise" />
2. Clique em **Obter chave Enterprise**
3. Quando você for redirecionado ao Stripe, insira seus dados de pagamento e confirme
4. Quando sua chave Enterprise for exibida, cole-a na página de configurações do Enterprise e ative a licença do Organization
@@ -16,6 +16,11 @@ Se você quiser hospedar por conta própria e precisar dos recursos Premium (SSO
Os recursos Premium estão disponíveis apenas nos planos Organization (Cloud ou Self-Hosted):
* **Integração de SSO**: Single Sign-On com seu provedor de identidade
* **Permissões em nível de linha**: Controle de acesso granular no nível de registro
* **Dados de uso de IA**: Acompanhe o consumo de IA em todo o espaço de trabalho
</Accordion>
<Accordion title="O plano Organization na nuvem e o plano Organization em ambiente autohospedado são iguais?">
Ambos oferecem os mesmos recursos Premium. No entanto, uma assinatura na nuvem não pode ser usada para implantações autohospedadas. O ambiente autohospedado requer uma chave Enterprise.
</Accordion>
<Accordion title="Você oferece assentos gratuitos para usuários apenas visualizadores?">
@@ -53,38 +53,45 @@ Primeiro, crie o objeto intermediário que manterá as conexões.
1. Vá a **Definições → Modelo de Dados**
2. Clique em **+ Novo objeto**
3. Dê um nome descritivo (por exemplo, "Atribuição de Projeto", "Membro da Equipe", "Pedido de Produto")
4. Clique em **Salvar**
4. Ative a opção "Ignorar a criação de um campo Nome"
<img src="/images/user-guide/fields/new-pivot-object.png" alt="Novo objeto pivô" />
5. Clique em **Salvar**
<Tip>
**Convenção de nomenclatura**: Use um nome que descreva a relação, como "Atribuição de Projeto" ou "Participação na Equipe". Isso torna o modelo de dados mais fácil de entender.
</Tip>
## Etapa 2: Criar Relações a partir do Objeto de Junção
## Etapa 2: Crie relações entre os objetos e o objeto de junção
Adicione campos de relação do objeto de junção para ambos os objetos que deseja conectar.
Adicione campos de relação de cada um dos seus dois objetos ao objeto de junção.
### Primeira Relação (Junção → Objeto A)
### Primeira Relação (Objeto A → Junção)
1. Selecione seu objeto de junção em **Settings → Data Model**
2. Clique em **+ Adicionar campo**
3. Escolha **Relação** como o tipo de campo
4. Selecione o primeiro objeto (por exemplo, "Pessoas")
5. Defina o tipo de relação como **Muitos-para-um** (muitas atribuições podem se vincular a uma pessoa)
6. Nomeie os campos:
* Campo na junção: por exemplo, "Pessoa"
* Campo em Pessoas: por exemplo, "Atribuições de Projeto"
7. Clique em **Salvar**
### Segunda Relação (Junção → Objeto B)
1. Ainda no objeto de junção, clique em **+ Add Field**
2. Escolha **Relação** como o tipo de campo
3. Selecione o segundo objeto (por exemplo, "Projetos")
4. Defina o tipo de relação como **Muitos-para-um**
1. Selecione seu primeiro objeto em **Settings → Data Model**
2. Clique em **+ Adicionar relação**
3. Selecione o objeto de junção (por exemplo, "Atribuições de Projeto")
4. Defina o tipo de relação como **Um-para-muitos** (uma pessoa pode se vincular a muitas atribuições)
5. Nomeie os campos:
* Campo em Pessoas: por exemplo, "Atribuições de Projeto"
* Campo na junção: por exemplo, "Pessoa"
6. Clique em **Salvar**
### Segunda Relação (Objeto B → Junção)
1. Selecione seu segundo objeto em **Settings → Data Model**
2. Clique em **+ Adicionar relação**
3. Selecione o objeto de junção (por exemplo, "Atribuições de Projeto")
4. Defina o tipo de relação como **Um-para-muitos** (um projeto pode se vincular a muitas atribuições)
5. Ative **"Esta é uma relação com um objeto de junção"**
<img src="/images/user-guide/fields/junction-relation-toggle.png" style={{width:'100%'}} />
6. Nomeie os campos:
* Campo na junção: por exemplo, "Projeto"
* Campo em Projetos: por exemplo, "Membros da Equipe"
6. Clique em **Salvar**
7. Clique em **Salvar**
## Etapa 3: Configurar a Exibição da Relação de Junção
@@ -98,18 +105,6 @@ Agora configure os objetos de origem para exibir diretamente os registros vincul
6. Selecione a **Relação de destino** (por exemplo, "Projeto" — o campo na junção que aponta para o outro lado)
7. Clique em **Salvar**
{/* TODO: Add image
<img src="/images/user-guide/fields/junction-relation-toggle.png" style={{width:'100%'}}/>
*/}
Repita para o outro objeto:
1. Selecione "Projetos" em Data Model
2. Edite o campo de relação "Membros da Equipe"
3. Ative o alternador de junção
4. Selecione "Pessoa" como a relação de destino
5. Salvar
## Resultado
Após a configuração:
@@ -130,15 +125,15 @@ Aqui está um passo a passo completo:
### Adicionar Relações
1. **Atribuição de Projeto → Pessoas**
* Tipo: Muitos-para-um
* Campo em Atribuição: "Pessoa"
1. **Pessoas → Atribuição de Projeto**
* Tipo: Um-para-muitos
* Campo em Pessoas: "Atribuições de Projeto"
* Campo em Atribuição: "Pessoa"
2. **Atribuição de Projeto → Projetos**
* Tipo: Muitos-para-um
* Campo em Atribuição: "Projeto"
2. **Projetos → Atribuição de Projeto**
* Tipo: Um-para-muitos
* Campo em Projetos: "Membros da Equipe"
* Campo em Atribuição: "Projeto"
### Configurar Exibição de Junção
@@ -30,3 +30,9 @@ Adicione links para ferramentas externas diretamente na barra lateral. Útil par
## Menu de Comandos
Pressione `Cmd+K` (ou `Ctrl+K`) para abrir o menu de comandos — uma barra de pesquisa de acesso rápido para ir a qualquer registro, visualização ou ação sem navegar pela barra lateral.
## Personalizando a barra lateral
Para personalizar a barra lateral, passe o cursor sobre a seção "Workspace" na barra lateral e clique no ícone de chave inglesa.
<img src="/images/user-guide/layout/navigation-edit-icon.png" alt="Ícone de edição da navegação" />
@@ -3,6 +3,8 @@ title: Páginas de Registos
description: Personalize o layout das páginas de detalhe de registos individuais com abas e widgets.
---
## Visão Geral
Ao abrir um registo no Twenty, a página de detalhe é composta por **abas** e **widgets**. Ambos são totalmente personalizáveis por tipo de objeto.
## Abas
@@ -38,12 +40,20 @@ Os widgets são os componentes básicos dentro de cada aba. Tipos de widget disp
1. Abra qualquer registro
2. Pressione `Cmd+K` e pesquise por "Edit record page layout"
ou
1. Vá para Configurações > Modelo de dados > objeto de sua escolha > Layout
2. Clique no botão "Personalizar página de registro" para esse objeto
3. Agora você está no modo de personalização:
* **Adicionar widgets** no seletor de widgets
* **Arraste widgets** para reposicioná-los na grade
* **Redimensione os widgets** arrastando suas bordas
* **Configure campos** exibidos em cada widget
* **Gerencie abas** — adicione, remova, renomeie, reordene
4. Salve suas alterações — elas se aplicam a todos os registros desse tipo de objeto
## Visibilidade de campos
@@ -94,15 +94,16 @@ const handler = async (event: RoutePayload) => {
Тип `RoutePayload` имеет следующую структуру:
| Свойство | Тип | Описание | Пример |
| ---------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `headers` | `Record\<string, string \| undefined>` | HTTP-заголовки (только перечисленные в `forwardedRequestHeaders`) | см. раздел ниже |
| `queryStringParameters` | `Record\<string, string \| undefined>` | Параметры строки запроса (несколько значений объединяются запятыми) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
| `pathParameters` | `Record\<string, string \| undefined>` | Параметры пути, извлечённые из шаблона маршрута | `/users/:id`, `/users/123` -> `{ id: '123' }` |
| `body` | `object \| null` | Разобранное тело запроса (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
| `isBase64Encoded` | `boolean` | Является ли тело закодированным в base64 | |
| `requestContext.http.method` | `string` | Метод HTTP (GET, POST, PUT, PATCH, DELETE) | |
| `requestContext.http.path` | `string` | Необработанный путь запроса | |
| Свойство | Тип | Описание | Пример |
| ---------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `headers` | `Record\<string, string \| undefined>` | HTTP-заголовки (только перечисленные в `forwardedRequestHeaders`) | см. раздел ниже |
| `queryStringParameters` | `Record\<string, string \| undefined>` | Параметры строки запроса (несколько значений объединяются запятыми) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
| `pathParameters` | `Record\<string, string \| undefined>` | Параметры пути, извлечённые из шаблона маршрута | `/users/:id`, `/users/123` -> `{ id: '123' }` |
| `body` | `object \| null` | Разобранное тело запроса (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
| `rawBody` | `string \| undefined` | Исходное тело запроса в кодировке UTF-8, до разбора JSON. Полезно для проверки подписей вебхуков в стиле HMAC (например, `X-Hub-Signature-256` от GitHub, Stripe). `undefined`, если среда выполнения не сохранила его. | |
| `isBase64Encoded` | `boolean` | Является ли тело закодированным в base64 | |
| `requestContext.http.method` | `string` | Метод HTTP (GET, POST, PUT, PATCH, DELETE) | |
| `requestContext.http.path` | `string` | Необработанный путь запроса | |
#### forwardedRequestHeaders
@@ -19,7 +19,7 @@ Twenty предлагает гибкие тарифы для команд люб
* Стандартная поддержка
<Note>
Премиальные функции (SSO и разрешения на уровне записей) не входят в план Pro.
Премиальные функции (SSO, разрешения на уровне строк и данные об использовании ИИ) не включены в тариф Pro.
</Note>
### Организация (облако)
@@ -27,7 +27,7 @@ Twenty предлагает гибкие тарифы для команд люб
Для крупных команд с расширенными потребностями:
* Все, что есть в Pro
* **Премиальные функции**: интеграция с SSO и разрешения на уровне записей
* **Премиальные функции**: интеграция SSO, разрешения на уровне строк и данные об использовании ИИ
* Приоритетная поддержка
## Планы для самостоятельного размещения
@@ -45,7 +45,7 @@ Twenty предлагает гибкие тарифы для команд люб
Для команд, которым нужны премиальные функции при самостоятельном размещении:
* Все возможности Pro
* **Премиальные функции**: интеграция с SSO и разрешения на уровне записей
* **Премиальные функции**: интеграция SSO, разрешения на уровне строк и данные об использовании ИИ
* Поддержка от команды Twenty
* Не требуется публиковать пользовательский код с открытым исходным кодом перед распространением
@@ -55,6 +55,7 @@ Twenty предлагает гибкие тарифы для команд люб
* **Интеграция с SSO**: единый вход через вашего поставщика идентификации
* **Разрешения на уровне записей**: тонкая настройка управления доступом на уровне записей
* **Данные об использовании ИИ**: Отслеживайте потребление ИИ в рабочем пространстве
## Переключение планов
@@ -77,3 +78,15 @@ Twenty предлагает гибкие тарифы для команд люб
### Переход на ежемесячную оплату
Свяжитесь со службой поддержки, чтобы вернуться на ежемесячную оплату.
## Получите ключ Enterprise для организации (самостоятельный хостинг)
Чтобы использовать тариф Organization (Self-Hosted), необходимо получить ключ Enterprise:
1. Перейдите в **Настройки → Панель администратора → Enterprise**
<img src="/images/user-guide/billing/enterprise-key.png" alt="Ключ Enterprise" />
2. Нажмите **Получить ключ Enterprise**
3. Когда вы будете перенаправлены на Stripe, введите платёжные данные и подтвердите
4. Когда ваш ключ Enterprise будет отображён, вставьте его на странице настроек Enterprise и активируйте лицензию Organization
@@ -16,6 +16,11 @@ description: Часто задаваемые вопросы о тарифах и
Премиум-функции доступны только в планах Organization (облако или Self-Hosted):
* **Интеграция с SSO**: единый вход с вашим провайдером идентификации
* **Разрешения на уровне записей**: детализированное управление доступом на уровне записей
* **Данные об использовании ИИ**: Отслеживайте потребление ИИ в рабочем пространстве
</Accordion>
<Accordion title="Одинаков ли тариф Organization в облаке и в самостоятельно размещаемой версии?">
Они предлагают одинаковые премиум-функции. Однако облачную подписку нельзя использовать для развертываний на собственном хостинге. Для самостоятельно размещаемой версии требуется ключ Enterprise.
</Accordion>
<Accordion title="Предлагаете ли вы бесплатные места для пользователей с правами только просмотра?">
@@ -27,7 +32,7 @@ description: Часто задаваемые вопросы о тарифах и
</Accordion>
<Accordion title="Где я могу переключить свою подписку на план Pro?">
Пожалуйста, свяжитесь с нашей командой напрямую через Поддержку, в данный момент нет простого способа сделать это через пользовательский интерфейс.
Пожалуйста, свяжитесь с нашей командой напрямую через раздел «Поддержка», в данный момент нет простого способа сделать это через пользовательский интерфейс.
</Accordion>
<Accordion title="Где я могу переключить свою подписку на годовой период?">
@@ -53,38 +53,45 @@ People ←→ Project Assignments ←→ Projects
1. Перейдите в **Настройки → Модель данных**
2. Нажмите **+ Новый объект**
3. Дайте ему понятное имя (например, "Project Assignment", "Team Member", "Product Order")
4. Нажмите **Сохранить**
4. Включите переключатель «Пропустить создание поля „Имя“»
<img src="/images/user-guide/fields/new-pivot-object.png" alt="Новый объект-связка" />
5. Нажмите **Сохранить**
<Tip>
**Рекомендации по именованию**: Используйте название, описывающее связь, например "Project Assignment" или "Team Membership". Так модель данных становится более понятной.
</Tip>
## Шаг 2: Создайте связи из объекта-связки
## Шаг 2: Создайте связи между объектами и объектом-связкой
Добавьте поля связи из объекта-связки к обоим объектам, которые вы хотите соединить.
Добавьте поля связи из каждого из двух объектов в объект-связку.
### Первая связь (Объект-связка → Объект A)
### Первая связь (Объект A → Объект-связка)
1. Выберите ваш объект-связку в **Настройки → Модель данных**
2. Нажмите **+ Добавить поле**
3. Выберите тип поля **Связь**
4. Выберите первый объект (например, "Люди")
5. Установите тип связи **Многие-к-одному** (много назначений могут ссылаться на одного человека)
6. Назовите поля:
* Поле на объекте-связке: например, "Person"
* Поле в объекте Люди: например, "Project Assignments"
7. Нажмите **Сохранить**
### Вторая связь (Объект-связка → Объект B)
1. Оставаясь на объекте-связке, нажмите **+ Добавить поле**
2. Выберите тип поля **Связь**
3. Выберите второй объект (например, "Проекты")
4. Установите тип связи **Многие-к-одному**
1. Выберите ваш первый объект в **Настройках → Модель данных**
2. Нажмите **+ Добавить связь**
3. Выберите объект-связку (например, "Назначения по проектам")
4. Установите тип связи **Один-ко-многим** (один человек может быть связан со многими назначениями)
5. Назовите поля:
* Поле в объекте Люди: например, "Project Assignments"
* Поле на объекте-связке: например, "Person"
6. Нажмите **Сохранить**
### Вторая связь (Объект B → Объект-связка)
1. Выберите ваш второй объект в **Настройках → Модель данных**
2. Нажмите **+ Добавить связь**
3. Выберите объект-связку (например, "Назначения по проектам")
4. Установите тип связи **Один-ко-многим** (один проект может быть связан со многими назначениями)
5. Включите **"Это связь с объектом-связкой"**
<img src="/images/user-guide/fields/junction-relation-toggle.png" style={{width:'100%'}} />
6. Назовите поля:
* Поле на объекте-связке: например, "Project"
* Поле в объекте Проекты: например, "Team Members"
6. Нажмите **Сохранить**
7. Нажмите **Сохранить**
## Шаг 3: Настройте отображение связи через объект-связку
@@ -98,18 +105,6 @@ People ←→ Project Assignments ←→ Projects
6. Выберите **Целевую связь** (например, "Project" — поле на объекте-связке, указывающее на другую сторону)
7. Нажмите **Сохранить**
{/* TODO: Add image
<img src="/images/user-guide/fields/junction-relation-toggle.png" style={{width:'100%'}}/>
*/}
Повторите для другого объекта:
1. Выберите "Проекты" в Модели данных
2. Отредактируйте поле связи "Team Members"
3. Включите переключатель объекта-связки
4. Выберите "Person" как целевую связь
5. Сохранить
## Результат
После настройки:
@@ -130,15 +125,15 @@ People ←→ Project Assignments ←→ Projects
### Добавьте связи
1. **Project Assignment → Люди**
* Тип: Многие-к-одному
* Поле в объекте Assignment: "Person"
1. **Люди → Назначение на проект**
* Тип: Один-ко-многим
* Поле в объекте Люди: "Project Assignments"
* Поле в объекте Assignment: "Person"
2. **Project Assignment → Проекты**
* Тип: Многие-к-одному
* Поле в объекте Assignment: "Project"
2. **Проекты → Назначение на проект**
* Тип: Один-ко-многим
* Поле в объекте Проекты: "Team Members"
* Поле в объекте Assignment: "Project"
### Настройте отображение объекта-связки
@@ -30,3 +30,9 @@ description: Настройте левую боковую панель под т
## Меню команд
Нажмите `Cmd+K` (или `Ctrl+K`), чтобы открыть меню команд — строку быстрого поиска для перехода к любой записи, представлению или действию, не используя боковую панель.
## Настройка боковой панели
Чтобы настроить боковую панель, наведите курсор на раздел "Workspace" на боковой панели и нажмите значок гаечного ключа.
<img src="/images/user-guide/layout/navigation-edit-icon.png" alt="Значок редактирования навигации" />
@@ -3,6 +3,8 @@ title: Страницы записей
description: Настройте макет отдельных страниц сведений о записях с вкладками и виджетами.
---
## Обзор
Когда вы открываете запись в Twenty, страница сведений состоит из **вкладок** и **виджетов**. Оба полностью настраиваются для каждого типа объекта.
## Вкладки
@@ -38,12 +40,20 @@ description: Настройте макет отдельных страниц с
1. Откройте любую запись
2. Нажмите `Cmd+K` и найдите "Изменить макет страницы записи"
или
1. Перейдите в Настройки > Модель данных > объект по вашему выбору > Макет
2. Нажмите кнопку "Настроить страницу записи" для этого объекта
3. Теперь вы в режиме настройки:
* **Добавляйте виджеты** из списка виджетов
* **Перетаскивайте виджеты**, чтобы изменить их положение на сетке
* **Изменяйте размер виджетов**, перетаскивая их границы
* **Настраивайте поля**, отображаемые в каждом виджете
* **Управляйте вкладками** — добавляйте, удаляйте, переименовывайте, меняйте порядок
4. Сохраните изменения — они применятся ко всем записям этого типа объекта
## Видимость полей
@@ -95,15 +95,16 @@ const handler = async (event: RoutePayload) => {
`RoutePayload` türünün yapısı şu şekildedir:
| Özellik | Tür | Açıklama | Örnek |
| ---------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `headers` | `Record\<string, string \| undefined>` | HTTP başlıkları (`forwardedRequestHeaders` içinde listelenenlerle sınırlı) | aşağıdaki bölüme bakın |
| `queryStringParameters` | `Record\<string, string \| undefined>` | Sorgu dizesi parametreleri (birden çok değer virgülle birleştirilir) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
| `pathParameters` | `Record\<string, string \| undefined>` | Rota deseninden çıkarılan yol parametreleri | `/users/:id`, `/users/123` -> `{ id: '123' }` |
| `body` | `object \| null` | Ayrıştırılmış istek gövdesi (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
| `isBase64Encoded` | `boolean` | Gövdenin base64 ile kodlanıp kodlanmadığı | |
| `requestContext.http.method` | `string` | HTTP yöntemi (GET, POST, PUT, PATCH, DELETE) | |
| `requestContext.http.path` | `string` | Ham istek yolu | |
| Özellik | Tür | Açıklama | Örnek |
| ---------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `headers` | `Record\<string, string \| undefined>` | HTTP başlıkları (`forwardedRequestHeaders` içinde listelenenlerle sınırlı) | aşağıdaki bölüme bakın |
| `queryStringParameters` | `Record\<string, string \| undefined>` | Sorgu dizesi parametreleri (birden çok değer virgülle birleştirilir) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
| `pathParameters` | `Record\<string, string \| undefined>` | Rota deseninden çıkarılan yol parametreleri | `/users/:id`, `/users/123` -> `{ id: '123' }` |
| `body` | `object \| null` | Ayrıştırılmış istek gövdesi (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
| `rawBody` | `string \| undefined` | JSON ayrıştırılmadan önceki özgün UTF-8 istek gövdesi. HMAC tarzı webhook imzalarını doğrulamak için kullanışlıdır (ör. GitHub'ın `X-Hub-Signature-256`, Stripe). Çalışma zamanı onu korumadığında `undefined` olur. | |
| `isBase64Encoded` | `boolean` | Gövdenin base64 ile kodlanıp kodlanmadığı | |
| `requestContext.http.method` | `string` | HTTP yöntemi (GET, POST, PUT, PATCH, DELETE) | |
| `requestContext.http.path` | `string` | Ham istek yolu | |
#### forwardedRequestHeaders
@@ -19,7 +19,7 @@ Büyümeye hazır ekipler için:
* Standart destek
<Note>
Premium özellikler (SSO ve satır düzeyi izinler) Pro planına dahil değildir.
Premium özellikler (SSO, satır düzeyi izinler ve Yapay Zeka kullanım verileri) Pro plana dahil değildir.
</Note>
### Kuruluş (Bulut)
@@ -27,7 +27,7 @@ Premium özellikler (SSO ve satır düzeyi izinler) Pro planına dahil değildir
Gelişmiş ihtiyaçları olan daha büyük ekipler için:
* Pro'daki her şey
* **Premium özellikler**: SSO entegrasyonu ve satır düzeyi izinler
* **Premium özellikler**: SSO entegrasyonu, satır düzeyi izinler ve Yapay Zeka kullanım verileri
* Öncelikli destek
## Kendi Kendine Barındırılan Planlar
@@ -45,7 +45,7 @@ Twenty'yi kendi altyapınızda hiçbir ücret ödemeden barındırın:
Kendi kendine barındırma yaparken premium özelliklere ihtiyaç duyan ekipler için:
* Tüm Pro özellikleri
* **Premium özellikler**: SSO entegrasyonu ve satır düzeyi izinler
* **Premium özellikler**: SSO entegrasyonu, satır düzeyi izinler ve Yapay Zeka kullanım verileri
* Twenty ekibinden destek
* Dağıtmadan önce özel kodu açık kaynak olarak yayınlama zorunluluğu yoktur.
@@ -55,6 +55,7 @@ Premium özellikler yalnızca Kuruluş planlarında (Bulut veya Kendi Kendine Ba
* **SSO entegrasyonu**: Kimlik sağlayıcınızla Tek Oturum Açma
* **Satır düzeyi izinler**: Kayıt düzeyinde ayrıntılı erişim denetimi
* **Yapay Zeka kullanım verileri**: Çalışma alanı genelinde Yapay Zeka tüketimini takip edin
## Planlar Arasında Geçiş
@@ -77,3 +78,15 @@ Planınızı düşürmek için destek ekibiyle iletişime geçin.
### Aylığa Geçiş Yap
Aylık faturalamaya geri dönmek için destek ekibiyle iletişime geçin.
## Organizasyon (Kendi Sunucunuzda Barındırılan) için Kurumsal Anahtar edinin
Organizasyon (Kendi Sunucunuzda Barındırılan) planını kullanmak için bir Kurumsal Anahtar edinmeniz gerekir:
1. **Ayarlar → Yönetici Paneli → Kurumsal** bölümüne gidin
<img src="/images/user-guide/billing/enterprise-key.png" alt="Kurumsal Anahtar" />
2. **Kurumsal Anahtar Alın**'ı tıklayın
3. Stripe'a yönlendirildiğinizde ödeme bilgilerinizi girin ve onaylayın
4. Kurumsal anahtarınız görüntülendiğinde, bunu Kurumsal ayarlar sayfasına yapıştırın ve Organizasyon lisansını etkinleştirin
@@ -16,6 +16,11 @@ Kendiniz barındırmak istiyor ve Premium özelliklere (SSO ve satır düzeyi iz
Premium özellikler yalnızca Kuruluş planlarında (Bulut veya Kendi Barındırmalı) kullanılabilir:
* **SSO entegrasyonu**: Kimlik sağlayıcınızla Tek Oturum Açma
* **Satır düzeyi izinler**: Kayıt düzeyinde ayrıntılı erişim denetimi
* **Yapay Zeka kullanım verileri**: Çalışma alanı genelinde Yapay Zeka tüketimini takip edin
</Accordion>
<Accordion title="Buluttaki Organization planı ile kendi barındırmalı ortamdaki Organization planı aynı mı?">
İkisi de aynı Premium özellikleri sunar. Ancak bulut aboneliği, kendi barındırmalı dağıtımlarda kullanılamaz. Kendi barındırmalı kurulumlar için bir Enterprise anahtarı gerekir.
</Accordion>
<Accordion title="Salt görüntüleme kullanıcıları için ücretsiz koltuklar sunuyor musunuz?">
@@ -53,38 +53,45 @@ Bağlantı ilişkisi anahtarını etkinleştirdiğinizde, Twenty aradaki bağlan
1. **Ayarlar → Veri Modeli** bölümüne gidin
2. **+ Yeni nesne**'ye tıklayın
3. Açıklayıcı bir ad verin (örn. "Project Assignment", "Team Member", "Product Order")
4. **Kaydet**'e tıklayın
4. "Ad alanı oluşturmayı atla" seçeneğini açın
<img src="/images/user-guide/fields/new-pivot-object.png" alt="Yeni pivot nesnesi" />
5. **Kaydet**'e tıklayın
<Tip>
**Adlandırma kuralı**: "Project Assignment" veya "Team Membership" gibi ilişkiyi tanımlayan bir ad kullanın. Bu, veri modelinin anlaşılmasını kolaylaştırır.
</Tip>
## Adım 2: Bağlantı Nesnesinden İlişkiler Oluşturun
## Adım 2: Nesneler ile Bağlantı nesnesi arasında ilişkiler oluşturun
Bağlamak istediğiniz her iki nesneye de bağlantı nesnesinden ilişki alanları ekleyin.
İki nesnenizin her birinden bağlantı nesnesine ilişki alanları ekleyin.
### İlk İlişki (Bağlantı → Nesne A)
### İlk İlişki (Nesne A → Bağlantı)
1. **Ayarlar → Veri Modeli**'nde bağlantı nesnenizi seçin
2. **+ Alan Ekle**'ye tıklayın
3. Alan türü olarak **İlişki**'yi seçin
4. İlk nesneyi seçin (örn. "People")
5. İlişki türünü **Çoktan-Bire** olarak ayarlayın (birçok atama bir kişiye bağlanabilir)
6. Alanları adlandırın:
* Bağlantı üzerindeki alan: örn. "Person"
* People üzerindeki alan: örn. "Project Assignments"
7. **Kaydet**'e tıklayın
### İkinci İlişki (Bağlantı → Nesne B)
1. Hâlâ bağlantı nesnesindeyken, **+ Add Field**'e tıklayın
2. Alan türü olarak **İlişki**'yi seçin
3. İkinci nesneyi seçin (örn. "Projects")
4. İlişki türünü **Çoktan-Bire** olarak ayarlayın
1. İlk nesnenizi **Ayarlar → Veri Modeli**'nde seçin
2. **+ İlişki Ekle**'ye tıklayın
3. Bağlantı nesnesini seçin (örn. "Project Assignments")
4. İlişki türünü **Birden-Çoğa** olarak ayarlayın (bir kişi birçok atamaya bağlanabilir)
5. Alanları adlandırın:
* People üzerindeki alan: örn. "Project Assignments"
* Bağlantı üzerindeki alan: örn. "Person"
6. **Kaydet**'e tıklayın
### İkinci İlişki (Nesne B → Bağlantı)
1. İkinci nesnenizi **Ayarlar → Veri Modeli**'nde seçin
2. **+ İlişki Ekle**'ye tıklayın
3. Bağlantı nesnesini seçin (örn. "Project Assignments")
4. İlişki türünü **Birden-Çoğa** olarak ayarlayın (bir proje birçok atamaya bağlanabilir)
5. **"Bu, bir bağlantı nesnesine kurulan bir ilişkidir"** seçeneğini etkinleştirin
<img src="/images/user-guide/fields/junction-relation-toggle.png" style={{width:'100%'}} />
6. Alanları adlandırın:
* Bağlantı üzerindeki alan: örn. "Project"
* Projects üzerindeki alan: örn. "Team Members"
6. **Kaydet**'e tıklayın
7. **Kaydet**'e tıklayın
## Adım 3: Bağlantı İlişkisi Görüntüsünü Yapılandırın
@@ -98,18 +105,6 @@ Bağlamak istediğiniz her iki nesneye de bağlantı nesnesinden ilişki alanlar
6. **Hedef ilişki**yi seçin (örn. "Project" — bağlantı üzerindeki, diğer tarafı işaret eden alan)
7. **Kaydet**'e tıklayın
{/* TODO: Add image
<img src="/images/user-guide/fields/junction-relation-toggle.png" style={{width:'100%'}}/>
*/}
Diğer nesne için tekrarlayın:
1. Veri Modeli'nde "Projects"i seçin
2. "Team Members" ilişki alanını düzenleyin
3. Bağlantı anahtarını etkinleştirin
4. Hedef ilişki olarak "Person"ı seçin
5. Kaydet
## Sonuç
Yapılandırmadan sonra:
@@ -130,15 +125,15 @@ Bağlantı nesnesi hâlâ mevcuttur ve bağlantıları saklar, ancak kullanıcı
### İlişkiler Ekleyin
1. **Project Assignment → People**
* Tür: Çoktan-Bire
* Assignment üzerindeki alan: "Person"
1. **People → Project Assignment**
* Tür: Birden-Çoğa
* People üzerindeki alan: "Project Assignments"
* Assignment üzerindeki alan: "Person"
2. **Project Assignment → Projects**
* Tür: Çoktan-Bire
* Assignment üzerindeki alan: "Project"
2. **Projects → Project Assignment**
* Tür: Birden-Çoğa
* Projects üzerindeki alan: "Team Members"
* Assignment üzerindeki alan: "Project"
### Bağlantı Görüntüsünü Yapılandırın
@@ -30,3 +30,9 @@ Harici araçlara yönelik bağlantıları doğrudan kenar çubuğuna ekleyin. Wi
## Komut Menüsü
Komut menüsünü açmak için `Cmd+K` (veya `Ctrl+K`) tuşlarına basın — kenar çubuğunda gezinmeden herhangi bir kayda, görünüme veya eyleme atlamak için hızlı erişim sunan bir arama çubuğu.
## Kenar Çubuğunu Özelleştirme
Kenar çubuğunu özelleştirmek için kenar çubuğundaki "Workspace" bölümünün üzerine gelin ve anahtar simgesine tıklayın.
<img src="/images/user-guide/layout/navigation-edit-icon.png" alt="Gezinti düzenleme simgesi" />
@@ -3,6 +3,8 @@ title: Kayıt Sayfaları
description: Her bir kayıt ayrıntı sayfasının düzenini sekmeler ve widget'larla özelleştirin.
---
## Genel Bakış
Twenty'de bir kaydı açtığınızda, ayrıntı sayfası **sekmeler** ve **widget'lar** içerir. Her ikisi de her nesne türü bazında tamamen özelleştirilebilir.
## Sekmeler
@@ -38,12 +40,20 @@ Widget'lar, her sekmenin içindeki yapı taşlarıdır. Kullanılabilir widget t
1. Herhangi bir kaydı açın
2. `Cmd+K` tuşlarına basın ve "Kayıt sayfası yerleşimini düzenle" ifadesini arayın
veya
1. Ayarlar > Veri modeli > seçtiğiniz nesne > Düzen'e gidin
2. O nesne için "Kayıt sayfasını özelleştir" düğmesine tıklayın
3. Artık özelleştirme modundasınız:
* **Widget ekleyin** widget seçicisinden
* **Widget'ları sürükleyin** ve ızgara üzerinde yeniden konumlandırın
* **Widget'ları yeniden boyutlandırın** kenarlarını sürükleyerek
* **Alanları yapılandırın** — her bir widget içinde gösterilen
* **Sekmeleri yönetin** — ekleyin, kaldırın, yeniden adlandırın, yeniden sıralayın
4. Değişikliklerinizi kaydedin — bu değişiklikler o nesne türündeki tüm kayıtlara uygulanır
## Alan görünürlüğü
@@ -95,15 +95,16 @@ const handler = async (event: RoutePayload) => {
`RoutePayload` 类型具有以下结构:
| 属性 | 类型 | 描述 | 示例 |
| ---------------------------- | ------------------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------- |
| `headers` | `Record\<string, string \| undefined>` | HTTP 请求头(仅限 `forwardedRequestHeaders` 中列出的那些) | 见下文 |
| `queryStringParameters` | `Record\<string, string \| undefined>` | 查询字符串参数(多个值以逗号连接) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
| `pathParameters` | `Record\<string, string \| undefined>` | 从路由模式中提取的路径参数 | `/users/:id``/users/123` -> `{ id: '123' }` |
| `body` | `object \| null` | 已解析的请求体(JSON) | `{ id: 1 }` -> `{ id: 1 }` |
| `isBase64Encoded` | `boolean` | 请求体是否为 base64 编码 | |
| `requestContext.http.method` | `string` | HTTP 方法(GET、POST、PUT、PATCH、DELETE | |
| `requestContext.http.path` | `string` | 原始请求路径 | |
| 属性 | 类型 | 描述 | 示例 |
| ---------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| `headers` | `Record\<string, string \| undefined>` | HTTP 请求头(仅限 `forwardedRequestHeaders` 中列出的那些) | 见下文 |
| `queryStringParameters` | `Record\<string, string \| undefined>` | 查询字符串参数(多个值以逗号连接) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
| `pathParameters` | `Record\<string, string \| undefined>` | 从路由模式中提取的路径参数 | `/users/:id``/users/123` -> `{ id: '123' }` |
| `body` | `object \| null` | 已解析的请求体(JSON) | `{ id: 1 }` -> `{ id: 1 }` |
| `rawBody` | `string \| undefined` | 在 JSON 解析之前的原始 UTF-8 请求体。 用于验证 HMAC 风格的 Webhook 签名(例如 GitHub 的 `X-Hub-Signature-256`、Stripe)。 当运行时未保留它时为 `undefined`。 | |
| `isBase64Encoded` | `boolean` | 请求体是否为 base64 编码 | |
| `requestContext.http.method` | `string` | HTTP 方法(GET、POST、PUT、PATCH、DELETE | |
| `requestContext.http.path` | `string` | 原始请求路径 | |
#### forwardedRequestHeaders
@@ -19,7 +19,7 @@ description: 了解 Twenty 的定价方案以及如何在它们之间切换。
* 标准支持
<Note>
Pro 计划不包含高级功能(SSO行级权限)。
Pro 方案不包含高级功能(SSO行级权限和 AI 使用数据)。
</Note>
### 组织计划(云端)
@@ -27,7 +27,7 @@ Pro 计划不包含高级功能(SSO 和行级权限)。
适用于具有高级需求的大型团队:
* 包含 Pro 的全部内容
* **高级功能**SSO 集成行级权限
* **高级功能**SSO 集成行级权限和 AI 使用数据
* 优先支持
## 自托管方案
@@ -45,7 +45,7 @@ Pro 计划不包含高级功能(SSO 和行级权限)。
适用于在自托管同时需要高级功能的团队:
* 所有 Pro 功能
* **高级功能**SSO 集成行级权限
* **高级功能**SSO 集成行级权限和 AI 使用数据
* Twenty 团队支持
* 分发前无需将自定义代码开源
@@ -55,6 +55,7 @@ Pro 计划不包含高级功能(SSO 和行级权限)。
* **SSO 集成**:与您的身份提供商进行单点登录
* **行级权限**:在记录级别进行细粒度访问控制
* **AI 使用数据**:在整个工作区跟踪 AI 消耗
## 切换计划
@@ -77,3 +78,15 @@ Pro 计划不包含高级功能(SSO 和行级权限)。
### 切换为按月计费
联系支持以切换回按月计费。
## 为 Organization (Self-Hosted) 获取企业密钥
要使用 Organization (Self-Hosted) 方案,您需要获取企业密钥:
1. 转到 **设置 → 管理员面板 → 企业版**
<img src="/images/user-guide/billing/enterprise-key.png" alt="企业密钥" />
2. 单击 **获取企业密钥**
3. 当您被重定向到 Stripe 时,输入您的付款信息并确认
4. 当显示出您的企业密钥时,将其粘贴到企业版设置页面并激活 Organization 许可证
@@ -16,6 +16,11 @@ description: 关于 Twenty 定价和账单的常见问题。
高级功能仅适用于 Organization 计划(云端或自托管):
* **SSO 集成**:与您的身份提供商进行单点登录
* **行级权限**:在记录级别进行细粒度的访问控制
* **AI 使用数据**:在整个工作区跟踪 AI 消耗
</Accordion>
<Accordion title="云端的 Organization 计划与自托管的 Organization 计划是否相同?">
它们提供相同的高级功能。 但是,云端订阅不能用于自托管部署。 自托管需要 Enterprise 密钥。
</Accordion>
<Accordion title="你们是否为只读用户提供免费席位?">
@@ -53,38 +53,45 @@ People ←→ Project Assignments ←→ Projects
1. 进入 **设置 → 数据模型**
2. 点击 **+ 新建对象**
3. 使用描述性名称(例如,"Project Assignment"、"Team Member"、"Product Order"
4. 单击 **保存**
4. 将“跳过创建名称字段”切换为开启
<img src="/images/user-guide/fields/new-pivot-object.png" alt="新建连接对象" />
5. 单击 **保存**
<Tip>
**命名约定**:使用能描述关系的名称,例如 "Project Assignment" 或 "Team Membership"。 这会让数据模型更易理解。
</Tip>
## 步骤 2:从连接对象创建关系
## 第 2 步:在对象与连接对象之间创建关系
从连接对象向您要连接的两个对象添加关系字段。
您的两个对象分别向连接对象添加关系字段。
### 第一条关系(连接对象 → 对象 A
### 第一条关系(对象 A → 连接对象)
1. 在 **Settings → Data Model** 中选择您的连接对象
2. 击 **+ 添加字段**
3. 将字段类型选择为 **Relation**
4. 选择第一个对象(例如,"People"
5. 将关系类型设置为 **Many-to-One**(多个分配可关联到一个人)
6. 为这些字段命名:
* 连接对象上的字段:例如,"Person"
* People 上的字段:例如,"Project Assignments"
7. 单击 **保存**
### 第二条关系(连接对象 → 对象 B)
1. 仍在连接对象中,点击 **+ Add Field**
2. 将字段类型选择为 **Relation**
3. 选择第二个对象(例如,"Projects"
4. 将关系类型设置为 **Many-to-One**
1. 在 **Settings → Data Model** 中选择您的第一个对象
2. 击 **+ 添加关系**
3. 选择连接对象(例如,"Project Assignments"
4. 将关系类型设置为 **One-To-Many**(一个人可以关联到多个分配
5. 为这些字段命名:
* People 上的字段:例如,"Project Assignments"
* 连接对象上的字段:例如,"Person"
6. 单击 **保存**
### 第二条关系(对象 B → 连接对象)
1. 在 **Settings → Data Model** 中选择您的第二个对象
2. 单击 **+ 添加关系**
3. 选择连接对象(例如,"Project Assignments"
4. 将关系类型设置为 **One-To-Many**(一个项目可以关联到多个分配)
5. 启用 **"This is a relation to a Junction Object"**
<img src="/images/user-guide/fields/junction-relation-toggle.png" style={{width:'100%'}} />
6. 为这些字段命名:
* 连接对象上的字段:例如,"Project"
* Projects 上的字段:例如,"Team Members"
6. 单击 **保存**
7. 单击 **保存**
## 步骤 3:配置连接关系的显示方式
@@ -98,18 +105,6 @@ People ←→ Project Assignments ←→ Projects
6. 选择 **Target relation**(例如,"Project" —— 连接对象上指向另一侧的字段)
7. 单击 **保存**
{/* TODO: Add image
<img src="/images/user-guide/fields/junction-relation-toggle.png" style={{width:'100%'}}/>
*/}
对另一侧对象重复上述操作:
1. 在 Data Model 中选择 "Projects"
2. 编辑 "Team Members" 关系字段
3. 启用连接关系开关
4. 将目标关系选择为 "Person"
5. 保存
## 结果
配置完成后:
@@ -130,15 +125,15 @@ People ←→ Project Assignments ←→ Projects
### 添加关系
1. **Project Assignment → People**
* 类型:Many-to-One
* Assignment 上的字段:"Person"
1. **People → Project Assignment**
* 类型:One-to-Many
* People 上的字段:"Project Assignments"
* Assignment 上的字段:"Person"
2. **Project Assignment → Projects**
* 类型:Many-to-One
* Assignment 上的字段:"Project"
2. **Projects → Project Assignment**
* 类型:One-to-Many
* Projects 上的字段:"Team Members"
* Assignment 上的字段:"Project"
### 配置连接关系显示
@@ -30,3 +30,9 @@ description: 自定义左侧边栏,使其符合您团队的工作方式。
## 命令菜单
按下`Cmd+K`(或`Ctrl+K`)打开命令菜单——一个快速访问的搜索栏,无需通过侧边栏即可跳转到任何记录、视图或操作。
## 自定义侧边栏
要自定义侧边栏,请将鼠标悬停在侧边栏中的"工作区"部分,然后单击扳手图标。
<img src="/images/user-guide/layout/navigation-edit-icon.png" alt="导航编辑图标" />
@@ -3,6 +3,8 @@ title: 记录页面
description: 使用选项卡和小部件自定义单个记录详情页的布局。
---
## 概览
当你在 Twenty 中打开一条记录时,详情页由**选项卡**和**小部件**组成。 两者都可按对象类型完全自定义。
## 选项卡
@@ -38,12 +40,20 @@ description: 使用选项卡和小部件自定义单个记录详情页的布局
1. 打开任意记录
2. 按下 `Cmd+K`,搜索“编辑记录页面布局”
1. 前往 设置 > 数据模型 > 所选对象 > 布局
2. 单击该对象的“自定义记录页面”按钮
3. 你现在处于自定义模式:
* 从小部件选择器中**添加小部件**
* **拖动小部件**,在网格上重新定位它们
* **调整小部件大小**,通过拖动其边缘
* **配置字段**,设置每个小部件内显示的字段
* **管理选项卡** — 添加、删除、重命名、重新排序
4. 保存你的更改—它们将应用于该对象类型的所有记录
## 字段可见性
+3 -19
View File
@@ -1,6 +1,6 @@
import { setupI18n, type I18n, type Messages } from '@lingui/core';
import { type Messages } from '@lingui/core';
import { createI18nInstanceFactory } from 'twenty-shared/i18n';
import { type APP_LOCALES } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils';
import { messages as afMessages } from '@/locales/generated/af-ZA';
import { messages as arMessages } from '@/locales/generated/ar-SA';
import { messages as caMessages } from '@/locales/generated/ca-ES';
@@ -67,20 +67,4 @@ const messages: Record<keyof typeof APP_LOCALES, Messages> = {
'zh-TW': zhHantMessages,
};
const i18nInstancesMap: Partial<Record<keyof typeof APP_LOCALES, I18n>> = {};
export const createI18nInstance = (locale: keyof typeof APP_LOCALES): I18n => {
if (isDefined(i18nInstancesMap[locale])) {
return i18nInstancesMap[locale];
}
const i18nInstance = setupI18n();
const localeMessages = messages[locale] ?? messages.en;
i18nInstance.load(locale, localeMessages);
i18nInstance.activate(locale);
i18nInstancesMap[locale] = i18nInstance;
return i18nInstance;
};
export const createI18nInstance = createI18nInstanceFactory(messages);
@@ -1,5 +1,4 @@
import { useExitLayoutCustomizationMode } from '@/layout-customization/hooks/useExitLayoutCustomizationMode';
import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout';
import { activeCustomizationPageLayoutIdsState } from '@/layout-customization/states/activeCustomizationPageLayoutIdsState';
import { fieldsWidgetEditorModeDraftComponentState } from '@/page-layout/states/fieldsWidgetEditorModeDraftComponentState';
import { fieldsWidgetEditorModePersistedComponentState } from '@/page-layout/states/fieldsWidgetEditorModePersistedComponentState';
@@ -10,6 +9,9 @@ import { fieldsWidgetUngroupedFieldsPersistedComponentState } from '@/page-layou
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { recordTableWidgetViewPersistedComponentState } from '@/page-layout/states/recordTableWidgetViewPersistedComponentState';
import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout';
import { convertPageLayoutToTabLayouts } from '@/page-layout/utils/convertPageLayoutToTabLayouts';
import { useStore } from 'jotai';
import { useCallback } from 'react';
@@ -90,6 +92,18 @@ export const useCancelLayoutCustomization = () => {
}),
fieldsWidgetEditorModePersisted,
);
const recordTableWidgetViewPersisted = store.get(
recordTableWidgetViewPersistedComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
store.set(
recordTableWidgetViewDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
recordTableWidgetViewPersisted,
);
}
exitLayoutCustomizationMode();
@@ -1,6 +1,5 @@
import { useCommandMenuItemsDraftState } from '@/command-menu-item/hooks/useCommandMenuItemsDraftState';
import { activeCustomizationPageLayoutIdsState } from '@/layout-customization/states/activeCustomizationPageLayoutIdsState';
import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout';
import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemsDraftState';
import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState';
import { fieldsWidgetGroupsPersistedComponentState } from '@/page-layout/states/fieldsWidgetGroupsPersistedComponentState';
@@ -8,6 +7,9 @@ import { fieldsWidgetUngroupedFieldsDraftComponentState } from '@/page-layout/st
import { fieldsWidgetUngroupedFieldsPersistedComponentState } from '@/page-layout/states/fieldsWidgetUngroupedFieldsPersistedComponentState';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { recordTableWidgetViewPersistedComponentState } from '@/page-layout/states/recordTableWidgetViewPersistedComponentState';
import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout';
import { atom, useAtomValue } from 'jotai';
import { useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
@@ -86,6 +88,26 @@ export const useIsLayoutCustomizationDirty = () => {
if (!isDeeplyEqual(ungroupedFieldsDraft, ungroupedFieldsPersisted)) {
return true;
}
const recordTableWidgetViewDraft = get(
recordTableWidgetViewDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
const recordTableWidgetViewPersisted = get(
recordTableWidgetViewPersistedComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
if (
!isDeeplyEqual(
recordTableWidgetViewDraft,
recordTableWidgetViewPersisted,
)
) {
return true;
}
}
return false;
@@ -7,6 +7,7 @@ import { navigationMenuItemsSelector } from '@/navigation-menu-item/common/state
import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/common/utils/filterWorkspaceNavigationMenuItems';
import { useSaveNavigationMenuItemsDraft } from '@/navigation-menu-item/edit/hooks/useSaveNavigationMenuItemsDraft';
import { useCreatePendingFieldsWidgetViews } from '@/page-layout/hooks/useCreatePendingFieldsWidgetViews';
import { useCreatePendingRecordTableWidgetViews } from '@/page-layout/hooks/useCreatePendingRecordTableWidgetViews';
import { useSavePageLayoutWidgetsData } from '@/page-layout/hooks/useSavePageLayoutWidgetsData';
import { useUpdatePageLayoutWithTabsAndWidgets } from '@/page-layout/hooks/useUpdatePageLayoutWithTabsAndWidgets';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
@@ -42,6 +43,8 @@ export const useSaveLayoutCustomization = () => {
useUpdatePageLayoutWithTabsAndWidgets();
const { createPendingFieldsWidgetViews } =
useCreatePendingFieldsWidgetViews();
const { createPendingRecordTableWidgetViews } =
useCreatePendingRecordTableWidgetViews();
const { exitLayoutCustomizationMode } = useExitLayoutCustomizationMode();
const { savePageLayoutWidgetsData } = useSavePageLayoutWidgetsData();
@@ -113,6 +116,7 @@ export const useSaveLayoutCustomization = () => {
);
await createPendingFieldsWidgetViews(pageLayoutId);
await createPendingRecordTableWidgetViews(pageLayoutId);
if (isPageLayoutStructureDirty) {
const updateInput = convertPageLayoutDraftToUpdateInput(draft, {
@@ -185,6 +189,7 @@ export const useSaveLayoutCustomization = () => {
saveCommandMenuItemsDraft,
isCommandMenuItemsDirty,
createPendingFieldsWidgetViews,
createPendingRecordTableWidgetViews,
updatePageLayoutWithTabsAndWidgets,
savePageLayoutWidgetsData,
exitLayoutCustomizationMode,
@@ -2,18 +2,27 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { NavigationDrawerAiChatContent } from '@/ai/components/NavigationDrawerAiChatContent';
import { MainNavigationDrawerNavigationContent } from '@/navigation/components/MainNavigationDrawerNavigationContent';
import { MainNavigationDrawerTabsRow } from '@/navigation/components/MainNavigationDrawerTabsRow';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { NavigationDrawer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawer';
import { NavigationDrawerFixedContent } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerFixedContent';
import { NavigationDrawerScrollableContent } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerScrollableContent';
import { navigationDrawerActiveTabState } from '@/ui/navigation/states/navigationDrawerActiveTabState';
import { NAVIGATION_DRAWER_TABS } from '@/ui/navigation/states/navigationDrawerTabs';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { PermissionFlagType } from '~/generated-metadata/graphql';
export const MainNavigationDrawer = ({ className }: { className?: string }) => {
const navigationDrawerActiveTab = useAtomStateValue(
navigationDrawerActiveTabState,
);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const hasAiSettingsPermission = useHasPermissionFlag(
PermissionFlagType.AI_SETTINGS,
);
const showAiChatContent =
hasAiSettingsPermission &&
navigationDrawerActiveTab === NAVIGATION_DRAWER_TABS.AI_CHAT_HISTORY;
return (
<NavigationDrawer
@@ -25,8 +34,7 @@ export const MainNavigationDrawer = ({ className }: { className?: string }) => {
</NavigationDrawerFixedContent>
<NavigationDrawerScrollableContent>
{navigationDrawerActiveTab ===
NAVIGATION_DRAWER_TABS.AI_CHAT_HISTORY ? (
{showAiChatContent ? (
<NavigationDrawerAiChatContent />
) : (
<MainNavigationDrawerNavigationContent />
@@ -12,6 +12,7 @@ import { useIsMobile } from 'twenty-ui/utilities';
import { useContext } from 'react';
import { useSwitchToNewAiChat } from '@/ai/hooks/useSwitchToNewAiChat';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper';
import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded';
import { navigationDrawerActiveTabState } from '@/ui/navigation/states/navigationDrawerActiveTabState';
@@ -22,6 +23,7 @@ import {
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { PermissionFlagType } from '~/generated-metadata/graphql';
const StyledRow = styled.div<{ isExpanded: boolean }>`
align-items: center;
@@ -142,9 +144,16 @@ export const MainNavigationDrawerTabsRow = () => {
const setIsNavigationDrawerExpanded = useSetAtomState(
isNavigationDrawerExpandedState,
);
const hasAiSettingsPermission = useHasPermissionFlag(
PermissionFlagType.AI_SETTINGS,
);
const isExpanded = isNavigationDrawerExpanded || isMobile;
if (!hasAiSettingsPermission) {
return null;
}
const handleTabClick = (tab: NavigationDrawerActiveTab) => () => {
setNavigationDrawerActiveTab(tab);
};
@@ -5,6 +5,7 @@ import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePat
import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage';
import { currentMobileNavigationDrawerState } from '@/navigation/states/currentMobileNavigationDrawerState';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { useOpenRecordsSearchPageInSidePanel } from '@/side-panel/hooks/useOpenRecordsSearchPageInSidePanel';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
@@ -20,6 +21,7 @@ import {
IconSearch,
} from 'twenty-ui/display';
import { NavigationBar } from 'twenty-ui/navigation';
import { PermissionFlagType } from '~/generated-metadata/graphql';
type NavigationBarItemName = 'main' | 'search' | 'newAiChat';
@@ -37,6 +39,9 @@ export const MobileNavigationBar = () => {
const { switchToNewChat } = useSwitchToNewAiChat();
const { alphaSortedActiveNonSystemObjectMetadataItems } =
useFilteredObjectMetadataItems();
const hasAiSettingsPermission = useHasPermissionFlag(
PermissionFlagType.AI_SETTINGS,
);
const setContextStoreCurrentObjectMetadataItemId = useSetAtomComponentState(
contextStoreCurrentObjectMetadataItemIdComponentState,
@@ -89,15 +94,19 @@ export const MobileNavigationBar = () => {
openRecordsSearchPage();
},
},
{
name: 'newAiChat' as const,
Icon: IconMessageCirclePlus,
onClick: () => {
setIsNavigationDrawerExpanded(false);
closeSidePanelMenu();
switchToNewChat();
},
},
...(hasAiSettingsPermission
? [
{
name: 'newAiChat' as const,
Icon: IconMessageCirclePlus,
onClick: () => {
setIsNavigationDrawerExpanded(false);
closeSidePanelMenu();
switchToNewChat();
},
},
]
: []),
];
return <NavigationBar activeItemName={activeItemName} items={items} />;
@@ -98,6 +98,7 @@ export const RecordTableWidgetProvider = ({
>
<RecordTableWidgetViewLoadEffect
viewId={viewId}
widgetId={widgetId}
objectMetadataItem={objectMetadataItem}
/>
{children}
@@ -1,6 +1,10 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { useLoadRecordIndexStates } from '@/object-record/record-index/hooks/useLoadRecordIndexStates';
import { lastLoadedRecordTableWidgetViewIdComponentState } from '@/object-record/record-table-widget/states/lastLoadedRecordTableWidgetViewIdComponentState';
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
import { recordTableWidgetViewDraftByWidgetIdComponentFamilySelector } from '@/page-layout/states/selectors/recordTableWidgetViewDraftByWidgetIdComponentFamilySelector';
import { constructViewFromRecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/utils/constructViewFromRecordTableWidgetViewSnapshot';
import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { viewFromViewIdFamilySelector } from '@/views/states/selectors/viewFromViewIdFamilySelector';
@@ -9,11 +13,13 @@ import { isDefined } from 'twenty-shared/utils';
type RecordTableWidgetViewLoadEffectProps = {
viewId: string;
widgetId: string;
objectMetadataItem: EnrichedObjectMetadataItem;
};
export const RecordTableWidgetViewLoadEffect = ({
viewId,
widgetId,
objectMetadataItem,
}: RecordTableWidgetViewLoadEffectProps) => {
const { loadRecordIndexStates } = useLoadRecordIndexStates();
@@ -23,18 +29,30 @@ export const RecordTableWidgetViewLoadEffect = ({
setLastLoadedRecordTableWidgetViewId,
] = useAtomComponentState(lastLoadedRecordTableWidgetViewIdComponentState);
const viewFromViewId = useAtomFamilySelectorValue(
viewFromViewIdFamilySelector,
{
viewId,
},
const isPageLayoutInEditMode = useIsPageLayoutInEditMode();
const draftSnapshot = useAtomComponentFamilySelectorValue(
recordTableWidgetViewDraftByWidgetIdComponentFamilySelector,
{ widgetId },
);
const viewFromDraft =
isPageLayoutInEditMode && isDefined(draftSnapshot)
? constructViewFromRecordTableWidgetViewSnapshot(draftSnapshot)
: undefined;
const viewFromSelector = useAtomFamilySelectorValue(
viewFromViewIdFamilySelector,
{ viewId },
);
const currentView = viewFromDraft ?? viewFromSelector;
const viewHasFields =
isDefined(viewFromViewId) && viewFromViewId.viewFields.length > 0;
isDefined(currentView) && currentView.viewFields.length > 0;
useEffect(() => {
if (!isDefined(viewFromViewId)) {
if (!isDefined(currentView)) {
return;
}
@@ -50,7 +68,7 @@ export const RecordTableWidgetViewLoadEffect = ({
return;
}
loadRecordIndexStates(viewFromViewId, objectMetadataItem);
loadRecordIndexStates(currentView, objectMetadataItem);
setLastLoadedRecordTableWidgetViewId({
viewId,
@@ -60,7 +78,7 @@ export const RecordTableWidgetViewLoadEffect = ({
viewId,
lastLoadedRecordTableWidgetViewId,
setLastLoadedRecordTableWidgetViewId,
viewFromViewId,
currentView,
viewHasFields,
objectMetadataItem,
loadRecordIndexStates,
@@ -9,10 +9,10 @@ import {
} from './PageLayoutTestWrapper';
jest.mock(
'@/page-layout/widgets/record-table/hooks/useDeleteViewForRecordTableWidget',
'@/page-layout/widgets/record-table/hooks/useRemoveDraftViewForRecordTableWidget',
() => ({
useDeleteViewForRecordTableWidget: () => ({
deleteViewForRecordTableWidget: jest.fn(),
useRemoveDraftViewForRecordTableWidget: () => ({
removeDraftViewForRecordTableWidget: jest.fn(),
}),
}),
);
@@ -0,0 +1,141 @@
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { recordTableWidgetViewPersistedComponentState } from '@/page-layout/states/recordTableWidgetViewPersistedComponentState';
import { getWidgetConfigurationViewId } from '@/page-layout/utils/getWidgetConfigurationViewId';
import { usePerformViewAPIPersist } from '@/views/hooks/internal/usePerformViewAPIPersist';
import { usePerformViewFieldAPIPersist } from '@/views/hooks/internal/usePerformViewFieldAPIPersist';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { WidgetType } from '~/generated-metadata/graphql';
export const useCreatePendingRecordTableWidgetViews = () => {
const { performViewAPICreate, performViewAPIDestroy } =
usePerformViewAPIPersist();
const { performViewFieldAPICreate } = usePerformViewFieldAPIPersist();
const store = useStore();
const createPendingRecordTableWidgetViews = useCallback(
async (pageLayoutId: string) => {
const draft = store.get(
pageLayoutDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
const persisted = store.get(
pageLayoutPersistedComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
const recordTableWidgetViewDraft = store.get(
recordTableWidgetViewDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
const persistedRecordTableWidgets = new Map(
(persisted?.tabs ?? [])
.flatMap((tab) => tab.widgets)
.filter((widget) => widget.type === WidgetType.RECORD_TABLE)
.map((widget) => [
widget.id,
getWidgetConfigurationViewId(widget.configuration),
]),
);
const draftRecordTableWidgets = draft.tabs
.flatMap((tab) => tab.widgets)
.filter((widget) => widget.type === WidgetType.RECORD_TABLE);
const draftWidgetIds = new Set(
draftRecordTableWidgets.map((widget) => widget.id),
);
for (const widget of draftRecordTableWidgets) {
const viewId = getWidgetConfigurationViewId(widget.configuration);
if (!isDefined(viewId)) {
continue;
}
const persistedViewId = persistedRecordTableWidgets.get(widget.id);
if (persistedViewId === viewId) {
continue;
}
if (isDefined(persistedViewId)) {
await performViewAPIDestroy({ id: persistedViewId });
}
const widgetViewDraft = recordTableWidgetViewDraft[widget.id];
if (!isDefined(widgetViewDraft)) {
continue;
}
const { view } = widgetViewDraft;
const result = await performViewAPICreate(
{
input: {
id: view.id,
name: view.name,
icon: view.icon,
objectMetadataId: view.objectMetadataId,
type: view.type,
isCompact: view.isCompact,
position: view.position,
openRecordIn: view.openRecordIn,
visibility: view.visibility,
shouldHideEmptyGroups: view.shouldHideEmptyGroups,
},
},
view.objectMetadataId,
);
if (result.status === 'failed') {
throw new Error(
`Failed to create view for RECORD_TABLE widget ${widget.id}`,
);
}
const viewFieldInputs = widgetViewDraft.viewFields.map((field) => ({
id: field.id,
viewId: field.viewId,
fieldMetadataId: field.fieldMetadataId,
position: field.position,
size: field.size,
isVisible: field.isVisible,
}));
if (viewFieldInputs.length > 0) {
await performViewFieldAPICreate({ inputs: viewFieldInputs });
}
}
for (const [widgetId, viewId] of persistedRecordTableWidgets) {
if (!draftWidgetIds.has(widgetId) && isDefined(viewId)) {
await performViewAPIDestroy({ id: viewId });
}
}
store.set(
recordTableWidgetViewPersistedComponentState.atomFamily({
instanceId: pageLayoutId,
}),
recordTableWidgetViewDraft,
);
},
[
performViewAPICreate,
performViewAPIDestroy,
performViewFieldAPICreate,
store,
],
);
return { createPendingRecordTableWidgetViews };
};
@@ -1,4 +1,5 @@
import { useCreatePendingFieldsWidgetViews } from '@/page-layout/hooks/useCreatePendingFieldsWidgetViews';
import { useCreatePendingRecordTableWidgetViews } from '@/page-layout/hooks/useCreatePendingRecordTableWidgetViews';
import { useUpdatePageLayoutWithTabsAndWidgets } from '@/page-layout/hooks/useUpdatePageLayoutWithTabsAndWidgets';
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
@@ -45,6 +46,9 @@ export const useSavePageLayout = (pageLayoutIdFromProps: string) => {
const { createPendingFieldsWidgetViews } =
useCreatePendingFieldsWidgetViews();
const { createPendingRecordTableWidgetViews } =
useCreatePendingRecordTableWidgetViews();
const featureFlags = useFeatureFlagsMap();
const isRecordPageLayoutEditingEnabled =
featureFlags[FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED];
@@ -52,6 +56,7 @@ export const useSavePageLayout = (pageLayoutIdFromProps: string) => {
const savePageLayout = useCallback(async () => {
await createPendingFieldsWidgetViews(pageLayoutId);
await createPendingRecordTableWidgetViews(pageLayoutId);
const pageLayoutDraft = store.get(pageLayoutDraftCallbackState);
const updateInput = convertPageLayoutDraftToUpdateInput(pageLayoutDraft, {
@@ -91,6 +96,7 @@ export const useSavePageLayout = (pageLayoutIdFromProps: string) => {
return result;
}, [
createPendingFieldsWidgetViews,
createPendingRecordTableWidgetViews,
isRecordPageLayoutEditingEnabled,
pageLayoutCurrentLayoutsCallbackState,
pageLayoutDraftCallbackState,
@@ -0,0 +1,11 @@
import { type RecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext';
export const recordTableWidgetViewDraftComponentState =
createAtomComponentState<Record<string, RecordTableWidgetViewSnapshot>>({
key: 'recordTableWidgetViewDraftComponentState',
defaultValue: {},
componentInstanceContext: PageLayoutComponentInstanceContext,
});
@@ -0,0 +1,11 @@
import { type RecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
import { PageLayoutComponentInstanceContext } from './contexts/PageLayoutComponentInstanceContext';
export const recordTableWidgetViewPersistedComponentState =
createAtomComponentState<Record<string, RecordTableWidgetViewSnapshot>>({
key: 'recordTableWidgetViewPersistedComponentState',
defaultValue: {},
componentInstanceContext: PageLayoutComponentInstanceContext,
});
@@ -0,0 +1,23 @@
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { type RecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot';
import { createAtomComponentFamilySelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilySelector';
import { PageLayoutComponentInstanceContext } from '../contexts/PageLayoutComponentInstanceContext';
export const recordTableWidgetViewDraftByWidgetIdComponentFamilySelector =
createAtomComponentFamilySelector<
RecordTableWidgetViewSnapshot | undefined,
{ widgetId: string }
>({
key: 'recordTableWidgetViewDraftByWidgetIdComponentFamilySelector',
componentInstanceContext: PageLayoutComponentInstanceContext,
get:
({ instanceId, familyKey }) =>
({ get }) => {
const draftMap = get(recordTableWidgetViewDraftComponentState, {
instanceId,
});
return draftMap[familyKey.widgetId];
},
});
@@ -0,0 +1,281 @@
import { type FlatViewField } from '@/metadata-store/types/FlatViewField';
import { type FlatViewFieldGroup } from '@/metadata-store/types/FlatViewFieldGroup';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { buildFieldsWidgetGroupsFromFlatViewData } from '@/page-layout/utils/buildFieldsWidgetGroupsFromFlatViewData';
import { FieldMetadataType } from 'twenty-shared/types';
const createFieldMetadata = (
overrides: Partial<FieldMetadataItem> & { id: string },
): FieldMetadataItem =>
({
name: 'field',
label: 'Field',
type: FieldMetadataType.TEXT,
isActive: true,
isSystem: false,
...overrides,
}) as FieldMetadataItem;
const createFlatViewField = (
overrides: Partial<FlatViewField> & {
id: string;
fieldMetadataId: string;
viewId: string;
},
): FlatViewField =>
({
position: 0,
isVisible: true,
isActive: true,
...overrides,
}) as FlatViewField;
const createFlatViewFieldGroup = (
overrides: Partial<FlatViewFieldGroup> & { id: string },
): FlatViewFieldGroup =>
({
name: 'Group',
position: 0,
isVisible: true,
...overrides,
}) as FlatViewFieldGroup;
describe('buildFieldsWidgetGroupsFromFlatViewData', () => {
const fm1 = createFieldMetadata({ id: 'fm-1', name: 'name', label: 'Name' });
const fm2 = createFieldMetadata({
id: 'fm-2',
name: 'email',
label: 'Email',
});
const fm3 = createFieldMetadata({
id: 'fm-3',
name: 'phone',
label: 'Phone',
});
describe('ungrouped mode', () => {
it('should return ungrouped fields sorted by position when no groups exist', () => {
const flatViewFields = [
createFlatViewField({
id: 'vf-2',
fieldMetadataId: 'fm-2',
viewId: 'v1',
position: 1,
}),
createFlatViewField({
id: 'vf-1',
fieldMetadataId: 'fm-1',
viewId: 'v1',
position: 0,
}),
];
const result = buildFieldsWidgetGroupsFromFlatViewData({
flatViewFieldGroups: [],
flatViewFields,
fieldMetadataItems: [fm1, fm2],
});
expect(result.editorMode).toBe('ungrouped');
expect(result.groups).toEqual([]);
expect(result.ungroupedFields).toHaveLength(2);
expect(result.ungroupedFields[0].fieldMetadataItem.id).toBe('fm-1');
expect(result.ungroupedFields[1].fieldMetadataItem.id).toBe('fm-2');
});
it('should assign sequential globalIndex based on sorted position', () => {
const flatViewFields = [
createFlatViewField({
id: 'vf-1',
fieldMetadataId: 'fm-1',
viewId: 'v1',
position: 5,
}),
createFlatViewField({
id: 'vf-2',
fieldMetadataId: 'fm-2',
viewId: 'v1',
position: 2,
}),
];
const result = buildFieldsWidgetGroupsFromFlatViewData({
flatViewFieldGroups: [],
flatViewFields,
fieldMetadataItems: [fm1, fm2],
});
expect(result.ungroupedFields[0].globalIndex).toBe(0);
expect(result.ungroupedFields[0].fieldMetadataItem.id).toBe('fm-2');
expect(result.ungroupedFields[1].globalIndex).toBe(1);
expect(result.ungroupedFields[1].fieldMetadataItem.id).toBe('fm-1');
});
it('should skip fields whose fieldMetadataId has no matching metadata', () => {
const flatViewFields = [
createFlatViewField({
id: 'vf-1',
fieldMetadataId: 'fm-1',
viewId: 'v1',
position: 0,
}),
createFlatViewField({
id: 'vf-orphan',
fieldMetadataId: 'fm-nonexistent',
viewId: 'v1',
position: 1,
}),
];
const result = buildFieldsWidgetGroupsFromFlatViewData({
flatViewFieldGroups: [],
flatViewFields,
fieldMetadataItems: [fm1],
});
expect(result.ungroupedFields).toHaveLength(1);
expect(result.ungroupedFields[0].fieldMetadataItem.id).toBe('fm-1');
});
it('should return empty ungroupedFields when there are no view fields', () => {
const result = buildFieldsWidgetGroupsFromFlatViewData({
flatViewFieldGroups: [],
flatViewFields: [],
fieldMetadataItems: [fm1],
});
expect(result.editorMode).toBe('ungrouped');
expect(result.ungroupedFields).toHaveLength(0);
});
});
describe('grouped mode', () => {
it('should return grouped fields when groups exist', () => {
const group1 = createFlatViewFieldGroup({
id: 'g1',
name: 'General',
position: 0,
});
const group2 = createFlatViewFieldGroup({
id: 'g2',
name: 'Details',
position: 1,
});
const flatViewFields = [
createFlatViewField({
id: 'vf-1',
fieldMetadataId: 'fm-1',
viewId: 'v1',
position: 0,
viewFieldGroupId: 'g1',
}),
createFlatViewField({
id: 'vf-2',
fieldMetadataId: 'fm-2',
viewId: 'v1',
position: 0,
viewFieldGroupId: 'g2',
}),
];
const result = buildFieldsWidgetGroupsFromFlatViewData({
flatViewFieldGroups: [group1, group2],
flatViewFields,
fieldMetadataItems: [fm1, fm2],
});
expect(result.editorMode).toBe('grouped');
expect(result.ungroupedFields).toEqual([]);
expect(result.groups).toHaveLength(2);
expect(result.groups[0].name).toBe('General');
expect(result.groups[0].fields).toHaveLength(1);
expect(result.groups[0].fields[0].fieldMetadataItem.id).toBe('fm-1');
expect(result.groups[1].name).toBe('Details');
expect(result.groups[1].fields[0].fieldMetadataItem.id).toBe('fm-2');
});
it('should sort fields within each group by position', () => {
const group = createFlatViewFieldGroup({ id: 'g1', name: 'All' });
const flatViewFields = [
createFlatViewField({
id: 'vf-3',
fieldMetadataId: 'fm-3',
viewId: 'v1',
position: 2,
viewFieldGroupId: 'g1',
}),
createFlatViewField({
id: 'vf-1',
fieldMetadataId: 'fm-1',
viewId: 'v1',
position: 0,
viewFieldGroupId: 'g1',
}),
createFlatViewField({
id: 'vf-2',
fieldMetadataId: 'fm-2',
viewId: 'v1',
position: 1,
viewFieldGroupId: 'g1',
}),
];
const result = buildFieldsWidgetGroupsFromFlatViewData({
flatViewFieldGroups: [group],
flatViewFields,
fieldMetadataItems: [fm1, fm2, fm3],
});
expect(
result.groups[0].fields.map((f) => f.fieldMetadataItem.id),
).toEqual(['fm-1', 'fm-2', 'fm-3']);
});
it('should skip fields with missing metadata in grouped mode', () => {
const group = createFlatViewFieldGroup({ id: 'g1', name: 'All' });
const flatViewFields = [
createFlatViewField({
id: 'vf-1',
fieldMetadataId: 'fm-1',
viewId: 'v1',
position: 0,
viewFieldGroupId: 'g1',
}),
createFlatViewField({
id: 'vf-orphan',
fieldMetadataId: 'fm-nonexistent',
viewId: 'v1',
position: 1,
viewFieldGroupId: 'g1',
}),
];
const result = buildFieldsWidgetGroupsFromFlatViewData({
flatViewFieldGroups: [group],
flatViewFields,
fieldMetadataItems: [fm1],
});
expect(result.groups[0].fields).toHaveLength(1);
});
it('should preserve group visibility in the output', () => {
const group = createFlatViewFieldGroup({
id: 'g1',
name: 'Hidden Group',
isVisible: false,
});
const result = buildFieldsWidgetGroupsFromFlatViewData({
flatViewFieldGroups: [group],
flatViewFields: [],
fieldMetadataItems: [],
});
expect(result.groups[0].isVisible).toBe(false);
});
});
});
@@ -1,6 +1,7 @@
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { RecordTableWidget } from '@/object-record/record-table-widget/components/RecordTableWidget';
import { RecordTableWidgetProvider } from '@/object-record/record-table-widget/components/RecordTableWidgetProvider';
import { RecordTableWidgetViewDraftInitEffect } from '@/page-layout/widgets/record-table/components/RecordTableWidgetViewDraftInitEffect';
type RecordTableWidgetRendererContentProps = {
objectMetadataId: string;
@@ -18,12 +19,18 @@ export const RecordTableWidgetRendererContent = ({
});
return (
<RecordTableWidgetProvider
objectNameSingular={objectMetadataItem.nameSingular}
viewId={viewId}
widgetId={widgetId}
>
<RecordTableWidget />
</RecordTableWidgetProvider>
<>
<RecordTableWidgetViewDraftInitEffect
widgetId={widgetId}
viewId={viewId}
/>
<RecordTableWidgetProvider
objectNameSingular={objectMetadataItem.nameSingular}
viewId={viewId}
widgetId={widgetId}
>
<RecordTableWidget />
</RecordTableWidgetProvider>
</>
);
};
@@ -0,0 +1,23 @@
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
import { useInitializeRecordTableWidgetViewDraft } from '@/page-layout/widgets/record-table/hooks/useInitializeRecordTableWidgetViewDraft';
import { useViewById } from '@/views/hooks/useViewById';
type RecordTableWidgetViewDraftInitEffectProps = {
widgetId: string;
viewId: string;
};
export const RecordTableWidgetViewDraftInitEffect = ({
widgetId,
viewId,
}: RecordTableWidgetViewDraftInitEffectProps) => {
const isPageLayoutInEditMode = useIsPageLayoutInEditMode();
const { view } = useViewById(viewId);
useInitializeRecordTableWidgetViewDraft({
widgetId,
view: isPageLayoutInEditMode ? view : undefined,
});
return null;
};
@@ -0,0 +1,89 @@
import { type FlatView } from '@/metadata-store/types/FlatView';
import { type FlatViewField } from '@/metadata-store/types/FlatViewField';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { filterFieldsForRecordTableViewCreation } from '@/page-layout/widgets/record-table/utils/filterFieldsForRecordTableViewCreation';
import { sortFieldsByRelevanceForRecordTableWidget } from '@/page-layout/widgets/record-table/utils/sortFieldsByRelevanceForRecordTableWidget';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { v4 } from 'uuid';
import {
ViewOpenRecordIn,
ViewType,
ViewVisibility,
WidgetConfigurationType,
} from '~/generated-metadata/graphql';
const DEFAULT_VIEW_FIELD_SIZE = 180;
const INITIAL_VISIBLE_FIELDS_COUNT_IN_WIDGET = 6;
export const useAddDraftViewForRecordTableWidget = (pageLayoutId: string) => {
const { updatePageLayoutWidget } = useUpdatePageLayoutWidget(pageLayoutId);
const recordTableWidgetViewDraftState = useAtomComponentStateCallbackState(
recordTableWidgetViewDraftComponentState,
pageLayoutId,
);
const store = useStore();
const addDraftViewForRecordTableWidget = useCallback(
(widgetId: string, objectMetadataItem: EnrichedObjectMetadataItem) => {
const newViewId = v4();
const flatView: FlatView = {
id: newViewId,
name: `${objectMetadataItem.labelPlural} Table`,
icon: objectMetadataItem.icon ?? 'IconTable',
objectMetadataId: objectMetadataItem.id,
type: ViewType.TABLE_WIDGET,
isCompact: false,
position: 0,
openRecordIn: ViewOpenRecordIn.RECORD_PAGE,
visibility: ViewVisibility.UNLISTED,
shouldHideEmptyGroups: false,
};
const eligibleFields = objectMetadataItem.fields.filter(
filterFieldsForRecordTableViewCreation,
);
const sortedFields = eligibleFields.toSorted(
sortFieldsByRelevanceForRecordTableWidget(
objectMetadataItem.labelIdentifierFieldMetadataId,
),
);
const flatViewFields: FlatViewField[] = sortedFields.map(
(field, index) => ({
id: v4(),
viewId: newViewId,
fieldMetadataId: field.id,
position: index,
size: DEFAULT_VIEW_FIELD_SIZE,
isVisible: index < INITIAL_VISIBLE_FIELDS_COUNT_IN_WIDGET,
isActive: true,
}),
);
store.set(recordTableWidgetViewDraftState, (prev) => ({
...prev,
[widgetId]: { view: flatView, viewFields: flatViewFields },
}));
requestAnimationFrame(() => {
updatePageLayoutWidget(widgetId, {
configuration: {
configurationType: WidgetConfigurationType.RECORD_TABLE,
viewId: newViewId,
},
});
});
},
[store, recordTableWidgetViewDraftState, updatePageLayoutWidget],
);
return { addDraftViewForRecordTableWidget };
};
@@ -1,85 +0,0 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { filterFieldsForRecordTableViewCreation } from '@/page-layout/widgets/record-table/utils/filterFieldsForRecordTableViewCreation';
import { sortFieldsByRelevanceForRecordTableWidget } from '@/page-layout/widgets/record-table/utils/sortFieldsByRelevanceForRecordTableWidget';
import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget';
import { usePerformViewAPIPersist } from '@/views/hooks/internal/usePerformViewAPIPersist';
import { usePerformViewFieldAPIPersist } from '@/views/hooks/internal/usePerformViewFieldAPIPersist';
import { useCallback } from 'react';
import { v4 } from 'uuid';
import {
WidgetConfigurationType,
ViewType,
} from '~/generated-metadata/graphql';
const DEFAULT_VIEW_FIELD_SIZE = 180;
const INITIAL_VISIBLE_FIELDS_COUNT_IN_WIDGET = 6;
export const useCreateViewForRecordTableWidget = (pageLayoutId: string) => {
const { performViewAPICreate } = usePerformViewAPIPersist();
const { performViewFieldAPICreate } = usePerformViewFieldAPIPersist();
const { updatePageLayoutWidget } = useUpdatePageLayoutWidget(pageLayoutId);
const createViewForRecordTableWidget = useCallback(
async (
widgetId: string,
objectMetadataItem: EnrichedObjectMetadataItem,
) => {
const newViewId = v4();
const viewResult = await performViewAPICreate(
{
input: {
id: newViewId,
name: `${objectMetadataItem.labelPlural} Table`,
icon: objectMetadataItem.icon ?? 'IconTable',
objectMetadataId: objectMetadataItem.id,
type: ViewType.TABLE_WIDGET,
},
},
objectMetadataItem.id,
);
if (viewResult.status !== 'successful') {
return;
}
const eligibleFields = objectMetadataItem.fields.filter(
filterFieldsForRecordTableViewCreation,
);
const sortedFields = eligibleFields.toSorted(
sortFieldsByRelevanceForRecordTableWidget(
objectMetadataItem.labelIdentifierFieldMetadataId,
),
);
const viewFieldInputs = sortedFields.map((field, index) => ({
id: v4(),
viewId: newViewId,
fieldMetadataId: field.id,
position: index,
size: DEFAULT_VIEW_FIELD_SIZE,
isVisible: index < INITIAL_VISIBLE_FIELDS_COUNT_IN_WIDGET,
}));
try {
await performViewFieldAPICreate({ inputs: viewFieldInputs });
updatePageLayoutWidget(widgetId, {
configuration: {
configurationType: WidgetConfigurationType.RECORD_TABLE,
viewId: newViewId,
},
});
} catch (error) {
throw new Error(
'Failed to create view fields for record table widget',
{ cause: error },
);
}
},
[performViewAPICreate, performViewFieldAPICreate, updatePageLayoutWidget],
);
return { createViewForRecordTableWidget };
};
@@ -1,15 +0,0 @@
import { usePerformViewAPIPersist } from '@/views/hooks/internal/usePerformViewAPIPersist';
import { useCallback } from 'react';
export const useDeleteViewForRecordTableWidget = () => {
const { performViewAPIDestroy } = usePerformViewAPIPersist();
const deleteViewForRecordTableWidget = useCallback(
async (viewId: string) => {
await performViewAPIDestroy({ id: viewId });
},
[performViewAPIDestroy],
);
return { deleteViewForRecordTableWidget };
};
@@ -0,0 +1,71 @@
import { type FlatViewField } from '@/metadata-store/types/FlatViewField';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { recordTableWidgetViewPersistedComponentState } from '@/page-layout/states/recordTableWidgetViewPersistedComponentState';
import { type RecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { type View } from '@/views/types/View';
import { useStore } from 'jotai';
import { useCallback, useEffect } from 'react';
type UseInitializeRecordTableWidgetViewDraftParams = {
widgetId: string;
view: View | undefined;
};
export const useInitializeRecordTableWidgetViewDraft = ({
widgetId,
view,
}: UseInitializeRecordTableWidgetViewDraftParams) => {
const recordTableWidgetViewDraftState = useAtomComponentStateCallbackState(
recordTableWidgetViewDraftComponentState,
);
const recordTableWidgetViewPersistedState =
useAtomComponentStateCallbackState(
recordTableWidgetViewPersistedComponentState,
);
const store = useStore();
const initializeDraft = useCallback(() => {
const currentDraft = store.get(recordTableWidgetViewDraftState);
if (widgetId in currentDraft) {
return;
}
if (!view || view.viewFields.length === 0) {
return;
}
const { viewFields, ...viewProps } = view;
const flatViewFields: FlatViewField[] = viewFields.map((field) => ({
...field,
viewId: view.id,
}));
const snapshot: RecordTableWidgetViewSnapshot = {
view: viewProps,
viewFields: flatViewFields,
};
store.set(recordTableWidgetViewDraftState, (prev) => ({
...prev,
[widgetId]: snapshot,
}));
store.set(recordTableWidgetViewPersistedState, (prev) => ({
...prev,
[widgetId]: snapshot,
}));
}, [
recordTableWidgetViewDraftState,
recordTableWidgetViewPersistedState,
widgetId,
view,
store,
]);
useEffect(initializeDraft, [initializeDraft]);
};
@@ -1,18 +1,33 @@
import { useMapViewFieldToRecordTableWidgetViewFieldItem } from '@/page-layout/widgets/record-table/hooks/useMapViewFieldToRecordTableWidgetViewFieldItem';
import { useRecordTableWidgetViewForDisplay } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetViewForDisplay';
import { type RecordTableWidgetViewFieldItem } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewFieldItem';
import { useViewById } from '@/views/hooks/useViewById';
import { useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { sortByProperty } from '~/utils/array/sortByProperty';
export const useRecordTableWidgetViewFieldItems = (viewId: string) => {
const { view } = useViewById(viewId);
type UseRecordTableWidgetViewFieldItemsParams = {
viewId: string;
widgetId: string;
pageLayoutId: string;
};
export const useRecordTableWidgetViewFieldItems = ({
viewId,
widgetId,
pageLayoutId,
}: UseRecordTableWidgetViewFieldItemsParams) => {
const { view } = useRecordTableWidgetViewForDisplay({
viewId,
widgetId,
pageLayoutId,
});
const { mapViewFieldToRecordTableWidgetViewFieldItem } =
useMapViewFieldToRecordTableWidgetViewFieldItem();
const recordTableWidgetViewFieldItems: RecordTableWidgetViewFieldItem[] =
useMemo(() => {
if (!view) {
if (!isDefined(view)) {
return [];
}
@@ -0,0 +1,35 @@
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { constructViewFromRecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/utils/constructViewFromRecordTableWidgetViewSnapshot';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useViewById } from '@/views/hooks/useViewById';
import { type View } from '@/views/types/View';
import { isDefined } from 'twenty-shared/utils';
type UseRecordTableWidgetViewForDisplayParams = {
viewId: string;
widgetId: string;
pageLayoutId: string;
};
export const useRecordTableWidgetViewForDisplay = ({
viewId,
widgetId,
pageLayoutId,
}: UseRecordTableWidgetViewForDisplayParams): {
view: View | undefined;
} => {
const { view } = useViewById(viewId);
const recordTableWidgetViewDraft = useAtomComponentStateValue(
recordTableWidgetViewDraftComponentState,
pageLayoutId,
);
const draftSnapshot = recordTableWidgetViewDraft[widgetId];
const viewFromDraft = isDefined(draftSnapshot)
? constructViewFromRecordTableWidgetViewSnapshot(draftSnapshot)
: undefined;
return { view: viewFromDraft ?? view };
};
@@ -0,0 +1,27 @@
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useStore } from 'jotai';
import { useCallback } from 'react';
export const useRemoveDraftViewForRecordTableWidget = (
pageLayoutId: string,
) => {
const recordTableWidgetViewDraftState = useAtomComponentStateCallbackState(
recordTableWidgetViewDraftComponentState,
pageLayoutId,
);
const store = useStore();
const removeDraftViewForRecordTableWidget = useCallback(
(widgetId: string) => {
store.set(recordTableWidgetViewDraftState, (prev) => {
const { [widgetId]: _, ...rest } = prev;
return rest;
});
},
[store, recordTableWidgetViewDraftState],
);
return { removeDraftViewForRecordTableWidget };
};
@@ -1,12 +1,28 @@
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { type RecordTableWidgetViewFieldItem } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewFieldItem';
import { usePerformViewFieldAPIPersist } from '@/views/hooks/internal/usePerformViewFieldAPIPersist';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const useReorderRecordTableWidgetFields = () => {
const { performViewFieldAPIUpdate } = usePerformViewFieldAPIPersist();
type UseReorderRecordTableWidgetFieldsParams = {
pageLayoutId: string;
widgetId: string;
};
export const useReorderRecordTableWidgetFields = ({
pageLayoutId,
widgetId,
}: UseReorderRecordTableWidgetFieldsParams) => {
const recordTableWidgetViewDraftState = useAtomComponentStateCallbackState(
recordTableWidgetViewDraftComponentState,
pageLayoutId,
);
const store = useStore();
const reorderRecordTableWidgetFields = useCallback(
async (
(
sourceIndex: number,
destinationIndex: number,
visibleFieldItems: RecordTableWidgetViewFieldItem[],
@@ -19,16 +35,35 @@ export const useReorderRecordTableWidgetFields = () => {
const [movedField] = reorderedFields.splice(sourceIndex, 1);
reorderedFields.splice(destinationIndex, 0, movedField);
const updates = reorderedFields.map((fieldItem, index) => ({
input: {
id: fieldItem.viewField.id,
update: { position: index },
},
}));
const updatedPositions = new Map(
reorderedFields.map((fieldItem, index) => [
fieldItem.viewField.id,
index,
]),
);
await performViewFieldAPIUpdate(updates);
store.set(recordTableWidgetViewDraftState, (prev) => {
const widgetViewDraft = prev[widgetId];
if (!isDefined(widgetViewDraft)) {
return prev;
}
return {
...prev,
[widgetId]: {
...widgetViewDraft,
viewFields: widgetViewDraft.viewFields.map((field) => {
const newPosition = updatedPositions.get(field.id);
return newPosition !== undefined
? { ...field, position: newPosition }
: field;
}),
},
};
});
},
[performViewFieldAPIUpdate],
[store, recordTableWidgetViewDraftState, widgetId],
);
return { reorderRecordTableWidgetFields };
@@ -5,6 +5,7 @@ import { currentRecordFiltersComponentState } from '@/object-record/record-filte
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { useMapRecordFieldToViewFieldWithCurrentAggregateOperation } from '@/page-layout/widgets/record-table/hooks/useMapRecordFieldToViewFieldWithCurrentAggregateOperation';
import { computeViewFieldsToCreateAndUpdate } from '@/page-layout/widgets/record-table/utils/computeViewFieldsToCreateAndUpdate';
import { usePerformViewFieldAPIPersist } from '@/views/hooks/internal/usePerformViewFieldAPIPersist';
@@ -65,6 +66,11 @@ export const useSaveRecordTableWidgetsViewDataOnDashboardSave = () => {
);
const views = store.get(viewsSelector.atom);
const objectMetadataItems = store.get(objectMetadataItemsSelector.atom);
const recordTableWidgetViewDraft = store.get(
recordTableWidgetViewDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
);
const recordTableWidgets = pageLayoutDraft.tabs.flatMap((tab) =>
tab.widgets.filter(
@@ -182,16 +188,21 @@ export const useSaveRecordTableWidgetsViewDataOnDashboardSave = () => {
}),
);
const newViewFields = currentRecordFields.map(
const recordIndexViewFields = currentRecordFields.map(
mapRecordFieldToViewFieldWithCurrentAggregateOperation,
);
const existingViewFields = currentView.viewFields ?? [];
const draftSnapshot = recordTableWidgetViewDraft[widget.id];
const draftViewFields = draftSnapshot?.viewFields ?? [];
const metadataStoreViewFields =
draftViewFields.length > 0
? draftViewFields
: (currentView.viewFields ?? []);
const { viewFieldsToCreate, viewFieldsToUpdate } =
computeViewFieldsToCreateAndUpdate({
newViewFields,
existingViewFields,
newViewFields: metadataStoreViewFields,
existingViewFields: recordIndexViewFields,
viewId,
});
@@ -1,21 +1,46 @@
import { usePerformViewFieldAPIPersist } from '@/views/hooks/internal/usePerformViewFieldAPIPersist';
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const useToggleRecordTableWidgetFieldVisibility = () => {
const { performViewFieldAPIUpdate } = usePerformViewFieldAPIPersist();
type UseToggleRecordTableWidgetFieldVisibilityParams = {
pageLayoutId: string;
widgetId: string;
};
export const useToggleRecordTableWidgetFieldVisibility = ({
pageLayoutId,
widgetId,
}: UseToggleRecordTableWidgetFieldVisibilityParams) => {
const recordTableWidgetViewDraftState = useAtomComponentStateCallbackState(
recordTableWidgetViewDraftComponentState,
pageLayoutId,
);
const store = useStore();
const toggleRecordTableWidgetFieldVisibility = useCallback(
async (viewFieldId: string, isVisible: boolean) => {
await performViewFieldAPIUpdate([
{
input: {
id: viewFieldId,
update: { isVisible },
(viewFieldId: string, isVisible: boolean) => {
store.set(recordTableWidgetViewDraftState, (prev) => {
const widgetViewDraft = prev[widgetId];
if (!isDefined(widgetViewDraft)) {
return prev;
}
return {
...prev,
[widgetId]: {
...widgetViewDraft,
viewFields: widgetViewDraft.viewFields.map((field) =>
field.id === viewFieldId ? { ...field, isVisible } : field,
),
},
},
]);
};
});
},
[performViewFieldAPIUpdate],
[store, recordTableWidgetViewDraftState, widgetId],
);
return { toggleRecordTableWidgetFieldVisibility };
@@ -0,0 +1,7 @@
import { type FlatView } from '@/metadata-store/types/FlatView';
import { type FlatViewField } from '@/metadata-store/types/FlatViewField';
export type RecordTableWidgetViewSnapshot = {
view: FlatView;
viewFields: FlatViewField[];
};
@@ -0,0 +1,130 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { sortFieldsByRelevanceForRecordTableWidget } from '@/page-layout/widgets/record-table/utils/sortFieldsByRelevanceForRecordTableWidget';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
const createField = (
overrides: Partial<FieldMetadataItem> & { id: string },
): FieldMetadataItem =>
({
name: 'field',
label: 'Field',
type: FieldMetadataType.TEXT,
isActive: true,
isSystem: false,
settings: null,
...overrides,
}) as FieldMetadataItem;
describe('sortFieldsByRelevanceForRecordTableWidget', () => {
const labelIdentifierId = 'label-field-id';
const sorter = sortFieldsByRelevanceForRecordTableWidget(labelIdentifierId);
it('should place the label identifier field first', () => {
const labelField = createField({ id: labelIdentifierId });
const textField = createField({ id: 'text-1' });
expect(sorter(labelField, textField)).toBe(-1);
expect(sorter(textField, labelField)).toBe(1);
});
it('should place ONE_TO_MANY relation fields after non-relation fields', () => {
const reverseSide = createField({
id: 'reverse-1',
type: FieldMetadataType.RELATION,
settings: { relationType: RelationType.ONE_TO_MANY },
});
const textField = createField({ id: 'text-1' });
expect(sorter(reverseSide, textField)).toBe(1);
expect(sorter(textField, reverseSide)).toBe(-1);
});
it('should place regular relation fields after non-relation fields', () => {
const relation = createField({
id: 'rel-1',
type: FieldMetadataType.RELATION,
settings: { relationType: RelationType.MANY_TO_ONE },
});
const textField = createField({ id: 'text-1' });
expect(sorter(relation, textField)).toBe(1);
expect(sorter(textField, relation)).toBe(-1);
});
it('should place ONE_TO_MANY relations after MANY_TO_ONE relations', () => {
const reverseSide = createField({
id: 'reverse-1',
type: FieldMetadataType.RELATION,
settings: { relationType: RelationType.ONE_TO_MANY },
});
const manyToOne = createField({
id: 'rel-1',
type: FieldMetadataType.RELATION,
settings: { relationType: RelationType.MANY_TO_ONE },
});
expect(sorter(reverseSide, manyToOne)).toBe(1);
expect(sorter(manyToOne, reverseSide)).toBe(-1);
});
it('should return 0 for two non-relation fields of equal priority', () => {
const fieldA = createField({ id: 'a' });
const fieldB = createField({ id: 'b' });
expect(sorter(fieldA, fieldB)).toBe(0);
});
it('should return 0 for two ONE_TO_MANY relation fields', () => {
const reverseA = createField({
id: 'reverse-a',
type: FieldMetadataType.RELATION,
settings: { relationType: RelationType.ONE_TO_MANY },
});
const reverseB = createField({
id: 'reverse-b',
type: FieldMetadataType.RELATION,
settings: { relationType: RelationType.ONE_TO_MANY },
});
expect(sorter(reverseA, reverseB)).toBe(0);
});
it('should produce a correct full sort order', () => {
const labelField = createField({ id: labelIdentifierId });
const textField = createField({ id: 'text-1' });
const numberField = createField({
id: 'number-1',
type: FieldMetadataType.NUMBER,
});
const manyToOneField = createField({
id: 'rel-1',
type: FieldMetadataType.RELATION,
settings: { relationType: RelationType.MANY_TO_ONE },
});
const oneToManyField = createField({
id: 'reverse-1',
type: FieldMetadataType.RELATION,
settings: { relationType: RelationType.ONE_TO_MANY },
});
const fields = [
oneToManyField,
manyToOneField,
textField,
numberField,
labelField,
];
const sorted = [...fields].sort(sorter);
expect(sorted[0].id).toBe(labelIdentifierId);
expect(sorted[sorted.length - 1].id).toBe('reverse-1');
const labelIdx = sorted.findIndex((f) => f.id === labelIdentifierId);
const textIdx = sorted.findIndex((f) => f.id === 'text-1');
const relIdx = sorted.findIndex((f) => f.id === 'rel-1');
const reverseIdx = sorted.findIndex((f) => f.id === 'reverse-1');
expect(labelIdx).toBeLessThan(textIdx);
expect(textIdx).toBeLessThan(relIdx);
expect(relIdx).toBeLessThan(reverseIdx);
});
});
@@ -0,0 +1,13 @@
import { type RecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot';
import { type View } from '@/views/types/View';
export const constructViewFromRecordTableWidgetViewSnapshot = (
snapshot: RecordTableWidgetViewSnapshot,
): View => ({
...snapshot.view,
viewFields: snapshot.viewFields,
viewFilters: [],
viewSorts: [],
viewGroups: [],
viewFilterGroups: [],
});
@@ -11,7 +11,7 @@ import { useRemovePageLayoutWidgetAndPreservePosition } from '@/page-layout/hook
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { getTabListInstanceIdFromPageLayoutAndRecord } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutAndRecord';
import { useCreateViewForRecordTableWidget } from '@/page-layout/widgets/record-table/hooks/useCreateViewForRecordTableWidget';
import { useAddDraftViewForRecordTableWidget } from '@/page-layout/widgets/record-table/hooks/useAddDraftViewForRecordTableWidget';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
@@ -83,8 +83,8 @@ export const SidePanelPageLayoutDashboardWidgetTypeSelect = () => {
const { removePageLayoutWidgetAndPreservePosition } =
useRemovePageLayoutWidgetAndPreservePosition(pageLayoutId);
const { createViewForRecordTableWidget } =
useCreateViewForRecordTableWidget(pageLayoutId);
const { addDraftViewForRecordTableWidget } =
useAddDraftViewForRecordTableWidget(pageLayoutId);
const { readableObjectMetadataItems } = useReadableObjectMetadataItems();
const firstAvailableObjectMetadataItem =
@@ -185,7 +185,7 @@ export const SidePanelPageLayoutDashboardWidgetTypeSelect = () => {
closeSidePanelMenu();
};
const handleNavigateToRecordTableSettings = async () => {
const handleNavigateToRecordTableSettings = () => {
if (
isExistingWidgetMissingOrDifferentType(
existingWidget?.type,
@@ -202,7 +202,7 @@ export const SidePanelPageLayoutDashboardWidgetTypeSelect = () => {
setPageLayoutEditingWidgetId(newRecordTableWidget.id);
await createViewForRecordTableWidget(
addDraftViewForRecordTableWidget(
newRecordTableWidget.id,
firstAvailableObjectMetadataItem,
);
@@ -3,8 +3,8 @@ import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadat
import { filterReadableActiveObjectMetadataItems } from '@/object-metadata/utils/filterReadableActiveObjectMetadataItems';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget';
import { useCreateViewForRecordTableWidget } from '@/page-layout/widgets/record-table/hooks/useCreateViewForRecordTableWidget';
import { useDeleteViewForRecordTableWidget } from '@/page-layout/widgets/record-table/hooks/useDeleteViewForRecordTableWidget';
import { useAddDraftViewForRecordTableWidget } from '@/page-layout/widgets/record-table/hooks/useAddDraftViewForRecordTableWidget';
import { useRemoveDraftViewForRecordTableWidget } from '@/page-layout/widgets/record-table/hooks/useRemoveDraftViewForRecordTableWidget';
import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore';
import { useUpdateCurrentWidgetConfig } from '@/side-panel/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWidgetInEditMode';
@@ -47,11 +47,11 @@ export const RecordTableDataSourceDropdownContent = () => {
const { updateCurrentWidgetConfig } =
useUpdateCurrentWidgetConfig(pageLayoutId);
const { createViewForRecordTableWidget } =
useCreateViewForRecordTableWidget(pageLayoutId);
const { addDraftViewForRecordTableWidget } =
useAddDraftViewForRecordTableWidget(pageLayoutId);
const { deleteViewForRecordTableWidget } =
useDeleteViewForRecordTableWidget();
const { removeDraftViewForRecordTableWidget } =
useRemoveDraftViewForRecordTableWidget(pageLayoutId);
const { updatePageLayoutWidget } = useUpdatePageLayoutWidget(pageLayoutId);
@@ -77,20 +77,14 @@ export const RecordTableDataSourceDropdownContent = () => {
getSearchableValues: (item) => [item.labelPlural, item.namePlural],
});
const currentViewId =
widgetInEditMode?.configuration &&
'viewId' in widgetInEditMode.configuration
? (widgetInEditMode.configuration.viewId as string | undefined)
: undefined;
const handleSelectSource = async (newObjectMetadataItemId: string) => {
const handleSelectSource = (newObjectMetadataItemId: string) => {
if (currentObjectMetadataItemId === newObjectMetadataItemId) {
closeDropdown();
return;
}
if (isDefined(currentViewId)) {
await deleteViewForRecordTableWidget(currentViewId);
if (isDefined(widgetInEditMode)) {
removeDraftViewForRecordTableWidget(widgetInEditMode.id);
}
updateCurrentWidgetConfig({
@@ -105,7 +99,7 @@ export const RecordTableDataSourceDropdownContent = () => {
);
if (isDefined(selectedObjectMetadataItem) && isDefined(widgetInEditMode)) {
await createViewForRecordTableWidget(
addDraftViewForRecordTableWidget(
widgetInEditMode.id,
selectedObjectMetadataItem,
);
@@ -27,19 +27,25 @@ const StyledSectionLabel = styled.div`
type RecordTableSettingsFieldVisibilityProps = {
viewId: string;
widgetId: string;
pageLayoutId: string;
};
export const RecordTableSettingsFieldVisibility = ({
viewId,
widgetId,
pageLayoutId,
}: RecordTableSettingsFieldVisibilityProps) => {
const { recordTableWidgetViewFieldItems } =
useRecordTableWidgetViewFieldItems(viewId);
useRecordTableWidgetViewFieldItems({ viewId, widgetId, pageLayoutId });
const { toggleRecordTableWidgetFieldVisibility } =
useToggleRecordTableWidgetFieldVisibility();
useToggleRecordTableWidgetFieldVisibility({ pageLayoutId, widgetId });
const { reorderRecordTableWidgetFields } =
useReorderRecordTableWidgetFields();
const { reorderRecordTableWidgetFields } = useReorderRecordTableWidgetFields({
pageLayoutId,
widgetId,
});
const { getIcon } = useIcons();
@@ -3,9 +3,9 @@ import { AdvancedFilterSidePanelContainer } from '@/object-record/advanced-filte
import { RecordFilterGroupsComponentInstanceContext } from '@/object-record/record-filter-group/states/context/RecordFilterGroupsComponentInstanceContext';
import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { useRecordTableWidgetViewForDisplay } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetViewForDisplay';
import { RecordTableSettingsFiltersInitializeStateEffect } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableSettingsFiltersInitializeStateEffect';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { useViewById } from '@/views/hooks/useViewById';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
@@ -20,14 +20,22 @@ const StyledFilterSettingsContainer = styled.div`
type RecordTableSettingsFiltersProps = {
viewId: string;
widgetId: string;
pageLayoutId: string;
objectMetadataId: string;
};
export const RecordTableSettingsFilters = ({
viewId,
widgetId,
pageLayoutId,
objectMetadataId,
}: RecordTableSettingsFiltersProps) => {
const { view } = useViewById(viewId);
const { view } = useRecordTableWidgetViewForDisplay({
viewId,
widgetId,
pageLayoutId,
});
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataId,
});
@@ -2,10 +2,10 @@ import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMeta
import { filterSortableFieldMetadataItems } from '@/object-metadata/utils/filterSortableFieldMetadataItems';
import { RecordSortsComponentInstanceContext } from '@/object-record/record-sort/states/context/RecordSortsComponentInstanceContext';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { useRecordTableWidgetViewForDisplay } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetViewForDisplay';
import { RecordTableSettingsSortsContent } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableSettingsSortsContent';
import { RecordTableSettingsSortsInitializeStateEffect } from '@/side-panel/pages/page-layout/components/record-table-settings/RecordTableSettingsSortsInitializeStateEffect';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { useViewById } from '@/views/hooks/useViewById';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
@@ -23,14 +23,22 @@ const StyledSortSettingsContainer = styled.div`
type RecordTableSettingsSortsProps = {
viewId: string;
widgetId: string;
pageLayoutId: string;
objectMetadataId: string;
};
export const RecordTableSettingsSorts = ({
viewId,
widgetId,
pageLayoutId,
objectMetadataId,
}: RecordTableSettingsSortsProps) => {
const { view } = useViewById(viewId);
const { view } = useRecordTableWidgetViewForDisplay({
viewId,
widgetId,
pageLayoutId,
});
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataId,
});
@@ -28,5 +28,11 @@ export const SidePanelRecordTableFieldsSubPage = () => {
return null;
}
return <RecordTableSettingsFieldVisibility viewId={viewId} />;
return (
<RecordTableSettingsFieldVisibility
viewId={viewId}
widgetId={widgetInEditMode.id}
pageLayoutId={pageLayoutId}
/>
);
};
@@ -31,6 +31,8 @@ export const SidePanelRecordTableFilterSubPage = () => {
return (
<RecordTableSettingsFilters
viewId={viewId}
widgetId={widgetInEditMode.id}
pageLayoutId={pageLayoutId}
objectMetadataId={widgetInEditMode.objectMetadataId}
/>
);
@@ -32,6 +32,8 @@ export const SidePanelRecordTableSortSubPage = () => {
return (
<RecordTableSettingsSorts
viewId={viewId}
widgetId={widgetInEditMode.id}
pageLayoutId={pageLayoutId}
objectMetadataId={widgetInEditMode.objectMetadataId}
/>
);
@@ -27,7 +27,7 @@ import {
import {
isDefined,
parseJson,
safeParseRelativeDateFilterJSONStringified,
safeParseRelativeDateFilterJsonStringified,
type RelativeDateFilter,
} from 'twenty-shared/utils';
import { parseBooleanFromStringValue } from 'twenty-shared/workflow';
@@ -151,7 +151,7 @@ export const WorkflowStepFilterValueInput = ({
const isRelativeDateFilter =
isDateField && stepFilter.operand === ViewFilterOperand.IS_RELATIVE;
const relativeDateFilter = safeParseRelativeDateFilterJSONStringified(
const relativeDateFilter = safeParseRelativeDateFilterJsonStringified(
stepFilter.value,
);
@@ -17,6 +17,7 @@ exports[`stub-twenty-sdk-define plugin > matches the recorded export partition 1
"RelationType",
"STANDARD_OBJECT",
"STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS",
"ViewCalendarLayout",
"ViewFilterGroupLogicalOperator",
"ViewFilterOperand",
"ViewKey",
@@ -8,7 +8,7 @@ describe('getNavigationMenuItemBaseFile', () => {
});
expect(result).toContain(
"import { defineNavigationMenuItem } from 'twenty-sdk/define';",
"import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';",
);
expect(result).toContain('export default defineNavigationMenuItem({');
expect(result).toContain(
@@ -26,7 +26,7 @@ describe('getNavigationMenuItemBaseFile', () => {
viewUniversalIdentifier: 'view-uuid-123',
});
expect(result).toContain("type: 'VIEW'");
expect(result).toContain('type: NavigationMenuItemType.VIEW');
expect(result).toContain("viewUniversalIdentifier: 'view-uuid-123'");
});
@@ -35,7 +35,7 @@ describe('getNavigationMenuItemBaseFile', () => {
name: 'unlinked-item',
});
expect(result).toContain("type: 'VIEW'");
expect(result).toContain('type: NavigationMenuItemType.VIEW');
expect(result).toContain('// viewUniversalIdentifier:');
});
@@ -46,7 +46,7 @@ describe('getNavigationMenuItemBaseFile', () => {
targetObjectUniversalIdentifier: 'obj-uuid-123',
});
expect(result).toContain("type: 'OBJECT'");
expect(result).toContain('type: NavigationMenuItemType.OBJECT');
expect(result).toContain("targetObjectUniversalIdentifier: 'obj-uuid-123'");
});
@@ -19,22 +19,22 @@ export const getNavigationMenuItemBaseFile = ({
let typeAndConfig: string;
if (type === 'OBJECT' && targetObjectUniversalIdentifier) {
typeAndConfig = ` type: 'OBJECT',
typeAndConfig = ` type: NavigationMenuItemType.OBJECT,
targetObjectUniversalIdentifier: '${targetObjectUniversalIdentifier}',`;
} else if (type === 'VIEW' && viewUniversalIdentifier) {
typeAndConfig = ` type: 'VIEW',
typeAndConfig = ` type: NavigationMenuItemType.VIEW,
viewUniversalIdentifier: '${viewUniversalIdentifier}',`;
} else if (type === 'LINK') {
typeAndConfig = ` type: 'LINK',
typeAndConfig = ` type: NavigationMenuItemType.LINK,
link: 'https://example.com',`;
} else if (type === 'FOLDER') {
typeAndConfig = ` type: 'FOLDER',`;
typeAndConfig = ` type: NavigationMenuItemType.FOLDER,`;
} else {
typeAndConfig = ` type: 'VIEW',
typeAndConfig = ` type: NavigationMenuItemType.VIEW,
// viewUniversalIdentifier: '...',`;
}
return `import { defineNavigationMenuItem } from 'twenty-sdk/define';
return `import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
export default defineNavigationMenuItem({
universalIdentifier: '${universalIdentifier}',
@@ -108,6 +108,7 @@ export {
NumberDataType,
ObjectRecordGroupByDateGranularity,
PageLayoutTabLayoutMode,
ViewCalendarLayout,
ViewFilterGroupLogicalOperator,
ViewFilterOperand,
ViewOpenRecordIn,
@@ -13,6 +13,7 @@ import { buildApiKeyAuthContext } from 'src/engine/core-modules/auth/utils/build
import { buildApplicationAuthContext } from 'src/engine/core-modules/auth/utils/build-application-auth-context.util';
import { buildPendingActivationUserAuthContext } from 'src/engine/core-modules/auth/utils/build-pending-activation-user-auth-context.util';
import { buildUserAuthContext } from 'src/engine/core-modules/auth/utils/build-user-auth-context.util';
import { applyWorkspaceSentryContext } from 'src/engine/core-modules/sentry/utils/apply-workspace-sentry-context.util';
@Injectable()
export class WorkspaceAuthContextMiddleware implements NestMiddleware {
@@ -25,6 +26,8 @@ export class WorkspaceAuthContextMiddleware implements NestMiddleware {
const authContext = this.buildAuthContext(req);
applyWorkspaceSentryContext(authContext);
withWorkspaceAuthContext(authContext, () => {
next();
});
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import crypto from 'crypto';
import { promises as fs } from 'fs';
import { dirname, join } from 'path';
import path, { dirname, join } from 'path';
import { type QueryRunner } from 'typeorm';
import { FileFolder } from 'twenty-shared/types';
@@ -10,6 +10,7 @@ import { isDefined } from 'twenty-shared/utils';
import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
import { SEED_DEPENDENCIES_DIRNAME } from 'src/engine/core-modules/application/application-package/constants/seed-dependencies-dirname';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import {
getLogicFunctionSeedProjectFiles,
@@ -257,24 +258,45 @@ export class LogicFunctionResourceService {
workspaceId: string;
inMemoryFolderPath: string;
}) {
const yarnLockExists = await this.fileStorageService.checkFileExists({
workspaceId,
applicationUniversalIdentifier,
fileFolder: FileFolder.Dependencies,
resourcePath: 'yarn.lock',
});
const promises = [];
promises.push(
this.fileStorageService.downloadFile({
const [packageJsonExists, yarnLockExists] = await Promise.all([
this.fileStorageService.checkFileExists({
workspaceId,
applicationUniversalIdentifier,
fileFolder: FileFolder.Dependencies,
resourcePath: 'package.json',
localPath: join(inMemoryFolderPath, 'package.json'),
}),
);
this.fileStorageService.checkFileExists({
workspaceId,
applicationUniversalIdentifier,
fileFolder: FileFolder.Dependencies,
resourcePath: 'yarn.lock',
}),
]);
const promises = [];
if (packageJsonExists) {
promises.push(
this.fileStorageService.downloadFile({
workspaceId,
applicationUniversalIdentifier,
fileFolder: FileFolder.Dependencies,
resourcePath: 'package.json',
localPath: join(inMemoryFolderPath, 'package.json'),
}),
);
} else {
const packageJsonPath = join(inMemoryFolderPath, 'package.json');
promises.push(
fs.mkdir(dirname(packageJsonPath), { recursive: true }).then(() =>
fs.copyFile(
path.join(SEED_DEPENDENCIES_DIRNAME, 'package.json'),
packageJsonPath,
),
),
);
}
if (yarnLockExists) {
promises.push(
@@ -4,6 +4,7 @@ import {
type OnModuleInit,
} from '@nestjs/common';
import * as Sentry from '@sentry/node';
import {
type JobsOptions,
MetricsTime,
@@ -28,6 +29,7 @@ import { type MessageQueue } from 'src/engine/core-modules/message-queue/message
import { getJobKey } from 'src/engine/core-modules/message-queue/utils/get-job-key.util';
import { type MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
import { applyWorkspaceSentryContextFromJobData } from 'src/engine/core-modules/sentry/utils/apply-workspace-sentry-context-from-job-data.util';
export type BullMQDriverOptions = QueueOptions;
@@ -108,25 +110,28 @@ export class BullMQDriver
this.workerMap[queueName] = new Worker(
queueName,
async (job) => {
// TODO: Correctly support for job.id
const timeStart = performance.now();
const workspaceId = job.data?.workspaceId;
const workspaceSuffix = workspaceId
? ` [workspace=${workspaceId}]`
: '';
async (job) =>
Sentry.withIsolationScope(async () => {
applyWorkspaceSentryContextFromJobData(job.data);
this.logger.log(
`Processing job ${job.id} with name ${job.name} on queue ${queueName}${workspaceSuffix}`,
);
await handler({ data: job.data, id: job.id ?? '', name: job.name });
const timeEnd = performance.now();
const executionTime = timeEnd - timeStart;
// TODO: Correctly support for job.id
const timeStart = performance.now();
const workspaceId = job.data?.workspaceId;
const workspaceSuffix = workspaceId
? ` [workspace=${workspaceId}]`
: '';
this.logger.log(
`Job ${job.id} with name ${job.name} processed on queue ${queueName} in ${executionTime.toFixed(2)}ms${workspaceSuffix}`,
);
},
this.logger.log(
`Processing job ${job.id} with name ${job.name} on queue ${queueName}${workspaceSuffix}`,
);
await handler({ data: job.data, id: job.id ?? '', name: job.name });
const timeEnd = performance.now();
const executionTime = timeEnd - timeStart;
this.logger.log(
`Job ${job.id} with name ${job.name} processed on queue ${queueName} in ${executionTime.toFixed(2)}ms${workspaceSuffix}`,
);
}),
workerOptions,
);
@@ -0,0 +1,25 @@
import { applyWorkspaceSentryFields } from 'src/engine/core-modules/sentry/utils/apply-workspace-sentry-fields.util';
export const applyWorkspaceSentryContextFromJobData = (
jobData: unknown,
): void => {
if (typeof jobData !== 'object' || jobData === null) {
return;
}
const workspaceId = (jobData as { workspaceId?: unknown }).workspaceId;
const userWorkspaceId = (jobData as { userWorkspaceId?: unknown })
.userWorkspaceId;
if (typeof workspaceId !== 'string' || workspaceId.length === 0) {
return;
}
applyWorkspaceSentryFields({
workspaceId,
userWorkspaceId:
typeof userWorkspaceId === 'string' && userWorkspaceId.length > 0
? userWorkspaceId
: undefined,
});
};
@@ -0,0 +1,27 @@
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { applyWorkspaceSentryFields } from 'src/engine/core-modules/sentry/utils/apply-workspace-sentry-fields.util';
export const applyWorkspaceSentryContext = (
authContext: WorkspaceAuthContext,
): void => {
const workspaceId = authContext.workspace?.id;
if (!workspaceId) {
return;
}
switch (authContext.type) {
case 'user':
case 'pendingActivationUser':
applyWorkspaceSentryFields({
workspaceId,
userWorkspaceId: authContext.userWorkspaceId,
});
return;
case 'apiKey':
case 'application':
case 'system':
applyWorkspaceSentryFields({ workspaceId });
return;
}
};

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