Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78c4f9c73c | ||
|
|
a710c105cf | ||
|
|
a5cd64daf5 | ||
|
|
df3e217d64 | ||
|
|
1c06506256 | ||
|
|
d271ba06f1 | ||
|
|
7d35979fcf | ||
|
|
8d3047c1fc | ||
|
|
e632b7dbb9 | ||
|
|
5b3ee3f1a7 | ||
|
|
875795cc30 | ||
|
|
350b835fbc | ||
|
|
ef49dad0bc | ||
|
|
e3a668a054 | ||
|
|
74536e6f98 | ||
|
|
b6ec2acb56 | ||
|
|
bb5f294c5a | ||
|
|
13aaea597e | ||
|
|
ec893c2767 | ||
|
|
f8c1ad5b3c | ||
|
|
a90895e167 | ||
|
|
9b7f7d059a |
@@ -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
|
||||
|
||||
@@ -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="هل تقدمون مقاعد مجانية للمستخدمين العارضين فقط؟">
|
||||
|
||||
+34
-39
@@ -53,38 +53,45 @@ People ←→ Project Assignments ←→ Projects
|
||||
1. اذهب إلى **الإعدادات → نموذج البيانات**
|
||||
2. انقر **+ كائن جديد**
|
||||
3. سمِّه تسمية وصفية (مثلًا: "تعيين مشروع"، "عضو فريق"، "طلب منتج")
|
||||
4. انقر على **حفظ**
|
||||
4. فعِّل خيار "تخطي إنشاء حقل الاسم"
|
||||
|
||||
<img src="/images/user-guide/fields/new-pivot-object.png" alt="كائن ربط جديد" />
|
||||
|
||||
5. انقر على **حفظ**
|
||||
|
||||
<Tip>
|
||||
**اتفاقية التسمية**: استخدم اسمًا يصف العلاقة، مثل "تعيين مشروع" أو "عضوية الفريق". هذا يجعل نموذج البيانات أسهل في الفهم.
|
||||
</Tip>
|
||||
|
||||
## الخطوة 2: إنشاء علاقات من كائن الربط
|
||||
## الخطوة 2: إنشاء علاقات بين الكائنات وكائن الربط
|
||||
|
||||
أضِف حقول علاقة من كائن الربط إلى كلا الكائنين اللذين تريد ربطهما.
|
||||
أضف حقول العلاقة من كلٍ من الكائنين لديك إلى كائن الربط.
|
||||
|
||||
### العلاقة الأولى (كائن الربط → الكائن A)
|
||||
### العلاقة الأولى (الكائن A → كائن الربط)
|
||||
|
||||
1. حدِّد كائن الربط في **الإعدادات → نموذج البيانات**
|
||||
2. انقر **+ إضافة حقل**
|
||||
3. اختر **العلاقة** كنوع الحقل
|
||||
4. اختر الكائن الأول (مثلًا، "الأشخاص")
|
||||
5. عيِّن نوع العلاقة إلى **متعدد-إلى-واحد** (يمكن لعديد من التعيينات الارتباط بشخص واحد)
|
||||
6. قم بتسمية الحقول:
|
||||
* الحقل على كائن الربط: مثلًا، "شخص"
|
||||
* الحقل على الأشخاص: مثلًا، "تعيينات المشروع"
|
||||
7. انقر على **حفظ**
|
||||
|
||||
### العلاقة الثانية (كائن الربط → الكائن B)
|
||||
|
||||
1. وأنت ما زلت في كائن الربط، انقر **+ إضافة حقل**
|
||||
2. اختر **العلاقة** كنوع الحقل
|
||||
3. اختر الكائن الثاني (مثلًا، "المشاريع")
|
||||
4. عيِّن نوع العلاقة إلى **متعدد-إلى-واحد**
|
||||
1. حدِّد الكائن الأول لديك في **الإعدادات → نموذج البيانات**
|
||||
2. انقر **+ إضافة علاقة**
|
||||
3. اختر كائن الربط (مثلًا، "تعيينات المشروع")
|
||||
4. عيِّن نوع العلاقة إلى **واحد-إلى-متعدد** (يمكن لشخص واحد الارتباط بالعديد من التعيينات)
|
||||
5. قم بتسمية الحقول:
|
||||
* الحقل على الأشخاص: مثلًا، "تعيينات المشروع"
|
||||
* الحقل على كائن الربط: مثلًا، "شخص"
|
||||
6. انقر على **حفظ**
|
||||
|
||||
### العلاقة الثانية (الكائن B → كائن الربط)
|
||||
|
||||
1. حدِّد الكائن الثاني لديك في **الإعدادات → نموذج البيانات**
|
||||
2. انقر **+ إضافة علاقة**
|
||||
3. اختر كائن الربط (مثلًا، "تعيينات المشروع")
|
||||
4. عيِّن نوع العلاقة إلى **واحد-إلى-متعدد** (يمكن لمشروع واحد الارتباط بالعديد من التعيينات)
|
||||
5. فعّل **"هذه علاقة بكائن ربط"**
|
||||
|
||||
<img src="/images/user-guide/fields/junction-relation-toggle.png" style={{width:'100%'}} />
|
||||
|
||||
6. قم بتسمية الحقول:
|
||||
* الحقل على كائن الربط: مثلًا، "مشروع"
|
||||
* الحقل على المشاريع: مثلًا، "أعضاء الفريق"
|
||||
6. انقر على **حفظ**
|
||||
7. انقر على **حفظ**
|
||||
|
||||
## الخطوة 3: ضبط عرض علاقة الربط
|
||||
|
||||
@@ -98,18 +105,6 @@ People ←→ Project Assignments ←→ Projects
|
||||
6. حدِّد **العلاقة الهدف** (مثلًا، "مشروع" — الحقل على كائن الربط الذي يشير إلى الجانب الآخر)
|
||||
7. انقر على **حفظ**
|
||||
|
||||
{/* TODO: Add image
|
||||
<img src="/images/user-guide/fields/junction-relation-toggle.png" style={{width:'100%'}}/>
|
||||
*/}
|
||||
|
||||
كرِّر على الكائن الآخر:
|
||||
|
||||
1. اختر "المشاريع" في نموذج البيانات
|
||||
2. حرِّر حقل العلاقة "أعضاء الفريق"
|
||||
3. فعّل مفتاح الربط
|
||||
4. حدِّد "شخص" كالعلاقة الهدف
|
||||
5. حفظ
|
||||
|
||||
## النتيجة
|
||||
|
||||
بعد التكوين:
|
||||
@@ -130,15 +125,15 @@ People ←→ Project Assignments ←→ Projects
|
||||
|
||||
### إضافة علاقات
|
||||
|
||||
1. **تعيين مشروع → الأشخاص**
|
||||
* النوع: متعدد-إلى-واحد
|
||||
* الحقل على التعيين: "شخص"
|
||||
1. **الأشخاص → تعيين مشروع**
|
||||
* النوع: واحد-إلى-متعدد
|
||||
* الحقل على الأشخاص: "تعيينات المشروع"
|
||||
* الحقل على التعيين: "شخص"
|
||||
|
||||
2. **تعيين مشروع → المشاريع**
|
||||
* النوع: متعدد-إلى-واحد
|
||||
* الحقل على التعيين: "مشروع"
|
||||
2. **المشاريع → تعيين مشروع**
|
||||
* النوع: واحد-إلى-متعدد
|
||||
* الحقل على المشاريع: "أعضاء الفريق"
|
||||
* الحقل على التعيين: "مشروع"
|
||||
|
||||
### ضبط عرض علاقة الربط
|
||||
|
||||
|
||||
@@ -30,3 +30,9 @@ description: خصص الشريط الجانبي الأيسر ليتوافق مع
|
||||
## قائمة الأوامر
|
||||
|
||||
اضغط `Cmd+K` (أو `Ctrl+K`) لفتح قائمة الأوامر — شريط بحث للوصول السريع يتيح لك الانتقال إلى أي سجل أو طريقة عرض أو إجراء دون التنقل عبر الشريط الجانبي.
|
||||
|
||||
## تخصيص الشريط الجانبي
|
||||
|
||||
لتخصيص الشريط الجانبي، مرّر المؤشر فوق قسم "مساحة العمل" في الشريط الجانبي وانقر على أيقونة مفتاح الربط.
|
||||
|
||||
<img src="/images/user-guide/layout/navigation-edit-icon.png" alt="أيقونة تحرير التنقّل" />
|
||||
|
||||
@@ -3,6 +3,8 @@ title: صفحات السجل
|
||||
description: خصص تخطيط صفحات تفاصيل السجلات باستخدام علامات تبويب وأدوات.
|
||||
---
|
||||
|
||||
## نظرة عامة
|
||||
|
||||
عند فتح سجل في Twenty، تتكون صفحة التفاصيل من **علامات تبويب** و**أدوات**. كلاهما قابل للتخصيص بالكامل حسب نوع الكائن.
|
||||
|
||||
## علامات التبويب
|
||||
@@ -38,12 +40,20 @@ description: خصص تخطيط صفحات تفاصيل السجلات باستخ
|
||||
|
||||
1. افتح أي سجل
|
||||
2. اضغط على `Cmd+K` وابحث عن "تحرير تخطيط صفحة السجل"
|
||||
|
||||
أو
|
||||
|
||||
1. انتقل إلى الإعدادات > نموذج البيانات > الكائن الذي تختاره > التخطيط
|
||||
|
||||
2. انقر على الزر "تخصيص صفحة السجل" لذلك الكائن
|
||||
|
||||
3. أنت الآن في وضع التخصيص:
|
||||
* **أضف أدوات** من منتقي الأدوات
|
||||
* **اسحب الأدوات** لإعادة وضعها على الشبكة
|
||||
* **غيّر حجم الأدوات** بسحب حوافها
|
||||
* **اضبط الحقول** المعروضة داخل كل أداة
|
||||
* **أدِر علامات التبويب** — أضف، أزل، أعد التسمية، وأعد الترتيب
|
||||
|
||||
4. احفظ تغييراتك — سيتم تطبيقها على جميع السجلات لذلك النوع من الكائنات
|
||||
|
||||
## ظهور الحقول
|
||||
|
||||
@@ -95,15 +95,16 @@ const handler = async (event: RoutePayload) => {
|
||||
|
||||
Typ `RoutePayload` má následující strukturu:
|
||||
|
||||
| Vlastnost | Typ | Popis | Příklad |
|
||||
| ---------------------------- | ------------------------------------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `headers` | `Record\<string, string \| undefined>` | Záhlaví HTTP (pouze ta uvedená v `forwardedRequestHeaders`) | viz sekci níže |
|
||||
| `queryStringParameters` | `Record\<string, string \| undefined>` | Parametry query stringu (více hodnot spojených čárkami) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
|
||||
| `pathParameters` | `Record\<string, string \| undefined>` | Parametry cesty extrahované ze vzoru trasy | `/users/:id`, `/users/123` -> `{ id: '123' }` |
|
||||
| `body` | `object \| null` | Parsované tělo požadavku (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
|
||||
| `isBase64Encoded` | `boolean` | Zda je tělo kódováno base64 | |
|
||||
| `requestContext.http.method` | `string` | Metoda HTTP (GET, POST, PUT, PATCH, DELETE) | |
|
||||
| `requestContext.http.path` | `string` | Nezpracovaná cesta požadavku | |
|
||||
| Vlastnost | Typ | Popis | Příklad |
|
||||
| ---------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `headers` | `Record\<string, string \| undefined>` | Záhlaví HTTP (pouze ta uvedená v `forwardedRequestHeaders`) | viz sekci níže |
|
||||
| `queryStringParameters` | `Record\<string, string \| undefined>` | Parametry query stringu (více hodnot spojených čárkami) | `/users?ids=1&ids=2&ids=3&name=Alice` -> `{ ids: '1,2,3', name: 'Alice' }` |
|
||||
| `pathParameters` | `Record\<string, string \| undefined>` | Parametry cesty extrahované ze vzoru trasy | `/users/:id`, `/users/123` -> `{ id: '123' }` |
|
||||
| `body` | `object \| null` | Parsované tělo požadavku (JSON) | `{ id: 1 }` -> `{ id: 1 }` |
|
||||
| `rawBody` | `string \| undefined` | Original UTF-8 request body, before JSON parsing. Useful for verifying HMAC-style webhook signatures (e.g. GitHub's `X-Hub-Signature-256`, Stripe). `undefined` when the runtime did not preserve it. | |
|
||||
| `isBase64Encoded` | `boolean` | Zda je tělo kódováno base64 | |
|
||||
| `requestContext.http.method` | `string` | Metoda HTTP (GET, POST, PUT, PATCH, DELETE) | |
|
||||
| `requestContext.http.path` | `string` | Nezpracovaná cesta požadavku | |
|
||||
|
||||
|
||||
#### forwardedRequestHeaders
|
||||
|
||||
@@ -19,7 +19,7 @@ Pro týmy připravené škálovat:
|
||||
* Standardní podpora
|
||||
|
||||
<Note>
|
||||
Prémiové funkce (SSO a oprávnění na úrovni řádků) nejsou součástí plánu Pro.
|
||||
Premium features (SSO, row-level permissions and AI usage data) are not included in the Pro plan.
|
||||
</Note>
|
||||
|
||||
### Organizace (Cloud)
|
||||
@@ -27,7 +27,7 @@ Prémiové funkce (SSO a oprávnění na úrovni řádků) nejsou součástí pl
|
||||
Pro větší týmy s pokročilými potřebami:
|
||||
|
||||
* Vše z plánu Pro
|
||||
* **Prémiové funkce**: integrace SSO a oprávnění na úrovni řádků
|
||||
* **Premium features**: SSO integration, row-level permissions and AI usage data
|
||||
* Prioritní podpora
|
||||
|
||||
## Plány pro self‑hosting
|
||||
@@ -45,7 +45,7 @@ Hostujte Twenty na vlastní infrastruktuře bez poplatků:
|
||||
Pro týmy, které při self‑hostingu potřebují prémiové funkce:
|
||||
|
||||
* Všechny funkce plánu Pro
|
||||
* **Prémiové funkce**: integrace SSO a oprávnění na úrovni řádků
|
||||
* **Premium features**: SSO integration, row-level permissions and AI usage data
|
||||
* Podpora týmu Twenty
|
||||
* Není vyžadováno zveřejnit vlastní kód jako open‑source před distribucí
|
||||
|
||||
@@ -55,6 +55,7 @@ Prémiové funkce jsou dostupné pouze v plánech Organizace (Cloud nebo self‑
|
||||
|
||||
* **Integrace SSO**: jednotné přihlášení s vaším poskytovatelem identity
|
||||
* **Oprávnění na úrovni řádků**: jemně odstupňované řízení přístupu na úrovni záznamu
|
||||
* **AI usage data**: Track AI consumption across the workspace
|
||||
|
||||
## Přepínání plánů
|
||||
|
||||
@@ -77,3 +78,15 @@ Chcete-li přejít na nižší plán, kontaktujte podporu.
|
||||
### Přepnout na měsíční fakturaci
|
||||
|
||||
Chcete-li přepnout zpět na měsíční fakturaci, kontaktujte podporu.
|
||||
|
||||
## Obtain an Enterprise Key for Organization (Self-Hosted)
|
||||
|
||||
To use the Organization (Self-Hosted) plan, you need to obtain an Enterprise key:
|
||||
|
||||
1. Go to **Settings → Admin Panel → Enterprise**
|
||||
|
||||
<img src="/images/user-guide/billing/enterprise-key.png" alt="Enterprise key" />
|
||||
|
||||
2. Click **Get Enterprise Key**
|
||||
3. When you are redirected to Stripe, enter your payment details and confirm
|
||||
4. When your Enterprise key is displayed, paste it into the Enterprise settings page and activate the Organization license
|
||||
|
||||
@@ -16,6 +16,11 @@ Pokud chcete hostovat sami a potřebujete prémiové funkce (SSO a oprávnění
|
||||
Prémiové funkce jsou dostupné pouze v plánech Organization (Cloud nebo Self-Hosted):
|
||||
* **Integrace SSO**: Jednotné přihlášení s vaším poskytovatelem identity
|
||||
* **Oprávnění na úrovni řádků**: Jemně granulované řízení přístupu na úrovni záznamu
|
||||
* **AI usage data**: Track AI consumption across the workspace
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Is Organization plan on cloud and Organization plan on self-hosted the same?">
|
||||
They offer the same premium features. However, a cloud subscription cannot be used for self-hosted deployments. Self-hosted requires an Enterprise key.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Nabízíte volná místa pro uživatele pouze s přístupem ke čtení?">
|
||||
|
||||
+34
-39
@@ -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?">
|
||||
|
||||
+34
-39
@@ -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 self‑hosted
|
||||
@@ -45,7 +45,7 @@ Esegui Twenty sulla tua infrastruttura senza costi:
|
||||
Per i team che necessitano di funzionalità premium con il self‑hosting:
|
||||
|
||||
* 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 Sign‑On 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?">
|
||||
|
||||
+34
-39
@@ -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?">
|
||||
|
||||
+34
-39
@@ -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="Где я могу переключить свою подписку на годовой период?">
|
||||
|
||||
+34
-39
@@ -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?">
|
||||
|
||||
+34
-39
@@ -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="你们是否为只读用户提供免费席位?">
|
||||
|
||||
+34
-39
@@ -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. 保存你的更改—它们将应用于该对象类型的所有记录
|
||||
|
||||
## 字段可见性
|
||||
|
||||
@@ -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);
|
||||
|
||||
+15
-1
@@ -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();
|
||||
|
||||
+23
-1
@@ -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;
|
||||
|
||||
+5
@@ -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 />
|
||||
|
||||
+9
@@ -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} />;
|
||||
|
||||
+1
@@ -98,6 +98,7 @@ export const RecordTableWidgetProvider = ({
|
||||
>
|
||||
<RecordTableWidgetViewLoadEffect
|
||||
viewId={viewId}
|
||||
widgetId={widgetId}
|
||||
objectMetadataItem={objectMetadataItem}
|
||||
/>
|
||||
{children}
|
||||
|
||||
+27
-9
@@ -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,
|
||||
|
||||
+3
-3
@@ -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(),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
+141
@@ -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,
|
||||
|
||||
+11
@@ -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,
|
||||
});
|
||||
+11
@@ -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,
|
||||
});
|
||||
+23
@@ -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];
|
||||
},
|
||||
});
|
||||
+281
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+14
-7
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+23
@@ -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;
|
||||
};
|
||||
+89
@@ -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 };
|
||||
};
|
||||
-85
@@ -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 };
|
||||
};
|
||||
-15
@@ -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 };
|
||||
};
|
||||
+71
@@ -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]);
|
||||
};
|
||||
+19
-4
@@ -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 [];
|
||||
}
|
||||
|
||||
|
||||
+35
@@ -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 };
|
||||
};
|
||||
+27
@@ -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 };
|
||||
};
|
||||
+47
-12
@@ -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 };
|
||||
|
||||
+15
-4
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
+37
-12
@@ -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 };
|
||||
|
||||
+7
@@ -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[];
|
||||
};
|
||||
+130
@@ -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);
|
||||
});
|
||||
});
|
||||
+13
@@ -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: [],
|
||||
});
|
||||
+5
-5
@@ -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,
|
||||
);
|
||||
|
||||
+10
-16
@@ -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,
|
||||
);
|
||||
|
||||
+10
-4
@@ -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();
|
||||
|
||||
|
||||
+10
-2
@@ -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,
|
||||
});
|
||||
|
||||
+10
-2
@@ -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,
|
||||
});
|
||||
|
||||
+7
-1
@@ -28,5 +28,11 @@ export const SidePanelRecordTableFieldsSubPage = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <RecordTableSettingsFieldVisibility viewId={viewId} />;
|
||||
return (
|
||||
<RecordTableSettingsFieldVisibility
|
||||
viewId={viewId}
|
||||
widgetId={widgetInEditMode.id}
|
||||
pageLayoutId={pageLayoutId}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+2
@@ -31,6 +31,8 @@ export const SidePanelRecordTableFilterSubPage = () => {
|
||||
return (
|
||||
<RecordTableSettingsFilters
|
||||
viewId={viewId}
|
||||
widgetId={widgetInEditMode.id}
|
||||
pageLayoutId={pageLayoutId}
|
||||
objectMetadataId={widgetInEditMode.objectMetadataId}
|
||||
/>
|
||||
);
|
||||
|
||||
+2
@@ -32,6 +32,8 @@ export const SidePanelRecordTableSortSubPage = () => {
|
||||
return (
|
||||
<RecordTableSettingsSorts
|
||||
viewId={viewId}
|
||||
widgetId={widgetInEditMode.id}
|
||||
pageLayoutId={pageLayoutId}
|
||||
objectMetadataId={widgetInEditMode.objectMetadataId}
|
||||
/>
|
||||
);
|
||||
|
||||
+2
-2
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
+1
@@ -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",
|
||||
|
||||
+4
-4
@@ -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'");
|
||||
});
|
||||
|
||||
|
||||
+6
-6
@@ -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,
|
||||
|
||||
+3
@@ -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();
|
||||
});
|
||||
|
||||
+36
-14
@@ -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(
|
||||
|
||||
+22
-17
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
+25
@@ -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,
|
||||
});
|
||||
};
|
||||
+27
@@ -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
Reference in New Issue
Block a user