Compare commits

..
6 Commits
4100 changed files with 106943 additions and 226297 deletions
-18
View File
@@ -1,18 +0,0 @@
{
"install": "yarn install && echo 'Setting up Docker Compose environment...' && cd packages/twenty-docker && cp -n docker-compose.yml docker-compose.dev.yml || true && echo 'Dependencies installed and docker-compose prepared'",
"start": "sudo service docker start && echo 'Docker service started' && cd packages/twenty-docker && echo 'Installing yq for YAML processing...' && sudo apt-get update -qq && sudo apt-get install -y wget && wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 && sudo chmod +x /usr/local/bin/yq && echo 'Patching docker-compose for local development...' && yq eval 'del(.services.server.image)' -i docker-compose.dev.yml && yq eval '.services.server.build.context = \"../../\"' -i docker-compose.dev.yml && yq eval '.services.server.build.dockerfile = \"./packages/twenty-docker/twenty/Dockerfile\"' -i docker-compose.dev.yml && yq eval 'del(.services.worker.image)' -i docker-compose.dev.yml && yq eval '.services.worker.build.context = \"../../\"' -i docker-compose.dev.yml && yq eval '.services.worker.build.dockerfile = \"./packages/twenty-docker/twenty/Dockerfile\"' -i docker-compose.dev.yml && echo 'Setting up .env file with database configuration...' && echo 'SERVER_URL=http://localhost:3000' > .env && echo 'APP_SECRET='$(openssl rand -base64 32) >> .env && echo 'PG_DATABASE_PASSWORD='$(openssl rand -hex 16) >> .env && echo 'PG_DATABASE_URL=postgres://postgres:password@localhost:5432/postgres' >> .env && echo 'SIGN_IN_PREFILLED=true' >> .env && echo 'Building and starting services...' && docker-compose -f docker-compose.dev.yml up -d --build && echo 'Waiting for services to initialize...' && sleep 30 && echo 'Checking service health...' && docker-compose -f docker-compose.dev.yml ps && echo 'Environment setup complete!'",
"terminals": [
{
"name": "Database Setup & Seed",
"command": "sleep 40 && cd packages/twenty-docker && echo 'Waiting for PostgreSQL to be ready...' && until docker-compose -f docker-compose.dev.yml exec -T db pg_isready -U postgres; do echo 'Waiting for PostgreSQL...'; sleep 5; done && echo 'PostgreSQL is ready!' && echo 'Waiting for Twenty server to be healthy...' && until docker-compose -f docker-compose.dev.yml exec -T server curl --fail http://localhost:3000/healthz 2>/dev/null; do echo 'Waiting for server...'; sleep 5; done && echo 'Server is healthy!' && echo 'Running database setup and seeding...' && docker-compose -f docker-compose.dev.yml exec -T server npx nx database:reset twenty-server && echo 'Database seeded successfully!' && bash"
},
{
"name": "Application Logs",
"command": "sleep 35 && cd packages/twenty-docker && echo 'Following application logs...' && docker-compose -f docker-compose.dev.yml logs -f server worker"
},
{
"name": "Service Monitor",
"command": "sleep 15 && cd packages/twenty-docker && echo '=== Service Status Monitor ===' && while true; do clear; echo '=== Service Status at $(date) ===' && docker-compose -f docker-compose.dev.yml ps && echo '\\n=== Health Status ===' && (docker-compose -f docker-compose.dev.yml exec -T server curl -s http://localhost:3000/healthz 2>/dev/null && echo '✅ Twenty Server: Healthy') || echo '❌ Twenty Server: Not Ready' && (docker-compose -f docker-compose.dev.yml exec -T db pg_isready -U postgres 2>/dev/null && echo '✅ PostgreSQL: Ready') || echo '❌ PostgreSQL: Not Ready' && echo '\\n=== Database Connection Test ===' && docker-compose -f docker-compose.dev.yml exec -T server node -e \"const { Client } = require('pg'); const client = new Client({connectionString: process.env.PG_DATABASE_URL}); client.connect().then(() => {console.log('✅ Database Connection: OK'); client.end();}).catch(e => console.log('❌ Database Connection: Failed -', e.message));\" || echo 'Connection test failed' && sleep 45; done"
}
]
}
-18
View File
@@ -1,18 +0,0 @@
{
"install": "yarn install && echo 'Installing dependencies complete'",
"start": "sudo service docker start && echo 'Docker service started' && sleep 3 && echo 'Starting PostgreSQL and Redis containers...' && make postgres-on-docker && make redis-on-docker && echo 'Waiting for containers to initialize...' && sleep 20 && echo 'Checking container status...' && docker ps --filter name=twenty_ && echo 'Waiting for PostgreSQL to be ready...' && until docker exec twenty_pg pg_isready -U postgres -h localhost; do echo 'PostgreSQL not ready yet, waiting...'; sleep 3; done && echo 'PostgreSQL is ready!' && echo 'Setting up database...' && cd packages/twenty-server && npm run database:init || echo 'Database already initialized' && echo 'Environment setup complete!'",
"terminals": [
{
"name": "Development Server",
"command": "echo 'Waiting for database to be fully ready...' && sleep 30 && until docker exec twenty_pg pg_isready -U postgres -h localhost; do echo 'Waiting for PostgreSQL...'; sleep 2; done && echo 'Starting Twenty development server...' && export SERVER_URL=http://localhost:3000 && export PG_DATABASE_URL=postgres://postgres:password@localhost:5432/postgres && yarn start"
},
{
"name": "Database Management",
"command": "sleep 25 && echo 'Database management terminal ready' && echo 'Waiting for PostgreSQL to be available...' && until docker exec twenty_pg pg_isready -U postgres -h localhost; do echo 'Waiting for PostgreSQL...'; sleep 2; done && echo 'PostgreSQL is ready for database operations!' && echo 'You can now run database commands like:' && echo ' npx nx database:reset twenty-server' && echo ' npx nx database:migrate twenty-server' && bash"
},
{
"name": "Container Logs & Status",
"command": "sleep 10 && echo '=== Container Status Monitor ===' && while true; do echo '\\n=== Container Status at $(date) ===' && docker ps --filter name=twenty_ --format 'table {{.Names}}\\t{{.Status}}\\t{{.Ports}}' && echo '\\n=== PostgreSQL Status ===' && (docker exec twenty_pg pg_isready -U postgres -h localhost && echo 'PostgreSQL: ✅ Ready') || echo 'PostgreSQL: ❌ Not Ready' && echo '\\n=== Redis Status ===' && (docker exec twenty_redis redis-cli ping && echo 'Redis: ✅ Ready') || echo 'Redis: ❌ Not Ready' && sleep 30; done"
}
]
}
+65
View File
@@ -0,0 +1,65 @@
# Twenty Development Rules
This directory contains Twenty's development guidelines and best practices. The rules are organized into several key categories:
## Guidelines Structure
### 1. Architecture and Structure
- `architecture.md`: Project overview, technology stack, and infrastructure setup
- `file-structure-guidelines.md`: File and directory organization patterns
### 2. Code Style and Development
- `typescript-guidelines.md`: TypeScript best practices and conventions
- `code-style-guidelines.md`: General coding standards and style guide
### 3. React Development
- `react-general-guidelines.md`: Core React development principles and patterns
- `react-state-management-guidelines.md`: State management approaches and best practices
### 4. Testing
- `testing-guidelines.md`: Testing strategies, patterns, and best practices
### 5. Internationalization
- `translations.md`: Translation workflow, i18n setup, and string management
## Common Development Commands
### Frontend Commands
```bash
# Testing
npx nx test twenty-front # Run unit tests
npx nx storybook:build twenty-front # Build Storybook
npx nx storybook:serve-and-test:static # Run Storybook tests
# Development
npx nx lint twenty-front # Run linter
npx nx typecheck twenty-front # Type checking
npx nx run twenty-front:graphql:generate # Generate GraphQL types
```
### Backend Commands
```bash
# Database
npx nx database:reset twenty-server # Reset database
npx nx run twenty-server:database:init:prod # Initialize database
npx nx run twenty-server:database:migrate:prod # Run migrations
# Development
npx nx run twenty-server:start # Start the server
npx nx run twenty-server:lint # Run linter (add --fix to auto-fix)
npx nx run twenty-server:typecheck # Type checking
npx nx run twenty-server:test # Run unit tests
npx nx run twenty-server:test:integration:with-db-reset # Run integration tests
# Migrations
npx nx run twenty-server:typeorm migration:generate src/database/typeorm/metadata/migrations/[name] -d src/database/typeorm/metadata/metadata.datasource.ts
# Workspace
npx nx run twenty-server:command workspace:sync-metadata -f # Sync metadata
```
## Usage
These rules are automatically attached to relevant files in your workspace through Cursor's context system. They help maintain consistency and quality across the Twenty codebase.
For the most up-to-date version of these guidelines, always refer to the files in this directory.
-137
View File
@@ -1,137 +0,0 @@
---
description:
globs:
alwaysApply: false
---
# Twenty Development Rules
This directory contains Twenty's development guidelines and best practices in the modern Cursor Rules format (MDC). These rules are automatically applied based on file patterns and provide context-aware guidance to AI assistants.
## Rules Overview
### Core Guidelines
- **architecture.mdc** - Project overview, technology stack, and infrastructure setup (Always Applied)
- **nx-rules.mdc** - Nx workspace guidelines and best practices (Auto-attached to Nx files)
### Code Quality
- **typescript-guidelines.mdc** - TypeScript best practices and conventions (Auto-attached to .ts/.tsx files)
- **code-style.mdc** - General coding standards and style guide (Auto-attached to code files)
- **file-structure.mdc** - File and directory organization patterns (Auto-attached to config files)
### React Development
- **react-general-guidelines.mdc** - Core React development principles (Auto-attached to React files)
- **react-state-management.mdc** - State management approaches with Recoil (Auto-attached to state files)
### Testing & Quality
- **testing-guidelines.mdc** - Testing strategies and best practices (Auto-attached to test files)
### Internationalization
- **translations.mdc** - Translation workflow and i18n setup (Auto-attached to locale files)
## How Rules Work
### Automatic Attachment
Rules are automatically included in your AI context based on file patterns (globs). When you work on TypeScript files, the TypeScript guidelines are automatically loaded.
### Manual Reference
You can manually reference any rule using the `@ruleName` syntax:
- `@nx-rules` - Include Nx-specific guidance
- `@react-general-guidelines` - Load React best practices
- `@testing-guidelines` - Get testing recommendations
### Rule Types Used
- **Always Applied** - Loaded in every context (architecture.mdc, README.mdc)
- **Auto Attached** - Loaded when matching file patterns are referenced
- **Agent Requested** - Available for AI to include when relevant
- **Manual** - Only included when explicitly mentioned
## Development Commands
### Frontend Commands
```bash
# Testing
npx nx test twenty-front # Run unit tests
npx nx storybook:build twenty-front # Build Storybook
npx nx storybook:serve-and-test:static # Run Storybook tests
# Development
npx nx lint twenty-front # Run linter
npx nx typecheck twenty-front # Type checking
npx nx run twenty-front:graphql:generate # Generate GraphQL types
```
### Backend Commands
```bash
# Database
npx nx database:reset twenty-server # Reset database
npx nx run twenty-server:database:init:prod # Initialize database
npx nx run twenty-server:database:migrate:prod # Run migrations
# Development
npx nx run twenty-server:start # Start the server
npx nx run twenty-server:lint # Run linter (add --fix to auto-fix)
npx nx run twenty-server:typecheck # Type checking
npx nx run twenty-server:test # Run unit tests
npx nx run twenty-server:test:integration:with-db-reset # Run integration tests
# Migrations
npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/migrations/[name] -d src/database/typeorm/core/core.datasource.ts
# Workspace
npx nx run twenty-server:command workspace:sync-metadata -f # Sync metadata
```
## Usage Guidelines
### For Developers
- Rules are automatically applied based on file context
- Check rule descriptions to understand when they're activated
- Use manual references (`@ruleName`) for additional context
- Keep rules updated as the codebase evolves
### For AI Assistants
- Rules provide consistent guidance across conversations
- Use rule context to maintain coding standards
- Reference specific rules when making recommendations
- Apply rule principles in code suggestions and reviews
## Contributing to Rules
### Adding New Rules
1. Create a new `.mdc` file in this directory
2. Include proper metadata headers with description and globs
3. Write clear, actionable guidelines with examples
4. Test the rule with relevant file patterns
5. Update this README if needed
### Updating Existing Rules
1. Modify the rule content while preserving metadata
2. Test changes with affected file patterns
3. Ensure consistency with other rules
4. Update examples and best practices as needed
## Rule Format Reference
Each rule file uses the MDC format with metadata:
```markdown
---
description: Brief description of the rule's purpose
globs: ["**/*.ts", "**/*.tsx"] # File patterns for auto-attachment
alwaysApply: false # Whether to always include this rule
---
# Rule Title
Rule content in Markdown format...
```
## Migration from Legacy Format
The rules have been migrated from the legacy `.md` format to the modern `.mdc` format, providing:
- Better context awareness through file pattern matching
- Improved organization with metadata headers
- More flexible rule application strategies
- Enhanced integration with Cursor's AI features
For the most up-to-date version of these guidelines, always refer to the files in this directory.
+97
View File
@@ -0,0 +1,97 @@
# Twenty Project Architecture
## Overview
Twenty is an open-source CRM built with modern technologies, using TypeScript for both frontend and backend development. This document outlines the core architectural decisions and structure of the project.
## Monorepo Structure
The project is organized as a monorepo using nx, with the following main packages:
### Main Packages
- `packages/twenty-front`: Main Frontend application
- Technology: React
- Purpose: Provides the main user interface for the CRM
- Key responsibilities: User interactions, state management, data display
- `packages/twenty-server`: Main Backend application
- Technology: NestJS
- Purpose: Handles business logic, data persistence, and API
- Key responsibilities: Data processing, authentication, API endpoints
- `packages/twenty-website`: Marketing Website and Documentation
- Technology: NextJS
- Purpose: Public-facing website and documentation
- Key responsibilities: Marketing content, documentation, SEO
- `packages/twenty-ui`: UI Component Library
- Technology: React
- Purpose: Shared UI components and design system
- Key responsibilities: Reusable components, design consistency
- `packages/twenty-shared`: Shared Utilities
- Purpose: Cross-package shared code between frontend and backend
- Contents: Utils, constants, types, interfaces
## Core Technology Stack
### Package Management
- Package Manager: yarn
- Monorepo Tool: nx
- Benefits: Consistent dependency management, shared configurations
### Database Layer
- Primary Database: PostgreSQL
- Schema Structure:
- Core schema: Main application data
- Metadata schema: Configuration and customization data
- Workspace schemas: One schema per tenant, containing tenant-specific data
- ORM Layer:
- TypeORM: For core and metadata schemas
- Purpose: Type-safe database operations for system data
- Benefits: Strong typing, migration support
- TwentyORM: For workspace schemas
- Purpose: Manages tenant-specific entities and customizations
- Benefits: Dynamic entity management, per-tenant customization
- Example: Entities like CompanyWorkspaceEntity are managed per workspace
### State Management
- Frontend State: Recoil
- Purpose: Global state management
- Use cases: User preferences, UI state, cached data
### Data Layer
- API Technology: GraphQL
- Client: Apollo Client
- Purpose: Data fetching and caching
- Benefits: Type safety, efficient data loading
### Infrastructure
- Cache: Redis
- Purpose: High-performance caching layer
- Use cases: Session data, frequent queries
- Authentication: JWT
- Purpose: Secure user authentication
- Implementation: Token-based auth flow
- Queue System: BullMQ
- Purpose: Background job processing
- Use cases: Emails, exports, imports
- Storage: S3/Local Filesystem
- Purpose: File storage and management
- Flexibility: Configurable for cloud or local storage
### Testing Infrastructure
- Backend Testing:
- Framework: Jest
- API Testing: Supertest
- Coverage: Unit tests, integration tests
- Frontend Testing:
- Framework: Jest
- Component Testing: Storybook
- API Mocking: MSW (Mock Service Worker)
- End-to-End Testing:
- Framework: Playwright
- Coverage: Critical user journeys
-35
View File
@@ -1,35 +0,0 @@
---
description:
globs:
alwaysApply: false
---
---
description: Twenty CRM architecture overview - monorepo structure, tech stack, and development principles
globs: []
alwaysApply: true
---
# Twenty Architecture
## Tech Stack
- **Frontend**: React 18, TypeScript, Recoil, Styled Components, Vite
- **Backend**: NestJS, TypeORM, PostgreSQL, Redis, GraphQL
- **Monorepo**: Nx workspace with yarn
## Package Structure
```
packages/
├── twenty-front/ # React app
├── twenty-server/ # NestJS API
├── twenty-ui/ # Shared components
├── twenty-shared/ # Common types/utils
└── twenty-emails/ # Email templates
```
## Key Principles
- **Functional components only** (no classes)
- **Named exports only** (no default exports)
- **Types over interfaces** (except for extending third-party)
- **String literals over enums** (except GraphQL)
- **No 'any' type allowed**
- **Event handlers over useEffect** for state updates
+259
View File
@@ -0,0 +1,259 @@
# Code Style Guidelines
## Core Code Style Principles
Twenty emphasizes clean, readable, and maintainable code. This document outlines our code style conventions and best practices.
## Control Flow
### Early Returns
- Use early returns to reduce nesting
- Handle edge cases first
```typescript
// ✅ Correct
const processUser = (user: User | null) => {
if (!user) return null;
if (!user.isActive) return null;
return {
id: user.id,
name: user.name,
};
};
// ❌ Incorrect
const processUser = (user: User | null) => {
if (user) {
if (user.isActive) {
return {
id: user.id,
name: user.name,
};
}
}
return null;
};
```
### No Nested Ternaries
- Avoid nested ternary operators
- Use if statements or early returns
```typescript
// ✅ Correct
const getUserDisplay = (user: User) => {
if (!user.name) return 'Anonymous';
if (!user.isActive) return 'Inactive User';
return user.name;
};
// ❌ Incorrect
const getUserDisplay = (user: User) =>
user.name
? user.isActive
? user.name
: 'Inactive User'
: 'Anonymous';
```
### No Else-If Chains
- Use switch statements or lookup objects
- Keep conditions flat
```typescript
// ✅ Correct
const getStatusColor = (status: Status): string => {
switch (status) {
case 'success':
return 'green';
case 'warning':
return 'yellow';
case 'error':
return 'red';
default:
return 'gray';
}
};
// Or using a lookup object
const statusColors: Record<Status, string> = {
success: 'green',
warning: 'yellow',
error: 'red',
default: 'gray',
};
// ❌ Incorrect
const getStatusColor = (status: Status): string => {
if (status === 'success') {
return 'green';
} else if (status === 'warning') {
return 'yellow';
} else if (status === 'error') {
return 'red';
} else {
return 'gray';
}
};
```
## Operators and Expressions
### Optional Chaining Over &&
- Use optional chaining for null/undefined checks
- Clearer intent and better type safety
```typescript
// ✅ Correct
const userName = user?.name;
const userAddress = user?.address?.street;
// ❌ Incorrect
const userName = user && user.name;
const userAddress = user && user.address && user.address.street;
```
## Function Design
### Small Focused Functions
- Keep functions small and single-purpose
- Extract complex logic into helper functions
```typescript
// ✅ Correct
const validateUser = (user: User) => {
if (!isValidName(user.name)) return false;
if (!isValidEmail(user.email)) return false;
if (!isValidAge(user.age)) return false;
return true;
};
const isValidName = (name: string) => {
return name.length >= 2 && /^[a-zA-Z\s]*$/.test(name);
};
const isValidEmail = (email: string) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
};
const isValidAge = (age: number) => {
return age >= 18 && age <= 120;
};
// ❌ Incorrect
const validateUser = (user: User) => {
if (user.name.length < 2 || !/^[a-zA-Z\s]*$/.test(user.name)) return false;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(user.email)) return false;
if (user.age < 18 || user.age > 120) return false;
return true;
};
```
## Naming and Documentation
### Clear Variable Names
- Use descriptive, intention-revealing names
- Avoid abbreviations unless common
```typescript
// ✅ Correct
const isUserActive = user.status === 'active';
const hasRequiredPermissions = user.permissions.includes('admin');
const userDisplayName = user.name || 'Anonymous';
// ❌ Incorrect
const active = user.status === 'active';
const hasPerm = user.permissions.includes('admin');
const udn = user.name || 'Anonymous';
```
### No Console.logs in Commits
- Remove all console.logs before committing
- Use proper logging/error tracking in production
```typescript
// ❌ Incorrect - Don't commit these
console.log('user:', user);
console.log('debug:', someValue);
// ✅ Correct - Use proper logging
logger.info('User action completed', { userId: user.id });
logger.error('Operation failed', { error });
```
### Minimal Comments
- Write self-documenting code
- Use comments only for complex business logic
```typescript
// ✅ Correct
// Calculate pro-rated amount based on billing cycle
const calculateProRatedAmount = (amount: number, daysLeft: number, totalDays: number) => {
return (amount * daysLeft) / totalDays;
};
// ❌ Incorrect - Unnecessary comments
// Get the user's name
const getUserName = (user: User) => user.name;
// Check if user is active
const isUserActive = (user: User) => user.status === 'active';
```
## Error Handling
### Proper Error Handling
- Use try-catch blocks appropriately
- Provide meaningful error messages
```typescript
// ✅ Correct
const fetchUserData = async (userId: string) => {
try {
const response = await api.get(`/users/${userId}`);
return response.data;
} catch (error) {
logger.error('Failed to fetch user data', {
userId,
error: error instanceof Error ? error.message : 'Unknown error',
});
throw new UserFetchError('Failed to fetch user data');
}
};
// ❌ Incorrect
const fetchUserData = async (userId: string) => {
try {
const response = await api.get(`/users/${userId}`);
return response.data;
} catch (error) {
console.log('error:', error);
throw error;
}
};
```
## Code Organization
### Logical Grouping
- Group related code together
- Maintain consistent organization
```typescript
// ✅ Correct
class UserService {
// Properties
private readonly api: Api;
private readonly logger: Logger;
// Constructor
constructor(api: Api, logger: Logger) {
this.api = api;
this.logger = logger;
}
// Public methods
public async getUser(id: string): Promise<User> {
// Implementation
}
public async updateUser(user: User): Promise<User> {
// Implementation
}
// Private helpers
private validateUser(user: User): boolean {
// Implementation
}
}
```
-103
View File
@@ -1,103 +0,0 @@
---
description:
globs:
alwaysApply: false
---
# Code Style Guidelines
## Formatting Standards
- **Prettier**: 2-space indentation, single quotes, trailing commas, semicolons
- **Print width**: 80 characters
- **ESLint**: No unused imports, consistent import ordering, prefer const over let
## Naming Conventions
```typescript
// ✅ Variables and functions - camelCase
const userAccountBalance = 1000;
const calculateMonthlyPayment = () => {};
// ✅ Constants - SCREAMING_SNAKE_CASE
const API_ENDPOINTS = {
USERS: '/api/users',
ORDERS: '/api/orders',
} as const;
// ✅ Types and Classes - PascalCase
class UserService {}
type UserAccountData = {};
type ButtonProps = {}; // Component props suffix with 'Props'
// ✅ Files and directories - kebab-case
// user-profile.component.tsx
// user-profile.styles.ts
```
## Import Organization
```typescript
// ✅ Correct import order
// 1. External libraries
import React from 'react';
import { useCallback } from 'react';
import styled from 'styled-components';
// 2. Internal modules (absolute paths)
import { Button } from '@/components/ui';
import { UserService } from '@/services';
// 3. Relative imports
import { UserCardProps } from './types';
```
## Function Structure
```typescript
// ✅ Small, focused functions
// ✅ Required parameters first, optional last
const processUserData = (
user: User,
options: ProcessingOptions,
callback?: (result: ProcessedUser) => void
): ProcessedUser => {
const processedUser = transformUserData(user);
applyOptions(processedUser, options);
if (callback) {
callback(processedUser);
}
return processedUser;
};
```
## Comments
```typescript
// ✅ Explain business logic and non-obvious intentions
// Apply 15% discount for premium users with orders > $100
const discount = isPremiumUser && orderTotal > 100 ? 0.15 : 0;
// TODO: Replace with proper authentication service
const isAuthenticated = localStorage.getItem('token') !== null;
/**
* JSDoc for public APIs
* @param basePrice - The base price before modifications
* @returns The final price after tax and discount
*/
const calculateTotalPrice = (basePrice: number): number => {
// Implementation
};
```
## Error Handling
```typescript
// ✅ Proper error types and meaningful messages
try {
const user = await userService.findById(userId);
if (!user) {
throw new UserNotFoundError(`User with ID ${userId} not found`);
}
return user;
} catch (error) {
logger.error('Failed to fetch user', { userId, error });
throw error;
}
```
+207
View File
@@ -0,0 +1,207 @@
# File Structure Guidelines
## Core File Structure Principles
Twenty follows a modular and organized file structure that promotes maintainability and scalability. This document outlines our file organization conventions and best practices.
## Component Organization
### One Component Per File
- Each component should have its own file
- File name should match component name
```typescript
// ✅ Correct
// UserProfile.tsx
export const UserProfile = () => {
return <div>...</div>;
};
// ❌ Incorrect
// users.tsx
export const UserProfile = () => {
return <div>...</div>;
};
export const UserList = () => {
return <div>...</div>;
};
```
## Directory Structure
### Feature Modules
- Place features in `modules/` directory
- Group related components and logic
```
modules/
├── users/
│ ├── components/
│ │ ├── UserList.tsx
│ │ ├── UserCard.tsx
│ │ └── UserProfile.tsx
│ ├── hooks/
│ │ └── useUser.ts
│ ├── states/
│ │ └── userStates.ts
│ └── types/
│ └── user.ts
├── workspace/
│ ├── components/
│ ├── hooks/
│ └── states/
└── settings/
├── components/
├── hooks/
└── states/
```
### Hooks Organization
- Place hooks in `hooks/` directory
- Group by feature or global usage
```
hooks/
├── useClickOutside.ts
├── useDebounce.ts
└── features/
├── users/
│ ├── useUserActions.ts
│ └── useUserData.ts
└── workspace/
└── useWorkspaceSettings.ts
```
### State Management
- Place state definitions in `states/` directory
- Organize by feature
```
states/
├── global/
│ ├── theme.ts
│ └── navigation.ts
├── users/
│ ├── atoms.ts
│ └── selectors.ts
└── workspace/
├── atoms.ts
└── selectors.ts
```
### Types Organization
- Place types in `types/` directory
- Group by domain or feature
```
types/
├── common.ts
├── api.ts
└── features/
├── user.ts
├── workspace.ts
└── settings.ts
```
## Naming Conventions
### Component Files
- Use PascalCase for component files
- Use descriptive, feature-specific names
```
components/
├── UserProfile.tsx
├── UserProfileHeader.tsx
└── UserProfileContent.tsx
```
### Non-Component Files
- Use camelCase for non-component files
- Use clear, descriptive names
```
hooks/
├── useClickOutside.ts
└── useDebounce.ts
utils/
├── dateFormatter.ts
└── stringUtils.ts
```
## Module Structure
### Feature Module Organization
- Consistent structure across features
- Clear separation of concerns
```
modules/users/
├── components/
│ ├── UserList/
│ │ ├── UserList.tsx
│ │ ├── UserListItem.tsx
│ │ └── UserListHeader.tsx
│ └── UserProfile/
│ ├── UserProfile.tsx
│ └── UserProfileHeader.tsx
├── hooks/
│ ├── useUserList.ts
│ └── useUserProfile.ts
├── states/
│ ├── atoms.ts
│ └── selectors.ts
├── types/
│ └── user.ts
└── utils/
└── userFormatter.ts
```
## Best Practices
### Import Organization
- Group imports by type
- Maintain consistent order
```typescript
// External dependencies
import { useState } from 'react';
import { styled } from '@emotion/styled';
// Internal modules
import { useUser } from '~/modules/users/hooks';
import { userState } from '~/modules/users/states';
// Local imports
import { UserAvatar } from './UserAvatar';
import { type UserProfileProps } from './types';
```
### Path Aliases
- Use path aliases for better imports
- Avoid deep relative paths
```typescript
// ✅ Correct
import { Button } from '~/components/Button';
import { useUser } from '~/modules/users/hooks';
// ❌ Incorrect
import { Button } from '../../../components/Button';
import { useUser } from '../../../modules/users/hooks';
```
### Component Co-location
- Keep related files close together
- Use index files for public APIs
```
components/UserProfile/
├── UserProfile.tsx
├── UserProfileHeader.tsx
├── UserProfileContent.tsx
├── styles.ts
├── types.ts
└── index.ts
```
### Test File Location
- Place test files next to implementation
- Use `.test.ts` or `.spec.ts` extension
```
components/
├── UserProfile.tsx
├── UserProfile.test.tsx
├── UserProfile.stories.tsx
└── types.ts
```
-107
View File
@@ -1,107 +0,0 @@
---
description:
globs:
alwaysApply: false
---
# File Structure Guidelines
## Directory Organization
```
packages/twenty-front/src/
├── components/ # Reusable UI components
├── pages/ # Route components
├── modules/ # Feature modules
├── hooks/ # Custom hooks
├── services/ # API services
└── types/ # Type definitions
packages/twenty-server/src/
├── modules/ # Feature modules
├── entities/ # Database entities
├── dto/ # Data transfer objects
└── utils/ # Helper functions
```
## File Naming
- **kebab-case** for all files and directories
- **Descriptive suffixes** for clarity
```
// ✅ Correct naming
user-profile.component.tsx
user-profile.styles.ts
user-profile.test.tsx
user.service.ts
user.entity.ts
create-user.dto.ts
```
## Index Files & Barrel Exports
```typescript
// ✅ Clean barrel exports in index.ts
export { UserCard } from './user-card.component';
export { UserList } from './user-list.component';
export type { UserCardProps, UserListProps } from './types';
// ✅ Usage - clean imports
import { UserCard, UserList } from '@/components/user';
```
## Module Structure
```
src/modules/user/
├── components/ # Module-specific components
├── hooks/ # Module hooks
├── services/ # API services
├── types/ # Type definitions
└── index.ts # Module exports
```
## Import/Export Patterns
```typescript
// ✅ Import organization
// 1. External libraries
import React from 'react';
import styled from 'styled-components';
// 2. Internal modules (absolute paths)
import { Button } from '@/components/ui';
import { UserService } from '@/services';
// 3. Relative imports
import { UserCardProps } from './types';
// ✅ Named exports only (no default exports)
export const UserComponent = ({ user }: UserProps) => {
// Component implementation
};
```
## File Size Guidelines
- **Components**: Under 300 lines
- **Services**: Under 500 lines
- **Extract logic** into hooks/utilities when files grow large
- **Use composition** over large monolithic components
## Configuration Files
### Project Configuration
```
.vscode/ # VSCode settings
├── settings.json
├── extensions.json
└── launch.json
.github/ # GitHub workflows
├── workflows/
└── templates/
.cursor/ # Cursor rules
├── rules/
└── environment.json
```
### Build Configuration
- Keep build configs in root or package directories
- Use consistent naming for config files
- Comment complex configurations
- Version control all configuration files
-83
View File
@@ -1,83 +0,0 @@
---
description: Guidelines and best practices for working with Nx in the Twenty workspace, including workspace architecture understanding, configuration management, and generator usage.
globs:
alwaysApply: false
---
---
description: Guidelines and best practices for working with Nx in the Twenty workspace, including workspace architecture understanding, configuration management, and generator usage.
globs: ["**/nx.json", "**/project.json", "**/workspace.json"]
alwaysApply: false
---
# Nx Guidelines
## Core Commands
```bash
# Run target for specific project
npx nx run twenty-front:build
npx nx run twenty-server:test
# Run target for all projects
npx nx run-many --target=build --all
npx nx run-many --target=test --projects=twenty-front,twenty-server
# Generate/modify projects
npx nx g @nx/react:app my-app
npx nx g @nx/react:component my-component
```
## Project Structure
- Each package has a `project.json` with targets
- Dependencies managed through `tsconfig.json` path mappings
- Shared libraries in `packages/` directory
## Build Targets
```json
// project.json
{
"targets": {
"build": {
"executor": "@nx/vite:build",
"options": { "outputPath": "dist/packages/twenty-front" }
},
"test": {
"executor": "@nx/jest:jest",
"options": { "jestConfig": "packages/twenty-front/jest.config.ts" }
}
}
}
```
## Dependency Graph
```bash
# View project dependencies
npx nx graph
# Check what's affected by changes
npx nx affected --target=test
npx nx affected --target=build --base=main
```
## Library Management
- Use `npx nx g @nx/workspace:library` generator for shared libs
- Internal imports use `@/` path mapping
- Libraries must export through index.ts barrel files
## Cache Configuration
- Nx caches build outputs and test results
- Configure `outputs` in project.json targets
- Use `inputs` to define what invalidates cache
```json
{
"build": {
"outputs": ["dist/packages/my-app"],
"inputs": ["source", "^source"],
"cache": true
}
}
```
+220
View File
@@ -0,0 +1,220 @@
# React Guidelines
## Core React Principles
Twenty follows modern React best practices with a focus on functional components and clean, maintainable code. This document outlines our React conventions and best practices.
## Component Structure
### Functional Components Only
- Use functional components exclusively
- No class components allowed
```typescript
// ✅ Correct
export const UserProfile = ({ user }: UserProfileProps) => {
return (
<StyledContainer>
<h1>{user.name}</h1>
</StyledContainer>
);
};
// ❌ Incorrect
export class UserProfile extends React.Component<UserProfileProps> {
render() {
return (
<StyledContainer>
<h1>{this.props.user.name}</h1>
</StyledContainer>
);
}
}
```
### Named Exports
- Use named exports exclusively
- No default exports
```typescript
// ✅ Correct
export const Button = ({ label }: ButtonProps) => {
return <button>{label}</button>;
};
// ❌ Incorrect
export default function Button({ label }: ButtonProps) {
return <button>{label}</button>;
}
```
## State and Effects
### Event Handlers Over useEffect
- Prefer event handlers for state updates
- Avoid useEffect for state synchronization
```typescript
// ✅ Correct
const UserForm = () => {
const handleSubmit = async (data: FormData) => {
await updateUser(data);
refreshUserList();
};
return <Form onSubmit={handleSubmit} />;
};
// ❌ Incorrect
const UserForm = () => {
useEffect(() => {
if (formData) {
updateUser(formData);
}
}, [formData]);
return <Form />;
};
```
## Component Design
### Small, Focused Components
- Keep components small and single-purpose
- Extract reusable logic into custom hooks
```typescript
// ✅ Correct
const UserCard = ({ user }: UserCardProps) => {
return (
<StyledCard>
<UserAvatar user={user} />
<UserInfo user={user} />
<UserActions user={user} />
</StyledCard>
);
};
// ❌ Incorrect
const UserCard = ({ user }: UserCardProps) => {
return (
<StyledCard>
{/* Too much logic in one component */}
<img src={user.avatar} />
<div>{user.name}</div>
<div>{user.email}</div>
<button onClick={() => handleEdit(user)}>Edit</button>
<button onClick={() => handleDelete(user)}>Delete</button>
{/* More complex logic... */}
</StyledCard>
);
};
```
## Props
### Prop Naming
- Use clear, descriptive prop names
- Follow React conventions (onClick, isActive, etc.)
```typescript
// ✅ Correct
type ButtonProps = {
onClick: () => void;
isDisabled?: boolean;
isLoading?: boolean;
};
// ❌ Incorrect
type ButtonProps = {
clickHandler: () => void;
disabled?: boolean;
loading?: boolean;
};
```
### Prop Destructuring
- Destructure props with proper typing
- Use TypeScript for prop types
```typescript
// ✅ Correct
const Button = ({ onClick, isDisabled, children }: ButtonProps) => {
return (
<button onClick={onClick} disabled={isDisabled}>
{children}
</button>
);
};
// ❌ Incorrect
const Button = (props: ButtonProps) => {
return (
<button onClick={props.onClick} disabled={props.isDisabled}>
{props.children}
</button>
);
};
```
## Performance Optimization
### Memoization
- Use memo for expensive computations
- Avoid premature optimization
```typescript
// ✅ Correct - Complex computation
const MemoizedChart = memo(({ data }: ChartProps) => {
// Complex rendering logic
return <ComplexChart data={data} />;
});
// ❌ Incorrect - Unnecessary memoization
const MemoizedText = memo(({ text }: { text: string }) => {
return <span>{text}</span>;
});
```
### Event Handlers
- Use callback refs for DOM manipulation
- Memoize callbacks when needed
```typescript
// ✅ Correct
const UserList = () => {
const handleScroll = useCallback((event: UIEvent) => {
// Complex scroll handling
}, []);
return <div onScroll={handleScroll}>{/* content */}</div>;
};
```
## Error Handling
### Error Boundaries
- Use error boundaries for component error handling
- Provide meaningful fallback UIs
```typescript
// ✅ Correct
const ErrorFallback = ({ error }: { error: Error }) => (
<StyledError>
<h2>Something went wrong</h2>
<pre>{error.message}</pre>
</StyledError>
);
const SafeComponent = () => (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<ComponentThatMightError />
</ErrorBoundary>
);
```
### Loading States
- Handle loading states gracefully
- Provide meaningful loading indicators
```typescript
// ✅ Correct
const UserProfile = () => {
const { data: user, isLoading, error } = useUser();
if (isLoading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
if (!user) return <NotFound />;
return <UserProfileContent user={user} />;
};
```
@@ -1,86 +0,0 @@
---
description:
globs:
alwaysApply: false
---
# React Guidelines
## Core Rules
- **Functional components only** (no classes)
- **Named exports only** (no default exports)
- **Event handlers over useEffect** for state updates
## Component Structure
```typescript
// ✅ Correct
export const UserProfile = ({ user, onEdit }: UserProfileProps) => {
const handleEdit = () => onEdit(user.id);
return (
<StyledContainer>
<h1>{user.name}</h1>
<Button onClick={handleEdit}>Edit</Button>
</StyledContainer>
);
};
```
## Props & Event Handlers
```typescript
// ✅ Correct - Destructure props
const Button = ({ onClick, isDisabled, children }: ButtonProps) => (
<button onClick={onClick} disabled={isDisabled}>
{children}
</button>
);
// ✅ Correct - Event handlers over useEffect
const UserForm = ({ onSubmit }: UserFormProps) => {
const handleSubmit = async (data: FormData) => {
await onSubmit(data);
// Direct event handling, not useEffect
};
return <Form onSubmit={handleSubmit} />;
};
```
## Component Design
- **Small, focused components** - Single responsibility
- **Composition over inheritance** - Combine simple components
- **Extract complex logic** into custom hooks
```typescript
// ✅ Good - Composed from smaller components
const UserCard = ({ user }: UserCardProps) => (
<StyledCard>
<UserAvatar user={user} />
<UserInfo user={user} />
<UserActions user={user} />
</StyledCard>
);
```
## Performance
```typescript
// ✅ Use memo for expensive components only
const ExpensiveChart = memo(({ data }: ChartProps) => {
// Complex rendering logic
return <ComplexChart data={data} />;
});
// ✅ Memoize callbacks when needed
const UserList = ({ users, onUserSelect }: UserListProps) => {
const handleUserSelect = useCallback((user: User) => {
onUserSelect(user);
}, [onUserSelect]);
return (
<div>
{users.map(user => (
<UserItem key={user.id} user={user} onSelect={handleUserSelect} />
))}
</div>
);
};
```
@@ -0,0 +1,219 @@
# State Management Guidelines
## Core State Management Principles
Twenty uses a combination of Recoil for global state and Apollo Client for server state management. This document outlines our state management conventions and best practices.
## Global State Management
### Recoil Usage
- Use Recoil for global application state
- Keep atoms small and focused
```typescript
// ✅ Correct
// states/theme.ts
export const themeState = atom<'light' | 'dark'>({
key: 'themeState',
default: 'light',
});
// states/user.ts
export const userState = atom<User | null>({
key: 'userState',
default: null,
});
// ❌ Incorrect
// states/globalState.ts
export const globalState = atom({
key: 'globalState',
default: {
theme: 'light',
user: null,
settings: {},
// ... many other unrelated pieces of state
},
});
```
### Atom Organization
- Place atoms in the `states/` directory
- Group related atoms in feature-specific files
```typescript
// states/workspace/atoms.ts
export const workspaceIdState = atom<string>({
key: 'workspaceIdState',
default: '',
});
export const workspaceSettingsState = atom<WorkspaceSettings>({
key: 'workspaceSettingsState',
default: defaultSettings,
});
```
## Server State Management
### Apollo Client Usage
- Use Apollo Client for all GraphQL operations
- Leverage Apollo's caching capabilities
```typescript
// ✅ Correct
const { data, loading } = useQuery(GET_USER_QUERY, {
variables: { id },
fetchPolicy: 'cache-first',
});
// ❌ Incorrect
const [user, setUser] = useState(null);
useEffect(() => {
fetch('/api/user/' + id).then(setUser);
}, [id]);
```
### Query Organization
- Separate operation files
- Use fragments for shared fields
```typescript
// queries/user.ts
export const UserFragment = gql`
fragment UserFields on User {
id
name
email
}
`;
export const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) {
...UserFields
}
}
${UserFragment}
`;
```
## State Management Best Practices
### Multiple Small Atoms
- Prefer multiple small atoms over prop drilling
- Keep atoms focused on specific features
```typescript
// ✅ Correct
export const selectedViewState = atom<string>({
key: 'selectedViewState',
default: '',
});
export const viewFiltersState = atom<ViewFilters>({
key: 'viewFiltersState',
default: {},
});
// ❌ Incorrect - Prop drilling
const ViewContainer = ({ selectedView, filters, onViewChange }) => {
return (
<ViewHeader view={selectedView} onViewChange={onViewChange}>
<ViewContent>
<ViewFilters filters={filters} />
</ViewContent>
</ViewHeader>
);
};
```
### No useRef for State
- Never use useRef for state management
- Use proper state management tools
```typescript
// ✅ Correct
const [count, setCount] = useState(0);
// or
const [count, setCount] = useRecoilState(countState);
// ❌ Incorrect
const countRef = useRef(0);
```
### Data Fetching
- Extract data fetching to sibling components
- Keep components focused on presentation
```typescript
// ✅ Correct
const UserProfileContainer = () => {
const { data, loading } = useQuery(GET_USER);
if (loading) return <LoadingSpinner />;
return <UserProfile user={data.user} />;
};
const UserProfile = ({ user }: UserProfileProps) => {
return <div>{user.name}</div>;
};
// ❌ Incorrect
const UserProfile = () => {
const { data, loading } = useQuery(GET_USER);
if (loading) return <LoadingSpinner />;
return <div>{data.user.name}</div>;
};
```
### Hook Usage
- Use appropriate hooks for state access
- Choose between useRecoilValue and useRecoilState based on needs
```typescript
// ✅ Correct - Read-only access
const theme = useRecoilValue(themeState);
// ✅ Correct - Read-write access
const [theme, setTheme] = useRecoilState(themeState);
// ❌ Incorrect - Using state setter when only reading
const [theme, _] = useRecoilState(themeState);
```
## Performance Considerations
### Selector Usage
- Use selectors for derived state
- Memoize complex calculations
```typescript
// ✅ Correct
const filteredUsersState = selector({
key: 'filteredUsersState',
get: ({ get }) => {
const users = get(usersState);
const filter = get(userFilterState);
return users.filter(user =>
user.name.toLowerCase().includes(filter.toLowerCase())
);
},
});
// ❌ Incorrect - Calculating in component
const UserList = () => {
const users = useRecoilValue(usersState);
const filter = useRecoilValue(userFilterState);
const filteredUsers = users.filter(user =>
user.name.toLowerCase().includes(filter.toLowerCase())
);
return <List users={filteredUsers} />;
};
```
### Cache Management
- Configure appropriate cache policies
- Handle cache invalidation properly
```typescript
// ✅ Correct
const [updateUser] = useMutation(UPDATE_USER, {
update: (cache, { data }) => {
cache.modify({
id: cache.identify(data.updateUser),
fields: {
name: () => data.updateUser.name,
},
});
},
});
```
-81
View File
@@ -1,81 +0,0 @@
---
description:
globs:
alwaysApply: false
---
# React State Management
## Recoil Patterns
```typescript
// ✅ Atoms for primitive state
export const currentUserState = atom<User | null>({
key: 'currentUserState',
default: null,
});
// ✅ Selectors for derived state
export const userDisplayNameSelector = selector({
key: 'userDisplayNameSelector',
get: ({ get }) => {
const user = get(currentUserState);
return user ? `${user.firstName} ${user.lastName}` : 'Guest';
},
});
// ✅ Atom families for dynamic atoms
export const userByIdState = atomFamily<User | null, string>({
key: 'userByIdState',
default: null,
});
```
## Local State Guidelines
```typescript
// ✅ Multiple useState for unrelated state
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<User[]>([]);
// ✅ useReducer for complex state logic
type FormAction =
| { type: 'SET_FIELD'; field: string; value: string }
| { type: 'SET_ERRORS'; errors: Record<string, string> }
| { type: 'RESET' };
const formReducer = (state: FormState, action: FormAction): FormState => {
switch (action.type) {
case 'SET_FIELD':
return { ...state, [action.field]: action.value };
case 'SET_ERRORS':
return { ...state, errors: action.errors };
case 'RESET':
return initialFormState;
default:
return state;
}
};
```
## Data Flow Rules
- **Props down, events up** - Unidirectional data flow
- **Avoid bidirectional binding** - Use callback functions
- **Normalize complex data** - Use lookup tables over nested objects
```typescript
// ✅ Normalized state structure
type UsersState = {
byId: Record<string, User>;
allIds: string[];
};
// ✅ Functional state updates
const increment = useCallback(() => {
setCount(prev => prev + 1);
}, []);
```
## Performance Tips
- Use atom families for dynamic data collections
- Implement proper selector caching
- Avoid heavy computations in selectors
- Batch state updates when possible
+253
View File
@@ -0,0 +1,253 @@
# Testing Guidelines
## Core Testing Principles
Twenty follows a comprehensive testing strategy across all packages, ensuring high-quality, maintainable code. This document outlines our testing conventions and best practices.
## Testing Stack
### Backend Testing
- Primary Framework: Jest
- API Testing: Supertest
- Coverage Requirements: 80% minimum
### Frontend Testing
- Component Testing: Jest + React Testing Library
- Visual Testing: Storybook
- API Mocking: MSW (Mock Service Worker)
### End-to-End Testing
- Framework: Playwright
- Coverage: Critical user journeys
- Cross-browser testing
## Test Organization
### Test File Location
- Co-locate tests with implementation files
- Use consistent naming patterns
```
src/
├── components/
│ ├── UserProfile.tsx
│ ├── UserProfile.test.tsx
│ └── UserProfile.stories.tsx
```
### Test File Naming
- Use `.test.ts(x)` for unit/integration tests
- Use `.spec.ts(x)` for E2E tests
- Use `.stories.tsx` for Storybook stories
## Unit Testing
### Component Testing
- Test behavior, not implementation
- Use React Testing Library best practices
```typescript
// ✅ Correct
test('displays user name when provided', () => {
render(<UserProfile user={{ name: 'John Doe' }} />);
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
// ❌ Incorrect - Testing implementation details
test('sets the text content', () => {
const { container } = render(<UserProfile user={{ name: 'John Doe' }} />);
expect(container.querySelector('h1').textContent).toBe('John Doe');
});
```
### Hook Testing
- Use `renderHook` from @testing-library/react-hooks
- Test all possible states
```typescript
// ✅ Correct
test('useUser hook manages user state', () => {
const { result } = renderHook(() => useUser());
act(() => {
result.current.setUser({ id: '1', name: 'John' });
});
expect(result.current.user).toEqual({ id: '1', name: 'John' });
});
```
### Mocking
- Mock external dependencies
- Use jest.mock for module mocking
```typescript
// ✅ Correct
jest.mock('~/services/api', () => ({
fetchUser: jest.fn().mockResolvedValue({ id: '1', name: 'John' }),
}));
test('fetches and displays user', async () => {
render(<UserProfile userId="1" />);
expect(await screen.findByText('John')).toBeInTheDocument();
});
```
## Integration Testing
### API Testing
- Test complete request/response cycles
- Use Supertest for backend API testing
```typescript
// ✅ Correct
describe('GET /api/users/:id', () => {
it('returns user when found', async () => {
const response = await request(app)
.get('/api/users/1')
.expect(200);
expect(response.body).toEqual({
id: '1',
name: 'John Doe',
});
});
it('returns 404 when user not found', async () => {
await request(app)
.get('/api/users/999')
.expect(404);
});
});
```
## E2E Testing
### Test Structure
- Organize by user journey
- Use page objects for reusability
```typescript
// pages/login.ts
export class LoginPage {
async login(email: string, password: string) {
await this.page.fill('[data-testid="email-input"]', email);
await this.page.fill('[data-testid="password-input"]', password);
await this.page.click('[data-testid="login-button"]');
}
}
// tests/auth.spec.ts
test('user can login successfully', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.login('user@example.com', 'password');
await expect(page).toHaveURL('/dashboard');
});
```
### Test Data
- Use dedicated test environments
- Reset state between tests
```typescript
// ✅ Correct
beforeEach(async () => {
await resetDatabase();
await seedTestData();
});
test('user workflow', async ({ page }) => {
// Test with clean, predictable state
});
```
## Visual Testing
### Storybook Guidelines
- Create stories for all components
- Document component variants
```typescript
// Button.stories.tsx
export default {
title: 'Components/Button',
component: Button,
} as Meta;
export const Primary = {
args: {
variant: 'primary',
label: 'Primary Button',
},
};
export const Secondary = {
args: {
variant: 'secondary',
label: 'Secondary Button',
},
};
```
### Visual Regression
- Use Storybook's visual regression testing
- Review changes carefully
```typescript
// jest.config.js
module.exports = {
preset: 'jest-image-snapshot',
setupFilesAfterEnv: ['<rootDir>/setup-tests.ts'],
};
// Button.visual.test.tsx
describe('Button', () => {
it('matches visual snapshot', async () => {
const image = await page.screenshot();
expect(image).toMatchImageSnapshot();
});
});
```
## Test Quality
### Test Data Attributes
- Use data-testid for test selectors
- Avoid selecting by CSS classes
```typescript
// ✅ Correct
<button data-testid="submit-button">Submit</button>
// In tests
const button = screen.getByTestId('submit-button');
// ❌ Incorrect
const button = container.querySelector('.submit-btn');
```
### Assertion Best Practices
- Use explicit assertions
- Test both positive and negative cases
```typescript
// ✅ Correct
test('form validation', async () => {
render(<UserForm />);
// Negative case
await userEvent.click(screen.getByText('Submit'));
expect(screen.getByText('Name is required')).toBeInTheDocument();
// Positive case
await userEvent.type(screen.getByLabelText('Name'), 'John Doe');
await userEvent.click(screen.getByText('Submit'));
expect(screen.queryByText('Name is required')).not.toBeInTheDocument();
});
```
### Coverage Requirements
- A new feature should have at least 80% coverage
- Focus on critical paths
- Run coverage reports in CI
```typescript
// jest.config.js
module.exports = {
coverageThreshold: {
global: {
statements: 80,
branches: 80,
functions: 80,
lines: 80,
},
},
};
```
-89
View File
@@ -1,89 +0,0 @@
---
description:
globs:
alwaysApply: false
---
# Testing Guidelines
## Test Structure (AAA Pattern)
```typescript
describe('UserService', () => {
describe('when getting user by ID', () => {
it('should return user data for valid ID', async () => {
// Arrange
const userId = '123';
const expectedUser = { id: '123', name: 'John' };
mockUserRepository.findById.mockResolvedValue(expectedUser);
// Act
const result = await userService.getUserById(userId);
// Assert
expect(result).toEqual(expectedUser);
});
});
});
```
## React Component Testing
```typescript
// ✅ Test user behavior, not implementation
describe('LoginForm', () => {
it('should display error message for invalid credentials', async () => {
const mockOnSubmit = jest.fn().mockRejectedValue(new Error('Invalid credentials'));
render(<LoginForm onSubmit={mockOnSubmit} />);
await user.type(screen.getByLabelText(/email/i), 'invalid@example.com');
await user.type(screen.getByLabelText(/password/i), 'wrongpassword');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(await screen.findByText(/invalid credentials/i)).toBeInTheDocument();
});
});
```
## Mocking Patterns
```typescript
// ✅ Service mocking
const mockEmailService = {
sendEmail: jest.fn().mockResolvedValue({ success: true }),
validateEmail: jest.fn().mockReturnValue(true),
};
// ✅ Test data factories
const createTestUser = (overrides = {}) => ({
id: uuid(),
email: 'test@example.com',
name: 'Test User',
...overrides,
});
beforeEach(() => {
jest.clearAllMocks();
});
```
## Testing Principles
- **Test behavior, not implementation** - Focus on what users see/do
- **Use descriptive test names** - "should [behavior] when [condition]"
- **Query by user-visible elements** - text, roles, labels over test IDs
- **Keep tests isolated** - Independent and repeatable
- **70% unit, 20% integration, 10% E2E** - Test pyramid
## Common Patterns
```typescript
// Async testing
await waitFor(() => {
expect(screen.getByText('Loading...')).not.toBeInTheDocument();
});
// User interactions
await user.click(screen.getByRole('button'));
await user.type(screen.getByLabelText(/search/i), 'query');
// API integration tests
const response = await request(app)
.post('/api/users')
.send(userData)
.expect(201);
```
+162
View File
@@ -0,0 +1,162 @@
# Translation Guidelines
## Core Translation Principles
Twenty uses Lingui for internationalization (i18n) and Crowdin for translation management. This document outlines our translation workflow and best practices.
## Technology Stack
### Translation Tools
- **Framework**: @lingui/react
- **Translation Management**: Crowdin
- **Workflow**: GitHub Actions for automation
### Package Structure
Translation files are managed in multiple packages:
- `twenty-front`: Frontend translations
- `twenty-server`: Backend translations
- `twenty-emails`: Email template translations
## Translation Process
### Adding New Strings
#### Using Lingui Macros
- Use `<Trans>` for components
- Use `t` macro for strings outside JSX
```typescript
// ✅ Correct - In JSX
import { Trans } from '@lingui/react/macro';
const WelcomeMessage = () => (
<h1>
<Trans>Welcome to Twenty</Trans>
</h1>
);
// ✅ Correct - Outside JSX
import { t } from '@lingui/react/macro';
const getMessage = () => {
return t`Welcome to Twenty`;
};
// ❌ Incorrect - Don't use raw strings
const WelcomeMessage = () => (
<h1>Welcome to Twenty</h1>
);
```
### String Guidelines
#### What to Translate
- User interface text
- Error messages
- Notifications
- Email content
#### What Not to Translate
- Variables
- Test data/mocks
### Translation Workflow
#### 1. Extracting Translations
- Automatically triggered on main branch changes
- Can be manually triggered in GitHub Actions
- Process:
```bash
# Extract new strings
nx run twenty-front:lingui:extract
nx run twenty-server:lingui:extract
nx run twenty-emails:lingui:extract
```
#### 2. Translation Management
- Translations are managed in Crowdin
- Changes are synced every 2 hours
- Process:
1. New strings are uploaded to Crowdin
2. Translators work on translations
3. Translations are pulled back to the repository
#### 3. Compiling Translations
- Happens automatically in CI/CD
- Required before running the application
```bash
# Compile translations
nx run twenty-front:lingui:compile
nx run twenty-server:lingui:compile
nx run twenty-emails:lingui:compile
```
## Best Practices
### String Management
#### Use Placeholders
- Use placeholders for dynamic content
```typescript
// ✅ Correct
<Trans>Hello {userName},</Trans>
// ❌ Incorrect - String concatenation
<Trans>Hello </Trans>{userName},
```
#### Provide Context
- Lingui provides powerfulway to add context for translators but we don't use them as of today.
### Code Organization
#### Translation Files
- Keep translation files organized by feature
- Use consistent naming patterns
```
src/
├── locales/
│ ├── en/
│ │ ├── messages.po
│ │ └── messages.js
│ └── fr/
│ ├── messages.po
│ └── messages.js
```
### Quality Assurance
#### Strict Mode
- Use --strict mode when compiling to identify missing translations
#### Testing Translations
- Test with different locales
- Verify string interpolation
- Check layout with different language lengths
## Automation
### GitHub Actions
#### Pull Workflow
- Runs every 2 hours
- Downloads new translations from Crowdin
- Creates PR if changes detected
- Can be manually triggered with force pull option
#### Push Workflow
- Runs on main branch changes
- Extracts and uploads new strings
- Compiles translations
- Creates PR with changes
### Error Handling
#### Missing Translations
- Development: Shown in original language
- Production: Falls back to default language
- Strict mode in CI catches missing translations
#### Compilation Errors
- Addressed before merging
- PR created for fixing missing translations
- Automated testing in CI pipeline
-291
View File
@@ -1,291 +0,0 @@
---
description:
globs:
alwaysApply: false
---
# Translation Guidelines
## Internationalization (i18n) Overview
### Supported Languages
- English (en) - Primary language
- French (fr) - Secondary language
- German (de) - Planned
- Spanish (es) - Planned
- Additional languages based on community contributions
### i18n Architecture
- Use react-i18next for React components
- Store translations in JSON files
- Implement namespace-based organization
- Support for interpolation and pluralization
## File Structure
### Translation Files
```
src/locales/
├── en/ # English translations
│ ├── common.json # Common UI strings
│ ├── auth.json # Authentication strings
│ ├── dashboard.json # Dashboard specific
│ ├── forms.json # Form labels and validation
│ └── errors.json # Error messages
├── fr/ # French translations
│ ├── common.json
│ ├── auth.json
│ └── ...
└── index.ts # i18n configuration
```
### Translation Keys
- Use nested objects for organization
- Follow consistent naming patterns
- Include context in key names
```json
{
"auth": {
"login": {
"title": "Sign In",
"email": "Email Address",
"password": "Password",
"submit": "Sign In",
"forgotPassword": "Forgot Password?"
},
"register": {
"title": "Create Account",
"confirmPassword": "Confirm Password"
}
}
}
```
## Translation Implementation
### React Components
- Use useTranslation hook
- Specify namespaces for better organization
- Handle loading states properly
```typescript
// ✅ Correct
import { useTranslation } from 'react-i18next';
const LoginForm = () => {
const { t } = useTranslation('auth');
return (
<form>
<h1>{t('login.title')}</h1>
<input
placeholder={t('login.email')}
type="email"
/>
<input
placeholder={t('login.password')}
type="password"
/>
<button type="submit">
{t('login.submit')}
</button>
</form>
);
};
```
### Interpolation
- Use interpolation for dynamic content
- Pass variables through t() function
- Keep interpolation simple and readable
```typescript
// ✅ Correct
const WelcomeMessage = ({ userName }: { userName: string }) => {
const { t } = useTranslation('common');
return (
<h1>{t('welcome.message', { name: userName })}</h1>
);
};
// Translation file
{
"welcome": {
"message": "Welcome back, {{name}}!"
}
}
```
### Pluralization
- Handle singular/plural forms correctly
- Use count-based pluralization
- Support different plural rules per language
```typescript
// ✅ Correct
const ItemCount = ({ count }: { count: number }) => {
const { t } = useTranslation('common');
return (
<span>{t('items.count', { count })}</span>
);
};
// Translation file
{
"items": {
"count_one": "{{count}} item",
"count_other": "{{count}} items"
}
}
```
## Translation Management
### Adding New Strings
1. Add English translation first
2. Use descriptive keys that indicate context
3. Include comments for translators when needed
4. Test with long translations to ensure UI flexibility
```json
{
"user": {
"profile": {
// Displayed in user profile header
"displayName": "Display Name",
// Used in forms when editing profile
"editDisplayName": "Edit Display Name",
// Confirmation message after profile update
"updateSuccess": "Profile updated successfully"
}
}
}
```
### Translation Validation
- Use TypeScript for translation key validation
- Implement automated checks for missing translations
- Validate interpolation parameters
```typescript
// ✅ Correct - Type-safe translations
type TranslationKey =
| 'auth.login.title'
| 'auth.login.email'
| 'auth.login.password'
| 'common.welcome.message';
const t = (key: TranslationKey, options?: any) => {
// Translation implementation
};
```
## Best Practices
### Key Naming
- Use descriptive, hierarchical keys
- Avoid abbreviations
- Group related translations
- Keep keys consistent across languages
```json
// ✅ Correct
{
"dashboard": {
"header": {
"title": "Dashboard",
"subtitle": "Welcome to your workspace"
},
"actions": {
"createNew": "Create New",
"refresh": "Refresh Data",
"export": "Export"
}
}
}
// ❌ Incorrect
{
"dash_title": "Dashboard",
"newBtn": "New",
"refreshData": "Refresh"
}
```
### String Guidelines
- Write clear, concise text
- Use consistent terminology
- Consider character limits for UI elements
- Avoid concatenating translated strings
```json
// ✅ Correct
{
"user": {
"status": {
"online": "Online",
"offline": "Offline",
"away": "Away"
}
}
}
// ❌ Incorrect - Don't concatenate
{
"user": {
"statusPrefix": "User is ",
"statusOnline": "online"
}
}
```
### Context Information
- Provide context for translators
- Include character limits when relevant
- Explain when/where text appears
- Note any technical constraints
```json
{
"button": {
// Primary action button, max 20 characters
"save": "Save Changes",
// Secondary button in modal footer
"cancel": "Cancel",
// Destructive action, should sound cautious
"delete": "Delete Permanently"
}
}
```
## Workflow
### Development Process
1. Develop features with English translations
2. Use placeholder keys during development
3. Finalize translation keys before feature completion
4. Add translations to all supported languages
5. Test with different language strings
### Translation Updates
1. Create translation tasks for new features
2. Provide context and screenshots to translators
3. Review translations for consistency
4. Test UI with translated strings
5. Update documentation when needed
### Quality Assurance
- Review translations in context
- Test with longest expected translations
- Verify formatting with interpolation
- Check for cultural appropriateness
- Ensure accessibility with screen readers
## Maintenance
### Regular Tasks
- Review and update outdated translations
- Check for unused translation keys
- Maintain consistency across languages
- Update translation documentation
- Monitor for missing translations in new features
### Tools and Automation
- Use automated translation validation
- Implement missing translation detection
- Set up continuous integration checks
- Maintain translation coverage reports
- Use translation management platforms when needed
@@ -1,14 +1,3 @@
---
description:
globs:
alwaysApply: false
---
---
description: TypeScript best practices and conventions for the Twenty codebase, including strict typing, naming conventions, and type safety guidelines.
globs: ["**/*.ts", "**/*.tsx"]
alwaysApply: false
---
# TypeScript Guidelines
## Core TypeScript Principles
@@ -180,4 +169,4 @@ Twenty enforces strict TypeScript usage to ensure type safety and maintainable c
type NonNullableProperties<T> = {
[P in keyof T]: NonNullable<T[P]>;
};
```
```
-1
View File
@@ -2,4 +2,3 @@
.env
node_modules
.nx/cache
packages/twenty-server/.env
@@ -10,8 +10,7 @@ inputs:
configuration:
required: false
default: 'ci'
args:
required: false
runs:
using: "composite"
steps:
@@ -19,4 +18,4 @@ runs:
uses: nrwl/nx-set-shas@v4
- name: Run affected command
shell: bash
run: npx nx affected --nxBail --configuration=${{ inputs.configuration }} -t=${{ inputs.tasks }} --parallel=${{ inputs.parallel }} --exclude='*,!tag:${{ inputs.tag }}' ${{ inputs.args }}
run: npx nx affected --nxBail --configuration=${{ inputs.configuration }} -t=${{ inputs.tasks }} --parallel=${{ inputs.parallel }} --exclude='*,!tag:${{ inputs.tag }}'
@@ -2,7 +2,7 @@ name: Yarn Install
inputs:
node-version:
required: false
default: '22'
default: '18'
runs:
using: "composite"
-777
View File
@@ -1,777 +0,0 @@
name: GraphQL and OpenAPI Breaking Changes Detection
on:
pull_request:
types: [opened, synchronize, edited]
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
MAIN_SERVER_PORT: 3000
CURRENT_SERVER_PORT: 3002
permissions:
contents: read
pull-requests: write
checks: write
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
package.json
packages/twenty-server/**
packages/twenty-emails/**
packages/twenty-shared/**
api-breaking-changes:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 45
runs-on: depot-ubuntu-24.04-8
env:
NX_REJECT_UNKNOWN_LOCAL_CACHE: 0
services:
postgres:
image: twentycrm/twenty-postgres-spilo
env:
PGUSER_SUPERUSER: postgres
PGPASSWORD_SUPERUSER: postgres
ALLOW_NOSSL: 'true'
SPILO_PROVIDER: 'local'
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
clickhouse:
image: clickhouse/clickhouse-server:latest
env:
CLICKHOUSE_PASSWORD: clickhousePassword
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
ports:
- 8123:8123
- 9000:9000
options: >-
--health-cmd "clickhouse-client --host=localhost --port=9000 --user=default --password=clickhousePassword --query='SELECT 1'"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout current branch
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/workflows/actions/yarn-install
- name: Build shared dependencies
run: |
npx nx build twenty-shared
npx nx build twenty-emails
- name: Build current branch server
run: npx nx build twenty-server
- name: Setup databases
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "current_branch";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "main_branch";'
- name: Run ClickHouse migrations
run: npx nx clickhouse:migrate twenty-server
env:
CLICKHOUSE_URL: http://default:clickhousePassword@localhost:8123/twenty
CLICKHOUSE_PASSWORD: clickhousePassword
- name: Setup current branch database
run: |
npx nx reset:env twenty-server
# Function to set or update environment variable
set_env_var() {
local var_name="$1"
local var_value="$2"
local env_file="packages/twenty-server/.env"
if grep -q "^${var_name}=" "$env_file"; then
sed -i "s|^${var_name}=.*|${var_name}=${var_value}|" "$env_file"
else
echo "${var_name}=${var_value}" >> "$env_file"
fi
}
set_env_var "PG_DATABASE_URL" "postgres://postgres:postgres@localhost:5432/current_branch"
set_env_var "NODE_PORT" "${{ env.CURRENT_SERVER_PORT }}"
set_env_var "REDIS_URL" "redis://localhost:6379"
set_env_var "CLICKHOUSE_URL" "http://default:clickhousePassword@localhost:8123/twenty"
set_env_var "CLICKHOUSE_PASSWORD" "clickhousePassword"
npx nx run twenty-server:database:init:prod
npx nx run twenty-server:database:migrate:prod
- name: Seed current branch database with test data
run: |
npx nx command-no-deps twenty-server -- workspace:seed:dev
- name: Start current branch server in background
run: |
echo "=== Current branch .env file contents ==="
cat packages/twenty-server/.env
echo "=== Starting current branch server ==="
nohup npx nx run twenty-server:start:prod > /tmp/current-server.log 2>&1 &
echo $! > /tmp/current-server.pid
echo "Current server PID: $(cat /tmp/current-server.pid)"
- name: Wait for current branch server to be ready
run: |
echo "Waiting for current branch server to start..."
timeout=300
interval=5
elapsed=0
while [ $elapsed -lt $timeout ]; do
if curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/graphql" > /dev/null 2>&1 && \
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/core" > /dev/null 2>&1; then
echo "Current branch server is ready!"
break
fi
echo "Current branch server not ready yet, waiting ${interval}s..."
sleep $interval
elapsed=$((elapsed + interval))
done
if [ $elapsed -ge $timeout ]; then
echo "Timeout waiting for current branch server to start"
echo "Current server log:"
cat /tmp/current-server.log || echo "No current server log found"
exit 1
fi
- name: Download GraphQL and REST responses from current branch
run: |
# Admin token from jest-integration.config.ts
ADMIN_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwiaWF0IjoxNzM5NTQ3NjYxLCJleHAiOjMzMjk3MTQ3NjYxfQ.fbOM9yhr3jWDicPZ1n771usUURiPGmNdeFApsgrbxOw"
# Load introspection query from file
INTROSPECTION_QUERY=$(cat packages/twenty-utils/graphql-introspection-query.graphql)
# Prepare the query payload
QUERY_PAYLOAD=$(echo "$INTROSPECTION_QUERY" | tr '\n' ' ' | sed 's/"/\\"/g')
echo "Downloading GraphQL schema from current server..."
curl -X POST "http://localhost:${{ env.CURRENT_SERVER_PORT }}/graphql" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-d "{\"query\":\"${QUERY_PAYLOAD}\"}" \
-o current-schema-introspection.json \
-w "HTTP Status: %{http_code}\n" \
-s
echo "Downloading GraphQL metadata schema from current server..."
curl -X POST "http://localhost:${{ env.CURRENT_SERVER_PORT }}/metadata" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-d "{\"query\":\"${QUERY_PAYLOAD}\"}" \
-o current-metadata-schema-introspection.json \
-w "HTTP Status: %{http_code}\n" \
-s
# Download current branch OpenAPI specs
echo "Downloading OpenAPI specifications from current server..."
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/core" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-o current-rest-api.json \
-w "HTTP Status: %{http_code}\n"
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/metadata" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-o current-rest-metadata-api.json \
-w "HTTP Status: %{http_code}\n"
# Verify the downloads
echo "Current branch files downloaded:"
ls -la current-*
- name: Preserve current branch files
run: |
# Create a temp directory to store current branch files
mkdir -p /tmp/current-branch-files
# Move current branch files to temp directory
mv current-* /tmp/current-branch-files/ 2>/dev/null || echo "No current-* files to preserve"
echo "Preserved current branch files for later restoration"
- name: Stop current branch server
run: |
if [ -f /tmp/current-server.pid ]; then
echo "Stopping current branch server..."
kill $(cat /tmp/current-server.pid) || true
# Wait a bit for graceful shutdown
sleep 5
# Force kill if still running
kill -9 $(cat /tmp/current-server.pid) 2>/dev/null || true
rm -f /tmp/current-server.pid
fi
- name: Checkout main branch
run: |
git stash
git checkout origin/main
git clean -fd
- name: Install dependencies for main branch
uses: ./.github/workflows/actions/yarn-install
- name: Build main branch dependencies
run: |
npx nx build twenty-shared
npx nx build twenty-emails
- name: Build main branch server
run: npx nx build twenty-server
- name: Setup main branch database
run: |
# Function to set or update environment variable
set_env_var() {
local var_name="$1"
local var_value="$2"
local env_file="packages/twenty-server/.env"
if grep -q "^${var_name}=" "$env_file"; then
sed -i "s|^${var_name}=.*|${var_name}=${var_value}|" "$env_file"
else
echo "${var_name}=${var_value}" >> "$env_file"
fi
}
set_env_var "PG_DATABASE_URL" "postgres://postgres:postgres@localhost:5432/main_branch"
set_env_var "NODE_PORT" "${{ env.MAIN_SERVER_PORT }}"
npx nx run twenty-server:database:init:prod
npx nx run twenty-server:database:migrate:prod
- name: Seed main branch database with test data
run: |
npx nx command-no-deps twenty-server -- workspace:seed:dev
- name: Start main branch server in background
run: |
echo "=== Main branch .env file contents ==="
cat packages/twenty-server/.env
echo "=== Starting main branch server ==="
nohup npx nx run twenty-server:start:prod > /tmp/main-server.log 2>&1 &
echo $! > /tmp/main-server.pid
echo "Main server PID: $(cat /tmp/main-server.pid)"
- name: Wait for main branch server to be ready
run: |
echo "Waiting for main branch server to start..."
timeout=300
interval=5
elapsed=0
while [ $elapsed -lt $timeout ]; do
if curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/graphql" > /dev/null 2>&1 && \
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/core" > /dev/null 2>&1; then
echo "Main branch server is ready!"
break
fi
echo "Main branch server not ready yet, waiting ${interval}s..."
sleep $interval
elapsed=$((elapsed + interval))
done
if [ $elapsed -ge $timeout ]; then
echo "Timeout waiting for main branch server to start"
echo "Main server log:"
cat /tmp/main-server.log || echo "No main server log found"
exit 1
fi
- name: Download GraphQL and REST responses from main branch
run: |
# Admin token from jest-integration.config.ts
ADMIN_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwiaWF0IjoxNzM5NTQ3NjYxLCJleHAiOjMzMjk3MTQ3NjYxfQ.fbOM9yhr3jWDicPZ1n771usUURiPGmNdeFApsgrbxOw"
# Load introspection query from file
INTROSPECTION_QUERY=$(cat packages/twenty-utils/graphql-introspection-query.graphql)
# Prepare the query payload
QUERY_PAYLOAD=$(echo "$INTROSPECTION_QUERY" | tr '\n' ' ' | sed 's/"/\\"/g')
echo "Downloading GraphQL schema from main server..."
curl -X POST "http://localhost:${{ env.MAIN_SERVER_PORT }}/graphql" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-d "{\"query\":\"${QUERY_PAYLOAD}\"}" \
-o main-schema-introspection.json \
-w "HTTP Status: %{http_code}\n" \
-s
echo "Downloading GraphQL metadata schema from main server..."
curl -X POST "http://localhost:${{ env.MAIN_SERVER_PORT }}/metadata" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-d "{\"query\":\"${QUERY_PAYLOAD}\"}" \
-o main-metadata-schema-introspection.json \
-w "HTTP Status: %{http_code}\n" \
-s
# Download main branch OpenAPI specs
echo "Downloading OpenAPI specifications from main server..."
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/core" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-o main-rest-api.json \
-w "HTTP Status: %{http_code}\n"
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/metadata" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-o main-rest-metadata-api.json \
-w "HTTP Status: %{http_code}\n"
# Verify the downloads
echo "Main branch files downloaded:"
ls -la main-*
- name: Restore current branch files
run: |
# Move current branch files back to working directory
mv /tmp/current-branch-files/* . 2>/dev/null || echo "No files to restore"
# Verify all files are present
echo "All API files restored:"
ls -la current-* main-* 2>/dev/null || echo "Some files may be missing"
# Clean up temp directory
rm -rf /tmp/current-branch-files
- name: Install OpenAPI Diff Tool
run: |
# Using the Java-based OpenAPITools/openapi-diff via Docker
echo "Using OpenAPITools/openapi-diff via Docker"
- name: Generate GraphQL Schema Diff Reports
run: |
echo "=== INSTALLING GRAPHQL INSPECTOR CLI ==="
npm install -g @graphql-inspector/cli
echo "=== GENERATING GRAPHQL DIFF REPORTS ==="
# Check if GraphQL schema has changes
echo "Checking GraphQL schema for changes..."
if graphql-inspector diff main-schema-introspection.json current-schema-introspection.json >/dev/null 2>&1; then
echo "✅ No changes in GraphQL schema"
# Don't create a diff file for no changes
else
echo "⚠️ Changes detected in GraphQL schema, generating report..."
echo "# GraphQL Schema Changes" > graphql-schema-diff.md
echo "" >> graphql-schema-diff.md
graphql-inspector diff main-schema-introspection.json current-schema-introspection.json >> graphql-schema-diff.md 2>&1 || {
echo "⚠️ **Breaking changes or errors detected in GraphQL schema**" >> graphql-schema-diff.md
echo "" >> graphql-schema-diff.md
echo "\`\`\`" >> graphql-schema-diff.md
graphql-inspector diff main-schema-introspection.json current-schema-introspection.json 2>&1 >> graphql-schema-diff.md || echo "Error generating diff" >> graphql-schema-diff.md
echo "\`\`\`" >> graphql-schema-diff.md
}
fi
# Check if GraphQL metadata schema has changes
echo "Checking GraphQL metadata schema for changes..."
if graphql-inspector diff main-metadata-schema-introspection.json current-metadata-schema-introspection.json >/dev/null 2>&1; then
echo "✅ No changes in GraphQL metadata schema"
# Don't create a diff file for no changes
else
echo "⚠️ Changes detected in GraphQL metadata schema, generating report..."
echo "# GraphQL Metadata Schema Changes" > graphql-metadata-diff.md
echo "" >> graphql-metadata-diff.md
graphql-inspector diff main-metadata-schema-introspection.json current-metadata-schema-introspection.json >> graphql-metadata-diff.md 2>&1 || {
echo "⚠️ **Breaking changes or errors detected in GraphQL metadata schema**" >> graphql-metadata-diff.md
echo "" >> graphql-metadata-diff.md
echo "\`\`\`" >> graphql-metadata-diff.md
graphql-inspector diff main-metadata-schema-introspection.json current-metadata-schema-introspection.json 2>&1 >> graphql-metadata-diff.md || echo "Error generating diff" >> graphql-metadata-diff.md
echo "\`\`\`" >> graphql-metadata-diff.md
}
fi
# Show summary
echo "Generated diff files:"
ls -la *-diff.md 2>/dev/null || echo "No diff files generated (no changes detected)"
- name: Check REST API Breaking Changes
run: |
echo "=== CHECKING REST API FOR BREAKING CHANGES ==="
# Use the Java-based openapi-diff via Docker
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest \
--json /specs/rest-api-diff.json \
/specs/main-rest-api.json /specs/current-rest-api.json || echo "OpenAPI diff completed with exit code $?"
# Check if the output file was created and is valid JSON
if [ -f "rest-api-diff.json" ] && jq empty rest-api-diff.json 2>/dev/null; then
# Check for breaking changes using Java openapi-diff JSON structure
incompatible=$(jq -r '.incompatible // false' rest-api-diff.json)
different=$(jq -r '.different // false' rest-api-diff.json)
# Count changes
new_endpoints=$(jq -r '.newEndpoints | length' rest-api-diff.json 2>/dev/null || echo "0")
missing_endpoints=$(jq -r '.missingEndpoints | length' rest-api-diff.json 2>/dev/null || echo "0")
changed_operations=$(jq -r '.changedOperations | length' rest-api-diff.json 2>/dev/null || echo "0")
if [ "$incompatible" = "true" ]; then
echo "❌ Breaking changes detected in REST API"
# Generate breaking changes report
echo "# REST API Breaking Changes" > rest-api-diff.md
echo "" >> rest-api-diff.md
echo "⚠️ **Breaking changes detected that may affect existing API consumers**" >> rest-api-diff.md
echo "" >> rest-api-diff.md
# Parse and format the changes from Java openapi-diff
jq -r '
if (.missingEndpoints | length) > 0 then
"## 🚨 Removed Endpoints (" + (.missingEndpoints | length | tostring) + ")\n" +
(.missingEndpoints | map("- **" + .method + " " + .pathUrl + "**: " + (.summary // "")) | join("\n"))
else "" end,
if (.changedOperations | length) > 0 then
"\n## ⚠️ Changed Operations (" + (.changedOperations | length | tostring) + ")\n" +
(.changedOperations | map("- **" + .method + " " + .pathUrl + "**: " + (.summary // "Modified operation")) | join("\n"))
else "" end,
if (.newEndpoints | length) > 0 then
"\n## ✅ New Endpoints (" + (.newEndpoints | length | tostring) + ")\n" +
(.newEndpoints | map("- " + .method + " " + .pathUrl + ": " + (.summary // "")) | join("\n"))
else "" end
' rest-api-diff.json >> rest-api-diff.md
elif [ "$different" = "true" ]; then
echo "📝 Non-breaking changes detected ($new_endpoints new endpoints, $missing_endpoints removed, $changed_operations changed)"
# Generate non-breaking changes report
echo "# REST API Changes" > rest-api-diff.md
echo "" >> rest-api-diff.md
echo "## Summary" >> rest-api-diff.md
jq -r '
if (.newEndpoints | length) > 0 then
"### ✅ New Endpoints (" + (.newEndpoints | length | tostring) + ")\n" +
(.newEndpoints | map("- " + .method + " " + .pathUrl + ": " + (.summary // "")) | join("\n"))
else "" end,
if (.changedOperations | length) > 0 then
"\n### 🔄 Changed Operations (" + (.changedOperations | length | tostring) + ")\n" +
(.changedOperations | map("- " + .method + " " + .pathUrl + ": " + (.summary // "Modified operation")) | join("\n"))
else "" end
' rest-api-diff.json >> rest-api-diff.md
else
echo "✅ No changes detected in REST API"
# Don't create diff file for no changes
fi
else
echo "⚠️ OpenAPI diff tool could not process the files"
echo "# REST API Analysis Error" > rest-api-diff.md
echo "" >> rest-api-diff.md
echo "⚠️ **Error occurred while analyzing REST API changes**" >> rest-api-diff.md
echo "" >> rest-api-diff.md
echo "## Error Output" >> rest-api-diff.md
echo "\`\`\`" >> rest-api-diff.md
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest /specs/main-rest-api.json /specs/current-rest-api.json 2>&1 >> rest-api-diff.md || echo "Could not capture error output"
echo "\`\`\`" >> rest-api-diff.md
# Don't fail the workflow for tool errors
echo "::warning::REST API analysis tool error - continuing workflow"
fi
- name: Check REST Metadata API Breaking Changes
run: |
echo "=== CHECKING REST METADATA API FOR BREAKING CHANGES ==="
# Use the Java-based openapi-diff for metadata API as well
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest \
--json /specs/rest-metadata-api-diff.json \
/specs/main-rest-metadata-api.json /specs/current-rest-metadata-api.json || echo "OpenAPI diff completed with exit code $?"
# Check if the output file was created and is valid JSON
if [ -f "rest-metadata-api-diff.json" ] && jq empty rest-metadata-api-diff.json 2>/dev/null; then
# Check for breaking changes using Java openapi-diff JSON structure
incompatible=$(jq -r '.incompatible // false' rest-metadata-api-diff.json)
different=$(jq -r '.different // false' rest-metadata-api-diff.json)
# Count changes
new_endpoints=$(jq -r '.newEndpoints | length' rest-metadata-api-diff.json 2>/dev/null || echo "0")
missing_endpoints=$(jq -r '.missingEndpoints | length' rest-metadata-api-diff.json 2>/dev/null || echo "0")
changed_operations=$(jq -r '.changedOperations | length' rest-metadata-api-diff.json 2>/dev/null || echo "0")
if [ "$incompatible" = "true" ]; then
echo "❌ Breaking changes detected in REST Metadata API"
# Generate breaking changes report
echo "# REST Metadata API Breaking Changes" > rest-metadata-api-diff.md
echo "" >> rest-metadata-api-diff.md
echo "⚠️ **Breaking changes detected that may affect existing API consumers**" >> rest-metadata-api-diff.md
echo "" >> rest-metadata-api-diff.md
# Parse and format the changes from Java openapi-diff
jq -r '
if (.missingEndpoints | length) > 0 then
"## 🚨 Removed Endpoints (" + (.missingEndpoints | length | tostring) + ")\n" +
(.missingEndpoints | map("- **" + .method + " " + .pathUrl + "**: " + (.summary // "")) | join("\n"))
else "" end,
if (.changedOperations | length) > 0 then
"\n## ⚠️ Changed Operations (" + (.changedOperations | length | tostring) + ")\n" +
(.changedOperations | map("- **" + .method + " " + .pathUrl + "**: " + (.summary // "Modified operation")) | join("\n"))
else "" end,
if (.newEndpoints | length) > 0 then
"\n## ✅ New Endpoints (" + (.newEndpoints | length | tostring) + ")\n" +
(.newEndpoints | map("- " + .method + " " + .pathUrl + ": " + (.summary // "")) | join("\n"))
else "" end
' rest-metadata-api-diff.json >> rest-metadata-api-diff.md
elif [ "$different" = "true" ]; then
echo "📝 Non-breaking changes detected ($new_endpoints new endpoints, $missing_endpoints removed, $changed_operations changed)"
# Generate non-breaking changes report
echo "# REST Metadata API Changes" > rest-metadata-api-diff.md
echo "" >> rest-metadata-api-diff.md
echo "## Summary" >> rest-metadata-api-diff.md
jq -r '
if (.newEndpoints | length) > 0 then
"### ✅ New Endpoints (" + (.newEndpoints | length | tostring) + ")\n" +
(.newEndpoints | map("- " + .method + " " + .pathUrl + ": " + (.summary // "")) | join("\n"))
else "" end,
if (.changedOperations | length) > 0 then
"\n### 🔄 Changed Operations (" + (.changedOperations | length | tostring) + ")\n" +
(.changedOperations | map("- " + .method + " " + .pathUrl + ": " + (.summary // "Modified operation")) | join("\n"))
else "" end
' rest-metadata-api-diff.json >> rest-metadata-api-diff.md
else
echo "✅ No changes detected in REST Metadata API"
# Don't create diff file for no changes
fi
else
echo "⚠️ OpenAPI diff tool could not process the metadata API files"
echo "# REST Metadata API Analysis Error" > rest-metadata-api-diff.md
echo "" >> rest-metadata-api-diff.md
echo "⚠️ **Error occurred while analyzing REST Metadata API changes**" >> rest-metadata-api-diff.md
echo "" >> rest-metadata-api-diff.md
echo "## Error Output" >> rest-metadata-api-diff.md
echo "\`\`\`" >> rest-metadata-api-diff.md
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest /specs/main-rest-metadata-api.json /specs/current-rest-metadata-api.json 2>&1 >> rest-metadata-api-diff.md || echo "Could not capture error output"
echo "\`\`\`" >> rest-metadata-api-diff.md
# Don't fail the workflow for tool errors
echo "::warning::REST Metadata API analysis tool error - continuing workflow"
fi
- name: Comment API Changes on PR
if: always()
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
let hasChanges = false;
let comment = '';
try {
if (fs.existsSync('graphql-schema-diff.md')) {
const graphqlDiff = fs.readFileSync('graphql-schema-diff.md', 'utf8');
if (graphqlDiff.trim()) {
if (!hasChanges) {
comment = '## 📊 API Changes Report\n\n';
hasChanges = true;
}
comment += '### GraphQL Schema Changes\n' + graphqlDiff + '\n\n';
}
}
if (fs.existsSync('graphql-metadata-diff.md')) {
const graphqlMetadataDiff = fs.readFileSync('graphql-metadata-diff.md', 'utf8');
if (graphqlMetadataDiff.trim()) {
if (!hasChanges) {
comment = '## 📊 API Changes Report\n\n';
hasChanges = true;
}
comment += '### GraphQL Metadata Schema Changes\n' + graphqlMetadataDiff + '\n\n';
}
}
if (fs.existsSync('rest-api-diff.md')) {
const restDiff = fs.readFileSync('rest-api-diff.md', 'utf8');
if (restDiff.trim()) {
if (!hasChanges) {
comment = '## 📊 API Changes Report\n\n';
hasChanges = true;
}
comment += restDiff + '\n\n';
}
}
if (fs.existsSync('rest-metadata-api-diff.md')) {
const metadataDiff = fs.readFileSync('rest-metadata-api-diff.md', 'utf8');
if (metadataDiff.trim()) {
if (!hasChanges) {
comment = '## 📊 API Changes Report\n\n';
hasChanges = true;
}
comment += metadataDiff + '\n\n';
}
}
// Only post comment if there are changes
if (hasChanges) {
// Check if there are any breaking changes detected
let hasBreakingChanges = false;
let breakingChangeNote = '';
// Check for breaking changes in any of the diff files
if (fs.existsSync('rest-api-diff.md')) {
const restDiff = fs.readFileSync('rest-api-diff.md', 'utf8');
if (restDiff.includes('Breaking Changes') || restDiff.includes('🚨') ||
restDiff.includes('Removed Endpoints') || restDiff.includes('Changed Operations')) {
hasBreakingChanges = true;
}
}
if (fs.existsSync('rest-metadata-api-diff.md')) {
const metadataDiff = fs.readFileSync('rest-metadata-api-diff.md', 'utf8');
if (metadataDiff.includes('Breaking Changes') || metadataDiff.includes('🚨') ||
metadataDiff.includes('Removed Endpoints') || metadataDiff.includes('Changed Operations')) {
hasBreakingChanges = true;
}
}
// Also check GraphQL changes for breaking changes indicators
if (fs.existsSync('graphql-schema-diff.md')) {
const graphqlDiff = fs.readFileSync('graphql-schema-diff.md', 'utf8');
if (graphqlDiff.includes('Breaking changes') || graphqlDiff.includes('BREAKING')) {
hasBreakingChanges = true;
}
}
if (fs.existsSync('graphql-metadata-diff.md')) {
const graphqlMetadataDiff = fs.readFileSync('graphql-metadata-diff.md', 'utf8');
if (graphqlMetadataDiff.includes('Breaking changes') || graphqlMetadataDiff.includes('BREAKING')) {
hasBreakingChanges = true;
}
}
// Check PR title for "breaking"
const prTitle = "${{ github.event.pull_request.title }}";
const titleContainsBreaking = prTitle.toLowerCase().includes('breaking');
if (hasBreakingChanges) {
if (titleContainsBreaking) {
breakingChangeNote = '\n\n## ✅ Breaking Change Protocol\n\n' +
'**This PR title contains "breaking" and breaking changes were detected - the CI will fail as expected.**\n\n' +
'📝 **Action Required**: Please add `BREAKING CHANGE:` to your commit message to trigger a major version bump.\n\n' +
'Example:\n```\nfeat: add new API endpoint\n\nBREAKING CHANGE: removed deprecated field from User schema\n```';
} else {
breakingChangeNote = '\n\n## ⚠️ Breaking Change Protocol\n\n' +
'**Breaking changes detected but PR title does not contain "breaking" - CI will pass but action needed.**\n\n' +
'🔄 **Options**:\n' +
'1. **If this IS a breaking change**: Add "breaking" to your PR title and add `BREAKING CHANGE:` to your commit message\n' +
'2. **If this is NOT a breaking change**: The API diff tool may have false positives - please review carefully\n\n' +
'For breaking changes, add to commit message:\n```\nfeat: add new API endpoint\n\nBREAKING CHANGE: removed deprecated field from User schema\n```';
}
}
const COMMENT_MARKER = '<!-- API_CHANGES_REPORT -->';
const commentBody = COMMENT_MARKER + '\n' + comment + '⚠️ **Please review these API changes carefully before merging.**' + breakingChangeNote;
// Get all comments to find existing API changes comment
const {data: comments} = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
// Find our existing comment
const botComment = comments.find(comment => comment.body.includes(COMMENT_MARKER));
if (botComment) {
// Update existing comment
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: commentBody
});
console.log('Updated existing API changes comment');
} else {
// Create new comment
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: commentBody
});
console.log('Created new API changes comment');
}
} else {
console.log('No API changes detected - skipping PR comment');
// Check if there's an existing comment to remove
const {data: comments} = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const COMMENT_MARKER = '<!-- API_CHANGES_REPORT -->';
const botComment = comments.find(comment => comment.body.includes(COMMENT_MARKER));
if (botComment) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
});
console.log('Deleted existing API changes comment (no changes detected)');
}
}
} catch (error) {
console.log('Could not post comment:', error);
}
- name: Cleanup servers
if: always()
run: |
if [ -f /tmp/current-server.pid ]; then
kill $(cat /tmp/current-server.pid) || true
fi
if [ -f /tmp/main-server.pid ]; then
kill $(cat /tmp/main-server.pid) || true
fi
- name: Upload API specifications and diffs
if: always()
uses: actions/upload-artifact@v4
with:
name: api-specifications-and-diffs
path: |
/tmp/main-server.log
/tmp/current-server.log
*-api.json
*-schema-introspection.json
*-diff.md
*-diff.json
+1 -1
View File
@@ -39,7 +39,7 @@ jobs:
run: npx nx build twenty-chrome-extension
ci-chrome-extension-status-check:
if: always() && !cancelled()
timeout-minutes: 5
timeout-minutes: 1
runs-on: ubuntu-latest
needs: [changed-files-check, chrome-extension-build]
steps:
+1 -1
View File
@@ -124,7 +124,7 @@ jobs:
retention-days: 30
ci-e2e-status-check:
if: always() && !cancelled()
timeout-minutes: 5
timeout-minutes: 1
runs-on: ubuntu-latest
needs: [changed-files-check, test]
steps:
+1 -1
View File
@@ -53,7 +53,7 @@ jobs:
kill $SERVER_PID
ci-emails-status-check:
if: always() && !cancelled()
timeout-minutes: 5
timeout-minutes: 1
runs-on: depot-ubuntu-24.04-8
needs: [changed-files-check, emails-test]
steps:
+1 -1
View File
@@ -189,7 +189,7 @@ jobs:
key: ${{ steps.restore-task-cache.outputs.cache-primary-key }}
ci-front-status-check:
if: always() && !cancelled()
timeout-minutes: 5
timeout-minutes: 1
runs-on: depot-ubuntu-24.04-8
needs:
[
+13 -9
View File
@@ -89,15 +89,19 @@ jobs:
fi
- name: Server / Check for Pending Migrations
run: |
METADATA_MIGRATION_OUTPUT=$(npx nx run twenty-server:typeorm migration:generate metadata-migration-check -d src/database/typeorm/metadata/metadata.datasource.ts || true)
CORE_MIGRATION_OUTPUT=$(npx nx run twenty-server:typeorm migration:generate core-migration-check -d src/database/typeorm/core/core.datasource.ts || true)
METADATA_MIGRATION_FILE=$(ls packages/twenty-server/*metadata-migration-check.ts 2>/dev/null || echo "")
CORE_MIGRATION_FILE=$(ls packages/twenty-server/*core-migration-check.ts 2>/dev/null || echo "")
if [ -n "$CORE_MIGRATION_FILE" ]; then
if [ -n "$METADATA_MIGRATION_FILE" ] || [ -n "$CORE_MIGRATION_FILE" ]; then
echo "::error::Unexpected migration files were generated. Please create a proper migration manually."
echo "$METADATA_MIGRATION_OUTPUT"
echo "$CORE_MIGRATION_OUTPUT"
rm -f packages/twenty-server/*core-migration-check.ts
rm -f packages/twenty-server/*metadata-migration-check.ts packages/twenty-server/*core-migration-check.ts
exit 1
fi
@@ -145,10 +149,6 @@ jobs:
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
needs: server-setup
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
services:
postgres:
image: twentycrm/twenty-postgres-spilo
@@ -187,7 +187,6 @@ jobs:
ANALYTICS_ENABLED: true
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
CLICKHOUSE_PASSWORD: clickhousePassword
SHARD_COUNTER: 4
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
@@ -225,10 +224,15 @@ jobs:
tag: scope:backend
tasks: 'test:integration'
configuration: 'with-db-reset'
args: --shard=${{ matrix.shard }}/${{ env.SHARD_COUNTER }}
- name: Server / Upload reset-logs file
if: always()
uses: actions/upload-artifact@v4
with:
name: reset-logs
path: reset-logs.log
ci-server-status-check:
if: always() && !cancelled()
timeout-minutes: 5
timeout-minutes: 1
runs-on: depot-ubuntu-24.04-8
needs: [changed-files-check, server-setup, server-test, server-integration-test]
steps:
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
tasks: ${{ matrix.task }}
ci-shared-status-check:
if: always() && !cancelled()
timeout-minutes: 5
timeout-minutes: 1
runs-on: ubuntu-latest
needs: [changed-files-check, shared-test]
steps:
@@ -84,7 +84,7 @@ jobs:
working-directory: ./packages/twenty-docker/
ci-test-docker-compose-status-check:
if: always() && !cancelled()
timeout-minutes: 5
timeout-minutes: 1
runs-on: ubuntu-latest
needs: [changed-files-check, test]
steps:
+1 -1
View File
@@ -62,7 +62,7 @@ jobs:
NEXT_PUBLIC_KEYSTATIC_GITHUB_APP_SLUG: xxx
ci-website-status-check:
if: always() && !cancelled()
timeout-minutes: 5
timeout-minutes: 10
runs-on: ubuntu-latest
needs: [changed-files-check, website-build]
steps:
@@ -1,36 +0,0 @@
name: 'Preview Environment Dispatch'
on:
# Using pull_request_target instead of pull_request to have access to secrets for external contributors
# Security note: This is safe because we're only using the repository-dispatch action with limited scope
# and not checking out or running any code from the external contributor's PR
pull_request_target:
types: [opened, synchronize, reopened, labeled]
paths:
- packages/twenty-docker/**
- packages/twenty-server/**
- packages/twenty-front/**
- .github/workflows/preview-env-dispatch.yaml
- .github/workflows/preview-env-keepalive.yaml
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
trigger-preview:
permissions:
contents: write
actions: write
pull-requests: read
if: github.event.action == 'opened' || github.event.action == 'synchronize' || github.event.action == 'reopened' || (github.event.action == 'labeled' && github.event.label.name == 'preview-app')
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- name: Trigger preview environment workflow
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: ${{ github.repository }}
event-type: preview-environment
client-payload: '{"pr_number": "${{ github.event.pull_request.number }}", "pr_head_sha": "${{ github.event.pull_request.head.sha }}", "repo_full_name": "${{ github.repository }}"}'
@@ -1,150 +0,0 @@
name: 'Preview Environment Keep Alive'
on:
repository_dispatch:
types: [preview-environment]
jobs:
preview-environment:
timeout-minutes: 310
runs-on: ubuntu-latest
steps:
- name: Checkout PR
uses: actions/checkout@v4
with:
ref: ${{ github.event.client_payload.pr_head_sha }}
- name: Run compose setup
run: |
echo "Patching docker-compose.yml..."
# change image to localbuild using yq
yq eval 'del(.services.server.image)' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.server.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
yq eval 'del(.services.worker.image)' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
yq eval '.services.worker.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
echo "Setting up .env file..."
cp packages/twenty-docker/.env.example packages/twenty-docker/.env
echo "Generating secrets..."
echo "# === Randomly generated secrets ===" >> packages/twenty-docker/.env
echo "APP_SECRET=$(openssl rand -base64 32)" >> packages/twenty-docker/.env
echo "PG_DATABASE_PASSWORD=$(openssl rand -hex 16)" >> packages/twenty-docker/.env
echo "SIGN_IN_PREFILLED=true" >> packages/twenty-docker/.env
echo "Docker compose build..."
cd packages/twenty-docker/
docker compose build
working-directory: ./
- name: Create Tunnel
id: expose-tunnel
uses: codetalkio/expose-tunnel@v1.5.0
with:
service: bore.pub
port: 3000
- name: Start services with correct SERVER_URL
run: |
cd packages/twenty-docker/
# Update the SERVER_URL with the tunnel URL
echo "Setting SERVER_URL to ${{ steps.expose-tunnel.outputs.tunnel-url }}"
sed -i '/SERVER_URL=/d' .env
echo "SERVER_URL=${{ steps.expose-tunnel.outputs.tunnel-url }}" >> .env
# Start the services
echo "Docker compose up..."
docker compose up -d || {
echo "Docker compose failed to start"
docker compose logs
exit 1
}
echo "Waiting for services to be ready..."
count=0
while [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-db-1) = "healthy" ] || [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-server-1) = "healthy" ]; do
sleep 5
count=$((count+1))
if [ $count -gt 60 ]; then
echo "Timeout waiting for services to be ready"
docker compose logs
exit 1
fi
echo "Still waiting for services... ($count/60)"
done
echo "All services are up and running!"
working-directory: ./
- name: Seed Dev Workspace
run: |
cd packages/twenty-docker/
echo "Seeding full dev workspace..."
if ! docker compose exec -T server yarn command:prod -- workspace:seed:dev; then
echo "❌ Seeding full dev workspace failed. Dumping server logs..."
docker compose logs server
exit 1
fi
working-directory: ./
- name: Output tunnel URL to logs
run: |
echo "✅ Preview Environment Ready!"
echo "🔗 Preview URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}"
echo "⏱️ This environment will be available for 5 hours"
- name: Post comment on PR
uses: actions/github-script@v6
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const COMMENT_MARKER = '<!-- PR_PREVIEW_ENV -->';
const commentBody = `${COMMENT_MARKER}
🚀 **Preview Environment Ready!**
Your preview environment is available at: ${{ steps.expose-tunnel.outputs.tunnel-url }}
This environment will automatically shut down when the PR is closed or after 5 hours.`;
// Get all comments
const {data: comments} = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ github.event.client_payload.pr_number }},
});
// Find our comment
const botComment = comments.find(comment => comment.body.includes(COMMENT_MARKER));
if (botComment) {
// Update existing comment
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: commentBody
});
console.log('Updated existing comment');
} else {
// Create new comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ github.event.client_payload.pr_number }},
body: commentBody
});
console.log('Created new comment');
}
- name: Keep tunnel alive for 5 hours
run: timeout 300m sleep 18000 # Stop on whichever we reach first (300m or 5hour sleep)
- name: Cleanup
if: always()
run: |
cd packages/twenty-docker/
docker compose down -v
working-directory: ./
+1 -3
View File
@@ -43,6 +43,4 @@ dump.rdb
/flake.nix
.crowdin.yml
.react-email/
mcp.json
.react-email/
+1 -1
View File
@@ -1 +1 @@
22.12.0
18.17.1
+1 -2
View File
@@ -5,7 +5,6 @@
{
"name": "twenty-server - start debug",
"type": "node",
"runtimeVersion": "22.12",
"request": "launch",
"runtimeExecutable": "npx",
"runtimeArgs": [
@@ -38,7 +37,7 @@
"type": "node",
"request": "launch",
"runtimeExecutable": "npx",
"runtimeVersion": "22.12",
"runtimeVersion": "18",
"runtimeArgs": [
"nx",
"run",
+2 -1
View File
@@ -43,11 +43,12 @@
],
"typescript.preferences.importModuleSpecifier": "non-relative",
"search.exclude": {
"**/.yarn": true
"**/.yarn": true,
},
"eslint.debug": true,
"files.associations": {
".cursorrules": "markdown"
},
"jestrunner.codeLensSelector": "**/*.{test,spec,integration-spec}.{js,jsx,ts,tsx}"
}
}
+5 -5
View File
@@ -1,12 +1,12 @@
postgres-on-docker:
docker run -d \
--name twenty_pg \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e PGUSER_SUPERUSER=postgres \
-e PGPASSWORD_SUPERUSER=postgres \
-e ALLOW_NOSSL=true \
-v twenty_db_data:/var/lib/postgresql/data \
-v twenty_db_data:/home/postgres/pgdata \
-p 5432:5432 \
postgres:16
twentycrm/twenty-postgres-spilo:latest
@echo "Waiting for PostgreSQL to be ready..."
@until docker exec twenty_pg psql -U postgres -d postgres \
-c 'SELECT pg_is_in_recovery();' 2>/dev/null | grep -q 'f'; do \
@@ -20,4 +20,4 @@ redis-on-docker:
docker run -d --name twenty_redis -p 6379:6379 redis/redis-stack-server:latest
clickhouse-on-docker:
docker run -d --name twenty_clickhouse -p 8123:8123 -p 9000:9000 -e CLICKHOUSE_PASSWORD=clickhousePassword clickhouse/clickhouse-server:latest \
docker run -d --name twenty_clickhouse -p 8123:8123 -p 9000:9000 -e CLICKHOUSE_PASSWORD=devPassword clickhouse/clickhouse-server:latest
+34 -45
View File
@@ -1,28 +1,28 @@
<br />
<br>
<p align="center">
<a href="https://www.twenty.com">
<img src="./packages/twenty-website/public/images/core/logo.svg" width="100px" alt="Twenty logo" />
</a>
</p>
<h2 align="center" >The #1 Open-Source CRM </h2>
<h2 align="center" >The #1 Open-Source CRM </h3>
<p align="center"><a href="https://twenty.com">🌐 Website</a> · <a href="https://twenty.com/developers">📚 Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website/public/images/readme/figma-icon.png" width="12" height="12"/> Figma</a></p>
<p align="center"><a href="https://twenty.com">🌐 Website</a> · <a href="https://twenty.com/developers">📚 Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website/public/images/readme/figma-icon.png" width="12" height="12"/> Figma</a><p>
<br />
<p align="center">
<a href="https://www.twenty.com">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/preview-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/preview-light.png" />
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/preview-dark.png">
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/preview-light.png">
<img src="./packages/twenty-docs/static/img/preview-light.png" alt="Companies view" />
</picture>
</a>
</p>
<br />
<br>
# Installation
@@ -40,7 +40,7 @@ We built Twenty for three reasons:
**We believe in Open-source and community.** Hundreds of developers are already building Twenty together. Once we have plugin capabilities, a whole ecosystem will grow around it.
<br />
<br>
# What You Can Do With Twenty
We're currently developing Twenty's beta version.
@@ -60,8 +60,8 @@ Below are a few features we have implemented to date:
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/index-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/index-light.png" />
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/index-dark.png">
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/index-light.png">
<img src="./packages/twenty-docs/static/img/visualise-customer-light.png" alt="Companies view" />
</picture>
</p>
@@ -70,9 +70,9 @@ Below are a few features we have implemented to date:
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/kanban-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/kanban-light.png" />
<img src="./packages/twenty-docs/static/img/follow-your-deals-light.png" alt="Opportunities view" />
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/kanban-dark.png">
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/kanban-light.png">
<img src="./packages/twenty-docs/static/img/follow-your-deals-light.png" alt="Companies view" />
</picture>
</p>
@@ -80,9 +80,9 @@ Below are a few features we have implemented to date:
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/emails-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/emails-light.png" />
<img src="./packages/twenty-docs/static/img/emails-light.png" alt="Emails" />
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/emails-dark.png">
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/emails-light.png">
<img src="./packages/twenty-docs/static/img/rich-notes-light.png" alt="Companies view" />
</picture>
</p>
@@ -90,9 +90,9 @@ Below are a few features we have implemented to date:
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/data-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/data-light.png" />
<img src="./packages/twenty-docs/static/img/data-light.png" alt="Data model" />
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/data-dark.png">
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/data-light.png">
<img src="./packages/twenty-docs/static/img/rich-notes-light.png" alt="Companies view" />
</picture>
</p>
@@ -100,9 +100,9 @@ Below are a few features we have implemented to date:
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/notes-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/notes-light.png" />
<img src="./packages/twenty-docs/static/img/notes-light.png" alt="Rich notes" />
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/notes-dark.png">
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/notes-light.png">
<img src="./packages/twenty-docs/static/img/rich-notes-light.png" alt="Companies view" />
</picture>
</p>
@@ -110,9 +110,9 @@ Below are a few features we have implemented to date:
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/tasks-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/tasks-light.png" />
<img src="./packages/twenty-docs/static/img/create-tasks-light.png" alt="Tasks" />
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/tasks-dark.png">
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/tasks-light.png">
<img src="./packages/twenty-docs/static/img/create-tasks-light.png" alt="Companies view" />
</picture>
</p>
@@ -120,9 +120,9 @@ Below are a few features we have implemented to date:
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/keyboard-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/keyboard-light.png" />
<img src="./packages/twenty-docs/static/img/keyboard-dark.png" alt="Keyboard shortcuts" />
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/keyboard-dark.png">
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/keyboard-light.png">
<img src="./packages/twenty-docs/static/img/shortcut-navigation-light.png" alt="Companies view" />
</picture>
</p>
@@ -130,33 +130,22 @@ Below are a few features we have implemented to date:
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/api-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/api-light.png" />
<img src="./packages/twenty-docs/static/img/api-light.png" alt="API" />
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/api-dark.png">
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/api-light.png">
<img src="./packages/twenty-docs/static/img/shortcut-navigation-light.png" alt="Companies view" />
</picture>
</p>
<br />
<br>
# Stack
- [TypeScript](https://www.typescriptlang.org/)
- [Nx](https://nx.dev/)
- [NestJS](https://nestjs.com/), with [BullMQ](https://bullmq.io/), [PostgreSQL](https://www.postgresql.org/), [Redis](https://redis.io/)
- [React](https://reactjs.org/), with [Recoil](https://recoiljs.org/), [Emotion](https://emotion.sh/) and [Lingui](https://lingui.dev/)
# Thanks
<p align="center">
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website/public/images/readme/chromatic.png" height="30" alt="Chromatic" /></a>
<a href="https://greptile.com"><img src="./packages/twenty-website/public/images/readme/greptile.png" height="30" alt="Greptile" /></a>
<a href="https://sentry.io/"><img src="./packages/twenty-website/public/images/readme/sentry.png" height="30" alt="Sentry" /></a>
<a href="https://crowdin.com/"><img src="./packages/twenty-website/public/images/readme/crowdin.png" height="30" alt="Crowdin" /></a>
</p>
Thanks to these amazing services that we use and recommend for UI testing (Chromatic), code review (Greptile), catching bugs (Sentry) and translating (Crowdin).
- [React](https://reactjs.org/), with [Recoil](https://recoiljs.org/) and [Emotion](https://emotion.sh/)
- [Greptile](https://greptile.com) for code reviews.
- [Lingui](https://lingui.dev/) and [Crowdin](https://twenty.crowdin.com/twenty) for translations.
# Join the Community
+24 -22
View File
@@ -1,13 +1,16 @@
{
"private": true,
"dependencies": {
"@air/react-drag-to-select": "^5.0.8",
"@apollo/client": "^3.7.17",
"@apollo/server": "^4.7.3",
"@aws-sdk/client-lambda": "^3.700.0",
"@aws-sdk/client-s3": "^3.700.0",
"@aws-sdk/client-sts": "^3.700.0",
"@aws-sdk/credential-providers": "^3.700.0",
"@calcom/embed-react": "^1.5.3",
"@aws-sdk/client-lambda": "^3.614.0",
"@aws-sdk/client-s3": "^3.363.0",
"@aws-sdk/client-sts": "^3.744.0",
"@aws-sdk/credential-providers": "^3.363.0",
"@blocknote/mantine": "^0.22.0",
"@blocknote/react": "^0.22.0",
"@blocknote/server-util": "0.17.1",
"@codesandbox/sandpack-react": "^2.13.5",
"@dagrejs/dagre": "^1.1.2",
"@emotion/react": "^11.11.1",
@@ -43,9 +46,9 @@
"@ptc-org/nestjs-query-typeorm": "4.2.1-alpha.2",
"@react-email/components": "0.0.35",
"@react-email/render": "0.0.17",
"@sentry/node": "^9.26.0",
"@sentry/profiling-node": "^9.26.0",
"@sentry/react": "^9.26.0",
"@sentry/node": "^8",
"@sentry/profiling-node": "^8",
"@sentry/react": "^8",
"@sniptt/guards": "^0.2.0",
"@stoplight/elements": "^8.0.5",
"@swc/jest": "^0.2.29",
@@ -68,12 +71,12 @@
"archiver": "^7.0.1",
"axios": "^1.6.2",
"bcrypt": "^5.1.1",
"better-sqlite3": "^9.2.2",
"body-parser": "^1.20.2",
"bullmq": "^5.40.0",
"bytes": "^3.1.2",
"class-transformer": "^0.5.1",
"clsx": "^2.1.1",
"cron-parser": "^5.1.1",
"cron-validate": "^1.4.5",
"cross-env": "^7.0.3",
"css-loader": "^7.1.2",
@@ -90,7 +93,6 @@
"facepaint": "^1.2.1",
"file-type": "16.5.4",
"framer-motion": "^11.18.0",
"fuse.js": "^7.1.0",
"googleapis": "105",
"graphiql": "^3.1.1",
"graphql": "16.8.0",
@@ -181,13 +183,13 @@
"semver": "^7.5.4",
"sharp": "^0.32.1",
"slash": "^5.1.0",
"storybook-addon-mock-date": "^0.6.0",
"stripe": "^17.3.1",
"ts-key-enum": "^2.0.12",
"tslib": "^2.3.0",
"tsup": "^8.2.4",
"type-fest": "4.10.1",
"typescript": "5.3.3",
"use-context-selector": "^2.0.0",
"use-debounce": "^10.0.0",
"uuid": "^9.0.0",
"vite-tsconfig-paths": "^4.2.1",
@@ -218,7 +220,7 @@
"@nx/vite": "18.3.3",
"@nx/web": "18.3.3",
"@playwright/test": "^1.46.0",
"@sentry/types": "^8",
"@sentry/types": "^7.109.0",
"@storybook/addon-actions": "^7.6.3",
"@storybook/addon-coverage": "^1.0.0",
"@storybook/addon-essentials": "^7.6.7",
@@ -239,11 +241,12 @@
"@swc/cli": "^0.3.12",
"@swc/core": "1.7.42",
"@swc/helpers": "~0.5.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "14.0.0",
"@types/addressparser": "^1.0.3",
"@types/apollo-upload-client": "^17.0.2",
"@types/bcrypt": "^5.0.0",
"@types/better-sqlite3": "^7.6.8",
"@types/bytes": "^3.1.1",
"@types/chrome": "^0.0.267",
"@types/deep-equal": "^1.0.1",
@@ -270,7 +273,7 @@
"@types/lodash.upperfirst": "^4.3.7",
"@types/luxon": "^3.3.0",
"@types/ms": "^0.7.31",
"@types/node": "^22.0.0",
"@types/node": "18.19.26",
"@types/passport-google-oauth20": "^2.0.11",
"@types/passport-jwt": "^3.0.8",
"@types/pluralize": "^0.0.33",
@@ -312,7 +315,7 @@
"eslint-plugin-unused-imports": "^3.0.0",
"http-server": "^14.1.1",
"jest": "29.7.0",
"jest-environment-jsdom": "30.0.0-beta.3",
"jest-environment-jsdom": "29.7.0",
"jest-environment-node": "^29.4.1",
"jest-fetch-mock": "^3.0.3",
"jsdom": "~22.1.0",
@@ -334,15 +337,14 @@
"ts-node": "10.9.1",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.17.0",
"vite": "^6.3.5",
"vite": "^5.4.0",
"vite-plugin-checker": "^0.6.2",
"vite-plugin-cjs-interop": "^2.2.0",
"vite-plugin-dts": "3.8.1",
"vite-plugin-svgr": "^4.2.0",
"vitest": "1.4.0"
},
"engines": {
"node": "^22.12.0",
"node": "^18.17.1",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
@@ -353,9 +355,9 @@
"graphql": "16.8.0",
"type-fest": "4.10.1",
"typescript": "5.3.3",
"graphql-redis-subscriptions/ioredis": "^5.6.0",
"prosemirror-view": "1.40.0",
"prosemirror-transform": "1.10.4"
"prosemirror-model": "1.23.0",
"yjs": "13.6.18",
"graphql-redis-subscriptions/ioredis": "^5.6.0"
},
"version": "0.2.1",
"nx": {},
@@ -1,7 +1,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="images/icons/android/android-launchericon-48-48.png" />
<link rel="icon" href="/icons/android/android-launchericon-48-48.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Twenty</title>
</head>
@@ -1,7 +1,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="images/icons/android/android-launchericon-48-48.png" />
<link rel="icon" href="/icons/android/android-launchericon-48-48.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Twenty</title>
</head>
@@ -1,7 +1,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="images/icons/android/android-launchericon-48-48.png" />
<link rel="icon" href="/icons/android/android-launchericon-48-48.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Twenty</title>
<style>
@@ -6358,7 +6358,7 @@ export type RelationConnection = {
};
export type RelationDefinition = {
direction: RelationType;
direction: RelationDefinitionType;
relationId: Scalars['UUID'];
sourceFieldMetadata: Field;
sourceObjectMetadata: Object;
@@ -6367,7 +6367,7 @@ export type RelationDefinition = {
};
/** Relation definition type */
export enum RelationType {
export enum RelationDefinitionType {
ManyToMany = 'MANY_TO_MANY',
ManyToOne = 'MANY_TO_ONE',
OneToMany = 'ONE_TO_MANY',
+1
View File
@@ -7,6 +7,7 @@ TAG=latest
#REDIS_URL=redis://redis:6379
SERVER_URL=http://localhost:3000
SIGN_IN_PREFILLED=false
# Use openssl rand -base64 32 for each secret
# APP_SECRET=replace_me_with_a_random_string
+21 -8
View File
@@ -1,10 +1,22 @@
name: twenty
services:
change-vol-ownership:
image: ubuntu
user: root
volumes:
- server-local-data:/tmp/server-local-data
- docker-data:/tmp/docker-data
command: >
bash -c "
chown -R 1000:1000 /tmp/server-local-data
&& chown -R 1000:1000 /tmp/docker-data"
server:
image: twentycrm/twenty:${TAG:-latest}
volumes:
- server-local-data:/app/packages/twenty-server/.local-storage
- server-local-data:/app/packages/twenty-server/${STORAGE_LOCAL_PATH:-.local-storage}
- docker-data:/app/docker-data
ports:
- "3000:3000"
environment:
@@ -12,8 +24,6 @@ services:
PG_DATABASE_URL: postgres://${PG_DATABASE_USER:-postgres}:${PG_DATABASE_PASSWORD:-postgres}@${PG_DATABASE_HOST:-db}:${PG_DATABASE_PORT:-5432}/default
SERVER_URL: ${SERVER_URL}
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
DISABLE_DB_MIGRATIONS: ${DISABLE_DB_MIGRATIONS}
DISABLE_CRON_JOBS_REGISTRATION: ${DISABLE_CRON_JOBS_REGISTRATION}
STORAGE_TYPE: ${STORAGE_TYPE}
STORAGE_S3_REGION: ${STORAGE_S3_REGION}
@@ -21,6 +31,7 @@ services:
STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT}
APP_SECRET: ${APP_SECRET:-replace_me_with_a_random_string}
# MESSAGING_PROVIDER_GMAIL_ENABLED: ${MESSAGING_PROVIDER_GMAIL_ENABLED}
# CALENDAR_PROVIDER_GOOGLE_ENABLED: ${CALENDAR_PROVIDER_GOOGLE_ENABLED}
# AUTH_GOOGLE_CLIENT_ID: ${AUTH_GOOGLE_CLIENT_ID}
@@ -46,26 +57,27 @@ services:
# EMAIL_SMTP_PASSWORD: ${EMAIL_SMTP_PASSWORD:-}
depends_on:
change-vol-ownership:
condition: service_completed_successfully
db:
condition: service_healthy
healthcheck:
test: curl --fail http://localhost:3000/healthz
interval: 5s
timeout: 5s
retries: 20
retries: 10
restart: always
worker:
image: twentycrm/twenty:${TAG:-latest}
volumes:
- server-local-data:/app/packages/twenty-server/.local-storage
- server-local-data:/app/packages/twenty-server/${STORAGE_LOCAL_PATH:-.local-storage}
command: ["yarn", "worker:prod"]
environment:
PG_DATABASE_URL: postgres://${PG_DATABASE_USER:-postgres}:${PG_DATABASE_PASSWORD:-postgres}@${PG_DATABASE_HOST:-db}:${PG_DATABASE_PORT:-5432}/default
SERVER_URL: ${SERVER_URL}
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
DISABLE_DB_MIGRATIONS: "true" # it already runs on the server
DISABLE_CRON_JOBS_REGISTRATION: "true" # it already runs on the server
STORAGE_TYPE: ${STORAGE_TYPE}
STORAGE_S3_REGION: ${STORAGE_S3_REGION}
@@ -73,6 +85,7 @@ services:
STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT}
APP_SECRET: ${APP_SECRET:-replace_me_with_a_random_string}
# MESSAGING_PROVIDER_GMAIL_ENABLED: ${MESSAGING_PROVIDER_GMAIL_ENABLED}
# CALENDAR_PROVIDER_GOOGLE_ENABLED: ${CALENDAR_PROVIDER_GOOGLE_ENABLED}
# AUTH_GOOGLE_CLIENT_ID: ${AUTH_GOOGLE_CLIENT_ID}
@@ -96,7 +109,7 @@ services:
# EMAIL_SMTP_PORT: ${EMAIL_SMTP_PORT:-465}
# EMAIL_SMTP_USER: ${EMAIL_SMTP_USER:-}
# EMAIL_SMTP_PASSWORD: ${EMAIL_SMTP_PASSWORD:-}
depends_on:
db:
condition: service_healthy
@@ -121,8 +134,8 @@ services:
redis:
image: redis
restart: always
command: ["--maxmemory-policy", "noeviction"]
volumes:
docker-data:
db-data:
server-local-data:
@@ -25,10 +25,9 @@ spec:
- name: redis
image: redis/redis-stack-server:latest
imagePullPolicy: Always
args: ["--maxmemory-policy", "noeviction"]
env:
- name: PORT
value: "6379"
value: 6379
ports:
- containerPort: 6379
name: redis
@@ -7,14 +7,13 @@ services:
environment:
POSTGRES_DB: twenty
POSTGRES_USER: twenty
POSTGRES_PASSWORD: ${PG_DATABASE_PASSWORD}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- twenty-db-data:/var/lib/postgresql/data:Z
redis:
container_name: twenty-redis
image: redis:latest
command: ["--maxmemory-policy", "noeviction"]
volumes:
- twenty-redis-data:/data:Z
@@ -24,7 +23,7 @@ services:
environment:
NODE_PORT: "3000"
SERVER_URL: ${SERVER_URL}
PG_DATABASE_URL: "postgresql://twenty:${PG_DATABASE_PASSWORD}@twenty-db:5432/twenty"
PG_DATABASE_URL: "postgresql://twenty:${POSTGRES_PASSWORD}@twenty-db:5432/twenty"
REDIS_URL: "redis://twenty-redis:6379"
APP_SECRET: ${APP_SECRET}
NODE_ENV: production
@@ -40,7 +39,7 @@ services:
command: yarn worker:prod
environment:
SERVER_URL: ${SERVER_URL}
PG_DATABASE_URL: "postgresql://twenty:${PG_DATABASE_PASSWORD}@twenty-db:5432/twenty"
PG_DATABASE_URL: "postgresql://twenty:${POSTGRES_PASSWORD}@twenty-db:5432/twenty"
REDIS_URL: "redis://twenty-redis:6379"
APP_SECRET: ${APP_SECRET}
DISABLE_DB_MIGRATIONS: "true"
@@ -1,4 +1,4 @@
FROM node:22-alpine as twenty-website-build
FROM node:18.17.1-alpine as twenty-website-build
WORKDIR /app
@@ -23,7 +23,7 @@ COPY ./packages/twenty-ui /app/packages/twenty-ui
COPY ./packages/twenty-website /app/packages/twenty-website
RUN npx nx build twenty-website
FROM node:22-alpine as twenty-website
FROM node:18.17.1-alpine as twenty-website
WORKDIR /app/packages/twenty-website
+6 -4
View File
@@ -1,5 +1,5 @@
# Base image for common dependencies
FROM node:22-alpine as common-deps
FROM node:18.17.1-alpine as common-deps
WORKDIR /app
@@ -49,7 +49,7 @@ RUN npx nx build twenty-front
# Final stage: Run the application
FROM node:22-alpine as twenty
FROM node:18.17.1-alpine as twenty
# Used to run healthcheck in docker
RUN apk add --no-cache curl jq
@@ -64,6 +64,8 @@ WORKDIR /app/packages/twenty-server
ARG REACT_APP_SERVER_BASE_URL
ENV REACT_APP_SERVER_BASE_URL $REACT_APP_SERVER_BASE_URL
ARG SENTRY_RELEASE
ENV SENTRY_RELEASE $SENTRY_RELEASE
ARG APP_VERSION
ENV APP_VERSION $APP_VERSION
@@ -77,8 +79,8 @@ COPY --chown=1000 --from=twenty-front-build /app/packages/twenty-front/build /ap
LABEL org.opencontainers.image.source=https://github.com/twentyhq/twenty
LABEL org.opencontainers.image.description="This image provides a consistent and reproducible environment for the backend and frontend, ensuring it deploys faster and runs the same way regardless of the deployment environment."
RUN mkdir -p /app/.local-storage /app/packages/twenty-server/.local-storage && \
chown -R 1000:1000 /app
RUN mkdir /app/.local-storage
RUN chown -R 1000 /app
# Use non root user with uid 1000
USER 1000
+13 -35
View File
@@ -1,50 +1,28 @@
#!/bin/sh
set -e
setup_and_migrate_db() {
if [ "${DISABLE_DB_MIGRATIONS}" = "true" ]; then
echo "Database setup and migrations are disabled, skipping..."
return
fi
# Check if the initialization has already been done and that we enabled automatic migration
if [ "${DISABLE_DB_MIGRATIONS}" != "true" ] && [ ! -f /app/docker-data/db_status ]; then
echo "Running database setup and migrations..."
# Creating the database if it doesn't exist
PGUSER=$(echo $PG_DATABASE_URL | awk -F '//' '{print $2}' | awk -F ':' '{print $1}')
PGPASS=$(echo $PG_DATABASE_URL | awk -F ':' '{print $3}' | awk -F '@' '{print $1}')
PGHOST=$(echo $PG_DATABASE_URL | awk -F '@' '{print $2}' | awk -F ':' '{print $1}')
PGPORT=$(echo $PG_DATABASE_URL | awk -F ':' '{print $4}' | awk -F '/' '{print $1}')
PGDATABASE=$(echo $PG_DATABASE_URL | awk -F ':' '{print $4}' | awk -F '/' '{print $2}')
PGPASSWORD=${PGPASS} psql -h ${PGHOST} -p ${PGPORT} -U ${PGUSER} -d postgres -tc "SELECT 1 FROM pg_database WHERE datname = '${PGDATABASE}'" | grep -q 1 || \
PGPASSWORD=${PGPASS} psql -h ${PGHOST} -p ${PGPORT} -U ${PGUSER} -d postgres -c "CREATE DATABASE \"${PGDATABASE}\""
# Creating the database if it doesn't exist
db_count=$(PGPASSWORD=${PGPASS} psql -h ${PGHOST} -p ${PGPORT} -U ${PGUSER} -d postgres -tAc "SELECT COUNT(*) FROM pg_database WHERE datname = '${PGDATABASE}'")
if [ "$db_count" = "0" ]; then
echo "Database ${PGDATABASE} does not exist, creating..."
PGPASSWORD=${PGPASS} psql -h ${PGHOST} -p ${PGPORT} -U ${PGUSER} -d postgres -c "CREATE DATABASE \"${PGDATABASE}\""
# Run setup and migration scripts
NODE_OPTIONS="--max-old-space-size=1500" tsx ./scripts/setup-db.ts
yarn database:migrate:prod
fi
# Run setup and migration scripts
NODE_OPTIONS="--max-old-space-size=1500" tsx ./scripts/setup-db.ts
yarn database:migrate:prod
yarn command:prod upgrade
echo "Successfully migrated DB!"
}
register_background_jobs() {
if [ "${DISABLE_CRON_JOBS_REGISTRATION}" = "true" ]; then
echo "Cron job registration is disabled, skipping..."
return
fi
echo "Registering background sync jobs..."
if yarn command:prod cron:register:all; then
echo "Successfully registered all background sync jobs!"
else
echo "Warning: Failed to register background jobs, but continuing startup..."
fi
}
setup_and_migrate_db
register_background_jobs
# Mark initialization as done
echo "Successfuly migrated DB!"
touch /app/docker-data/db_status
fi
# Continue with the original Docker command
exec "$@"
+1 -1
View File
@@ -2,5 +2,5 @@
FRONTEND_BASE_URL=http://localhost:3001
BACKEND_BASE_URL=http://localhost:3000
DEFAULT_LOGIN=tim@apple.dev
DEFAULT_PASSWORD=tim@apple.dev
DEFAULT_PASSWORD=Applecar2025
WEBSITE_URL=https://twenty.com
@@ -24,8 +24,6 @@ export class WorkflowVisualizerPage {
readonly useAsDraftButton: Locator;
readonly overrideDraftButton: Locator;
readonly discardDraftButton: Locator;
readonly seeRunsButton: Locator;
readonly goBackInCommandMenu: Locator;
#actionNames: Record<WorkflowActionType, string> = {
'create-record': 'Create Record',
@@ -33,7 +31,6 @@ export class WorkflowVisualizerPage {
'delete-record': 'Delete Record',
code: 'Code',
'send-email': 'Send Email',
form: 'Form',
};
#createdActionNames: Record<WorkflowActionType, string> = {
@@ -42,7 +39,6 @@ export class WorkflowVisualizerPage {
'delete-record': 'Delete Record',
code: 'Code - Serverless Function',
'send-email': 'Send Email',
form: 'Form',
};
#triggerNames: Record<WorkflowTriggerType, string> = {
@@ -88,10 +84,6 @@ export class WorkflowVisualizerPage {
this.discardDraftButton = page.getByRole('button', {
name: 'Discard Draft',
});
this.seeRunsButton = page.getByRole('link', { name: 'See runs' });
this.goBackInCommandMenu = this.commandMenu
.getByRole('button')
.and(this.commandMenu.getByTestId('command-menu-go-back-button'));
}
async createOneWorkflow() {
@@ -126,7 +118,7 @@ export class WorkflowVisualizerPage {
.getByTestId(`row-id-${this.workflowId}`)
.getByRole('link', { name: this.workflowName });
await workflowLink.click({ force: true });
await workflowLink.click();
await this.waitForWorkflowVisualizerLoad();
}
@@ -258,64 +250,29 @@ export class WorkflowVisualizerPage {
await expect(this.#page.getByTestId('command-menu')).not.toBeVisible();
}
async goToWorkflowsIndexPage() {
await this.#page.goto('/objects/workflows');
}
async setWorkflowsOpenInMode(mode: 'side-panel' | 'record-page') {
const recordTableOptionsButton = this.#page.getByText('Options');
await recordTableOptionsButton.click();
const layoutButton = this.#page.getByText('Layout');
await layoutButton.click();
const openInButton = this.#page.getByText('Open in');
await openInButton.click();
if (mode === 'side-panel') {
const openInSidePanelOption = this.#page.getByRole('option', {
name: 'Side Panel',
});
await openInSidePanelOption.click();
} else {
const openInRecordPageOption = this.#page.getByRole('option', {
name: 'Record Page',
});
await openInRecordPageOption.click();
}
// Close the dropdown
await recordTableOptionsButton.click();
}
}
export const test = base.extend<{
workflowVisualizer: WorkflowVisualizerPage;
}>({
workflowVisualizer: async ({ page }, use) => {
const workflowVisualizer = new WorkflowVisualizerPage({
page,
workflowName: 'Test Workflow',
});
export const test = base.extend<{ workflowVisualizer: WorkflowVisualizerPage }>(
{
workflowVisualizer: async ({ page }, use) => {
const workflowVisualizer = new WorkflowVisualizerPage({
page,
workflowName: 'Test Workflow',
});
await workflowVisualizer.createOneWorkflow();
await workflowVisualizer.goToWorkflowVisualizerPage();
await workflowVisualizer.createOneWorkflow();
await workflowVisualizer.goToWorkflowVisualizerPage();
await use(workflowVisualizer);
await use(workflowVisualizer);
await deleteWorkflow({
page,
workflowId: workflowVisualizer.workflowId,
});
await destroyWorkflow({
page,
workflowId: workflowVisualizer.workflowId,
});
await workflowVisualizer.goToWorkflowsIndexPage();
await workflowVisualizer.setWorkflowsOpenInMode('record-page');
await deleteWorkflow({
page,
workflowId: workflowVisualizer.workflowId,
});
await destroyWorkflow({
page,
workflowId: workflowVisualizer.workflowId,
});
},
},
});
);
@@ -19,7 +19,7 @@ export const deleteWorkflow = async ({
operationName: 'DeleteOneWorkflow',
variables: { idToDelete: workflowId },
query:
'mutation DeleteOneWorkflow($idToDelete: UUID!) {\n deleteWorkflow(id: $idToDelete) {\n __typename\n deletedAt\n id\n }\n}',
'mutation DeleteOneWorkflow($idToDelete: ID!) {\n deleteWorkflow(id: $idToDelete) {\n __typename\n deletedAt\n id\n }\n}',
},
});
};
@@ -19,7 +19,7 @@ export const destroyWorkflow = async ({
operationName: 'DestroyOneWorkflow',
variables: { idToDestroy: workflowId },
query:
'mutation DestroyOneWorkflow($idToDestroy: UUID!) {\n destroyWorkflow(id: $idToDestroy) {\n id\n __typename\n }\n}',
'mutation DestroyOneWorkflow($idToDestroy: ID!) {\n destroyWorkflow(id: $idToDestroy) {\n id\n __typename\n }\n}',
},
});
};
@@ -9,5 +9,4 @@ export type WorkflowActionType =
| 'update-record'
| 'delete-record'
| 'code'
| 'send-email'
| 'form';
| 'send-email';
+1
View File
@@ -1,5 +1,6 @@
{
"name": "twenty-e2e-testing",
"version": "0.51.5",
"description": "",
"author": "",
"private": true,
@@ -8,12 +8,14 @@ test('The workflow run visualizer shows the executed draft version without the l
await workflowVisualizer.createInitialTrigger('manual');
const manualTriggerAvailabilitySelect = page.getByRole('button', {
name: 'When record is selected',
name: 'When record(s) are selected',
});
await manualTriggerAvailabilitySelect.click();
const alwaysAvailableOption = page.getByText('When no record is selected');
const alwaysAvailableOption = page.getByText(
'When no record(s) are selected',
);
await alwaysAvailableOption.click();
@@ -28,31 +30,21 @@ test('The workflow run visualizer shows the executed draft version without the l
await launchTestButton.click();
await workflowVisualizer.closeSidePanel();
const goToExecutionPageLink = page.getByRole('link', {
name: 'View execution details',
});
const executionPageUrl = await goToExecutionPageLink.getAttribute('href');
expect(executionPageUrl).not.toBeNull();
await workflowVisualizer.deleteStep(firstStepId);
await page.goto('/objects/workflowRuns');
await page.goto(executionPageUrl!);
const recordTableRowForWorkflowRun = page
.getByRole('row', {
name: workflowVisualizer.workflowName,
})
.first();
const workflowRunName = page.getByText(
`#1 - ${workflowVisualizer.workflowName}`,
);
const linkToWorkflowRun = recordTableRowForWorkflowRun
.getByRole('link', {
name: workflowVisualizer.workflowName,
})
.first();
await linkToWorkflowRun.click({ force: true });
const workflowRunNameElement = page
.getByText(`#1 - ${workflowVisualizer.workflowName}`)
.nth(1);
await expect(workflowRunNameElement).toBeVisible();
await expect(workflowRunName).toBeVisible();
const executedFirstStepNode = workflowVisualizer.getStepNode(firstStepId);
@@ -64,87 +56,3 @@ test('The workflow run visualizer shows the executed draft version without the l
'Create Record',
);
});
test('Workflow Runs with a pending form step can be opened in the side panel and then in full screen', async ({
workflowVisualizer,
page,
}) => {
await workflowVisualizer.createInitialTrigger('manual');
const manualTriggerAvailabilitySelect = page.getByRole('button', {
name: 'When record is selected',
});
await manualTriggerAvailabilitySelect.click();
const alwaysAvailableOption = page.getByText('When no record is selected');
await alwaysAvailableOption.click();
await workflowVisualizer.closeSidePanel();
const { createdStepId: firstStepId } =
await workflowVisualizer.createStep('form');
const addFormFieldButton = page.getByText('Add Field', { exact: true });
await addFormFieldButton.click();
await workflowVisualizer.closeSidePanel();
const launchTestButton = page.getByLabel(workflowVisualizer.workflowName);
await launchTestButton.click();
const workflowRunName = `#1 - ${workflowVisualizer.workflowName}`;
await expect(workflowVisualizer.stepHeaderInCommandMenu).toContainText(
'Form',
{
timeout: 30_000,
},
);
await workflowVisualizer.goBackInCommandMenu.click();
const workflowRunNameInCommandMenu =
workflowVisualizer.commandMenu.getByText(workflowRunName);
await expect(workflowRunNameInCommandMenu).toBeVisible();
await workflowVisualizer.commandMenu
.locator(workflowVisualizer.triggerNode)
.click();
await expect(workflowVisualizer.stepHeaderInCommandMenu).toContainText(
'Launch manually',
);
await workflowVisualizer.goBackInCommandMenu.click();
const formStep = workflowVisualizer.commandMenu.locator(
workflowVisualizer.getStepNode(firstStepId),
);
await formStep.click();
await workflowVisualizer.goBackInCommandMenu.click();
const openInFullScreenButton = workflowVisualizer.commandMenu.getByRole(
'button',
{ name: 'Open' },
);
await openInFullScreenButton.click();
const workflowRunNameInShowPage = page
.getByText(`#1 - ${workflowVisualizer.workflowName}`)
.nth(1);
await expect(workflowRunNameInShowPage).toBeVisible();
// Expect the side panel to be opened by default on the form.
await expect(workflowVisualizer.stepHeaderInCommandMenu).toContainText(
'Form',
);
});
@@ -8,19 +8,25 @@ test('Use an old version as draft', async ({ workflowVisualizer, page }) => {
await workflowVisualizer.background.click();
await workflowVisualizer.activateWorkflowButton.click();
await Promise.all([
expect(workflowVisualizer.workflowStatus).toHaveText('Active'),
await expect(workflowVisualizer.workflowStatus).toHaveText('Active');
workflowVisualizer.activateWorkflowButton.click(),
]);
await workflowVisualizer.createStep('delete-record');
await Promise.all([
expect(workflowVisualizer.workflowStatus).toHaveText('Draft'),
await expect(workflowVisualizer.workflowStatus).toHaveText('Draft');
workflowVisualizer.createStep('delete-record'),
]);
await workflowVisualizer.closeSidePanel();
await workflowVisualizer.background.click();
await workflowVisualizer.activateWorkflowButton.click();
await Promise.all([
expect(workflowVisualizer.workflowStatus).toHaveText('Active'),
await expect(workflowVisualizer.workflowStatus).toHaveText('Active');
workflowVisualizer.activateWorkflowButton.click(),
]);
await expect(workflowVisualizer.triggerNode).toContainText(
'Record is Created',
@@ -35,8 +41,6 @@ test('Use an old version as draft', async ({ workflowVisualizer, page }) => {
const workflowsLink = page.getByRole('link', { name: 'Workflows' });
await workflowsLink.click();
await workflowVisualizer.setWorkflowsOpenInMode('record-page');
const recordTableRowForWorkflow = page.getByRole('row', {
name: workflowVisualizer.workflowName,
});
@@ -44,7 +48,7 @@ test('Use an old version as draft', async ({ workflowVisualizer, page }) => {
const linkToWorkflow = recordTableRowForWorkflow.getByRole('link', {
name: workflowVisualizer.workflowName,
});
await expect(linkToWorkflow).toBeVisible();
expect(linkToWorkflow).toBeVisible();
const linkToFirstWorkflowVersion = recordTableRowForWorkflow.getByRole(
'link',
@@ -53,7 +57,7 @@ test('Use an old version as draft', async ({ workflowVisualizer, page }) => {
},
);
await linkToFirstWorkflowVersion.click({ force: true });
await linkToFirstWorkflowVersion.click();
await expect(workflowVisualizer.workflowStatus).toHaveText('Archived');
await expect(workflowVisualizer.useAsDraftButton).toBeVisible();
@@ -65,9 +69,11 @@ test('Use an old version as draft', async ({ workflowVisualizer, page }) => {
]);
await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(1);
await workflowVisualizer.useAsDraftButton.click();
await Promise.all([
page.waitForURL(`/object/workflow/${workflowVisualizer.workflowId}`),
await page.waitForURL(`/object/workflow/${workflowVisualizer.workflowId}`);
workflowVisualizer.useAsDraftButton.click(),
]);
await expect(workflowVisualizer.workflowStatus).toHaveText('Draft');
await expect(workflowVisualizer.useAsDraftButton).not.toBeVisible();
@@ -90,13 +96,17 @@ test('Use an old version as draft while having a pending draft version', async (
await workflowVisualizer.background.click();
await workflowVisualizer.activateWorkflowButton.click();
await Promise.all([
expect(workflowVisualizer.workflowStatus).toHaveText('Active'),
await expect(workflowVisualizer.workflowStatus).toHaveText('Active');
workflowVisualizer.activateWorkflowButton.click(),
]);
await workflowVisualizer.createStep('delete-record');
await Promise.all([
expect(workflowVisualizer.workflowStatus).toHaveText('Draft'),
await expect(workflowVisualizer.workflowStatus).toHaveText('Draft');
workflowVisualizer.createStep('delete-record'),
]);
await expect(workflowVisualizer.triggerNode).toContainText(
'Record is Created',
@@ -120,7 +130,7 @@ test('Use an old version as draft while having a pending draft version', async (
const linkToWorkflow = recordTableRowForWorkflow.getByRole('link', {
name: workflowVisualizer.workflowName,
});
await expect(linkToWorkflow).toBeVisible();
expect(linkToWorkflow).toBeVisible();
const linkToFirstWorkflowVersion = recordTableRowForWorkflow.getByRole(
'link',
@@ -129,7 +139,7 @@ test('Use an old version as draft while having a pending draft version', async (
},
);
await linkToFirstWorkflowVersion.click({ force: true });
await linkToFirstWorkflowVersion.click();
await expect(workflowVisualizer.workflowStatus).toHaveText('Active');
await expect(workflowVisualizer.useAsDraftButton).toBeVisible();
@@ -141,13 +151,17 @@ test('Use an old version as draft while having a pending draft version', async (
]);
await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(1);
await workflowVisualizer.useAsDraftButton.click();
await Promise.all([
expect(workflowVisualizer.overrideDraftButton).toBeVisible(),
await expect(workflowVisualizer.overrideDraftButton).toBeVisible();
workflowVisualizer.useAsDraftButton.click(),
]);
await workflowVisualizer.overrideDraftButton.click();
await Promise.all([
page.waitForURL(`/object/workflow/${workflowVisualizer.workflowId}`),
await page.waitForURL(`/object/workflow/${workflowVisualizer.workflowId}`);
workflowVisualizer.overrideDraftButton.click(),
]);
await expect(workflowVisualizer.workflowStatus).toHaveText('Draft');
await expect(workflowVisualizer.useAsDraftButton).not.toBeVisible();
+4 -3
View File
@@ -1,5 +1,6 @@
{
"name": "twenty-emails",
"version": "0.51.5",
"description": "",
"author": "",
"private": true,
@@ -27,13 +28,13 @@
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"engines": {
"node": "^22.12.0",
"node": "^18.17.1",
"npm": "please-use-yarn",
"yarn": "^4.0.2"
}
+2 -1
View File
@@ -54,7 +54,8 @@ export const emailTheme = {
},
background: {
colors: { highlight: grayScale.gray15 },
button: grayScale.gray60,
radialGradient: `radial-gradient(50% 62.62% at 50% 0%, #505050 0%, ${grayScale.gray60} 100%)`,
radialGradientHover: `radial-gradient(76.32% 95.59% at 50% 0%, #505050 0%, ${grayScale.gray60} 100%)`,
transparent: {
medium: 'rgba(0, 0, 0, 0.08)',
light: 'rgba(0, 0, 0, 0.04)',
@@ -3,16 +3,15 @@ import { Button } from '@react-email/components';
import { emailTheme } from 'src/common-style';
const callToActionStyle = {
display: 'inline-flex',
display: 'flex',
padding: '8px 32px',
borderRadius: emailTheme.border.radius.md,
border: `1px solid ${emailTheme.background.transparent.light}`,
background: emailTheme.background.button,
background: emailTheme.background.radialGradient,
boxShadow: `0px 2px 4px 0px ${emailTheme.background.transparent.light}, 0px 0px 4px 0px ${emailTheme.background.transparent.medium}`,
color: emailTheme.font.colors.inverted,
fontSize: emailTheme.font.size.md,
fontWeight: emailTheme.font.weight.bold,
width: 'auto',
};
type CallToActionProps = {
@@ -7,7 +7,7 @@ const logoStyle = {
export const Logo = () => {
return (
<Img
src="https://app.twenty.com/images/icons/windows11/Square150x150Logo.scale-100.png"
src="https://app.twenty.com/icons/windows11/Square150x150Logo.scale-100.png"
alt="Twenty logo"
width="40"
height="40"
@@ -42,13 +42,10 @@ export const CleanSuspendedWorkspaceEmail = ({
<br />
<Trans id="If you wish to use Twenty again, you can create a new workspace." />
</MainText>
<br />
<CallToAction
href="https://app.twenty.com/"
value={i18n._('Create a new workspace')}
/>
<br />
<br />
</BaseEmail>
);
};
@@ -21,6 +21,7 @@ export const PasswordResetLinkEmail = ({
return (
<BaseEmail locale={locale}>
<Title value={i18n._('Reset your password 🗝')} />
<CallToAction href={link} value={i18n._('Reset')} />
<MainText>
<Trans
id="This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:"
@@ -29,10 +30,6 @@ export const PasswordResetLinkEmail = ({
<br />
<Link href={link} value={link} />
</MainText>
<br />
<CallToAction href={link} value={i18n._('Reset')} />
<br />
<br />
</BaseEmail>
);
};
@@ -41,10 +41,7 @@ export const PasswordUpdateNotifyEmail = ({
<Trans id="If you did not initiate this change, please contact your workspace owner immediately." />
<br />
</MainText>
<br />
<CallToAction value={i18n._('Connect to Twenty')} href={link} />
<br />
<br />
</BaseEmail>
);
};
@@ -18,13 +18,12 @@ export const SendEmailVerificationLinkEmail = ({
return (
<BaseEmail width={333} locale={locale}>
<Title value={i18n._('Confirm your email address')} />
<MainText>
<Trans id="Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click below to verify your email address." />
</MainText>
<br />
<CallToAction href={link} value={i18n._('Verify Email')} />
<br />
<br />
<MainText>
<Trans id="Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click above to verify your email address." />
</MainText>
</BaseEmail>
);
};
@@ -13,8 +13,6 @@ export const TestEmail = ({ locale }: TestEmailProps) => {
return (
<BaseEmail locale={locale}>
<Title value={i18n._('Test email')} />
<br />
<br />
</BaseEmail>
);
};
@@ -76,7 +76,6 @@ export const SendApprovedAccessDomainValidation = ({
{workspace.name ? <HighlightedText value={workspace.name} /> : <></>}
<CallToAction href={link} value={i18n._('Validate domain')} />
</HighlightedContainer>
<br />
</BaseEmail>
);
};
@@ -54,13 +54,10 @@ export const WarnSuspendedWorkspaceEmail = ({
values={{ remainingDays, dayOrDays }}
/>
</MainText>
<br />
<CallToAction
href="https://app.twenty.com/settings/billing"
value={i18n._('Update your subscription')}
/>
<br />
<br />
</BaseEmail>
);
};
+4 -9
View File
@@ -95,12 +95,12 @@ msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click below to verify your email address."
msgid "Verify Email"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
msgid "Verify Email"
msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click above to verify your email address."
msgstr ""
#. js-lingui-explicit-id
@@ -130,12 +130,12 @@ msgstr ""
#. js-lingui-explicit-id
#: src/emails/password-reset-link.email.tsx
msgid "This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:"
msgid "Reset"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/password-reset-link.email.tsx
msgid "Reset"
msgid "This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:"
msgstr ""
#. js-lingui-explicit-id
@@ -223,11 +223,6 @@ msgstr ""
msgid "San Francisco / Paris"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
#~ msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click above to verify your email address."
#~ msgstr ""
#. js-lingui-id: 4WPI3S
#: src/emails/send-invite-link.email.tsx
#~ msgid "Accept invite"
+4 -9
View File
@@ -95,12 +95,12 @@ msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click below to verify your email address."
msgid "Verify Email"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
msgid "Verify Email"
msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click above to verify your email address."
msgstr ""
#. js-lingui-explicit-id
@@ -130,12 +130,12 @@ msgstr ""
#. js-lingui-explicit-id
#: src/emails/password-reset-link.email.tsx
msgid "This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:"
msgid "Reset"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/password-reset-link.email.tsx
msgid "Reset"
msgid "This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:"
msgstr ""
#. js-lingui-explicit-id
@@ -223,11 +223,6 @@ msgstr ""
msgid "San Francisco / Paris"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
#~ msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click above to verify your email address."
#~ msgstr ""
#. js-lingui-id: 4WPI3S
#: src/emails/send-invite-link.email.tsx
#~ msgid "Accept invite"
+4 -9
View File
@@ -95,12 +95,12 @@ msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click below to verify your email address."
msgid "Verify Email"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
msgid "Verify Email"
msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click above to verify your email address."
msgstr ""
#. js-lingui-explicit-id
@@ -130,12 +130,12 @@ msgstr ""
#. js-lingui-explicit-id
#: src/emails/password-reset-link.email.tsx
msgid "This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:"
msgid "Reset"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/password-reset-link.email.tsx
msgid "Reset"
msgid "This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:"
msgstr ""
#. js-lingui-explicit-id
@@ -223,11 +223,6 @@ msgstr ""
msgid "San Francisco / Paris"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
#~ msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click above to verify your email address."
#~ msgstr ""
#. js-lingui-id: 4WPI3S
#: src/emails/send-invite-link.email.tsx
#~ msgid "Accept invite"
+4 -9
View File
@@ -95,12 +95,12 @@ msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click below to verify your email address."
msgid "Verify Email"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
msgid "Verify Email"
msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click above to verify your email address."
msgstr ""
#. js-lingui-explicit-id
@@ -130,12 +130,12 @@ msgstr ""
#. js-lingui-explicit-id
#: src/emails/password-reset-link.email.tsx
msgid "This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:"
msgid "Reset"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/password-reset-link.email.tsx
msgid "Reset"
msgid "This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:"
msgstr ""
#. js-lingui-explicit-id
@@ -223,11 +223,6 @@ msgstr ""
msgid "San Francisco / Paris"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
#~ msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click above to verify your email address."
#~ msgstr ""
#. js-lingui-id: 4WPI3S
#: src/emails/send-invite-link.email.tsx
#~ msgid "Accept invite"
+4 -9
View File
@@ -95,12 +95,12 @@ msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click below to verify your email address."
msgid "Verify Email"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
msgid "Verify Email"
msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click above to verify your email address."
msgstr ""
#. js-lingui-explicit-id
@@ -130,12 +130,12 @@ msgstr ""
#. js-lingui-explicit-id
#: src/emails/password-reset-link.email.tsx
msgid "This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:"
msgid "Reset"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/password-reset-link.email.tsx
msgid "Reset"
msgid "This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:"
msgstr ""
#. js-lingui-explicit-id
@@ -223,11 +223,6 @@ msgstr ""
msgid "San Francisco / Paris"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
#~ msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click above to verify your email address."
#~ msgstr ""
#. js-lingui-id: 4WPI3S
#: src/emails/send-invite-link.email.tsx
#~ msgid "Accept invite"
+4 -9
View File
@@ -95,12 +95,12 @@ msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click below to verify your email address."
msgid "Verify Email"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
msgid "Verify Email"
msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click above to verify your email address."
msgstr ""
#. js-lingui-explicit-id
@@ -130,12 +130,12 @@ msgstr ""
#. js-lingui-explicit-id
#: src/emails/password-reset-link.email.tsx
msgid "This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:"
msgid "Reset"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/password-reset-link.email.tsx
msgid "Reset"
msgid "This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:"
msgstr ""
#. js-lingui-explicit-id
@@ -223,11 +223,6 @@ msgstr ""
msgid "San Francisco / Paris"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
#~ msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click above to verify your email address."
#~ msgstr ""
#. js-lingui-id: 4WPI3S
#: src/emails/send-invite-link.email.tsx
#~ msgid "Accept invite"
+4 -9
View File
@@ -95,12 +95,12 @@ msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click below to verify your email address."
msgid "Verify Email"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
msgid "Verify Email"
msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click above to verify your email address."
msgstr ""
#. js-lingui-explicit-id
@@ -130,12 +130,12 @@ msgstr ""
#. js-lingui-explicit-id
#: src/emails/password-reset-link.email.tsx
msgid "This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:"
msgid "Reset"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/password-reset-link.email.tsx
msgid "Reset"
msgid "This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:"
msgstr ""
#. js-lingui-explicit-id
@@ -223,11 +223,6 @@ msgstr ""
msgid "San Francisco / Paris"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
#~ msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click above to verify your email address."
#~ msgstr ""
#. js-lingui-id: 4WPI3S
#: src/emails/send-invite-link.email.tsx
#~ msgid "Accept invite"
+8 -13
View File
@@ -90,13 +90,13 @@ msgstr "Confirm your email address"
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click below to verify your email address."
msgstr "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click below to verify your email address."
msgid "Verify Email"
msgstr "Verify Email"
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
msgid "Verify Email"
msgstr "Verify Email"
msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click above to verify your email address."
msgstr "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click above to verify your email address."
#. js-lingui-explicit-id
#: src/emails/password-update-notify.email.tsx
@@ -125,13 +125,13 @@ msgstr "Reset your password 🗝"
#. js-lingui-explicit-id
#: src/emails/password-reset-link.email.tsx
msgid "This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:"
msgstr "This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:"
msgid "Reset"
msgstr "Reset"
#. js-lingui-explicit-id
#: src/emails/password-reset-link.email.tsx
msgid "Reset"
msgstr "Reset"
msgid "This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:"
msgstr "This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:"
#. js-lingui-explicit-id
#: src/emails/clean-suspended-workspace.email.tsx
@@ -218,11 +218,6 @@ msgstr "Twenty.com, Public Benefit Corporation"
msgid "San Francisco / Paris"
msgstr "San Francisco / Paris"
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
#~ msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click above to verify your email address."
#~ msgstr "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click above to verify your email address."
#. js-lingui-id: 4WPI3S
#: src/emails/send-invite-link.email.tsx
#~ msgid "Accept invite"
+4 -9
View File
@@ -95,12 +95,12 @@ msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click below to verify your email address."
msgid "Verify Email"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
msgid "Verify Email"
msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click above to verify your email address."
msgstr ""
#. js-lingui-explicit-id
@@ -130,12 +130,12 @@ msgstr ""
#. js-lingui-explicit-id
#: src/emails/password-reset-link.email.tsx
msgid "This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:"
msgid "Reset"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/password-reset-link.email.tsx
msgid "Reset"
msgid "This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:"
msgstr ""
#. js-lingui-explicit-id
@@ -223,11 +223,6 @@ msgstr ""
msgid "San Francisco / Paris"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
#~ msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click above to verify your email address."
#~ msgstr ""
#. js-lingui-id: 4WPI3S
#: src/emails/send-invite-link.email.tsx
#~ msgid "Accept invite"
+4 -9
View File
@@ -95,12 +95,12 @@ msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click below to verify your email address."
msgid "Verify Email"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
msgid "Verify Email"
msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click above to verify your email address."
msgstr ""
#. js-lingui-explicit-id
@@ -130,12 +130,12 @@ msgstr ""
#. js-lingui-explicit-id
#: src/emails/password-reset-link.email.tsx
msgid "This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:"
msgid "Reset"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/password-reset-link.email.tsx
msgid "Reset"
msgid "This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:"
msgstr ""
#. js-lingui-explicit-id
@@ -223,11 +223,6 @@ msgstr ""
msgid "San Francisco / Paris"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
#~ msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click above to verify your email address."
#~ msgstr ""
#. js-lingui-id: 4WPI3S
#: src/emails/send-invite-link.email.tsx
#~ msgid "Accept invite"
+4 -9
View File
@@ -95,12 +95,12 @@ msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click below to verify your email address."
msgid "Verify Email"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
msgid "Verify Email"
msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click above to verify your email address."
msgstr ""
#. js-lingui-explicit-id
@@ -130,12 +130,12 @@ msgstr ""
#. js-lingui-explicit-id
#: src/emails/password-reset-link.email.tsx
msgid "This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:"
msgid "Reset"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/password-reset-link.email.tsx
msgid "Reset"
msgid "This link is only valid for the next {duration}. If the link does not work, you can use the login verification link directly:"
msgstr ""
#. js-lingui-explicit-id
@@ -223,11 +223,6 @@ msgstr ""
msgid "San Francisco / Paris"
msgstr ""
#. js-lingui-explicit-id
#: src/emails/send-email-verification-link.email.tsx
#~ msgid "Thanks for registering for an account on Twenty! Before we get started, we just need to confirm that this is you. Click above to verify your email address."
#~ msgstr ""
#. js-lingui-id: 4WPI3S
#: src/emails/send-invite-link.email.tsx
#~ msgid "Accept invite"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

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