Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d68868df5 | |||
| 4dfb4a8fcb | |||
| c5be4d2d60 | |||
| 70aae670f4 | |||
| 668cd7593b | |||
| 4180ba969b | |||
| 5065d75a6c | |||
| 715eed85be | |||
| 03651cab6c | |||
| 493e6a7f8f | |||
| c4775450f5 | |||
| cb6957fa51 | |||
| 9633740132 |
@@ -107,10 +107,13 @@ jobs:
|
||||
- name: Regenerate docs.json
|
||||
run: yarn docs:generate
|
||||
|
||||
- name: Regenerate documentation paths constants
|
||||
run: yarn docs:generate-paths
|
||||
|
||||
- name: Commit artifacts to pull request branch
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
git add packages/twenty-docs/docs.json packages/twenty-docs/navigation/navigation.template.json
|
||||
git add packages/twenty-docs/docs.json packages/twenty-docs/navigation/navigation.template.json packages/twenty-shared/src/constants/DocumentationPaths.ts
|
||||
if git diff --staged --quiet --exit-code; then
|
||||
echo "No navigation/doc changes to commit."
|
||||
exit 0
|
||||
|
||||
@@ -210,6 +210,7 @@
|
||||
"scripts": {
|
||||
"docs:generate": "tsx packages/twenty-docs/scripts/generate-docs-json.ts",
|
||||
"docs:generate-navigation-template": "tsx packages/twenty-docs/scripts/generate-navigation-template.ts",
|
||||
"docs:generate-paths": "tsx packages/twenty-docs/scripts/generate-documentation-paths.ts",
|
||||
"start": "npx concurrently --kill-others 'npx nx run-many -t start -p twenty-server twenty-front' 'npx wait-on tcp:3000 && npx nx run twenty-server:worker'"
|
||||
},
|
||||
"workspaces": {
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
"user-guide/data-model/how-tos/create-custom-objects",
|
||||
"user-guide/data-model/how-tos/create-custom-fields",
|
||||
"user-guide/data-model/how-tos/create-relation-fields",
|
||||
"user-guide/data-model/how-tos/create-many-to-many-relations",
|
||||
"user-guide/data-model/how-tos/customize-your-data-model",
|
||||
"user-guide/data-model/how-tos/data-model-faq"
|
||||
]
|
||||
@@ -524,6 +525,7 @@
|
||||
"l/fr/user-guide/data-model/how-tos/create-custom-objects",
|
||||
"l/fr/user-guide/data-model/how-tos/create-custom-fields",
|
||||
"l/fr/user-guide/data-model/how-tos/create-relation-fields",
|
||||
"l/fr/user-guide/data-model/how-tos/create-many-to-many-relations",
|
||||
"l/fr/user-guide/data-model/how-tos/customize-your-data-model",
|
||||
"l/fr/user-guide/data-model/how-tos/data-model-faq"
|
||||
]
|
||||
@@ -968,6 +970,7 @@
|
||||
"l/ar/user-guide/data-model/how-tos/create-custom-objects",
|
||||
"l/ar/user-guide/data-model/how-tos/create-custom-fields",
|
||||
"l/ar/user-guide/data-model/how-tos/create-relation-fields",
|
||||
"l/ar/user-guide/data-model/how-tos/create-many-to-many-relations",
|
||||
"l/ar/user-guide/data-model/how-tos/customize-your-data-model",
|
||||
"l/ar/user-guide/data-model/how-tos/data-model-faq"
|
||||
]
|
||||
@@ -1412,6 +1415,7 @@
|
||||
"l/cs/user-guide/data-model/how-tos/create-custom-objects",
|
||||
"l/cs/user-guide/data-model/how-tos/create-custom-fields",
|
||||
"l/cs/user-guide/data-model/how-tos/create-relation-fields",
|
||||
"l/cs/user-guide/data-model/how-tos/create-many-to-many-relations",
|
||||
"l/cs/user-guide/data-model/how-tos/customize-your-data-model",
|
||||
"l/cs/user-guide/data-model/how-tos/data-model-faq"
|
||||
]
|
||||
@@ -1856,6 +1860,7 @@
|
||||
"l/de/user-guide/data-model/how-tos/create-custom-objects",
|
||||
"l/de/user-guide/data-model/how-tos/create-custom-fields",
|
||||
"l/de/user-guide/data-model/how-tos/create-relation-fields",
|
||||
"l/de/user-guide/data-model/how-tos/create-many-to-many-relations",
|
||||
"l/de/user-guide/data-model/how-tos/customize-your-data-model",
|
||||
"l/de/user-guide/data-model/how-tos/data-model-faq"
|
||||
]
|
||||
@@ -2300,6 +2305,7 @@
|
||||
"l/es/user-guide/data-model/how-tos/create-custom-objects",
|
||||
"l/es/user-guide/data-model/how-tos/create-custom-fields",
|
||||
"l/es/user-guide/data-model/how-tos/create-relation-fields",
|
||||
"l/es/user-guide/data-model/how-tos/create-many-to-many-relations",
|
||||
"l/es/user-guide/data-model/how-tos/customize-your-data-model",
|
||||
"l/es/user-guide/data-model/how-tos/data-model-faq"
|
||||
]
|
||||
@@ -2744,6 +2750,7 @@
|
||||
"l/it/user-guide/data-model/how-tos/create-custom-objects",
|
||||
"l/it/user-guide/data-model/how-tos/create-custom-fields",
|
||||
"l/it/user-guide/data-model/how-tos/create-relation-fields",
|
||||
"l/it/user-guide/data-model/how-tos/create-many-to-many-relations",
|
||||
"l/it/user-guide/data-model/how-tos/customize-your-data-model",
|
||||
"l/it/user-guide/data-model/how-tos/data-model-faq"
|
||||
]
|
||||
@@ -3188,6 +3195,7 @@
|
||||
"l/ja/user-guide/data-model/how-tos/create-custom-objects",
|
||||
"l/ja/user-guide/data-model/how-tos/create-custom-fields",
|
||||
"l/ja/user-guide/data-model/how-tos/create-relation-fields",
|
||||
"l/ja/user-guide/data-model/how-tos/create-many-to-many-relations",
|
||||
"l/ja/user-guide/data-model/how-tos/customize-your-data-model",
|
||||
"l/ja/user-guide/data-model/how-tos/data-model-faq"
|
||||
]
|
||||
@@ -3632,6 +3640,7 @@
|
||||
"l/ko/user-guide/data-model/how-tos/create-custom-objects",
|
||||
"l/ko/user-guide/data-model/how-tos/create-custom-fields",
|
||||
"l/ko/user-guide/data-model/how-tos/create-relation-fields",
|
||||
"l/ko/user-guide/data-model/how-tos/create-many-to-many-relations",
|
||||
"l/ko/user-guide/data-model/how-tos/customize-your-data-model",
|
||||
"l/ko/user-guide/data-model/how-tos/data-model-faq"
|
||||
]
|
||||
@@ -4076,6 +4085,7 @@
|
||||
"l/pt/user-guide/data-model/how-tos/create-custom-objects",
|
||||
"l/pt/user-guide/data-model/how-tos/create-custom-fields",
|
||||
"l/pt/user-guide/data-model/how-tos/create-relation-fields",
|
||||
"l/pt/user-guide/data-model/how-tos/create-many-to-many-relations",
|
||||
"l/pt/user-guide/data-model/how-tos/customize-your-data-model",
|
||||
"l/pt/user-guide/data-model/how-tos/data-model-faq"
|
||||
]
|
||||
@@ -4520,6 +4530,7 @@
|
||||
"l/ro/user-guide/data-model/how-tos/create-custom-objects",
|
||||
"l/ro/user-guide/data-model/how-tos/create-custom-fields",
|
||||
"l/ro/user-guide/data-model/how-tos/create-relation-fields",
|
||||
"l/ro/user-guide/data-model/how-tos/create-many-to-many-relations",
|
||||
"l/ro/user-guide/data-model/how-tos/customize-your-data-model",
|
||||
"l/ro/user-guide/data-model/how-tos/data-model-faq"
|
||||
]
|
||||
@@ -4964,6 +4975,7 @@
|
||||
"l/ru/user-guide/data-model/how-tos/create-custom-objects",
|
||||
"l/ru/user-guide/data-model/how-tos/create-custom-fields",
|
||||
"l/ru/user-guide/data-model/how-tos/create-relation-fields",
|
||||
"l/ru/user-guide/data-model/how-tos/create-many-to-many-relations",
|
||||
"l/ru/user-guide/data-model/how-tos/customize-your-data-model",
|
||||
"l/ru/user-guide/data-model/how-tos/data-model-faq"
|
||||
]
|
||||
@@ -5408,6 +5420,7 @@
|
||||
"l/tr/user-guide/data-model/how-tos/create-custom-objects",
|
||||
"l/tr/user-guide/data-model/how-tos/create-custom-fields",
|
||||
"l/tr/user-guide/data-model/how-tos/create-relation-fields",
|
||||
"l/tr/user-guide/data-model/how-tos/create-many-to-many-relations",
|
||||
"l/tr/user-guide/data-model/how-tos/customize-your-data-model",
|
||||
"l/tr/user-guide/data-model/how-tos/data-model-faq"
|
||||
]
|
||||
@@ -5852,6 +5865,7 @@
|
||||
"l/zh/user-guide/data-model/how-tos/create-custom-objects",
|
||||
"l/zh/user-guide/data-model/how-tos/create-custom-fields",
|
||||
"l/zh/user-guide/data-model/how-tos/create-relation-fields",
|
||||
"l/zh/user-guide/data-model/how-tos/create-many-to-many-relations",
|
||||
"l/zh/user-guide/data-model/how-tos/customize-your-data-model",
|
||||
"l/zh/user-guide/data-model/how-tos/data-model-faq"
|
||||
]
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"user-guide/data-model/how-tos/create-custom-objects",
|
||||
"user-guide/data-model/how-tos/create-custom-fields",
|
||||
"user-guide/data-model/how-tos/create-relation-fields",
|
||||
"user-guide/data-model/how-tos/create-many-to-many-relations",
|
||||
"user-guide/data-model/how-tos/customize-your-data-model",
|
||||
"user-guide/data-model/how-tos/data-model-faq"
|
||||
]
|
||||
|
||||
@@ -1,21 +1,7 @@
|
||||
export const DEFAULT_LANGUAGE = 'en' as const;
|
||||
|
||||
export const SUPPORTED_LANGUAGES = [
|
||||
DEFAULT_LANGUAGE,
|
||||
'fr',
|
||||
'ar',
|
||||
'cs',
|
||||
'de',
|
||||
'es',
|
||||
'it',
|
||||
'ja',
|
||||
'ko',
|
||||
'pt',
|
||||
'ro',
|
||||
'ru',
|
||||
'tr',
|
||||
'zh',
|
||||
] as const;
|
||||
|
||||
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
|
||||
|
||||
// Re-export from twenty-shared (source of truth)
|
||||
// Using relative path because these scripts run via tsx from workspace root
|
||||
export { DOCUMENTATION_DEFAULT_LANGUAGE as DEFAULT_LANGUAGE } from '../../twenty-shared/src/constants/DocumentationDefaultLanguage';
|
||||
export {
|
||||
DOCUMENTATION_SUPPORTED_LANGUAGES as SUPPORTED_LANGUAGES,
|
||||
type DocumentationSupportedLanguage as SupportedLanguage,
|
||||
} from '../../twenty-shared/src/constants/DocumentationSupportedLanguages';
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
"dependencies": {
|
||||
"mintlify": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"twenty-shared": "workspace:*"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
"npm": "please-use-yarn",
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
type BasePage = string | BaseGroup;
|
||||
|
||||
type BaseGroup = {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
pages: BasePage[];
|
||||
};
|
||||
|
||||
type BaseTab = {
|
||||
key: string;
|
||||
label: string;
|
||||
groups: BaseGroup[];
|
||||
};
|
||||
|
||||
type BaseStructure = {
|
||||
tabs: BaseTab[];
|
||||
};
|
||||
|
||||
const baseStructurePath = path.resolve(
|
||||
__dirname,
|
||||
'../navigation/base-structure.json',
|
||||
);
|
||||
|
||||
const outputDir = path.resolve(__dirname, '../../twenty-shared/src/constants');
|
||||
|
||||
const baseStructure: BaseStructure = JSON.parse(
|
||||
fs.readFileSync(baseStructurePath, 'utf8'),
|
||||
);
|
||||
|
||||
const extractPaths = (pages: BasePage[]): string[] => {
|
||||
const paths: string[] = [];
|
||||
|
||||
for (const page of pages) {
|
||||
if (typeof page === 'string') {
|
||||
paths.push(page);
|
||||
} else {
|
||||
paths.push(...extractPaths(page.pages));
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
};
|
||||
|
||||
const pathToConstantName = (docPath: string): string => {
|
||||
return docPath.replace(/^\//, '').replace(/[/-]/g, '_').toUpperCase();
|
||||
};
|
||||
|
||||
const allPaths: string[] = [];
|
||||
|
||||
for (const tab of baseStructure.tabs) {
|
||||
for (const group of tab.groups) {
|
||||
allPaths.push(...extractPaths(group.pages));
|
||||
}
|
||||
}
|
||||
|
||||
const sortedPaths = [...allPaths].sort();
|
||||
|
||||
const AUTO_GENERATED_HEADER = `/*
|
||||
* _____ _
|
||||
*|_ _|_ _____ _ __ | |_ _ _
|
||||
* | | \\ \\ /\\ / / _ \\ '_ \\| __| | | | Auto-generated file
|
||||
* | | \\ V V / __/ | | | |_| |_| | DO NOT EDIT - changes will be overwritten
|
||||
* |_| \\_/\\_/ \\___|_| |_|\\__|\\__, | Generated by: yarn docs:generate-paths
|
||||
* |___/
|
||||
*
|
||||
* This file is generated from packages/twenty-docs/navigation/base-structure.json
|
||||
* To add new documentation pages, add them to base-structure.json and run:
|
||||
* yarn docs:generate-paths
|
||||
*/
|
||||
|
||||
`;
|
||||
|
||||
// Generate DocumentationPaths.ts
|
||||
const pathEntries = sortedPaths
|
||||
.map((docPath) => {
|
||||
const constName = pathToConstantName(docPath);
|
||||
const value = `'/${docPath}'`;
|
||||
// Check if line would be too long (80 char limit)
|
||||
if (` ${constName}: ${value},`.length > 80) {
|
||||
return ` ${constName}:\n ${value},`;
|
||||
}
|
||||
return ` ${constName}: ${value},`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const pathsContent = `${AUTO_GENERATED_HEADER}export const DOCUMENTATION_PATHS = {
|
||||
${pathEntries}
|
||||
} as const;
|
||||
|
||||
export type DocumentationPath =
|
||||
(typeof DOCUMENTATION_PATHS)[keyof typeof DOCUMENTATION_PATHS];
|
||||
`;
|
||||
|
||||
fs.writeFileSync(path.join(outputDir, 'DocumentationPaths.ts'), pathsContent);
|
||||
|
||||
console.log(`Generated documentation constants to ${outputDir}`);
|
||||
console.log(`Total paths: ${sortedPaths.length}`);
|
||||
@@ -37,12 +37,16 @@ Many records in Object A can be linked to many records in Object B.
|
||||
|
||||
**Example:** Many People can be linked to many Projects, and vice versa.
|
||||
|
||||
<Warning>
|
||||
**Many-to-Many is not yet supported.**
|
||||
Many-to-many relations use a **junction object** pattern: an intermediate object that connects both sides. With the junction relation feature, Twenty displays the final linked records directly, hiding the intermediate object from the UI.
|
||||
|
||||
This relation type is planned for H1 2026. As a workaround, create an intermediate "junction" object (e.g., "Project Assignments") that has Many-to-One relations to both objects.
|
||||
<img src="/images/user-guide/fields/junction-relation-diagram.png" style={{width:'100%'}}/>
|
||||
|
||||
<Warning>
|
||||
**Lab Feature**: Junction relations must be enabled at **Settings → Updates → Lab** before use.
|
||||
</Warning>
|
||||
|
||||
See [How to Create Many-to-Many Relations](/user-guide/data-model/how-tos/create-many-to-many-relations) for a complete step-by-step guide.
|
||||
|
||||
## Creating a Relation Field
|
||||
|
||||
1. Go to **Settings → Data Model**
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
---
|
||||
title: Create Many-to-Many Relations
|
||||
description: Connect records where many items on both sides can be linked together using junction objects.
|
||||
---
|
||||
|
||||
Many-to-many relations let you connect multiple records on both sides. For example: many People can work on many Projects, and each Project can have many People.
|
||||
|
||||
<Warning>
|
||||
**Lab Feature**: Junction relations are currently in the Lab. Enable them at **Settings → Updates → Lab** before following this guide.
|
||||
</Warning>
|
||||
|
||||
<Note>
|
||||
This feature also requires **Advanced mode** to be enabled (toggle at the bottom right of Settings).
|
||||
</Note>
|
||||
|
||||
## When to Use Many-to-Many
|
||||
|
||||
Use many-to-many when both sides of a relationship can have multiple connections:
|
||||
|
||||
| Relationship | Example |
|
||||
|-------------|---------|
|
||||
| People ↔ Projects | A person works on multiple projects; a project has multiple team members |
|
||||
| Companies ↔ Tags | A company can have multiple tags; a tag can apply to multiple companies |
|
||||
| Products ↔ Orders | A product can be in multiple orders; an order contains multiple products |
|
||||
|
||||
## How It Works
|
||||
|
||||
Twenty uses a **junction object** pattern for many-to-many relations. A junction object sits between two objects and holds the connections:
|
||||
|
||||
```
|
||||
People ←→ Project Assignments ←→ Projects
|
||||
```
|
||||
|
||||
The **Project Assignments** object (junction) has:
|
||||
- A relation to People (many-to-one)
|
||||
- A relation to Projects (many-to-one)
|
||||
|
||||
When you enable the junction relation toggle, Twenty displays linked records directly instead of showing the intermediate junction records.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Enable Junction Relations in Lab**: Go to **Settings → Updates → Lab** and enable **Junction Relations**
|
||||
2. **Enable Advanced mode**: Toggle on **Advanced mode** at the bottom right of the Settings sidebar
|
||||
3. Plan your data model:
|
||||
- Which two objects are you connecting?
|
||||
- What should the junction object be called?
|
||||
|
||||
## Step 1: Create the Junction Object
|
||||
|
||||
First, create the intermediate object that will hold the connections.
|
||||
|
||||
1. Go to **Settings → Data Model**
|
||||
2. Click **+ New object**
|
||||
3. Name it descriptively (e.g., "Project Assignment", "Team Member", "Product Order")
|
||||
4. Click **Save**
|
||||
|
||||
<Tip>
|
||||
**Naming convention**: Use a name that describes the relationship, like "Project Assignment" or "Team Membership". This makes the data model easier to understand.
|
||||
</Tip>
|
||||
|
||||
## Step 2: Create Relations from the Junction Object
|
||||
|
||||
Add relation fields from the junction object to both objects you want to connect.
|
||||
|
||||
### First Relation (Junction → Object A)
|
||||
|
||||
1. Select your junction object in **Settings → Data Model**
|
||||
2. Click **+ Add Field**
|
||||
3. Choose **Relation** as the field type
|
||||
4. Select the first object (e.g., "People")
|
||||
5. Set the relation type to **Many-to-One** (many assignments can link to one person)
|
||||
6. Name the fields:
|
||||
- Field on junction: e.g., "Person"
|
||||
- Field on People: e.g., "Project Assignments"
|
||||
7. Click **Save**
|
||||
|
||||
### Second Relation (Junction → Object B)
|
||||
|
||||
1. Still on the junction object, click **+ Add Field**
|
||||
2. Choose **Relation** as the field type
|
||||
3. Select the second object (e.g., "Projects")
|
||||
4. Set the relation type to **Many-to-One**
|
||||
5. Name the fields:
|
||||
- Field on junction: e.g., "Project"
|
||||
- Field on Projects: e.g., "Team Members"
|
||||
6. Click **Save**
|
||||
|
||||
## Step 3: Configure the Junction Relation Display
|
||||
|
||||
Now configure the source objects to display linked records directly, skipping the intermediate junction object.
|
||||
|
||||
1. Go to **Settings → Data Model**
|
||||
2. Select the first object (e.g., "People")
|
||||
3. Find the relation field pointing to the junction object (e.g., "Project Assignments")
|
||||
4. Click to edit the field
|
||||
5. Enable **"This is a relation to a Junction Object"**
|
||||
6. Select the **Target relation** (e.g., "Project" — the field on the junction that points to the other side)
|
||||
7. Click **Save**
|
||||
|
||||
|
||||
{/* TODO: Add image
|
||||
<img src="/images/user-guide/fields/junction-relation-toggle.png" style={{width:'100%'}}/>
|
||||
*/}
|
||||
|
||||
Repeat for the other object:
|
||||
1. Select "Projects" in Data Model
|
||||
2. Edit the "Team Members" relation field
|
||||
3. Enable the junction toggle
|
||||
4. Select "Person" as the target relation
|
||||
5. Save
|
||||
|
||||
## Result
|
||||
|
||||
After configuration:
|
||||
|
||||
- On a **Person** record, the "Project Assignments" field displays **Projects** directly (not assignment records)
|
||||
- On a **Project** record, the "Team Members" field displays **People** directly
|
||||
|
||||
The junction object still exists and stores the connections, but the UI presents a cleaner many-to-many view.
|
||||
|
||||
## Example: People ↔ Projects
|
||||
|
||||
Here's a complete walkthrough:
|
||||
|
||||
### Create the Junction Object
|
||||
- Name: **Project Assignment**
|
||||
- Description: "Links people to projects they work on"
|
||||
|
||||
### Add Relations
|
||||
1. **Project Assignment → People**
|
||||
- Type: Many-to-One
|
||||
- Field on Assignment: "Person"
|
||||
- Field on People: "Project Assignments"
|
||||
|
||||
2. **Project Assignment → Projects**
|
||||
- Type: Many-to-One
|
||||
- Field on Assignment: "Project"
|
||||
- Field on Projects: "Team Members"
|
||||
|
||||
### Configure Junction Display
|
||||
1. On **People** object:
|
||||
- Edit "Project Assignments" field
|
||||
- Enable junction toggle
|
||||
- Target: "Project"
|
||||
|
||||
2. On **Projects** object:
|
||||
- Edit "Team Members" field
|
||||
- Enable junction toggle
|
||||
- Target: "Person"
|
||||
|
||||
### Use It
|
||||
- Open a Person record → See their Projects directly
|
||||
- Open a Project record → See team members directly
|
||||
- Create new connections from either side
|
||||
|
||||
## Adding Extra Data to Connections
|
||||
|
||||
Since the junction object is a real object, you can add custom fields to store information about the relationship:
|
||||
|
||||
- **Role**: "Developer", "Designer", "Manager"
|
||||
- **Start Date**: When they joined the project
|
||||
- **Hours Allocated**: Weekly hours on this project
|
||||
|
||||
To access this data, navigate to the junction object directly or query it via the API.
|
||||
|
||||
## Limitations
|
||||
|
||||
- **CSV Import/Export**: Importing many-to-many relations directly is not supported. Import records to the junction object instead.
|
||||
- **Filters**: Filtering by many-to-many relations may have limited options.
|
||||
|
||||
## Related
|
||||
|
||||
- [Relation Fields](/user-guide/data-model/capabilities/relation-fields) — relation types explained
|
||||
- [Create Custom Objects](/user-guide/data-model/how-tos/create-custom-objects) — how to create objects
|
||||
- [Create Relation Fields](/user-guide/data-model/how-tos/create-relation-fields) — basic relation setup
|
||||
+6
@@ -32,4 +32,10 @@ export const StyledSettingsOptionCardTitle = styled.div`
|
||||
export const StyledSettingsOptionCardDescription = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
|
||||
a {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ const StyledSettingsOptionCardToggleCover = styled.span`
|
||||
type SettingsOptionCardContentToggleProps = {
|
||||
Icon?: IconComponent;
|
||||
title: React.ReactNode;
|
||||
description?: string;
|
||||
description?: React.ReactNode;
|
||||
divider?: boolean;
|
||||
disabled?: boolean;
|
||||
advancedMode?: boolean;
|
||||
|
||||
+6
@@ -55,6 +55,12 @@ export const settingsDataModelFieldMorphRelationFormSchema = z.object({
|
||||
),
|
||||
targetFieldLabel: z.string().min(1),
|
||||
iconOnDestination: z.string().min(1),
|
||||
settings: z
|
||||
.object({
|
||||
junctionTargetFieldId: z.string().optional(),
|
||||
})
|
||||
.catchall(z.unknown())
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type SettingsDataModelFieldMorphRelationFormValues = z.infer<
|
||||
|
||||
+54
-15
@@ -1,16 +1,19 @@
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { DOCUMENTATION_PATHS } from 'twenty-shared/constants';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconLink } from 'twenty-ui/display';
|
||||
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
|
||||
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
|
||||
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { isAdvancedModeEnabledState } from '@/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconLink } from 'twenty-ui/display';
|
||||
import { RelationType } from '~/generated-metadata/graphql';
|
||||
import { type SettingsDataModelFieldEditFormValues } from '~/pages/settings/data-model/SettingsObjectFieldEdit';
|
||||
|
||||
@@ -26,6 +29,12 @@ export const SettingsDataModelFieldRelationJunctionForm = ({
|
||||
useFormContext<SettingsDataModelFieldEditFormValues>();
|
||||
|
||||
const isAdvancedModeEnabled = useRecoilValue(isAdvancedModeEnabledState);
|
||||
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
|
||||
|
||||
const documentationUrl = getDocumentationUrl({
|
||||
locale: currentWorkspaceMember?.locale,
|
||||
path: DOCUMENTATION_PATHS.USER_GUIDE_DATA_MODEL_HOW_TOS_CREATE_MANY_TO_MANY_RELATIONS,
|
||||
});
|
||||
|
||||
const { objectMetadataItem: sourceObjectMetadataItem } =
|
||||
useObjectMetadataItem({ objectNameSingular });
|
||||
@@ -34,7 +43,8 @@ export const SettingsDataModelFieldRelationJunctionForm = ({
|
||||
|
||||
const relationType = watch('relationType') ?? RelationType.ONE_TO_MANY;
|
||||
const targetObjectIds = watch('morphRelationObjectMetadataIds') ?? [];
|
||||
const junctionTargetFieldId = watch('settings.junctionTargetFieldId');
|
||||
const currentSettings = watch('settings');
|
||||
const junctionTargetFieldId = currentSettings?.junctionTargetFieldId;
|
||||
|
||||
// Only applies to ONE_TO_MANY with single target
|
||||
if (
|
||||
@@ -112,23 +122,40 @@ export const SettingsDataModelFieldRelationJunctionForm = ({
|
||||
const handleJunctionToggle = (checked: boolean) => {
|
||||
if (checked && junctionFieldOptions.length > 0) {
|
||||
setValue(
|
||||
'settings.junctionTargetFieldId',
|
||||
junctionFieldOptions[0].value,
|
||||
'settings',
|
||||
{
|
||||
...currentSettings,
|
||||
junctionTargetFieldId: junctionFieldOptions[0].value,
|
||||
},
|
||||
{
|
||||
shouldDirty: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
setValue('settings.junctionTargetFieldId', undefined, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
setValue(
|
||||
'settings',
|
||||
{
|
||||
...currentSettings,
|
||||
junctionTargetFieldId: undefined,
|
||||
},
|
||||
{
|
||||
shouldDirty: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectionChange = (selectedValue: string) => {
|
||||
setValue('settings.junctionTargetFieldId', selectedValue, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
setValue(
|
||||
'settings',
|
||||
{
|
||||
...currentSettings,
|
||||
junctionTargetFieldId: selectedValue,
|
||||
},
|
||||
{
|
||||
shouldDirty: true,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -136,7 +163,19 @@ export const SettingsDataModelFieldRelationJunctionForm = ({
|
||||
<SettingsOptionCardContentToggle
|
||||
Icon={IconLink}
|
||||
title={t`This is a relation to a Junction Object`}
|
||||
description={t`Will show linked records directly instead of intermediate junction record`}
|
||||
description={
|
||||
<Trans>
|
||||
Build many-to-many relations.{' '}
|
||||
<a
|
||||
href={documentationUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ textDecoration: 'underline', color: 'inherit' }}
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</Trans>
|
||||
}
|
||||
checked={isJunctionConfigEnabled}
|
||||
onChange={handleJunctionToggle}
|
||||
divider={isJunctionConfigEnabled}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
const DOCUMENTATION_BASE_URL = 'https://docs.twenty.com';
|
||||
const DOCUMENTATION_PATH = '/user-guide/introduction';
|
||||
|
||||
// Locales that have documentation translations available
|
||||
const SUPPORTED_DOC_LOCALES = ['fr', 'pt', 'de', 'es', 'it', 'ja', 'ko', 'zh'];
|
||||
import {
|
||||
DOCUMENTATION_BASE_URL,
|
||||
DOCUMENTATION_DEFAULT_LANGUAGE,
|
||||
DOCUMENTATION_DEFAULT_PATH,
|
||||
DOCUMENTATION_SUPPORTED_LANGUAGES,
|
||||
type DocumentationPath,
|
||||
} from 'twenty-shared/constants';
|
||||
|
||||
export const getDocumentationUrl = ({
|
||||
locale,
|
||||
path = DOCUMENTATION_PATH,
|
||||
path = DOCUMENTATION_DEFAULT_PATH,
|
||||
}: {
|
||||
locale?: string | null;
|
||||
path?: string;
|
||||
path?: DocumentationPath | string;
|
||||
}): string => {
|
||||
if (!locale) {
|
||||
return `${DOCUMENTATION_BASE_URL}${path}`;
|
||||
@@ -18,7 +20,16 @@ export const getDocumentationUrl = ({
|
||||
// Extract language code from locale (e.g., 'fr' from 'fr-FR')
|
||||
const langCode = locale.split('-')[0].toLowerCase();
|
||||
|
||||
if (SUPPORTED_DOC_LOCALES.includes(langCode)) {
|
||||
// English content is served at root path (no /l/en/ prefix)
|
||||
if (langCode === DOCUMENTATION_DEFAULT_LANGUAGE) {
|
||||
return `${DOCUMENTATION_BASE_URL}${path}`;
|
||||
}
|
||||
|
||||
const isSupported = DOCUMENTATION_SUPPORTED_LANGUAGES.includes(
|
||||
langCode as (typeof DOCUMENTATION_SUPPORTED_LANGUAGES)[number],
|
||||
);
|
||||
|
||||
if (isSupported) {
|
||||
return `${DOCUMENTATION_BASE_URL}/l/${langCode}${path}`;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -164,7 +164,7 @@ export const WorkflowDropdownStepOutputItems = ({
|
||||
updateStepFilter({
|
||||
rawVariableName: getVariableTemplateFromPath({
|
||||
stepId: step.id,
|
||||
path: [...currentPath, 'id'],
|
||||
path: [...currentPath, currentSubStep.object.fieldIdName ?? 'id'],
|
||||
}),
|
||||
isFullRecord: true,
|
||||
});
|
||||
|
||||
+110
-24
@@ -15,7 +15,11 @@ import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-
|
||||
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service';
|
||||
import { MetadataFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity-maps.type';
|
||||
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
||||
import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
|
||||
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { STANDARD_OBJECTS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-object.constant';
|
||||
@@ -115,32 +119,22 @@ export class BackfillOpportunityOwnerFieldCommand extends ActiveOrSuspendedWorks
|
||||
return;
|
||||
}
|
||||
|
||||
const customOwnerField = flatFieldMetadatas.find(
|
||||
(field) =>
|
||||
field.name === 'owner' &&
|
||||
field.universalIdentifier !== ownerUniversalIdentifier,
|
||||
);
|
||||
const customOwnerField =
|
||||
flatFieldMetadatas.find(
|
||||
(field) =>
|
||||
field.name === 'owner' &&
|
||||
field.universalIdentifier !== ownerUniversalIdentifier,
|
||||
// Some self hoster might have run the name renaming successfully but not the joinColumnName
|
||||
// Rather than asking them to restore db also grabbing them here
|
||||
) ?? flatFieldMetadatas.find((field) => field.name === 'ownerOld');
|
||||
|
||||
if (isDefined(customOwnerField)) {
|
||||
if (options.dryRun) {
|
||||
this.logger.log(
|
||||
`[DRY RUN] Would rename custom owner field to 'ownerOld' in workspace ${workspaceId}`,
|
||||
);
|
||||
} else {
|
||||
await this.fieldMetadataService.updateOneField({
|
||||
updateFieldInput: {
|
||||
id: customOwnerField.id,
|
||||
name: 'ownerOld',
|
||||
label: 'Owner (Old)',
|
||||
},
|
||||
workspaceId,
|
||||
isSystemBuild: true,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Renamed custom owner field to 'ownerOld' in workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
await this.renameCustomOwnerFieldToOwnerOld({
|
||||
customOwnerField,
|
||||
flatFieldMetadataMaps,
|
||||
workspaceId,
|
||||
dryRun: options.dryRun,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
@@ -232,4 +226,96 @@ export class BackfillOpportunityOwnerFieldCommand extends ActiveOrSuspendedWorks
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async renameCustomOwnerFieldToOwnerOld({
|
||||
customOwnerField,
|
||||
flatFieldMetadataMaps,
|
||||
workspaceId,
|
||||
dryRun,
|
||||
}: {
|
||||
customOwnerField: FlatFieldMetadata;
|
||||
flatFieldMetadataMaps: MetadataFlatEntityMaps<'fieldMetadata'>;
|
||||
workspaceId: string;
|
||||
dryRun?: boolean;
|
||||
}): Promise<void> {
|
||||
if (dryRun) {
|
||||
this.logger.log(
|
||||
`[DRY RUN] Would rename custom owner field to 'ownerOld' in workspace and search for colliding foreignKey ${workspaceId}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const isMorphOrRelationField =
|
||||
isMorphOrRelationFlatFieldMetadata(customOwnerField);
|
||||
const hasCollidingJoinColumnName =
|
||||
isMorphOrRelationField &&
|
||||
isDefined(customOwnerField.settings.joinColumnName) &&
|
||||
customOwnerField.settings.joinColumnName === 'ownerId';
|
||||
|
||||
await this.fieldMetadataService.updateOneField({
|
||||
updateFieldInput: {
|
||||
id: customOwnerField.id,
|
||||
name: 'ownerOld',
|
||||
label: 'Owner (Old)',
|
||||
...(hasCollidingJoinColumnName
|
||||
? {
|
||||
settings: {
|
||||
...customOwnerField.settings,
|
||||
joinColumnName: `ownerOldId`,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
workspaceId,
|
||||
isSystemBuild: true,
|
||||
});
|
||||
this.logger.log(
|
||||
`Renamed custom owner field to 'ownerOld' in workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
if (!isMorphOrRelationField || hasCollidingJoinColumnName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const relatedFlatFieldMetadata = findFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityId: customOwnerField.relationTargetFieldMetadataId,
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
if (
|
||||
!isDefined(relatedFlatFieldMetadata) ||
|
||||
!isMorphOrRelationFlatFieldMetadata(relatedFlatFieldMetadata)
|
||||
) {
|
||||
this.logger.error(
|
||||
`Could not find custom owner relation target field ${customOwnerField.relationTargetFieldMetadataId}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const targetHasCollidingJoinColumnName =
|
||||
isDefined(relatedFlatFieldMetadata.settings.joinColumnName) &&
|
||||
relatedFlatFieldMetadata.settings.joinColumnName === 'ownerId';
|
||||
|
||||
if (!targetHasCollidingJoinColumnName) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.fieldMetadataService.updateOneField({
|
||||
updateFieldInput: {
|
||||
id: relatedFlatFieldMetadata.id,
|
||||
settings: {
|
||||
...relatedFlatFieldMetadata.settings,
|
||||
joinColumnName: `ownerOldId`,
|
||||
},
|
||||
},
|
||||
workspaceId,
|
||||
isSystemBuild: true,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Renamed custom owner field target related field join column name to 'ownerOldId' in workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Command } from 'nest-commander';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
|
||||
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
|
||||
@Command({
|
||||
name: 'upgrade:delete-file-records',
|
||||
description: 'Delete all file records from FileRepository for workspace',
|
||||
})
|
||||
export class DeleteFileRecordsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
|
||||
protected readonly logger = new Logger(DeleteFileRecordsCommand.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
@InjectRepository(FileEntity)
|
||||
private readonly fileRepository: Repository<FileEntity>,
|
||||
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
|
||||
protected readonly dataSourceService: DataSourceService,
|
||||
) {
|
||||
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
workspaceId,
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
const isDryRun = options.dryRun || false;
|
||||
|
||||
this.logger.log(
|
||||
`${isDryRun ? '[DRY RUN] ' : ''}Starting deletion of file records for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
const files = await this.fileRepository.find({
|
||||
where: { workspaceId },
|
||||
withDeleted: true,
|
||||
});
|
||||
|
||||
if (files.length === 0) {
|
||||
this.logger.log(
|
||||
`No file records found for workspace ${workspaceId}, skipping`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`${isDryRun ? '[DRY RUN] Would delete' : 'Deleting'} ${files.length} file record(s) for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
if (isDryRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.fileRepository.delete({ workspaceId });
|
||||
|
||||
this.logger.log(
|
||||
`Successfully deleted ${files.length} file record(s) for workspace ${workspaceId}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to delete file records for workspace ${workspaceId}: ${error.message}`,
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Command } from 'nest-commander';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
|
||||
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
import { ALL_FLAT_ENTITY_MAPS_PROPERTIES } from 'src/engine/metadata-modules/flat-entity/constant/all-flat-entity-maps-properties.constant';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service';
|
||||
|
||||
@Command({
|
||||
name: 'upgrade:1-16:flush-v2-cache-and-increment-metadata-version',
|
||||
description: 'Flush the whole v2 cache and increment the metadata version',
|
||||
})
|
||||
export class FlushV2CacheAndIncrementMetadataVersionCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
|
||||
constructor(
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
protected readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
|
||||
protected readonly dataSourceService: DataSourceService,
|
||||
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
|
||||
) {
|
||||
super(workspaceRepository, globalWorkspaceOrmManager, dataSourceService);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
workspaceId,
|
||||
options,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
this.logger.log(
|
||||
`Flushing v2 cache and incrementing metadata version for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
if (options.dryRun) {
|
||||
this.logger.log(
|
||||
`DRY RUN: Would flush v2 cache and increment metadata version for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.workspaceMigrationRunnerService.invalidateCache({
|
||||
allFlatEntityMapsKeys: ALL_FLAT_ENTITY_MAPS_PROPERTIES,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Successfully flushed v2 cache and incremented metadata version for workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+156
-24
@@ -15,11 +15,11 @@ import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/
|
||||
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service';
|
||||
import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { TASK_TARGET_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-migration/constant/standard-field-ids';
|
||||
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
|
||||
|
||||
@Command({
|
||||
name: 'upgrade:1-16:update-task-on-delete-action',
|
||||
@@ -35,7 +35,7 @@ export class UpdateTaskOnDeleteActionCommand extends ActiveOrSuspendedWorkspaces
|
||||
protected readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
|
||||
protected readonly dataSourceService: DataSourceService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly fieldMetadataService: FieldMetadataService,
|
||||
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
|
||||
) {
|
||||
super(workspaceRepository, globalWorkspaceOrmManager, dataSourceService);
|
||||
}
|
||||
@@ -47,24 +47,52 @@ export class UpdateTaskOnDeleteActionCommand extends ActiveOrSuspendedWorkspaces
|
||||
const isDryRun = options.dryRun ?? false;
|
||||
|
||||
this.logger.log(
|
||||
`Running UpdateTaskOnDeleteActionCommand for workspace ${workspaceId}`,
|
||||
`Running UpdateTaskOnDeleteActionCommand for workspace ${workspaceId} (dryRun: ${isDryRun})`,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`[Phase 1] Starting task relation onDelete action update for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
await this.updateTaskRelationOnDeleteAction(workspaceId, isDryRun);
|
||||
|
||||
this.logger.log(
|
||||
`[Phase 1] Completed task relation onDelete action update for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`[Phase 2] Starting orphaned taskTarget cleanup for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
await this.deleteOrphanedTaskTargets(workspaceId, isDryRun);
|
||||
|
||||
this.logger.log(
|
||||
`[Phase 2] Completed orphaned taskTarget cleanup for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`UpdateTaskOnDeleteActionCommand completed successfully for workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
private async updateTaskRelationOnDeleteAction(
|
||||
workspaceId: string,
|
||||
isDryRun: boolean,
|
||||
): Promise<void> {
|
||||
this.logger.log(
|
||||
`[Step 1/4] Fetching workspace cache for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
const { flatFieldMetadataMaps, flatObjectMetadataMaps } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'flatFieldMetadataMaps',
|
||||
'flatObjectMetadataMaps',
|
||||
]);
|
||||
|
||||
this.logger.log(
|
||||
`[Step 2/4] Looking for taskTarget object metadata in workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
const taskTargetObjectMetadata = Object.values(
|
||||
flatObjectMetadataMaps.byId,
|
||||
).find(
|
||||
@@ -80,6 +108,16 @@ export class UpdateTaskOnDeleteActionCommand extends ActiveOrSuspendedWorkspaces
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(JSON.stringify(taskTargetObjectMetadata, null, 2));
|
||||
|
||||
this.logger.log(
|
||||
`[Step 2/4] Found taskTarget object metadata with id ${taskTargetObjectMetadata.id} in workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`[Step 3/4] Looking for task field on taskTarget object in workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
const taskTargetFields = findManyFlatEntityByIdInFlatEntityMapsOrThrow({
|
||||
flatEntityIds: taskTargetObjectMetadata.fieldMetadataIds,
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
@@ -97,9 +135,13 @@ export class UpdateTaskOnDeleteActionCommand extends ActiveOrSuspendedWorkspaces
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[Step 3/4] Found task field with id ${taskField.id} in workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
if (taskField.type !== FieldMetadataType.RELATION) {
|
||||
this.logger.warn(
|
||||
`Task field is not a relation field in workspace ${workspaceId}`,
|
||||
`Task field is not a relation field (type: ${taskField.type}) in workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
return;
|
||||
@@ -110,14 +152,14 @@ export class UpdateTaskOnDeleteActionCommand extends ActiveOrSuspendedWorkspaces
|
||||
|
||||
if (taskFieldSettings?.onDelete === RelationOnDeleteAction.CASCADE) {
|
||||
this.logger.log(
|
||||
`Task relation already has CASCADE onDelete in workspace ${workspaceId}`,
|
||||
`[Step 4/4] Task relation already has CASCADE onDelete in workspace ${workspaceId}, skipping update`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Updating task relation onDelete from ${taskFieldSettings?.onDelete} to CASCADE in workspace ${workspaceId}`,
|
||||
`[Step 4/4] Updating task relation onDelete from ${taskFieldSettings?.onDelete ?? 'undefined'} to CASCADE in workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
if (!isDryRun) {
|
||||
@@ -126,21 +168,72 @@ export class UpdateTaskOnDeleteActionCommand extends ActiveOrSuspendedWorkspaces
|
||||
onDelete: RelationOnDeleteAction.CASCADE,
|
||||
};
|
||||
|
||||
await this.fieldMetadataService.updateOneField({
|
||||
updateFieldInput: {
|
||||
id: taskField.id,
|
||||
settings: updatedSettings,
|
||||
},
|
||||
workspaceId,
|
||||
isSystemBuild: true,
|
||||
});
|
||||
const updatedTaskField = {
|
||||
...taskField,
|
||||
settings: updatedSettings,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.logger.log(
|
||||
`Successfully updated task relation onDelete to CASCADE in workspace ${workspaceId}`,
|
||||
`[Step 4/4] Calling validateBuildAndRunWorkspaceMigration for field ${taskField.id} in workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const validateAndBuildResult =
|
||||
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
|
||||
{
|
||||
allFlatEntityOperationByMetadataName: {
|
||||
fieldMetadata: {
|
||||
flatEntityToCreate: [],
|
||||
flatEntityToDelete: [],
|
||||
flatEntityToUpdate: [updatedTaskField],
|
||||
},
|
||||
},
|
||||
workspaceId,
|
||||
isSystemBuild: true,
|
||||
},
|
||||
);
|
||||
this.logger.error(
|
||||
`[Step 4/4] Validation result: ${JSON.stringify(validateAndBuildResult, null, 2)}`,
|
||||
);
|
||||
|
||||
if (isDefined(validateAndBuildResult)) {
|
||||
this.logger.error(
|
||||
`[Step 4/4] Failed to update task relation onDelete in workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
throw new Error(
|
||||
`Failed to update task relation onDelete in workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[Step 4/4] Successfully updated task relation onDelete to CASCADE in workspace ${workspaceId}`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[Step 4/4] Failed to update task relation onDelete in workspace ${workspaceId}`,
|
||||
);
|
||||
this.logger.error(
|
||||
`[Step 4/4] Field id: ${taskField.id}, current settings: ${JSON.stringify(taskFieldSettings)}`,
|
||||
);
|
||||
this.logger.error(
|
||||
`[Step 4/4] Target settings: ${JSON.stringify(updatedSettings)}`,
|
||||
);
|
||||
this.logger.error(
|
||||
`[Step 4/4] Error message: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
this.logger.error(
|
||||
`[Step 4/4] Error stack: ${error instanceof Error ? error.stack : 'No stack available'}`,
|
||||
);
|
||||
|
||||
this.logger.error(JSON.stringify(error, null, 2));
|
||||
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
this.logger.log(
|
||||
`DRY RUN: Would update task relation onDelete to CASCADE in workspace ${workspaceId}`,
|
||||
`[Step 4/4] DRY RUN: Would update task relation onDelete to CASCADE in workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -149,6 +242,10 @@ export class UpdateTaskOnDeleteActionCommand extends ActiveOrSuspendedWorkspaces
|
||||
workspaceId: string,
|
||||
isDryRun: boolean,
|
||||
): Promise<void> {
|
||||
this.logger.log(
|
||||
`[Orphan cleanup 1/3] Getting taskTarget repository for workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
const taskTargetRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository(
|
||||
workspaceId,
|
||||
@@ -156,6 +253,10 @@ export class UpdateTaskOnDeleteActionCommand extends ActiveOrSuspendedWorkspaces
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`[Orphan cleanup 2/3] Searching for orphaned taskTarget records (taskId is null) in workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
const orphanedTaskTargets = await taskTargetRepository.find({
|
||||
withDeleted: true,
|
||||
select: ['id'],
|
||||
@@ -167,10 +268,14 @@ export class UpdateTaskOnDeleteActionCommand extends ActiveOrSuspendedWorkspaces
|
||||
const orphanedCount = orphanedTaskTargets.length;
|
||||
|
||||
this.logger.log(
|
||||
`Found ${orphanedCount} orphaned taskTarget record(s) in workspace ${workspaceId}`,
|
||||
`[Orphan cleanup 2/3] Found ${orphanedCount} orphaned taskTarget record(s) in workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
if (orphanedCount === 0) {
|
||||
this.logger.log(
|
||||
`[Orphan cleanup 3/3] No orphaned records to delete in workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -178,23 +283,50 @@ export class UpdateTaskOnDeleteActionCommand extends ActiveOrSuspendedWorkspaces
|
||||
const orphanedIds = orphanedTaskTargets.map((record) => record.id);
|
||||
|
||||
const batchSize = 100;
|
||||
const totalBatches = Math.ceil(orphanedIds.length / batchSize);
|
||||
|
||||
this.logger.log(
|
||||
`[Orphan cleanup 3/3] Deleting ${orphanedCount} orphaned taskTarget record(s) in ${totalBatches} batch(es) in workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
for (let i = 0; i < orphanedIds.length; i += batchSize) {
|
||||
const batch = orphanedIds.slice(i, i + batchSize);
|
||||
const batchNumber = Math.floor(i / batchSize) + 1;
|
||||
|
||||
await taskTargetRepository
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.whereInIds(batch)
|
||||
.execute();
|
||||
this.logger.log(
|
||||
`[Orphan cleanup 3/3] Deleting batch ${batchNumber}/${totalBatches} (${batch.length} records) in workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await taskTargetRepository
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.whereInIds(batch)
|
||||
.execute();
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`[Orphan cleanup 3/3] Failed to delete batch ${batchNumber}/${totalBatches} in workspace ${workspaceId}`,
|
||||
);
|
||||
this.logger.error(
|
||||
`[Orphan cleanup 3/3] Batch record ids: ${JSON.stringify(batch)}`,
|
||||
);
|
||||
this.logger.error(
|
||||
`[Orphan cleanup 3/3] Error message: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
this.logger.error(
|
||||
`[Orphan cleanup 3/3] Error stack: ${error instanceof Error ? error.stack : 'No stack available'}`,
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Deleted ${orphanedCount} orphaned taskTarget record(s) in workspace ${workspaceId}`,
|
||||
`[Orphan cleanup 3/3] Successfully deleted ${orphanedCount} orphaned taskTarget record(s) in workspace ${workspaceId}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.log(
|
||||
`DRY RUN: Would delete ${orphanedCount} orphaned taskTarget record(s) in workspace ${workspaceId}`,
|
||||
`[Orphan cleanup 3/3] DRY RUN: Would delete ${orphanedCount} orphaned taskTarget record(s) in workspace ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -3,10 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { BackfillOpportunityOwnerFieldCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-backfill-opportunity-owner-field.command';
|
||||
import { BackfillStandardPageLayoutsCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-backfill-standard-page-layouts.command';
|
||||
import { DeleteFileRecordsCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-delete-all-files.command';
|
||||
import { FlushV2CacheAndIncrementMetadataVersionCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-flush-v2-cache-and-increment-metadata-version.command';
|
||||
import { IdentifyAgentMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-agent-metadata.command';
|
||||
import { IdentifyFieldMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-field-metadata.command';
|
||||
import { IdentifyIndexMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-index-metadata.command';
|
||||
import { IdentifyObjectMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-object-metadata.command';
|
||||
import { IdentifyRemainingEntitiesMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-remaining-entities-metadata.command';
|
||||
import { IdentifyRoleMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-role-metadata.command';
|
||||
import { IdentifyViewFieldMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-view-field-metadata.command';
|
||||
import { IdentifyViewFilterMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-view-filter-metadata.command';
|
||||
@@ -16,7 +19,6 @@ import { MakeAgentUniversalIdentifierAndApplicationIdNotNullableMigrationCommand
|
||||
import { MakeFieldMetadataUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-make-field-metadata-universal-identifier-and-application-id-not-nullable-migration.command';
|
||||
import { MakeIndexMetadataUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-make-index-metadata-universal-identifier-and-application-id-not-nullable-migration.command';
|
||||
import { MakeObjectMetadataUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-make-object-metadata-universal-identifier-and-application-id-not-nullable-migration.command';
|
||||
import { IdentifyRemainingEntitiesMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-remaining-entities-metadata.command';
|
||||
import { MakeRemainingEntitiesUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-make-remaining-entities-universal-identifier-and-application-id-not-nullable-migration.command';
|
||||
import { MakeRoleUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-make-role-universal-identifier-and-application-id-not-nullable-migration.command';
|
||||
import { MakeViewFieldUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-make-view-field-universal-identifier-and-application-id-not-nullable-migration.command';
|
||||
@@ -25,6 +27,7 @@ import { MakeViewGroupUniversalIdentifierAndApplicationIdNotNullableMigrationCom
|
||||
import { MakeViewUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-make-view-universal-identifier-and-application-id-not-nullable-migration.command';
|
||||
import { UpdateTaskOnDeleteActionCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-update-task-on-delete-action.command';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
@@ -41,6 +44,7 @@ import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entit
|
||||
import { GlobalWorkspaceDataSourceModule } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource.module';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
import { TwentyStandardApplicationModule } from 'src/engine/workspace-manager/twenty-standard-application/twenty-standard-application.module';
|
||||
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/workspace-migration-runner.module';
|
||||
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
|
||||
|
||||
@Module({
|
||||
@@ -56,6 +60,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
ViewFieldEntity,
|
||||
ViewFilterEntity,
|
||||
ViewGroupEntity,
|
||||
FileEntity,
|
||||
]),
|
||||
DataSourceModule,
|
||||
WorkspaceCacheModule,
|
||||
@@ -64,12 +69,14 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
GlobalWorkspaceDataSourceModule,
|
||||
TwentyStandardApplicationModule,
|
||||
WorkspaceMigrationModule,
|
||||
WorkspaceMigrationRunnerModule,
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheModule,
|
||||
],
|
||||
providers: [
|
||||
UpdateTaskOnDeleteActionCommand,
|
||||
BackfillOpportunityOwnerFieldCommand,
|
||||
BackfillStandardPageLayoutsCommand,
|
||||
DeleteFileRecordsCommand,
|
||||
IdentifyAgentMetadataCommand,
|
||||
IdentifyFieldMetadataCommand,
|
||||
IdentifyIndexMetadataCommand,
|
||||
@@ -90,11 +97,13 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
MakeIndexMetadataUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
|
||||
MakeRemainingEntitiesUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
|
||||
IdentifyRemainingEntitiesMetadataCommand,
|
||||
FlushV2CacheAndIncrementMetadataVersionCommand,
|
||||
],
|
||||
exports: [
|
||||
UpdateTaskOnDeleteActionCommand,
|
||||
BackfillOpportunityOwnerFieldCommand,
|
||||
BackfillStandardPageLayoutsCommand,
|
||||
DeleteFileRecordsCommand,
|
||||
IdentifyAgentMetadataCommand,
|
||||
IdentifyFieldMetadataCommand,
|
||||
IdentifyIndexMetadataCommand,
|
||||
@@ -115,6 +124,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
|
||||
MakeIndexMetadataUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
|
||||
MakeRemainingEntitiesUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
|
||||
IdentifyRemainingEntitiesMetadataCommand,
|
||||
FlushV2CacheAndIncrementMetadataVersionCommand,
|
||||
],
|
||||
})
|
||||
export class V1_16_UpgradeVersionCommandModule {}
|
||||
|
||||
+10
-4
@@ -24,14 +24,16 @@ import { FixNanPositionValuesInNotesCommand } from 'src/database/commands/upgrad
|
||||
import { MigratePageLayoutWidgetConfigurationCommand } from 'src/database/commands/upgrade-version-command/1-15/1-15-migrate-page-layout-widget-configuration.command';
|
||||
import { BackfillOpportunityOwnerFieldCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-backfill-opportunity-owner-field.command';
|
||||
import { BackfillStandardPageLayoutsCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-backfill-standard-page-layouts.command';
|
||||
import { DeleteFileRecordsCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-delete-all-files.command';
|
||||
import { FlushV2CacheAndIncrementMetadataVersionCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-flush-v2-cache-and-increment-metadata-version.command';
|
||||
import { IdentifyAgentMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-agent-metadata.command';
|
||||
import { IdentifyFieldMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-field-metadata.command';
|
||||
import { IdentifyIndexMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-index-metadata.command';
|
||||
import { IdentifyObjectMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-object-metadata.command';
|
||||
import { IdentifyRemainingEntitiesMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-remaining-entities-metadata.command';
|
||||
import { IdentifyRoleMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-role-metadata.command';
|
||||
import { IdentifyViewFieldMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-view-field-metadata.command';
|
||||
import { IdentifyViewFilterMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-view-filter-metadata.command';
|
||||
import { IdentifyRemainingEntitiesMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-remaining-entities-metadata.command';
|
||||
import { IdentifyViewGroupMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-view-group-metadata.command';
|
||||
import { IdentifyViewMetadataCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-identify-view-metadata.command';
|
||||
import { MakeAgentUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-16/1-16-make-agent-universal-identifier-and-application-id-not-nullable-migration.command';
|
||||
@@ -107,6 +109,8 @@ export class UpgradeCommand extends UpgradeCommandRunner {
|
||||
protected readonly makeIndexMetadataUniversalIdentifierAndApplicationIdNotNullableMigrationCommand: MakeIndexMetadataUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
|
||||
protected readonly identifyRemainingEntitiesMetadataCommand: IdentifyRemainingEntitiesMetadataCommand,
|
||||
protected readonly makeRemainingEntitiesUniversalIdentifierAndApplicationIdNotNullableMigrationCommand: MakeRemainingEntitiesUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
|
||||
protected readonly flushV2CacheAndIncrementMetadataVersionCommand: FlushV2CacheAndIncrementMetadataVersionCommand,
|
||||
protected readonly deleteFileRecordsCommand: DeleteFileRecordsCommand,
|
||||
) {
|
||||
super(
|
||||
workspaceRepository,
|
||||
@@ -141,9 +145,6 @@ export class UpgradeCommand extends UpgradeCommandRunner {
|
||||
];
|
||||
|
||||
const commands_1160: VersionCommands = [
|
||||
this.updateTaskOnDeleteActionCommand,
|
||||
this.backfillOpportunityOwnerFieldCommand,
|
||||
this.backfillStandardPageLayoutsCommand,
|
||||
this.identifyAgentMetadataCommand,
|
||||
this.identifyFieldMetadataCommand,
|
||||
this.identifyObjectMetadataCommand,
|
||||
@@ -153,6 +154,8 @@ export class UpgradeCommand extends UpgradeCommandRunner {
|
||||
this.identifyViewFilterMetadataCommand,
|
||||
this.identifyViewGroupMetadataCommand,
|
||||
this.identifyIndexMetadataCommand,
|
||||
this.backfillOpportunityOwnerFieldCommand,
|
||||
this.backfillStandardPageLayoutsCommand,
|
||||
this
|
||||
.makeAgentUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
|
||||
this
|
||||
@@ -174,6 +177,9 @@ export class UpgradeCommand extends UpgradeCommandRunner {
|
||||
this.identifyRemainingEntitiesMetadataCommand,
|
||||
this
|
||||
.makeRemainingEntitiesUniversalIdentifierAndApplicationIdNotNullableMigrationCommand,
|
||||
this.flushV2CacheAndIncrementMetadataVersionCommand,
|
||||
this.updateTaskOnDeleteActionCommand,
|
||||
this.deleteFileRecordsCommand,
|
||||
];
|
||||
|
||||
this.allCommands = {
|
||||
|
||||
+12
-1
@@ -6,6 +6,7 @@ import {
|
||||
} from 'twenty-shared/metadata';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION } from 'src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant';
|
||||
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
|
||||
import { AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
|
||||
@@ -38,12 +39,18 @@ export class WorkspaceMigrationValidateBuildAndRunService {
|
||||
private readonly logger = new Logger(
|
||||
WorkspaceMigrationValidateBuildAndRunService.name,
|
||||
);
|
||||
private readonly isDebugEnabled: boolean;
|
||||
|
||||
constructor(
|
||||
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
|
||||
private readonly workspaceMigrationBuildOrchestratorService: WorkspaceMigrationBuildOrchestratorService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
) {}
|
||||
twentyConfigService: TwentyConfigService,
|
||||
) {
|
||||
const logLevels = twentyConfigService.get('LOG_LEVELS');
|
||||
|
||||
this.isDebugEnabled = logLevels.includes('debug');
|
||||
}
|
||||
|
||||
private async computeAllRelatedFlatEntityMaps({
|
||||
allFlatEntityOperationByMetadataName,
|
||||
@@ -186,6 +193,10 @@ export class WorkspaceMigrationValidateBuildAndRunService {
|
||||
});
|
||||
|
||||
if (validateAndBuildResult.status === 'fail') {
|
||||
if (this.isDebugEnabled) {
|
||||
this.logger.debug(JSON.stringify(validateAndBuildResult, null, 2));
|
||||
}
|
||||
|
||||
return validateAndBuildResult;
|
||||
}
|
||||
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Command, Option } from 'nest-commander';
|
||||
import {
|
||||
ALL_METADATA_NAME,
|
||||
type AllMetadataName,
|
||||
} from 'twenty-shared/metadata';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
ActiveOrSuspendedWorkspacesMigrationCommandRunner,
|
||||
type ActiveOrSuspendedWorkspacesMigrationCommandOptions,
|
||||
} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
|
||||
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
|
||||
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
|
||||
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
|
||||
import { getMetadataRelatedMetadataNames } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-related-metadata-names.util';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service';
|
||||
|
||||
type FlatCacheFlushCommandOptions =
|
||||
ActiveOrSuspendedWorkspacesMigrationCommandOptions & {
|
||||
allMetadata?: boolean;
|
||||
};
|
||||
|
||||
@Command({
|
||||
name: 'cache:flat-cache-invalidate',
|
||||
description:
|
||||
'Flush flat entity cache for specific metadata names and workspaces',
|
||||
})
|
||||
export class FlatCacheInvalidateCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner<FlatCacheFlushCommandOptions> {
|
||||
private metadataNames: string[] = [];
|
||||
private flatMapsKeysToFlush: (keyof AllFlatEntityMaps)[] = [];
|
||||
|
||||
constructor(
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
protected readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
|
||||
protected readonly dataSourceService: DataSourceService,
|
||||
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
|
||||
) {
|
||||
super(workspaceRepository, globalWorkspaceOrmManager, dataSourceService);
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '--metadataName <metadataName>',
|
||||
description:
|
||||
'Metadata name(s) to flush cache for. Can be specified multiple times.',
|
||||
required: false,
|
||||
})
|
||||
parseMetadataName(val: string): string[] {
|
||||
this.metadataNames.push(val);
|
||||
|
||||
return this.metadataNames;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '--all-metadata',
|
||||
description:
|
||||
'Flush cache for all metadata names. Takes precedence over --metadataName.',
|
||||
required: false,
|
||||
})
|
||||
parseAllMetadata(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
override async runMigrationCommand(
|
||||
passedParams: string[],
|
||||
options: FlatCacheFlushCommandOptions,
|
||||
): Promise<void> {
|
||||
if (!options.allMetadata && this.metadataNames.length === 0) {
|
||||
this.logger.error(
|
||||
'Either --all-metadata or at least one --metadataName must be provided.',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const validatedMetadataNames = this.validateAndExpandMetadataNames({
|
||||
inputMetadataNames: this.metadataNames,
|
||||
allMetadata: options.allMetadata,
|
||||
});
|
||||
|
||||
if (validatedMetadataNames === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.flatMapsKeysToFlush = this.computeFlatMapsKeysWithRelated(
|
||||
validatedMetadataNames,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Will flush cache for the following flat maps keys: ${this.flatMapsKeysToFlush.join(', ')}`,
|
||||
);
|
||||
|
||||
await super.runMigrationCommand(passedParams, options);
|
||||
}
|
||||
|
||||
override async runOnWorkspace({
|
||||
workspaceId,
|
||||
}: RunOnWorkspaceArgs): Promise<void> {
|
||||
await this.workspaceMigrationRunnerService.invalidateCache({
|
||||
allFlatEntityMapsKeys: this.flatMapsKeysToFlush,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Successfully invalidated cache for workspace: ${workspaceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
private validateAndExpandMetadataNames({
|
||||
inputMetadataNames,
|
||||
allMetadata,
|
||||
}: {
|
||||
inputMetadataNames: string[];
|
||||
allMetadata?: boolean;
|
||||
}): AllMetadataName[] | null {
|
||||
const validMetadataNames = Object.keys(
|
||||
ALL_METADATA_NAME,
|
||||
) as AllMetadataName[];
|
||||
|
||||
if (allMetadata) {
|
||||
this.logger.log('Using all metadata names');
|
||||
|
||||
return validMetadataNames;
|
||||
}
|
||||
|
||||
const invalidNames = inputMetadataNames.filter(
|
||||
(name) => !validMetadataNames.includes(name as AllMetadataName),
|
||||
);
|
||||
|
||||
if (invalidNames.length > 0) {
|
||||
this.logger.error(
|
||||
`Invalid metadata name(s) provided: ${invalidNames.join(', ')}`,
|
||||
);
|
||||
this.logger.error(
|
||||
`Valid metadata names are: ${validMetadataNames.join(', ')}, or use --all-metadata`,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return inputMetadataNames as AllMetadataName[];
|
||||
}
|
||||
|
||||
private computeFlatMapsKeysWithRelated(
|
||||
metadataNames: AllMetadataName[],
|
||||
): ReturnType<typeof getMetadataFlatEntityMapsKey>[] {
|
||||
const allMetadataNamesToFlush = [
|
||||
...new Set([
|
||||
...metadataNames,
|
||||
...metadataNames.flatMap(getMetadataRelatedMetadataNames),
|
||||
]),
|
||||
];
|
||||
|
||||
const allFlatMapsKeys = allMetadataNamesToFlush.map(
|
||||
getMetadataFlatEntityMapsKey,
|
||||
);
|
||||
|
||||
return allFlatMapsKeys;
|
||||
}
|
||||
}
|
||||
+24
-34
@@ -14,7 +14,6 @@ import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/wor
|
||||
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { WorkspaceMigration } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration';
|
||||
import { WorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common';
|
||||
import {
|
||||
WorkspaceMigrationRunnerException,
|
||||
WorkspaceMigrationRunnerExceptionCode,
|
||||
@@ -35,19 +34,18 @@ export class WorkspaceMigrationRunnerService {
|
||||
) {}
|
||||
|
||||
private getLegacyCacheInvalidationPromises({
|
||||
workspaceMigration: { actions, workspaceId },
|
||||
allFlatEntityMapsKeys,
|
||||
workspaceId,
|
||||
}: {
|
||||
workspaceMigration: Omit<WorkspaceMigration, 'relatedFlatEntityMapsKeys'>;
|
||||
allFlatEntityMapsKeys: (keyof AllFlatEntityMaps)[];
|
||||
workspaceId: string;
|
||||
}): Promise<void>[] {
|
||||
const asyncOperations: Promise<void>[] = [];
|
||||
const shouldIncrementMetadataGraphqlSchemaVersion = actions.some(
|
||||
(action) => {
|
||||
return (
|
||||
action.metadataName === 'objectMetadata' ||
|
||||
action.metadataName === 'fieldMetadata'
|
||||
);
|
||||
},
|
||||
);
|
||||
const flatMapsKeysSet = new Set(allFlatEntityMapsKeys);
|
||||
|
||||
const shouldIncrementMetadataGraphqlSchemaVersion =
|
||||
flatMapsKeysSet.has('flatObjectMetadataMaps') ||
|
||||
flatMapsKeysSet.has('flatFieldMetadataMaps');
|
||||
|
||||
if (shouldIncrementMetadataGraphqlSchemaVersion) {
|
||||
asyncOperations.push(
|
||||
@@ -57,16 +55,15 @@ export class WorkspaceMigrationRunnerService {
|
||||
);
|
||||
}
|
||||
|
||||
const viewRelatedMetadataNames = [
|
||||
'view',
|
||||
'viewFilter',
|
||||
'viewGroup',
|
||||
'viewField',
|
||||
'viewFilterGroup',
|
||||
const viewRelatedFlatMapsKeys: (keyof AllFlatEntityMaps)[] = [
|
||||
'flatViewMaps',
|
||||
'flatViewFilterMaps',
|
||||
'flatViewGroupMaps',
|
||||
'flatViewFieldMaps',
|
||||
'flatViewFilterGroupMaps',
|
||||
];
|
||||
const shouldInvalidFindCoreViewsGraphqlCacheOperation = actions.some(
|
||||
(action) => viewRelatedMetadataNames.includes(action.metadataName),
|
||||
);
|
||||
const shouldInvalidFindCoreViewsGraphqlCacheOperation =
|
||||
viewRelatedFlatMapsKeys.some((key) => flatMapsKeysSet.has(key));
|
||||
|
||||
if (
|
||||
shouldInvalidFindCoreViewsGraphqlCacheOperation ||
|
||||
@@ -80,11 +77,9 @@ export class WorkspaceMigrationRunnerService {
|
||||
);
|
||||
}
|
||||
|
||||
const shouldInvalidateRoleMapCache = actions.some((action) => {
|
||||
return (
|
||||
action.metadataName === 'role' || action.metadataName === 'roleTarget'
|
||||
);
|
||||
});
|
||||
const shouldInvalidateRoleMapCache =
|
||||
flatMapsKeysSet.has('flatRoleMaps') ||
|
||||
flatMapsKeysSet.has('flatRoleTargetMaps');
|
||||
|
||||
if (
|
||||
shouldIncrementMetadataGraphqlSchemaVersion ||
|
||||
@@ -105,14 +100,12 @@ export class WorkspaceMigrationRunnerService {
|
||||
return asyncOperations;
|
||||
}
|
||||
|
||||
private async invalidateCachePostExecution({
|
||||
async invalidateCache({
|
||||
allFlatEntityMapsKeys,
|
||||
workspaceId,
|
||||
actions,
|
||||
}: {
|
||||
allFlatEntityMapsKeys: (keyof AllFlatEntityMaps)[];
|
||||
workspaceId: string;
|
||||
actions: WorkspaceMigrationAction[];
|
||||
}): Promise<void> {
|
||||
this.logger.time(
|
||||
'Runner',
|
||||
@@ -126,10 +119,8 @@ export class WorkspaceMigrationRunnerService {
|
||||
|
||||
const invalidationResults = await Promise.allSettled(
|
||||
this.getLegacyCacheInvalidationPromises({
|
||||
workspaceMigration: {
|
||||
actions,
|
||||
workspaceId,
|
||||
},
|
||||
allFlatEntityMapsKeys,
|
||||
workspaceId,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -214,10 +205,9 @@ export class WorkspaceMigrationRunnerService {
|
||||
|
||||
this.logger.timeEnd('Runner', 'Transaction execution');
|
||||
|
||||
await this.invalidateCachePostExecution({
|
||||
await this.invalidateCache({
|
||||
allFlatEntityMapsKeys,
|
||||
workspaceId,
|
||||
actions,
|
||||
});
|
||||
|
||||
this.logger.timeEnd('Runner', 'Total execution');
|
||||
|
||||
+5
@@ -1,14 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DiscoveryModule } from '@nestjs/core';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
|
||||
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
import { WorkspaceSchemaMigrationRunnerActionHandlersModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module';
|
||||
import { FlatCacheInvalidateCommand } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/commands/flat-cache-invalidate.command';
|
||||
import { WorkspaceMigrationRunnerActionHandlerRegistryService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/registry/workspace-migration-runner-action-handler-registry.service';
|
||||
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service';
|
||||
|
||||
@@ -23,10 +26,12 @@ import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/wo
|
||||
DiscoveryModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
WorkspaceCacheModule,
|
||||
TypeOrmModule.forFeature([WorkspaceEntity]),
|
||||
],
|
||||
providers: [
|
||||
WorkspaceMigrationRunnerService,
|
||||
WorkspaceMigrationRunnerActionHandlerRegistryService,
|
||||
FlatCacheInvalidateCommand,
|
||||
],
|
||||
exports: [WorkspaceMigrationRunnerService],
|
||||
})
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const DOCUMENTATION_BASE_URL = 'https://docs.twenty.com';
|
||||
@@ -0,0 +1 @@
|
||||
export const DOCUMENTATION_DEFAULT_LANGUAGE = 'en' as const;
|
||||
@@ -0,0 +1,4 @@
|
||||
import { DOCUMENTATION_PATHS } from './DocumentationPaths';
|
||||
|
||||
export const DOCUMENTATION_DEFAULT_PATH =
|
||||
DOCUMENTATION_PATHS.USER_GUIDE_INTRODUCTION;
|
||||
@@ -0,0 +1,298 @@
|
||||
/*
|
||||
* _____ _
|
||||
*|_ _|_ _____ _ __ | |_ _ _
|
||||
* | | \ \ /\ / / _ \ '_ \| __| | | | Auto-generated file
|
||||
* | | \ V V / __/ | | | |_| |_| | DO NOT EDIT - changes will be overwritten
|
||||
* |_| \_/\_/ \___|_| |_|\__|\__, | Generated by: yarn docs:generate-paths
|
||||
* |___/
|
||||
*
|
||||
* This file is generated from packages/twenty-docs/navigation/base-structure.json
|
||||
* To add new documentation pages, add them to base-structure.json and run:
|
||||
* yarn docs:generate-paths
|
||||
*/
|
||||
|
||||
export const DOCUMENTATION_PATHS = {
|
||||
DEVELOPERS_CONTRIBUTE_CAPABILITIES_BACKEND_DEVELOPMENT_BEST_PRACTICES_SERVER:
|
||||
'/developers/contribute/capabilities/backend-development/best-practices-server',
|
||||
DEVELOPERS_CONTRIBUTE_CAPABILITIES_BACKEND_DEVELOPMENT_CUSTOM_OBJECTS:
|
||||
'/developers/contribute/capabilities/backend-development/custom-objects',
|
||||
DEVELOPERS_CONTRIBUTE_CAPABILITIES_BACKEND_DEVELOPMENT_FEATURE_FLAGS:
|
||||
'/developers/contribute/capabilities/backend-development/feature-flags',
|
||||
DEVELOPERS_CONTRIBUTE_CAPABILITIES_BACKEND_DEVELOPMENT_FOLDER_ARCHITECTURE_SERVER:
|
||||
'/developers/contribute/capabilities/backend-development/folder-architecture-server',
|
||||
DEVELOPERS_CONTRIBUTE_CAPABILITIES_BACKEND_DEVELOPMENT_QUEUE:
|
||||
'/developers/contribute/capabilities/backend-development/queue',
|
||||
DEVELOPERS_CONTRIBUTE_CAPABILITIES_BACKEND_DEVELOPMENT_SERVER_COMMANDS:
|
||||
'/developers/contribute/capabilities/backend-development/server-commands',
|
||||
DEVELOPERS_CONTRIBUTE_CAPABILITIES_BACKEND_DEVELOPMENT_ZAPIER:
|
||||
'/developers/contribute/capabilities/backend-development/zapier',
|
||||
DEVELOPERS_CONTRIBUTE_CAPABILITIES_BUG_AND_REQUESTS:
|
||||
'/developers/contribute/capabilities/bug-and-requests',
|
||||
DEVELOPERS_CONTRIBUTE_CAPABILITIES_FRONTEND_DEVELOPMENT_BEST_PRACTICES_FRONT:
|
||||
'/developers/contribute/capabilities/frontend-development/best-practices-front',
|
||||
DEVELOPERS_CONTRIBUTE_CAPABILITIES_FRONTEND_DEVELOPMENT_FOLDER_ARCHITECTURE_FRONT:
|
||||
'/developers/contribute/capabilities/frontend-development/folder-architecture-front',
|
||||
DEVELOPERS_CONTRIBUTE_CAPABILITIES_FRONTEND_DEVELOPMENT_FRONTEND_COMMANDS:
|
||||
'/developers/contribute/capabilities/frontend-development/frontend-commands',
|
||||
DEVELOPERS_CONTRIBUTE_CAPABILITIES_FRONTEND_DEVELOPMENT_HOTKEYS:
|
||||
'/developers/contribute/capabilities/frontend-development/hotkeys',
|
||||
DEVELOPERS_CONTRIBUTE_CAPABILITIES_FRONTEND_DEVELOPMENT_STORYBOOK:
|
||||
'/developers/contribute/capabilities/frontend-development/storybook',
|
||||
DEVELOPERS_CONTRIBUTE_CAPABILITIES_FRONTEND_DEVELOPMENT_STYLE_GUIDE:
|
||||
'/developers/contribute/capabilities/frontend-development/style-guide',
|
||||
DEVELOPERS_CONTRIBUTE_CAPABILITIES_FRONTEND_DEVELOPMENT_WORK_WITH_FIGMA:
|
||||
'/developers/contribute/capabilities/frontend-development/work-with-figma',
|
||||
DEVELOPERS_CONTRIBUTE_CAPABILITIES_LOCAL_SETUP:
|
||||
'/developers/contribute/capabilities/local-setup',
|
||||
DEVELOPERS_CONTRIBUTE_CONTRIBUTE: '/developers/contribute/contribute',
|
||||
DEVELOPERS_EXTEND_CAPABILITIES_APIS: '/developers/extend/capabilities/apis',
|
||||
DEVELOPERS_EXTEND_CAPABILITIES_APPS: '/developers/extend/capabilities/apps',
|
||||
DEVELOPERS_EXTEND_CAPABILITIES_WEBHOOKS:
|
||||
'/developers/extend/capabilities/webhooks',
|
||||
DEVELOPERS_EXTEND_EXTEND: '/developers/extend/extend',
|
||||
DEVELOPERS_INTRODUCTION: '/developers/introduction',
|
||||
DEVELOPERS_SELF_HOST_CAPABILITIES_CLOUD_PROVIDERS:
|
||||
'/developers/self-host/capabilities/cloud-providers',
|
||||
DEVELOPERS_SELF_HOST_CAPABILITIES_DOCKER_COMPOSE:
|
||||
'/developers/self-host/capabilities/docker-compose',
|
||||
DEVELOPERS_SELF_HOST_CAPABILITIES_SETUP:
|
||||
'/developers/self-host/capabilities/setup',
|
||||
DEVELOPERS_SELF_HOST_CAPABILITIES_TROUBLESHOOTING:
|
||||
'/developers/self-host/capabilities/troubleshooting',
|
||||
DEVELOPERS_SELF_HOST_CAPABILITIES_UPGRADE_GUIDE:
|
||||
'/developers/self-host/capabilities/upgrade-guide',
|
||||
DEVELOPERS_SELF_HOST_SELF_HOST: '/developers/self-host/self-host',
|
||||
TWENTY_UI_DISPLAY_APP_TOOLTIP: '/twenty-ui/display/app-tooltip',
|
||||
TWENTY_UI_DISPLAY_CHECKMARK: '/twenty-ui/display/checkmark',
|
||||
TWENTY_UI_DISPLAY_CHIP: '/twenty-ui/display/chip',
|
||||
TWENTY_UI_DISPLAY_ICONS: '/twenty-ui/display/icons',
|
||||
TWENTY_UI_DISPLAY_SOON_PILL: '/twenty-ui/display/soon-pill',
|
||||
TWENTY_UI_DISPLAY_TAG: '/twenty-ui/display/tag',
|
||||
TWENTY_UI_INPUT_BLOCK_EDITOR: '/twenty-ui/input/block-editor',
|
||||
TWENTY_UI_INPUT_BUTTONS: '/twenty-ui/input/buttons',
|
||||
TWENTY_UI_INPUT_CHECKBOX: '/twenty-ui/input/checkbox',
|
||||
TWENTY_UI_INPUT_COLOR_SCHEME: '/twenty-ui/input/color-scheme',
|
||||
TWENTY_UI_INPUT_ICON_PICKER: '/twenty-ui/input/icon-picker',
|
||||
TWENTY_UI_INPUT_IMAGE_INPUT: '/twenty-ui/input/image-input',
|
||||
TWENTY_UI_INPUT_RADIO: '/twenty-ui/input/radio',
|
||||
TWENTY_UI_INPUT_SELECT: '/twenty-ui/input/select',
|
||||
TWENTY_UI_INPUT_TEXT: '/twenty-ui/input/text',
|
||||
TWENTY_UI_INPUT_TOGGLE: '/twenty-ui/input/toggle',
|
||||
TWENTY_UI_INTRODUCTION: '/twenty-ui/introduction',
|
||||
TWENTY_UI_NAVIGATION: '/twenty-ui/navigation',
|
||||
TWENTY_UI_NAVIGATION_BREADCRUMB: '/twenty-ui/navigation/breadcrumb',
|
||||
TWENTY_UI_NAVIGATION_LINKS: '/twenty-ui/navigation/links',
|
||||
TWENTY_UI_NAVIGATION_MENU_ITEM: '/twenty-ui/navigation/menu-item',
|
||||
TWENTY_UI_NAVIGATION_NAVIGATION_BAR: '/twenty-ui/navigation/navigation-bar',
|
||||
TWENTY_UI_NAVIGATION_STEP_BAR: '/twenty-ui/navigation/step-bar',
|
||||
TWENTY_UI_PROGRESS_BAR: '/twenty-ui/progress-bar',
|
||||
USER_GUIDE_AI_CAPABILITIES_AI_AGENTS: '/user-guide/ai/capabilities/ai-agents',
|
||||
USER_GUIDE_AI_CAPABILITIES_AI_CHATBOT:
|
||||
'/user-guide/ai/capabilities/ai-chatbot',
|
||||
USER_GUIDE_AI_CAPABILITIES_PERMISSIONS_ACCESS_CONTROL:
|
||||
'/user-guide/ai/capabilities/permissions-access-control',
|
||||
USER_GUIDE_AI_HOW_TOS_AI_FAQ: '/user-guide/ai/how-tos/ai-faq',
|
||||
USER_GUIDE_AI_OVERVIEW: '/user-guide/ai/overview',
|
||||
USER_GUIDE_BILLING_CAPABILITIES_PRICING_PLANS:
|
||||
'/user-guide/billing/capabilities/pricing-plans',
|
||||
USER_GUIDE_BILLING_CAPABILITIES_WORKFLOW_CREDITS:
|
||||
'/user-guide/billing/capabilities/workflow-credits',
|
||||
USER_GUIDE_BILLING_HOW_TOS_BILLING_FAQ:
|
||||
'/user-guide/billing/how-tos/billing-faq',
|
||||
USER_GUIDE_BILLING_OVERVIEW: '/user-guide/billing/overview',
|
||||
USER_GUIDE_CALENDAR_EMAILS_CAPABILITIES_CALENDAR:
|
||||
'/user-guide/calendar-emails/capabilities/calendar',
|
||||
USER_GUIDE_CALENDAR_EMAILS_CAPABILITIES_MAILBOX:
|
||||
'/user-guide/calendar-emails/capabilities/mailbox',
|
||||
USER_GUIDE_CALENDAR_EMAILS_HOW_TOS_CAN_I_BOOK_MEETINGS_FROM_TWENTY:
|
||||
'/user-guide/calendar-emails/how-tos/can-i-book-meetings-from-twenty',
|
||||
USER_GUIDE_CALENDAR_EMAILS_HOW_TOS_CAN_I_SEND_EMAILS_FROM_TWENTY:
|
||||
'/user-guide/calendar-emails/how-tos/can-i-send-emails-from-twenty',
|
||||
USER_GUIDE_CALENDAR_EMAILS_HOW_TOS_CAN_I_TRACK_EMAIL_ACTIVITY_ON_ALL_OBJECTS:
|
||||
'/user-guide/calendar-emails/how-tos/can-i-track-email-activity-on-all-objects',
|
||||
USER_GUIDE_CALENDAR_EMAILS_HOW_TOS_CONNECT_SEVERAL_MAILBOXES_PER_USER:
|
||||
'/user-guide/calendar-emails/how-tos/connect-several-mailboxes-per-user',
|
||||
USER_GUIDE_CALENDAR_EMAILS_HOW_TOS_I_DONT_SEE_EMAILS_ON_RECORDS:
|
||||
'/user-guide/calendar-emails/how-tos/i-dont-see-emails-on-records',
|
||||
USER_GUIDE_CALENDAR_EMAILS_HOW_TOS_LIMIT_EMAILS_IMPORTED:
|
||||
'/user-guide/calendar-emails/how-tos/limit-emails-imported',
|
||||
USER_GUIDE_CALENDAR_EMAILS_OVERVIEW: '/user-guide/calendar-emails/overview',
|
||||
USER_GUIDE_DASHBOARDS_CAPABILITIES_CHART_SETTINGS:
|
||||
'/user-guide/dashboards/capabilities/chart-settings',
|
||||
USER_GUIDE_DASHBOARDS_CAPABILITIES_DASHBOARDS:
|
||||
'/user-guide/dashboards/capabilities/dashboards',
|
||||
USER_GUIDE_DASHBOARDS_CAPABILITIES_WIDGETS:
|
||||
'/user-guide/dashboards/capabilities/widgets',
|
||||
USER_GUIDE_DASHBOARDS_HOW_TOS_DASHBOARDS_FAQ:
|
||||
'/user-guide/dashboards/how-tos/dashboards-faq',
|
||||
USER_GUIDE_DASHBOARDS_HOW_TOS_WIDGET_FAQ:
|
||||
'/user-guide/dashboards/how-tos/widget-faq',
|
||||
USER_GUIDE_DASHBOARDS_OVERVIEW: '/user-guide/dashboards/overview',
|
||||
USER_GUIDE_DATA_MIGRATION_CAPABILITIES_ERROR_HANDLING:
|
||||
'/user-guide/data-migration/capabilities/error-handling',
|
||||
USER_GUIDE_DATA_MIGRATION_CAPABILITIES_FIELD_MAPPING:
|
||||
'/user-guide/data-migration/capabilities/field-mapping',
|
||||
USER_GUIDE_DATA_MIGRATION_CAPABILITIES_FILE_FORMATS:
|
||||
'/user-guide/data-migration/capabilities/file-formats',
|
||||
USER_GUIDE_DATA_MIGRATION_CAPABILITIES_IMPORT_RELATIONS:
|
||||
'/user-guide/data-migration/capabilities/import-relations',
|
||||
USER_GUIDE_DATA_MIGRATION_CAPABILITIES_UNIQUENESS_CONSTRAINTS:
|
||||
'/user-guide/data-migration/capabilities/uniqueness-constraints',
|
||||
USER_GUIDE_DATA_MIGRATION_HOW_TOS_EXPORT_YOUR_DATA:
|
||||
'/user-guide/data-migration/how-tos/export-your-data',
|
||||
USER_GUIDE_DATA_MIGRATION_HOW_TOS_FIX_IMPORT_ERRORS:
|
||||
'/user-guide/data-migration/how-tos/fix-import-errors',
|
||||
USER_GUIDE_DATA_MIGRATION_HOW_TOS_IMPORT_COMPANIES_VIA_CSV:
|
||||
'/user-guide/data-migration/how-tos/import-companies-via-csv',
|
||||
USER_GUIDE_DATA_MIGRATION_HOW_TOS_IMPORT_CONTACTS_VIA_CSV:
|
||||
'/user-guide/data-migration/how-tos/import-contacts-via-csv',
|
||||
USER_GUIDE_DATA_MIGRATION_HOW_TOS_IMPORT_DATA_VIA_API:
|
||||
'/user-guide/data-migration/how-tos/import-data-via-api',
|
||||
USER_GUIDE_DATA_MIGRATION_HOW_TOS_IMPORT_RELATIONS_BETWEEN_OBJECTS_VIA_CSV:
|
||||
'/user-guide/data-migration/how-tos/import-relations-between-objects-via-csv',
|
||||
USER_GUIDE_DATA_MIGRATION_HOW_TOS_MIGRATING_FROM_OTHER_CRMS:
|
||||
'/user-guide/data-migration/how-tos/migrating-from-other-crms',
|
||||
USER_GUIDE_DATA_MIGRATION_HOW_TOS_MIGRATING_FROM_SELF_HOSTED_TO_CLOUD:
|
||||
'/user-guide/data-migration/how-tos/migrating-from-self-hosted-to-cloud',
|
||||
USER_GUIDE_DATA_MIGRATION_HOW_TOS_PREPARE_YOUR_CSV_FILES:
|
||||
'/user-guide/data-migration/how-tos/prepare-your-csv-files',
|
||||
USER_GUIDE_DATA_MIGRATION_HOW_TOS_UPDATE_EXISTING_RECORDS_VIA_IMPORT:
|
||||
'/user-guide/data-migration/how-tos/update-existing-records-via-import',
|
||||
USER_GUIDE_DATA_MIGRATION_OVERVIEW: '/user-guide/data-migration/overview',
|
||||
USER_GUIDE_DATA_MODEL_CAPABILITIES_FIELDS:
|
||||
'/user-guide/data-model/capabilities/fields',
|
||||
USER_GUIDE_DATA_MODEL_CAPABILITIES_OBJECTS:
|
||||
'/user-guide/data-model/capabilities/objects',
|
||||
USER_GUIDE_DATA_MODEL_CAPABILITIES_RELATION_FIELDS:
|
||||
'/user-guide/data-model/capabilities/relation-fields',
|
||||
USER_GUIDE_DATA_MODEL_HOW_TOS_CREATE_CUSTOM_FIELDS:
|
||||
'/user-guide/data-model/how-tos/create-custom-fields',
|
||||
USER_GUIDE_DATA_MODEL_HOW_TOS_CREATE_CUSTOM_OBJECTS:
|
||||
'/user-guide/data-model/how-tos/create-custom-objects',
|
||||
USER_GUIDE_DATA_MODEL_HOW_TOS_CREATE_MANY_TO_MANY_RELATIONS:
|
||||
'/user-guide/data-model/how-tos/create-many-to-many-relations',
|
||||
USER_GUIDE_DATA_MODEL_HOW_TOS_CREATE_RELATION_FIELDS:
|
||||
'/user-guide/data-model/how-tos/create-relation-fields',
|
||||
USER_GUIDE_DATA_MODEL_HOW_TOS_CUSTOMIZE_YOUR_DATA_MODEL:
|
||||
'/user-guide/data-model/how-tos/customize-your-data-model',
|
||||
USER_GUIDE_DATA_MODEL_HOW_TOS_DATA_MODEL_FAQ:
|
||||
'/user-guide/data-model/how-tos/data-model-faq',
|
||||
USER_GUIDE_DATA_MODEL_OVERVIEW: '/user-guide/data-model/overview',
|
||||
USER_GUIDE_GETTING_STARTED_CAPABILITIES_GLOSSARY:
|
||||
'/user-guide/getting-started/capabilities/glossary',
|
||||
USER_GUIDE_GETTING_STARTED_CAPABILITIES_IMPLEMENTATION_SERVICES:
|
||||
'/user-guide/getting-started/capabilities/implementation-services',
|
||||
USER_GUIDE_GETTING_STARTED_CAPABILITIES_WHAT_IS_TWENTY:
|
||||
'/user-guide/getting-started/capabilities/what-is-twenty',
|
||||
USER_GUIDE_GETTING_STARTED_HOW_TOS_CONFIGURE_YOUR_WORKSPACE:
|
||||
'/user-guide/getting-started/how-tos/configure-your-workspace',
|
||||
USER_GUIDE_GETTING_STARTED_HOW_TOS_CREATE_WORKSPACE:
|
||||
'/user-guide/getting-started/how-tos/create-workspace',
|
||||
USER_GUIDE_GETTING_STARTED_HOW_TOS_NAVIGATE_AROUND_TWENTY:
|
||||
'/user-guide/getting-started/how-tos/navigate-around-twenty',
|
||||
USER_GUIDE_INTRODUCTION: '/user-guide/introduction',
|
||||
USER_GUIDE_PERMISSIONS_ACCESS_CAPABILITIES_PERMISSIONS:
|
||||
'/user-guide/permissions-access/capabilities/permissions',
|
||||
USER_GUIDE_PERMISSIONS_ACCESS_CAPABILITIES_SSO_CONFIGURATION:
|
||||
'/user-guide/permissions-access/capabilities/sso-configuration',
|
||||
USER_GUIDE_PERMISSIONS_ACCESS_HOW_TOS_PERMISSIONS_FAQ:
|
||||
'/user-guide/permissions-access/how-tos/permissions-faq',
|
||||
USER_GUIDE_PERMISSIONS_ACCESS_OVERVIEW:
|
||||
'/user-guide/permissions-access/overview',
|
||||
USER_GUIDE_SETTINGS_CAPABILITIES_DOMAINS_SETTINGS:
|
||||
'/user-guide/settings/capabilities/domains-settings',
|
||||
USER_GUIDE_SETTINGS_CAPABILITIES_EXPERIENCE_SETTINGS:
|
||||
'/user-guide/settings/capabilities/experience-settings',
|
||||
USER_GUIDE_SETTINGS_CAPABILITIES_MEMBER_MANAGEMENT:
|
||||
'/user-guide/settings/capabilities/member-management',
|
||||
USER_GUIDE_SETTINGS_CAPABILITIES_PROFILE_SETTINGS:
|
||||
'/user-guide/settings/capabilities/profile-settings',
|
||||
USER_GUIDE_SETTINGS_CAPABILITIES_UPDATES_SETTINGS:
|
||||
'/user-guide/settings/capabilities/updates-settings',
|
||||
USER_GUIDE_SETTINGS_CAPABILITIES_WORKSPACE_SETTINGS:
|
||||
'/user-guide/settings/capabilities/workspace-settings',
|
||||
USER_GUIDE_SETTINGS_HOW_TOS_SETTINGS_FAQ:
|
||||
'/user-guide/settings/how-tos/settings-faq',
|
||||
USER_GUIDE_SETTINGS_OVERVIEW: '/user-guide/settings/overview',
|
||||
USER_GUIDE_VIEWS_PIPELINES_CAPABILITIES_CALENDAR_VIEW:
|
||||
'/user-guide/views-pipelines/capabilities/calendar-view',
|
||||
USER_GUIDE_VIEWS_PIPELINES_CAPABILITIES_FIELDS_AND_COLUMNS:
|
||||
'/user-guide/views-pipelines/capabilities/fields-and-columns',
|
||||
USER_GUIDE_VIEWS_PIPELINES_CAPABILITIES_FILTERS_AND_SORTING:
|
||||
'/user-guide/views-pipelines/capabilities/filters-and-sorting',
|
||||
USER_GUIDE_VIEWS_PIPELINES_CAPABILITIES_KANBAN_VIEWS:
|
||||
'/user-guide/views-pipelines/capabilities/kanban-views',
|
||||
USER_GUIDE_VIEWS_PIPELINES_CAPABILITIES_TABLE_VIEWS:
|
||||
'/user-guide/views-pipelines/capabilities/table-views',
|
||||
USER_GUIDE_VIEWS_PIPELINES_CAPABILITIES_VIEW_SETTINGS:
|
||||
'/user-guide/views-pipelines/capabilities/view-settings',
|
||||
USER_GUIDE_VIEWS_PIPELINES_HOW_TOS_CREATE_A_CALENDAR_VIEW_FOR_TASKS_DUE:
|
||||
'/user-guide/views-pipelines/how-tos/create-a-calendar-view-for-tasks-due',
|
||||
USER_GUIDE_VIEWS_PIPELINES_HOW_TOS_CREATE_A_KANBAN_VIEW_FOR_PROJECTS:
|
||||
'/user-guide/views-pipelines/how-tos/create-a-kanban-view-for-projects',
|
||||
USER_GUIDE_VIEWS_PIPELINES_HOW_TOS_CREATE_A_TABLE_VIEW_WITH_GROUPING:
|
||||
'/user-guide/views-pipelines/how-tos/create-a-table-view-with-grouping',
|
||||
USER_GUIDE_VIEWS_PIPELINES_HOW_TOS_RESTRICT_ACCESS_TO_YOUR_VIEW:
|
||||
'/user-guide/views-pipelines/how-tos/restrict-access-to-your-view',
|
||||
USER_GUIDE_VIEWS_PIPELINES_HOW_TOS_SET_UP_A_SALES_PIPELINE:
|
||||
'/user-guide/views-pipelines/how-tos/set-up-a-sales-pipeline',
|
||||
USER_GUIDE_VIEWS_PIPELINES_HOW_TOS_SHOW_EXPECTED_AMOUNT_IN_PIPELINE:
|
||||
'/user-guide/views-pipelines/how-tos/show-expected-amount-in-pipeline',
|
||||
USER_GUIDE_VIEWS_PIPELINES_HOW_TOS_TRACK_TIME_IN_STAGE:
|
||||
'/user-guide/views-pipelines/how-tos/track-time-in-stage',
|
||||
USER_GUIDE_VIEWS_PIPELINES_OVERVIEW: '/user-guide/views-pipelines/overview',
|
||||
USER_GUIDE_WORKFLOWS_CAPABILITIES_SEND_EMAILS_FROM_WORKFLOWS:
|
||||
'/user-guide/workflows/capabilities/send-emails-from-workflows',
|
||||
USER_GUIDE_WORKFLOWS_CAPABILITIES_USE_BRANCHES_IN_WORKFLOWS:
|
||||
'/user-guide/workflows/capabilities/use-branches-in-workflows',
|
||||
USER_GUIDE_WORKFLOWS_CAPABILITIES_USE_ITERATOR:
|
||||
'/user-guide/workflows/capabilities/use-iterator',
|
||||
USER_GUIDE_WORKFLOWS_CAPABILITIES_WORKFLOW_ACTIONS:
|
||||
'/user-guide/workflows/capabilities/workflow-actions',
|
||||
USER_GUIDE_WORKFLOWS_CAPABILITIES_WORKFLOW_BRANCHES:
|
||||
'/user-guide/workflows/capabilities/workflow-branches',
|
||||
USER_GUIDE_WORKFLOWS_CAPABILITIES_WORKFLOW_CREDITS:
|
||||
'/user-guide/workflows/capabilities/workflow-credits',
|
||||
USER_GUIDE_WORKFLOWS_CAPABILITIES_WORKFLOW_RUNS:
|
||||
'/user-guide/workflows/capabilities/workflow-runs',
|
||||
USER_GUIDE_WORKFLOWS_CAPABILITIES_WORKFLOW_TRIGGERS:
|
||||
'/user-guide/workflows/capabilities/workflow-triggers',
|
||||
USER_GUIDE_WORKFLOWS_CAPABILITIES_WORKFLOW_VERSIONS:
|
||||
'/user-guide/workflows/capabilities/workflow-versions',
|
||||
USER_GUIDE_WORKFLOWS_HOW_TOS_ADVANCED_CONFIGURATIONS_HANDLE_ARRAYS_IN_CODE_ACTIONS:
|
||||
'/user-guide/workflows/how-tos/advanced-configurations/handle-arrays-in-code-actions',
|
||||
USER_GUIDE_WORKFLOWS_HOW_TOS_CONNECT_TO_OTHER_TOOLS_BRING_PRODUCT_DATA_IN_TWENTY:
|
||||
'/user-guide/workflows/how-tos/connect-to-other-tools/bring-product-data-in-twenty',
|
||||
USER_GUIDE_WORKFLOWS_HOW_TOS_CONNECT_TO_OTHER_TOOLS_BRING_TYPEFORM_SUBMISSIONS_IN_TWENTY:
|
||||
'/user-guide/workflows/how-tos/connect-to-other-tools/bring-typeform-submissions-in-twenty',
|
||||
USER_GUIDE_WORKFLOWS_HOW_TOS_CONNECT_TO_OTHER_TOOLS_GENERATE_PDF_FROM_TWENTY:
|
||||
'/user-guide/workflows/how-tos/connect-to-other-tools/generate-pdf-from-twenty',
|
||||
USER_GUIDE_WORKFLOWS_HOW_TOS_CONNECT_TO_OTHER_TOOLS_GENERATE_QUOTE_OR_INVOICE_FROM_TWENTY:
|
||||
'/user-guide/workflows/how-tos/connect-to-other-tools/generate-quote-or-invoice-from-twenty',
|
||||
USER_GUIDE_WORKFLOWS_HOW_TOS_CONNECT_TO_OTHER_TOOLS_SET_UP_A_WEBHOOK_TRIGGER:
|
||||
'/user-guide/workflows/how-tos/connect-to-other-tools/set-up-a-webhook-trigger',
|
||||
USER_GUIDE_WORKFLOWS_HOW_TOS_CRM_AUTOMATIONS_CLOSED_WON_AUTOMATIONS:
|
||||
'/user-guide/workflows/how-tos/crm-automations/closed-won-automations',
|
||||
USER_GUIDE_WORKFLOWS_HOW_TOS_CRM_AUTOMATIONS_DETECT_STALE_OPPORTUNITIES:
|
||||
'/user-guide/workflows/how-tos/crm-automations/detect-stale-opportunities',
|
||||
USER_GUIDE_WORKFLOWS_HOW_TOS_CRM_AUTOMATIONS_DISPLAY_NUMBER_OF_EMAILS_RECEIVED:
|
||||
'/user-guide/workflows/how-tos/crm-automations/display-number-of-emails-received',
|
||||
USER_GUIDE_WORKFLOWS_HOW_TOS_CRM_AUTOMATIONS_DISPLAY_RELATED_RECORD_DATA:
|
||||
'/user-guide/workflows/how-tos/crm-automations/display-related-record-data',
|
||||
USER_GUIDE_WORKFLOWS_HOW_TOS_CRM_AUTOMATIONS_FORMULA_FIELDS:
|
||||
'/user-guide/workflows/how-tos/crm-automations/formula-fields',
|
||||
USER_GUIDE_WORKFLOWS_HOW_TOS_CRM_AUTOMATIONS_NOTIFY_TEAMMATES_OF_NOTE_TO_REVIEW:
|
||||
'/user-guide/workflows/how-tos/crm-automations/notify-teammates-of-note-to-review',
|
||||
USER_GUIDE_WORKFLOWS_HOW_TOS_CRM_AUTOMATIONS_SEND_EMAIL_ALERTS_WITH_TASKS_DUE:
|
||||
'/user-guide/workflows/how-tos/crm-automations/send-email-alerts-with-tasks-due',
|
||||
USER_GUIDE_WORKFLOWS_HOW_TOS_NEED_MORE_HELP_PROFESSIONAL_SERVICES:
|
||||
'/user-guide/workflows/how-tos/need-more-help/professional-services',
|
||||
USER_GUIDE_WORKFLOWS_HOW_TOS_NEED_MORE_HELP_WORKFLOW_TROUBLESHOOTING:
|
||||
'/user-guide/workflows/how-tos/need-more-help/workflow-troubleshooting',
|
||||
USER_GUIDE_WORKFLOWS_HOW_TOS_NEED_MORE_HELP_WORKFLOWS_FAQ:
|
||||
'/user-guide/workflows/how-tos/need-more-help/workflows-faq',
|
||||
USER_GUIDE_WORKFLOWS_OVERVIEW: '/user-guide/workflows/overview',
|
||||
} as const;
|
||||
|
||||
export type DocumentationPath =
|
||||
(typeof DOCUMENTATION_PATHS)[keyof typeof DOCUMENTATION_PATHS];
|
||||
@@ -0,0 +1,21 @@
|
||||
import { DOCUMENTATION_DEFAULT_LANGUAGE } from './DocumentationDefaultLanguage';
|
||||
|
||||
export const DOCUMENTATION_SUPPORTED_LANGUAGES = [
|
||||
DOCUMENTATION_DEFAULT_LANGUAGE,
|
||||
'fr',
|
||||
'ar',
|
||||
'cs',
|
||||
'de',
|
||||
'es',
|
||||
'it',
|
||||
'ja',
|
||||
'ko',
|
||||
'pt',
|
||||
'ro',
|
||||
'ru',
|
||||
'tr',
|
||||
'zh',
|
||||
] as const;
|
||||
|
||||
export type DocumentationSupportedLanguage =
|
||||
(typeof DOCUMENTATION_SUPPORTED_LANGUAGES)[number];
|
||||
@@ -14,6 +14,13 @@ export { CURRENCY_CODE_LABELS } from './CurrencyCodeLabels';
|
||||
export { DATE_TYPE_FORMAT } from './DateTypeFormat';
|
||||
export { DEFAULT_NUMBER_OF_GROUPS_LIMIT } from './DefaultNumberOfGroupsLimit';
|
||||
export { DEFAULT_RELATIVE_DATE_FILTER_VALUE } from './DefaultRelativeDateFilterValue';
|
||||
export { DOCUMENTATION_BASE_URL } from './DocumentationBaseUrl';
|
||||
export { DOCUMENTATION_DEFAULT_LANGUAGE } from './DocumentationDefaultLanguage';
|
||||
export { DOCUMENTATION_DEFAULT_PATH } from './DocumentationDefaultPath';
|
||||
export type { DocumentationPath } from './DocumentationPaths';
|
||||
export { DOCUMENTATION_PATHS } from './DocumentationPaths';
|
||||
export type { DocumentationSupportedLanguage } from './DocumentationSupportedLanguages';
|
||||
export { DOCUMENTATION_SUPPORTED_LANGUAGES } from './DocumentationSupportedLanguages';
|
||||
export { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from './FieldForTotalCountAggregateOperation';
|
||||
export { MAX_OPTIONS_TO_DISPLAY } from './FieldMetadataMaxOptionsToDisplay';
|
||||
export { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from './FieldRestrictedAdditionalPermissionsRequired';
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { APP_LOCALES, type AppLocale } from '@/translations/constants/AppLocales';
|
||||
import {
|
||||
APP_LOCALES,
|
||||
type AppLocale,
|
||||
} from '@/translations/constants/AppLocales';
|
||||
|
||||
export const isValidLocale = (value: string | null): value is AppLocale =>
|
||||
value !== null && value in APP_LOCALES;
|
||||
|
||||
@@ -12,10 +12,6 @@ describe('variable path utility functions', () => {
|
||||
expect(needsEscaping('toto toto')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for keys with dots', () => {
|
||||
expect(needsEscaping('key.with.dots')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for keys with brackets', () => {
|
||||
expect(needsEscaping('key[0]')).toBe(true);
|
||||
expect(needsEscaping('[key]')).toBe(true);
|
||||
@@ -33,10 +29,6 @@ describe('variable path utility functions', () => {
|
||||
expect(escapePathSegment('key with space')).toBe('[key with space]');
|
||||
});
|
||||
|
||||
it('should wrap keys with dots in brackets', () => {
|
||||
expect(escapePathSegment('key.with.dots')).toBe('[key.with.dots]');
|
||||
});
|
||||
|
||||
it('should not modify simple keys', () => {
|
||||
expect(escapePathSegment('simpleKey')).toBe('simpleKey');
|
||||
});
|
||||
@@ -55,12 +47,6 @@ describe('variable path utility functions', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should escape segments with dots', () => {
|
||||
expect(joinVariablePath(['step', 'key.with.dots'])).toBe(
|
||||
'step.[key.with.dots]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle mixed simple and special segments', () => {
|
||||
expect(
|
||||
joinVariablePath(['step', 'normal', 'has space', 'another']),
|
||||
@@ -94,13 +80,6 @@ describe('variable path utility functions', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should parse path with bracketed segments containing dots', () => {
|
||||
expect(parseVariablePath('step.[key.with.dots]')).toEqual([
|
||||
'step',
|
||||
'key.with.dots',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle multiple bracketed segments', () => {
|
||||
expect(
|
||||
parseVariablePath('[first key].[second key].[third key]'),
|
||||
@@ -131,7 +110,6 @@ describe('variable path utility functions', () => {
|
||||
const paths = [
|
||||
['step', 'field', 'value'],
|
||||
['step', 'key with space', 'value'],
|
||||
['step', 'key.with.dots'],
|
||||
['step', 'normal', 'has space', 'another'],
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Characters that require bracket escaping in variable paths
|
||||
// Spaces, dots, and brackets would break the dot-notation parsing
|
||||
const SPECIAL_CHARS_REGEX = /[\s.[]/;
|
||||
const SPECIAL_CHARS_REGEX = /[\s[]/;
|
||||
|
||||
export const needsEscaping = (key: string): boolean =>
|
||||
SPECIAL_CHARS_REGEX.test(key);
|
||||
|
||||
Reference in New Issue
Block a user