Compare commits

..
4 Commits
Author SHA1 Message Date
Charles BochetandCharles Bochet 42d0fdeeeb Refactor new relation sync (#11711)
In this PR:
- this should fix the sync metadata for new relation system

This goes with the recent PR:
https://github.com/twentyhq/twenty/pull/11725

What we want:
- ONE_TO_MANY relations should have no joinColumn and no onDelete
- MANY_TO_ONE should have both
2025-04-25 01:04:15 +02:00
martmullandCharles Bochet 8d95166af8 Fix missing on delete cascade for relation (#11725)
Follow up from https://github.com/twentyhq/twenty/pull/10217
Adding onDelete action on Many_to_one sides
2025-04-25 01:04:04 +02:00
Félix MalfaitandFélix Malfait 676866a554 Fix json type in clickhouse migrations (#11710)
Settings in clickhouse are only set for the duration of the session so
we need to keep the same client
2025-04-23 22:09:46 +02:00
Félix MalfaitandFélix Malfait fc6aafd3c6 Fix clickhouse migration not in dist (#11709)
Clickhouse migrations were not correctly copied to the dist folder (for
command yarn clickhouse:migrate:prod)
2025-04-23 21:23:31 +02:00
9851 changed files with 246307 additions and 608024 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: Twenty CRM development rules and best practices
globs: []
alwaysApply: true
---
# 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
-30
View File
@@ -1,30 +0,0 @@
---
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
}
}
```
-117
View File
@@ -1,117 +0,0 @@
---
description: Code style guidelines for Twenty CRM
globs: []
alwaysApply: true
---
# 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
// ✅ Use short-form comments, NOT JSDoc blocks
// ✅ 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;
// ✅ Multi-line comments use multiple // lines (NOT /** */ blocks)
// Calculates the total price after applying tax and discount
// Returns the final price that should be charged to the customer
const calculateTotalPrice = (basePrice: number): number => {
// Implementation
};
// ❌ AVOID JSDoc blocks - use short comments instead
/**
* This style is NOT preferred in this codebase
*/
```
## Security Patterns
```typescript
// ✅ CSV Export: Always apply security first, then formatting
const safeValue = formatValueForCSV(sanitizeValueForCSVExport(userInput));
// ✅ Input validation before processing
const sanitizedInput = validateAndSanitize(userInput);
const result = processData(sanitizedInput);
```
## 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;
}
```
-100
View File
@@ -1,100 +0,0 @@
---
description: Guidelines for incorporating user feedback and improving cursor rules
globs: []
alwaysApply: true
---
# Feedback Incorporation Guidelines
## Post-Interaction Reflection
After each coding session or significant interaction, the AI should:
### 1. Reflect on User Feedback
- **Identify patterns** in user corrections or suggestions
- **Note recurring issues** that could be prevented with better rules
- **Recognize gaps** in current cursor rules or guidelines
### 2. Suggest Rule Improvements
When user provides feedback that reveals a pattern or preference:
```typescript
// Example feedback patterns to watch for:
// - "We don't use useEffect, handle state changes in event callbacks"
// - "We don't use JSDoc blocks, prefer // comments"
// - "Always use named exports, never default exports"
// - "We prefer functional components over class components"
// - "Use event handlers over useEffect for state updates"
```
### 3. Proactive Rule Suggestions
At the end of interactions, suggest:
```markdown
## 💡 Suggested Cursor Rule Updates
Based on your feedback today, I recommend adding/updating these rules:
**Code Style Rule Update:**
- Add preference for // comments over JSDoc blocks
- Enforce named exports only (no default exports)
**React Guidelines Update:**
- Document preference for event handlers over useEffect
- Add functional components only rule
Would you like me to help incorporate these into your cursor rules?
```
## Implementation Process
### When to Suggest Updates
- User corrects the same type of mistake multiple times
- User explains a codebase-specific preference
- User points out missing functionality or incomplete implementations
- User provides context about existing patterns not captured in rules
### How to Present Suggestions
1. **Summarize the pattern** observed from feedback
2. **Propose specific rule language** that would prevent the issue
3. **Explain the benefit** of codifying this knowledge
4. **Ask for confirmation** before implementing
### Rule Categories to Consider
- **Code Style**: Formatting, naming, comment styles, export patterns
- **React Patterns**: Hook usage, component structure, state management
- **Architecture**: File organization, import patterns, component composition
- **Testing**: Test structure, naming, coverage expectations
- **Performance**: Optimization patterns, anti-patterns to avoid
## Example Feedback Integration
```markdown
## Today's Learning: React State Management Patterns
**User Feedback Received:**
- "We don't use useEffect, handle state changes in event callbacks"
- "We don't use JSDoc blocks, prefer // comments"
- "Always use named exports, never default exports"
**Proposed Rule Additions:**
```typescript
// ✅ React State Updates - Use event handlers, not useEffect
const handleButtonClick = () => {
setData(newData); // Direct state update in event handler
};
// ❌ Avoid useEffect for state updates
// useEffect(() => { setData(newData); }, [trigger]);
// ✅ Named exports only
export const UserComponent = () => {};
export const useUserData = () => {};
```
**Benefits:**
- Prevents useEffect overuse and related bugs
- Ensures consistent export patterns across codebase
- Documents preferred React patterns for the team
```
This approach helps the AI learn from each interaction and continuously improve the development experience.
+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: File structure guidelines for Twenty CRM
globs: []
alwaysApply: true
---
# 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
-78
View File
@@ -1,78 +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: ["**/nx.json", "**/project.json", "**/workspace.json"]
alwaysApply: true
---
# 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.mjs" }
}
}
}
```
## 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,85 +0,0 @@
---
description: React general guidelines for Twenty CRM
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,
},
});
},
});
```
-80
View File
@@ -1,80 +0,0 @@
---
description: React state management guidelines for Twenty CRM
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,
},
},
};
```
-88
View File
@@ -1,88 +0,0 @@
---
description: Testing guidelines for Twenty CRM
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
-290
View File
@@ -1,290 +0,0 @@
---
description: Translation guidelines for Twenty CRM
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
+172
View File
@@ -0,0 +1,172 @@
# TypeScript Guidelines
## Core TypeScript Principles
Twenty enforces strict TypeScript usage to ensure type safety and maintainable code. This document outlines our TypeScript conventions and best practices.
## Type Safety
### Strict Typing
- **No 'any' type allowed**
- TypeScript strict mode enabled
- noImplicitAny enabled
```typescript
// ✅ Correct
function processUser(user: User) {
return user.name;
}
// ❌ Incorrect
function processUser(user: any) {
return user.name;
}
```
### Type Definitions
#### Types over Interfaces
- Use `type` for all type definitions
- Exception: When extending third-party interfaces
```typescript
// ✅ Correct
type User = {
id: string;
name: string;
email: string;
};
// ❌ Incorrect
interface User {
id: string;
name: string;
email: string;
}
```
### String Literals over Enums
- Use string literal unions instead of enums
- Exception: GraphQL enums
```typescript
// ✅ Correct
type UserRole = 'admin' | 'user' | 'guest';
// ❌ Incorrect
enum UserRole {
Admin = 'admin',
User = 'user',
Guest = 'guest',
}
```
## Naming Conventions
### Component Props
- Suffix component prop types with 'Props'
- Keep props focused and single-purpose
```typescript
// ✅ Correct
type ButtonProps = {
label: string;
onClick: () => void;
variant?: 'primary' | 'secondary';
};
// ❌ Incorrect
type ButtonParameters = {
label: string;
onClick: () => void;
variant?: 'primary' | 'secondary';
};
```
## Type Inference
### Leverage TypeScript Inference
- Use type inference when types are clear
- Explicitly type when inference is ambiguous
```typescript
// ✅ Correct - Clear inference
const users = ['John', 'Jane']; // inferred as string[]
// ✅ Correct - Explicit typing needed
const processUser = (user: User): UserResponse => {
// Complex processing
return response;
};
// ❌ Incorrect - Unnecessary explicit typing
const users: string[] = ['John', 'Jane'];
```
## Best Practices
### Type Guards
- Use type guards for runtime type checking
- Prefer discriminated unions
```typescript
// ✅ Correct
type Success = {
type: 'success';
data: User;
};
type Error = {
type: 'error';
message: string;
};
type Result = Success | Error;
function handleResult(result: Result) {
if (result.type === 'success') {
// TypeScript knows result.data exists
console.log(result.data);
}
}
```
### Generics
- Use generics for reusable type patterns
- Keep generic names descriptive
```typescript
// ✅ Correct
type ApiResponse<TData> = {
data: TData;
status: number;
message: string;
};
// ❌ Incorrect
type ApiResponse<T> = {
data: T;
status: number;
message: string;
};
```
### Type Exports
- Export types when they're used across files
- Keep type definitions close to their usage
```typescript
// types.ts
export type User = {
id: string;
name: string;
};
// UserComponent.tsx
import { type User } from './types';
```
### Utility Types
- Leverage TypeScript utility types
- Create custom utility types for repeated patterns
```typescript
// Built-in utility types
type UserPartial = Partial<User>;
type UserReadonly = Readonly<User>;
// Custom utility types
type NonNullableProperties<T> = {
[P in keyof T]: NonNullable<T[P]>;
};
```
-182
View File
@@ -1,182 +0,0 @@
---
description: TypeScript best practices and conventions for the Twenty codebase, including strict typing, naming conventions, and type safety guidelines.
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
Twenty enforces strict TypeScript usage to ensure type safety and maintainable code. This document outlines our TypeScript conventions and best practices.
## Type Safety
### Strict Typing
- **No 'any' type allowed**
- TypeScript strict mode enabled
- noImplicitAny enabled
```typescript
// ✅ Correct
function processUser(user: User) {
return user.name;
}
// ❌ Incorrect
function processUser(user: any) {
return user.name;
}
```
### Type Definitions
#### Types over Interfaces
- Use `type` for all type definitions
- Exception: When extending third-party interfaces
```typescript
// ✅ Correct
type User = {
id: string;
name: string;
email: string;
};
// ❌ Incorrect
interface User {
id: string;
name: string;
email: string;
}
```
### String Literals over Enums
- Use string literal unions instead of enums
- Exception: GraphQL enums
```typescript
// ✅ Correct
type UserRole = 'admin' | 'user' | 'guest';
// ❌ Incorrect
enum UserRole {
Admin = 'admin',
User = 'user',
Guest = 'guest',
}
```
## Naming Conventions
### Component Props
- Suffix component prop types with 'Props'
- Keep props focused and single-purpose
```typescript
// ✅ Correct
type ButtonProps = {
label: string;
onClick: () => void;
variant?: 'primary' | 'secondary';
};
// ❌ Incorrect
type ButtonParameters = {
label: string;
onClick: () => void;
variant?: 'primary' | 'secondary';
};
```
## Type Inference
### Leverage TypeScript Inference
- Use type inference when types are clear
- Explicitly type when inference is ambiguous
```typescript
// ✅ Correct - Clear inference
const users = ['John', 'Jane']; // inferred as string[]
// ✅ Correct - Explicit typing needed
const processUser = (user: User): UserResponse => {
// Complex processing
return response;
};
// ❌ Incorrect - Unnecessary explicit typing
const users: string[] = ['John', 'Jane'];
```
## Best Practices
### Type Guards
- Use type guards for runtime type checking
- Prefer discriminated unions
```typescript
// ✅ Correct
type Success = {
type: 'success';
data: User;
};
type Error = {
type: 'error';
message: string;
};
type Result = Success | Error;
function handleResult(result: Result) {
if (result.type === 'success') {
// TypeScript knows result.data exists
console.log(result.data);
}
}
```
### Generics
- Use generics for reusable type patterns
- Keep generic names descriptive
```typescript
// ✅ Correct
type ApiResponse<TData> = {
data: TData;
status: number;
message: string;
};
// ❌ Incorrect
type ApiResponse<T> = {
data: T;
status: number;
message: string;
};
```
### Type Exports
- Export types when they're used across files
- Keep type definitions close to their usage
```typescript
// types.ts
export type User = {
id: string;
name: string;
};
// UserComponent.tsx
import { type User } from './types';
```
### Utility Types
- Leverage TypeScript utility types
- Create custom utility types for repeated patterns
```typescript
// Built-in utility types
type UserPartial = Partial<User>;
type UserReadonly = Readonly<User>;
// Custom utility types
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
+149
View File
@@ -0,0 +1,149 @@
module.exports = {
root: true,
extends: ['plugin:prettier/recommended', 'plugin:lingui/recommended'],
ignorePatterns: ['node_modules'],
plugins: [
'@nx',
'prefer-arrow',
'import',
'unused-imports',
'unicorn',
'lingui',
],
rules: {
'lingui/no-single-variables-to-translate': 'off',
'func-style': ['error', 'declaration', { allowArrowFunctions: true }],
'no-console': ['warn', { allow: ['group', 'groupCollapsed', 'groupEnd'] }],
'no-control-regex': 0,
'no-debugger': 'error',
'no-duplicate-imports': 'error',
'no-undef': 'off',
'no-unused-vars': 'off',
'@nx/enforce-module-boundaries': [
'error',
{
enforceBuildableLibDependency: true,
allow: [],
depConstraints: [
{
sourceTag: 'scope:shared',
onlyDependOnLibsWithTags: ['scope:shared'],
},
{
sourceTag: 'scope:backend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:backend'],
},
{
sourceTag: 'scope:frontend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
},
{
sourceTag: 'scope:zapier',
onlyDependOnLibsWithTags: ['scope:shared'],
},
],
},
],
'import/no-relative-packages': 'error',
'import/no-useless-path-segments': 'error',
'import/no-duplicates': ['error', { considerQueryString: true }],
'prefer-arrow/prefer-arrow-functions': [
'error',
{
disallowPrototype: true,
singleReturnOnly: false,
classPropertiesAllowed: false,
},
],
'unused-imports/no-unused-imports': 'warn',
'unused-imports/no-unused-vars': [
'warn',
{
vars: 'all',
varsIgnorePattern: '^_',
args: 'after-used',
argsIgnorePattern: '^_',
},
],
},
overrides: [
{
files: ['**/*.ts', '**/*.tsx'],
extends: ['plugin:@nx/typescript'],
rules: {
'@typescript-eslint/ban-ts-comment': 'error',
'@typescript-eslint/consistent-type-imports': [
'error',
{ prefer: 'no-type-imports' },
],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/no-empty-interface': [
'error',
{
allowSingleExtends: true,
},
],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
vars: 'all',
varsIgnorePattern: '^_',
args: 'after-used',
argsIgnorePattern: '^_',
},
],
},
},
{
files: ['*.js', '*.jsx'],
extends: ['plugin:@nx/javascript'],
rules: {},
},
{
files: [
'*.spec.@(ts|tsx|js|jsx)',
'*.integration-spec.@(ts|tsx|js|jsx)',
'*.test.@(ts|tsx|js|jsx)',
],
env: {
jest: true,
},
rules: {
'@typescript-eslint/no-non-null-assertion': 'off',
},
},
{
files: ['**/constants/*.ts', '**/*.constants.ts'],
rules: {
'@typescript-eslint/naming-convention': [
'error',
{
selector: 'variable',
format: ['UPPER_CASE'],
},
],
'unicorn/filename-case': [
'warn',
{
cases: {
pascalCase: true,
},
},
],
'@nx/workspace-max-consts-per-file': ['error', { max: 1 }],
},
},
{
files: ['*.json'],
parser: 'jsonc-eslint-parser',
},
],
};
+199
View File
@@ -0,0 +1,199 @@
var path = require('path');
module.exports = {
extends: [
'plugin:@nx/react',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:storybook/recommended',
'plugin:prettier/recommended',
'plugin:lingui/recommended',
'plugin:@nx/typescript'
],
plugins: ['react-hooks', 'react-refresh', '@nx', 'prefer-arrow', 'import', 'unused-imports', 'unicorn', 'lingui'],
rules: {
'lingui/no-single-variables-to-translate': 'off',
'func-style': ['error', 'declaration', { allowArrowFunctions: true }],
'no-console': ['warn', { allow: ['group', 'groupCollapsed', 'groupEnd'] }],
'no-control-regex': 0,
'no-debugger': 'error',
'no-duplicate-imports': 'error',
'no-undef': 'off',
'no-unused-vars': 'off',
'@nx/enforce-module-boundaries': [
'error',
{
enforceBuildableLibDependency: true,
allow: [],
depConstraints: [
{
sourceTag: 'scope:shared',
onlyDependOnLibsWithTags: ['scope:shared'],
},
{
sourceTag: 'scope:backend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:backend'],
},
{
sourceTag: 'scope:frontend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
},
{
sourceTag: 'scope:zapier',
onlyDependOnLibsWithTags: ['scope:shared'],
},
],
},
],
'import/no-relative-packages': 'error',
'import/no-useless-path-segments': 'error',
'import/no-duplicates': ['error', { considerQueryString: true }],
'prefer-arrow/prefer-arrow-functions': [
'error',
{
disallowPrototype: true,
singleReturnOnly: false,
classPropertiesAllowed: false,
},
],
'unused-imports/no-unused-imports': 'warn',
'unused-imports/no-unused-vars': [
'warn',
{
vars: 'all',
varsIgnorePattern: '^_',
args: 'after-used',
argsIgnorePattern: '^_',
},
],
},
overrides: [
{
files: ['**/*.ts', '**/*.tsx'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@tabler/icons-react'],
message: 'Please import icons from `twenty-ui`',
},
{
group: ['react-hotkeys-web-hook'],
importNames: ['useHotkeys'],
message:
'Please use the custom wrapper: `useScopedHotkeys` from `twenty-ui`',
},
{
group: ['lodash'],
message:
"Please use the standalone lodash package (for instance: `import groupBy from 'lodash.groupby'` instead of `import { groupBy } from 'lodash'`)",
},
],
},
],
'@typescript-eslint/no-empty-interface': [
'error',
{
allowSingleExtends: true,
},
],
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@nx/workspace-effect-components': 'error',
'@nx/workspace-no-hardcoded-colors': 'error',
'@nx/workspace-matching-state-variable': 'error',
'@nx/workspace-sort-css-properties-alphabetically': 'error',
'@nx/workspace-styled-components-prefixed-with-styled': 'error',
'@nx/workspace-no-state-useref': 'error',
'@nx/workspace-component-props-naming': 'error',
'@nx/workspace-explicit-boolean-predicates-in-if': 'error',
'@nx/workspace-use-getLoadable-and-getValue-to-get-atoms': 'error',
'@nx/workspace-useRecoilCallback-has-dependency-array': 'error',
'@nx/workspace-no-navigate-prefer-link': 'error',
'react/no-unescaped-entities': 'off',
'react/prop-types': 'off',
'react/jsx-key': 'off',
'react/display-name': 'off',
'react/jsx-uses-react': 'off',
'react/react-in-jsx-scope': 'off',
'react/jsx-no-useless-fragment': 'off',
'react/jsx-props-no-spreading': [
'error',
{
explicitSpread: 'ignore',
},
],
'react-hooks/exhaustive-deps': [
'warn',
{
additionalHooks: 'useRecoilCallback',
},
],
},
},
{
files: ['*.stories.@(ts|tsx|js|jsx)'],
rules: {
'@typescript-eslint/no-non-null-assertion': 'off',
},
},
{
files: ['.storybook/main.@(js|cjs|mjs|ts)'],
rules: {
'storybook/no-uninstalled-addons': [
'error',
{
packageJsonLocation: path.resolve(__dirname, './package.json'),
},
],
},
},
{
files: ['*.js', '*.jsx'],
extends: ['plugin:@nx/javascript'],
rules: {},
},
{
files: [
'*.test.@(ts|tsx|js|jsx)',
],
env: {
jest: true,
},
rules: {
'@typescript-eslint/no-non-null-assertion': 'off',
},
},
{
files: ['**/*.constants.ts'],
rules: {
'@typescript-eslint/naming-convention': [
'error',
{
selector: 'variable',
format: ['UPPER_CASE'],
},
],
'unicorn/filename-case': [
'warn',
{
cases: {
pascalCase: true,
},
},
],
'@nx/workspace-max-consts-per-file': ['error', { max: 1 }],
},
},
{
files: ['*.json'],
parser: 'jsonc-eslint-parser',
},
],
};
@@ -27,7 +27,7 @@ Having a list that is draggable will be useful, not only in dropdown.
Create a folder @/ui/draggable-list with a DraggableList component
This component should take as prop: itemsComponents, onDragEnd((previousIndex, nextIndex) => {})
Use this component in ObjectOptionsDropdownHiddenFieldsContent (move the logic from ObjectOptionsDropdownHiddenFieldsContent to DraggableList) by passing a list of DraggableMenuItems
Use this component in ViewFieldsVisibilityDropdownSection (move the logic from ViewFieldsVisibilityDropdownSection to DraggableList) by passing a list of DraggableMenuItems
Add a storybook test on this list (we don't know how to actually test the draggable behavior, but we can at least make sure the component renders correctly a list of items)
```
@@ -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 }}'
@@ -25,11 +25,11 @@ runs:
uses: actions/cache/restore@v4
id: restore-cache
with:
key: ${{ steps.cache-primary-key-builder.outputs.CACHE_PRIMARY_KEY_PREFIX }}-${{ github.sha }}
key: ${{ steps.cache-primary-key-builder.outputs.CACHE_PRIMARY_KEY_PREFIX }}-${{ github.sha }}
restore-keys: ${{ steps.cache-primary-key-builder.outputs.CACHE_PRIMARY_KEY_PREFIX }}-
path: |
.cache
.nx
.nx/cache
node_modules/.cache
packages/*/node_modules/.cache
${{ inputs.additional-paths }}
@@ -15,7 +15,7 @@ runs:
key: ${{ inputs.key }}
path: |
.cache
.nx
.nx/cache
node_modules/.cache
packages/*/node_modules/.cache
${{ inputs.additional-paths }}
@@ -2,10 +2,10 @@ name: Yarn Install
inputs:
node-version:
required: false
default: '24'
default: '18'
runs:
using: 'composite'
using: "composite"
steps:
- name: Cache primary key builder
id: globals
@@ -25,8 +25,8 @@ runs:
id: cache-node-modules
uses: actions/cache/restore@v4
with:
key: v4-${{ steps.globals.outputs.CACHE_KEY_PREFIX }}-${{github.sha}}
restore-keys: v4-${{ steps.globals.outputs.CACHE_KEY_PREFIX }}-
key: v3-${{ steps.globals.outputs.CACHE_KEY_PREFIX }}-${{github.sha}}
restore-keys: v3-${{ steps.globals.outputs.CACHE_KEY_PREFIX }}-
path: ${{ steps.globals.outputs.PATH_TO_CACHE }}
- name: Install Dependencies
if: ${{ steps.cache-node-modules.outputs.cache-hit != 'true' && steps.cache-node-modules.outputs.cache-matched-key == '' }}
@@ -40,3 +40,4 @@ runs:
with:
key: ${{ steps.cache-node-modules.outputs.cache-primary-key }}
path: ${{ steps.globals.outputs.PATH_TO_CACHE }}
-3
View File
@@ -9,9 +9,6 @@ on:
any_changed:
value: ${{ jobs.changed-files.outputs.any_changed }}
permissions:
contents: read
jobs:
changed-files:
timeout-minutes: 5
-780
View File
@@ -1,780 +0,0 @@
name: GraphQL and OpenAPI Breaking Changes Detection
on:
pull_request:
types: [opened, synchronize, edited]
branches:
- main
permissions:
contents: read
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/**
.github/workflows/ci-breaking-changes.yaml
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
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: Try to merge main into current branch
id: merge_attempt
run: |
echo "Attempting to merge main into current branch..."
git fetch origin main
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
echo "Current branch: $CURRENT_BRANCH"
if git merge origin/main --no-edit; then
echo "✅ Successfully merged main into current branch"
echo "merged=true" >> $GITHUB_OUTPUT
echo "BRANCH_STATE=merged" >> $GITHUB_ENV
else
echo "❌ Merge failed due to conflicts"
echo "⚠️ Falling back to comparing current branch against main without merge"
# Abort the failed merge
git merge --abort
echo "merged=false" >> $GITHUB_OUTPUT
echo "BRANCH_STATE=conflicts" >> $GITHUB_ENV
fi
- 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) - no PR comment will be posted"
# Don't create markdown file for non-breaking changes to avoid PR comments
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 (only for breaking changes)
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) - no PR comment will be posted"
# Don't create markdown file for non-breaking changes to avoid PR comments
else
echo "✅ No changes detected in REST Metadata API"
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) {
// Add branch state information only if there were conflicts
const branchState = process.env.BRANCH_STATE || 'unknown';
let branchStateNote = '';
if (branchState === 'conflicts') {
branchStateNote = '\n\n⚠️ **Note**: Could not merge with `main` due to conflicts. This comparison shows changes between the current branch and `main` as separate states.\n';
}
// 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 = ${{ toJSON(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 + branchStateNote + '\n⚠️ **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
@@ -0,0 +1,48 @@
name: CI Chrome Extension
on:
push:
branches:
- main
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
package.json
packages/twenty-chrome-extension/**
chrome-extension-build:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 15
runs-on: ubuntu-latest
env:
VITE_SERVER_BASE_URL: http://localhost:3000
VITE_FRONT_BASE_URL: http://localhost:3001
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/workflows/actions/yarn-install
- name: Chrome Extension / Run build
run: npx nx build twenty-chrome-extension
ci-chrome-extension-status-check:
if: always() && !cancelled()
timeout-minutes: 1
runs-on: ubuntu-latest
needs: [changed-files-check, chrome-extension-build]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
-54
View File
@@ -1,54 +0,0 @@
name: CI CLI
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
changed-files-check:
uses: ./.github/workflows/changed-files.yaml
with:
files: |
packages/twenty-cli/**
cli-test:
needs: changed-files-check
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
matrix:
task: [lint, typecheck, test, build]
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
uses: ./.github/workflows/actions/yarn-install
- name: Run ${{ matrix.task }} task
uses: ./.github/workflows/actions/nx-affected
with:
tag: scope:cli
tasks: ${{ matrix.task }}
ci-cli-status-check:
if: always() && !cancelled()
timeout-minutes: 5
runs-on: ubuntu-latest
needs: [changed-files-check, cli-test]
steps:
- name: Fail job if any needs failed
if: contains(needs.*.result, 'failure')
run: exit 1
+63
View File
@@ -0,0 +1,63 @@
name: CI Demo check
on:
schedule:
- cron: '30 7,19 * * *'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
timeout-minutes: 15
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./packages/twenty-e2e-testing
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
uses: ./.github/workflows/actions/yarn-install
- name: Install Playwright Browsers
run: yarn playwright install --with-deps
- name: Run Playwright tests
id: test
run: yarn playwright test --grep "@demo-only"
- name: Upload report after tests
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 90
- name: Send Discord notification
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.3.2
with:
args: 'Demo check ${{ steps.test.outcome }} - check ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
- name: Send email if demo is not working
if: steps.test.outcome == 'failure'
uses: dawidd6/action-send-mail@v3.12.0
with:
connection_url: ${{ secrets.MAIL_CONNECTION }}
server_address: smtp.gmail.com
server_port: 465
secure: true
username: ${{ secrets.MAIL_USERNAME }}
subject: 'Demo is not working'
from: 'Github CI Demo check'
to: ${{ secrets.RECIPIENTS }}
body: '<a href="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}">Link</a>'
priority: high
+2 -4
View File
@@ -6,9 +6,6 @@ on:
pull_request:
types: [opened, synchronize, reopened, labeled]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
@@ -27,6 +24,7 @@ jobs:
if: needs.changed-files-check.outputs.any_changed == 'true' && ( github.event_name == 'push' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-e2e')))
timeout-minutes: 30
env:
NX_REJECT_UNKNOWN_LOCAL_CACHE: 0
# https://github.com/actions/runner-images/issues/70#issuecomment-589562148
NODE_OPTIONS: "--max-old-space-size=10240"
services:
@@ -126,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:
+3 -1
View File
@@ -22,6 +22,8 @@ jobs:
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 10
runs-on: depot-ubuntu-24.04-8
env:
NX_REJECT_UNKNOWN_LOCAL_CACHE: 0
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
@@ -51,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:
+14 -9
View File
@@ -6,16 +6,12 @@ on:
pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION: storybook-build-depot-ubuntu-24.04-8-runner
STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION: v3-storybook-build-depot-ubuntu-24.04-8-runner-${{ github.ref_name }}-${{ github.sha }}
STORYBOOK_BUILD_CACHE_KEY: storybook-build-depot-ubuntu-24.04-8-runner
jobs:
changed-files-check:
@@ -27,12 +23,13 @@ jobs:
packages/twenty-ui/**
packages/twenty-shared/**
front-sb-build:
needs: changed-files-check
needs: [changed-files-check]
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
env:
REACT_APP_SERVER_BASE_URL: http://localhost:3000
NX_REJECT_UNKNOWN_LOCAL_CACHE: 0
steps:
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
@@ -46,6 +43,11 @@ jobs:
uses: ./.github/workflows/actions/yarn-install
- name: Diagnostic disk space issue
run: df -h
- name: Restore storybook build cache
id: restore-storybook-build-cache
uses: ./.github/workflows/actions/restore-cache
with:
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY }}
- name: Front / Write .env
run: npx nx reset:env twenty-front
- name: Front / Build storybook
@@ -53,7 +55,7 @@ jobs:
- name: Save storybook build cache
uses: ./.github/workflows/actions/save-cache
with:
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_SAVE_ACTION }}
key: ${{ steps.restore-storybook-build-cache.outputs.cache-primary-key }}
front-sb-test:
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
@@ -66,6 +68,7 @@ jobs:
env:
SHARD_COUNTER: 4
REACT_APP_SERVER_BASE_URL: http://localhost:3000
NX_REJECT_UNKNOWN_LOCAL_CACHE: 0
steps:
- name: Fetch local actions
uses: actions/checkout@v4
@@ -78,7 +81,7 @@ jobs:
- name: Restore storybook build cache
uses: ./.github/workflows/actions/restore-cache
with:
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY_FOR_RESTORE_ACTION }}
key: ${{ env.STORYBOOK_BUILD_CACHE_KEY }}
- name: Front / Write .env
run: npx nx reset:env twenty-front
- name: Run storybook tests
@@ -125,6 +128,7 @@ jobs:
env:
REACT_APP_SERVER_BASE_URL: http://127.0.0.1:3000
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
NX_REJECT_UNKNOWN_LOCAL_CACHE: 0
steps:
- uses: actions/checkout@v4
with:
@@ -148,6 +152,7 @@ jobs:
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
env:
NX_REJECT_UNKNOWN_LOCAL_CACHE: 0
TASK_CACHE_KEY: front-task-${{ matrix.task }}
strategy:
matrix:
@@ -184,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:
[
+22 -38
View File
@@ -6,9 +6,6 @@ on:
pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
@@ -23,8 +20,6 @@ jobs:
files: |
package.json
packages/twenty-server/**
packages/twenty-front/src/generated/**
packages/twenty-front/src/generated-metadata/**
packages/twenty-emails/**
packages/twenty-shared/**
server-setup:
@@ -32,6 +27,8 @@ jobs:
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
env:
NX_REJECT_UNKNOWN_LOCAL_CACHE: 0
services:
postgres:
image: twentycrm/twenty-postgres-spilo
@@ -70,10 +67,10 @@ jobs:
with:
tag: scope:backend
tasks: lint,typecheck
- name: Server / Write .env
run: npx nx reset:env twenty-server
- name: Server / Build
run: npx nx build twenty-server
- name: Server / Write .env
run: npx nx reset:env twenty-server
- name: Server / Create DB
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
@@ -90,35 +87,26 @@ jobs:
# If worker failed for other reasons, fail the build
exit $exit_code
fi
- name: Server / Start
run: npx nx start:ci twenty-server &
- name: Waiting for server starting...
run: |
for i in {1..10}; do
if curl -f http://localhost:3000/healthz; then
echo "Server ready!"
exit 0
fi
echo "Waiting..."
sleep 2
done
echo "Server did not become healthy in time" >&2
exit 1
- 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
- name: GraphQL / Check for Pending Generation
if: steps.changed-files.outputs.any_changed == 'true'
run: |
# Run GraphQL generation commands
npx nx run twenty-front:graphql:generate
@@ -127,24 +115,19 @@ jobs:
# Check if any files were modified
if ! git diff --quiet; then
echo "::error::GraphQL schema changes detected. Please run 'npx nx run twenty-front:graphql:generate' and 'npx nx run twenty-front:graphql:generate --configuration=metadata' and commit the changes."
echo ""
echo "The following GraphQL schema changes were detected:"
echo "==================================================="
git diff
echo "==================================================="
echo ""
echo "Please run 'npx nx run twenty-front:graphql:generate' and 'npx nx run twenty-front:graphql:generate --configuration=metadata' and commit the changes."
echo ""
exit 1
fi
- name: Save server setup
uses: ./.github/workflows/actions/save-cache
with:
key: ${{ steps.restore-server-setup-cache.outputs.cache-primary-key }}
server-test:
timeout-minutes: 30
runs-on: depot-ubuntu-24.04-8
needs: server-setup
env:
NX_REJECT_UNKNOWN_LOCAL_CACHE: 0
steps:
- name: Fetch custom Github Actions and base branch history
uses: actions/checkout@v4
@@ -166,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
@@ -203,11 +182,11 @@ jobs:
--health-timeout 5s
--health-retries 5
env:
NX_REJECT_UNKNOWN_LOCAL_CACHE: 0
NODE_ENV: test
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
@@ -245,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:
+3 -4
View File
@@ -6,9 +6,6 @@ on:
pull_request:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
@@ -24,6 +21,8 @@ jobs:
if: needs.changed-files-check.outputs.any_changed == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
env:
NX_REJECT_UNKNOWN_LOCAL_CACHE: 0
strategy:
matrix:
task: [lint, typecheck, test]
@@ -45,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 -2
View File
@@ -99,8 +99,7 @@ jobs:
- name: Compile translations
id: compile_translations
# Because we have set English as a fallback locale, this condition does not work anymore
# if: inputs.force_pull || steps.compile_translations_strict.outcome == 'failure'
if: inputs.force_pull || steps.compile_translations_strict.outcome == 'failure'
run: |
npx nx run twenty-server:lingui:compile
npx nx run twenty-emails:lingui:compile
@@ -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,153 +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 "Adding SIGN_IN_PREFILLED environment variable to server service..."
yq eval '.services.server.environment.SIGN_IN_PREFILLED = "${SIGN_IN_PREFILLED}"' -i packages/twenty-docker/docker-compose.yml
echo "Setting up .env file..."
cp packages/twenty-docker/.env.example packages/twenty-docker/.env
echo "Generating secrets..."
echo "# === 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 -5
View File
@@ -9,7 +9,6 @@
.nx/installation
.nx/cache
.nx/workspace-data
.pnp.*
.yarn/*
@@ -44,7 +43,4 @@ dump.rdb
/flake.nix
.crowdin.yml
.react-email/
mcp.json
/.junie/
.react-email/
+1 -1
View File
@@ -1 +1 @@
24.5.0
18.17.1
+1 -23
View File
@@ -5,7 +5,6 @@
{
"name": "twenty-server - start debug",
"type": "node",
"runtimeVersion": "24.5",
"request": "launch",
"runtimeExecutable": "npx",
"runtimeArgs": [
@@ -38,7 +37,7 @@
"type": "node",
"request": "launch",
"runtimeExecutable": "npx",
"runtimeVersion": "24.5",
"runtimeVersion": "18",
"runtimeArgs": [
"nx",
"run",
@@ -82,27 +81,6 @@
"env": {
"NODE_ENV": "test"
},
},
{
"type": "node",
"request": "launch",
"name": "twenty-server - debug unit test file (to launch with test file open)",
"runtimeExecutable": "npx",
"runtimeArgs": [
"nx",
"run",
"twenty-server:jest",
"--",
"--config",
"./jest.config.mjs",
"${relativeFile}"
],
"cwd": "${workspaceFolder}/packages/twenty-server",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"env": {
"NODE_ENV": "test"
},
}
]
}
+3 -3
View File
@@ -43,12 +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}",
"typescript.tsdk": "node_modules/typescript/lib"
"jestrunner.codeLensSelector": "**/*.{test,spec,integration-spec}.{js,jsx,ts,tsx}"
}
}
+4
View File
@@ -4,6 +4,10 @@
"name": "ROOT",
"path": "../"
},
{
"name": "packages/twenty-chrome-extension",
"path": "../packages/twenty-chrome-extension"
},
{
"name": "packages/twenty-docker",
"path": "../packages/twenty-docker"
@@ -1,45 +0,0 @@
diff --git a/commonjs/helpers/phoneInputHelpers.js b/commonjs/helpers/phoneInputHelpers.js
index 8fd1b16ec670ab7d0abcb800a2edd7848e86ce1e..ab6e0dd90def507629c2ead59fba929a739f0759 100644
--- a/commonjs/helpers/phoneInputHelpers.js
+++ b/commonjs/helpers/phoneInputHelpers.js
@@ -574,7 +574,9 @@ function onPhoneDigitsChange(phoneDigits, _ref5) {
// Generate the new `value` property.
var value;
if (phoneDigits) {
- if (phoneDigits[0] === '+') {
+ if(phoneDigits === prevPhoneDigits) {
+ value = e164(phoneDigits, country, metadata)
+ } else if (phoneDigits[0] === '+') {
if (phoneDigits === '+') {
value = undefined;
} else if (country && (0, _getInternationalPhoneNumberPrefix["default"])(country, metadata).indexOf(phoneDigits) === 0) {
diff --git a/modules/helpers/phoneInputHelpers.js b/modules/helpers/phoneInputHelpers.js
index 75e8463f6b5d32c560d1dec2776671bee2af1d07..99af2904807cc3c23aea566bbeebda2a5bc9afa4 100644
--- a/modules/helpers/phoneInputHelpers.js
+++ b/modules/helpers/phoneInputHelpers.js
@@ -551,7 +551,9 @@ export function onPhoneDigitsChange(phoneDigits, _ref5) {
// Generate the new `value` property.
var value;
if (phoneDigits) {
- if (phoneDigits[0] === '+') {
+ if(phoneDigits === prevPhoneDigits) {
+ value = e164(phoneDigits, country, metadata)
+ } else if (phoneDigits[0] === '+') {
if (phoneDigits === '+') {
value = undefined;
} else if (country && getInternationalPhoneNumberPrefix(country, metadata).indexOf(phoneDigits) === 0) {
diff --git a/react-styleguidist/project/source/helpers/phoneInputHelpers.js b/react-styleguidist/project/source/helpers/phoneInputHelpers.js
index b844ef0fd183fe4f45e436bdb2382fc2987dcef2..8960834900e59a57e130521fd369e82fe05f5090 100644
--- a/react-styleguidist/project/source/helpers/phoneInputHelpers.js
+++ b/react-styleguidist/project/source/helpers/phoneInputHelpers.js
@@ -505,7 +505,9 @@ export function onPhoneDigitsChange(phoneDigits, {
// Generate the new `value` property.
let value
if (phoneDigits) {
- if (phoneDigits[0] === '+') {
+ if(phoneDigits === prevPhoneDigits) {
+ value = e164(phoneDigits, country, metadata)
+ } else if (phoneDigits[0] === '+') {
if (phoneDigits === '+') {
value = undefined
} else if (country && getInternationalPhoneNumberPrefix(country, metadata).indexOf(phoneDigits) !== 0) {
+925
View File
File diff suppressed because one or more lines are too long
-942
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -6,4 +6,4 @@ enableInlineHunks: true
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.9.2.cjs
yarnPath: .yarn/releases/yarn-4.4.0.cjs
-147
View File
@@ -1,147 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Twenty is an open-source CRM built with modern technologies in a monorepo structure. The codebase is organized as an Nx workspace with multiple packages.
## Key Commands
### Development
```bash
# Start development environment (frontend + backend + worker)
yarn start
# Individual package development
npx nx start twenty-front # Start frontend dev server
npx nx start twenty-server # Start backend server
npx nx run twenty-server:worker # Start background worker
```
### Testing
```bash
# Run tests
npx nx test twenty-front # Frontend unit tests
npx nx test twenty-server # Backend unit tests
npx nx run twenty-server:test:integration:with-db-reset # Integration tests with DB reset
# Storybook
npx nx storybook:build twenty-front # Build Storybook
npx nx storybook:serve-and-test:static twenty-front # Run Storybook tests
```
### Code Quality
```bash
# Linting
npx nx lint twenty-front # Frontend linting
npx nx lint twenty-server # Backend linting
npx nx lint twenty-front --fix # Auto-fix linting issues
# Type checking
npx nx typecheck twenty-front
npx nx typecheck twenty-server
# Format code
npx nx fmt twenty-front
npx nx fmt twenty-server
```
### Build
```bash
# Build packages
npx nx build twenty-front
npx nx build twenty-server
```
### Database Operations
```bash
# Database management
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
# Generate migration
npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/migrations/[name] -d src/database/typeorm/core/core.datasource.ts
# Sync metadata
npx nx run twenty-server:command workspace:sync-metadata -f
```
### GraphQL
```bash
# Generate GraphQL types
npx nx run twenty-front:graphql:generate
```
## Architecture Overview
### Tech Stack
- **Frontend**: React 18, TypeScript, Recoil (state management), Emotion (styling), Vite
- **Backend**: NestJS, TypeORM, PostgreSQL, Redis, GraphQL (with GraphQL Yoga)
- **Monorepo**: Nx workspace managed with Yarn 4
### Package Structure
```
packages/
├── twenty-front/ # React frontend application
├── twenty-server/ # NestJS backend API
├── twenty-ui/ # Shared UI components library
├── twenty-shared/ # Common types and utilities
├── twenty-emails/ # Email templates with React Email
├── twenty-website/ # Next.js documentation website
├── twenty-zapier/ # Zapier integration
└── twenty-e2e-testing/ # Playwright E2E tests
```
### Key Development Principles
- **Functional components only** (no class components)
- **Named exports only** (no default exports)
- **Types over interfaces** (except when extending third-party interfaces)
- **String literals over enums** (except for GraphQL enums)
- **No 'any' type allowed**
- **Event handlers preferred over useEffect** for state updates
### State Management
- **Recoil** for global state management
- Component-specific state with React hooks
- GraphQL cache managed by Apollo Client
### Backend Architecture
- **NestJS modules** for feature organization
- **TypeORM** for database ORM with PostgreSQL
- **GraphQL** API with code-first approach
- **Redis** for caching and session management
- **BullMQ** for background job processing
### Database
- **PostgreSQL** as primary database
- **Redis** for caching and sessions
- **TypeORM migrations** for schema management
- **ClickHouse** for analytics (when enabled)
## Development Workflow
### Before Making Changes
1. Always run linting and type checking after code changes
2. Test changes with relevant test suites
3. Ensure database migrations are properly structured
4. Check that GraphQL schema changes are backward compatible
### Code Style Notes
- Use **Emotion** for styling with styled-components pattern
- Follow **Nx** workspace conventions for imports
- Use **Lingui** for internationalization
- Components should be in their own directories with tests and stories
### Testing Strategy
- **Unit tests** with Jest for both frontend and backend
- **Integration tests** for critical backend workflows
- **Storybook** for component development and testing
- **E2E tests** with Playwright for critical user flows
## Important Files
- `nx.json` - Nx workspace configuration with task definitions
- `tsconfig.base.json` - Base TypeScript configuration
- `package.json` - Root package with workspace definitions
- `.cursor/rules/` - Development guidelines and best practices
+7 -37
View File
@@ -1,18 +1,12 @@
DOCKER_NETWORK=twenty_network
ensure-docker-network:
docker network inspect $(DOCKER_NETWORK) >/dev/null 2>&1 || docker network create $(DOCKER_NETWORK)
postgres-on-docker:
make ensure-docker-network
docker run -d --network $(DOCKER_NETWORK) \
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 \
@@ -23,31 +17,7 @@ postgres-on-docker:
-c "CREATE DATABASE \"test\" WITH OWNER postgres;"
redis-on-docker:
make ensure-docker-network
docker run -d --network $(DOCKER_NETWORK) --name twenty_redis -p 6379:6379 redis/redis-stack-server:latest
docker run -d --name twenty_redis -p 6379:6379 redis/redis-stack-server:latest
clickhouse-on-docker:
make ensure-docker-network
docker run -d --network $(DOCKER_NETWORK) --name twenty_clickhouse -p 8123:8123 -p 9000:9000 -e CLICKHOUSE_PASSWORD=devPassword clickhouse/clickhouse-server:latest \
grafana-on-docker:
make ensure-docker-network
docker run -d --network $(DOCKER_NETWORK) \
--name twenty_grafana \
-p 4000:3000 \
-e GF_SECURITY_ADMIN_USER=admin \
-e GF_SECURITY_ADMIN_PASSWORD=admin \
-e GF_INSTALL_PLUGINS=grafana-clickhouse-datasource \
-v $(PWD)/packages/twenty-docker/grafana/provisioning/datasources:/etc/grafana/provisioning/datasources \
grafana/grafana-oss:latest
opentelemetry-collector-on-docker:
make ensure-docker-network
docker run -d --network $(DOCKER_NETWORK) \
--name twenty_otlp_collector \
-p 4317:4317 \
-p 4318:4318 \
-p 13133:13133 \
-v $(PWD)/packages/twenty-docker/otel-collector/otel-collector-config.yaml:/etc/otel-collector-config.yaml \
otel/opentelemetry-collector-contrib:latest \
--config /etc/otel-collector-config.yaml
docker run -d --name twenty_clickhouse -p 8123:8123 -p 9000:9000 -e CLICKHOUSE_PASSWORD=devPassword clickhouse/clickhouse-server:latest
+73 -48
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/refs/heads/main/packages/twenty-website/public/images/readme/github-cover-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/github-cover-light.png" />
<img src="./packages/twenty-website/public/images/readme/github-cover-light.png" alt="Cover" />
<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,91 +40,112 @@ 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.
Please feel free to flag any specific needs you have by creating an issue.
Below are a few features we have implemented to date:
+ [Personalize layouts with filters, sort, group by, kanban and table views](#personalize-layouts-with-filters-sort-group-by-kanban-and-table-views)
+ [Customize your objects and fields](#customize-your-objects-and-fields)
+ [Create and manage permissions with custom roles](#create-and-manage-permissions-with-custom-roles)
+ [Automate workflow with triggers and actions](#automate-workflow-with-triggers-and-actions)
+ [Emails, calendar events, files, and more](#emails-calendar-events-files-and-more)
+ [Add, filter, sort, edit, and track customers](#add-filter-sort-edit-and-track-customers)
+ [Create one or several opportunities for each company](#create-one-or-several-opportunities-for-each-company)
+ [See rich notes tasks displayed in a timeline](#see-rich-notes-tasks-displayed-in-a-timeline)
+ [Create tasks on records](#create-tasks-on-records)
+ [Navigate quickly through the app using keyboard shortcuts and search](#navigate-quickly-through-the-app-using-keyboard-shortcuts-and-search)
## Personalize layouts with filters, sort, group by, kanban and table views
## Add, filter, sort, edit, and track customers:
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/views-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/views-light.png" />
<img src="./packages/twenty-website/public/images/readme/views-light.png" alt="Companies Kanban Views" />
<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>
## Customize your objects and fields
## Create one or several opportunities for each company:
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/data-model-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/data-model-light.png" />
<img src="./packages/twenty-website/public/images/readme/data-model-light.png" alt="Setting Custom Objects" />
<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>
## Create and manage permissions with custom roles
## Track deals effortlessly with the email integration:
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/permissions-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/permissions-light.png" />
<img src="./packages/twenty-website/public/images/readme/permissions-light.png" alt="Permissions" />
<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>
## Automate workflow with triggers and actions
## Tailor your data model to meet business needs:
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/workflows-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/workflows-light.png" />
<img src="./packages/twenty-website/public/images/readme/workflows-light.png" alt="Workflows" />
<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>
## Emails, calendar events, files, and more
## See rich notes displayed in a timeline:
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/plus-other-features-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/plus-other-features-light.png" />
<img src="./packages/twenty-website/public/images/readme/plus-other-features-light.png" alt="Other Features" />
<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>
<br />
## Create tasks on records
<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="Companies view" />
</picture>
</p>
## Navigate quickly through the app using keyboard shortcuts and search:
<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/shortcut-navigation-light.png" alt="Companies view" />
</picture>
</p>
## Connect your CRM to all your tools through our APIs and Webhooks.
<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/shortcut-navigation-light.png" alt="Companies view" />
</picture>
</p>
<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
@@ -135,3 +156,7 @@ Below are a few features we have implemented to date:
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
- Improve translations on [Crowdin](https://twenty.crowdin.com/twenty)
- [Contributions](https://github.com/twentyhq/twenty/contribute) are, of course, most welcome!
-185
View File
@@ -1,185 +0,0 @@
import js from '@eslint/js';
import nxPlugin from '@nx/eslint-plugin';
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import typescriptParser from '@typescript-eslint/parser';
import importPlugin from 'eslint-plugin-import';
import linguiPlugin from 'eslint-plugin-lingui';
import preferArrowPlugin from 'eslint-plugin-prefer-arrow';
import prettierPlugin from 'eslint-plugin-prettier';
import unicornPlugin from 'eslint-plugin-unicorn';
import unusedImportsPlugin from 'eslint-plugin-unused-imports';
import jsoncParser from 'jsonc-eslint-parser';
export default [
// Base JavaScript configuration
js.configs.recommended,
// Lingui recommended rules
linguiPlugin.configs['flat/recommended'],
// Global ignores
{
ignores: [
'**/node_modules/**',
],
},
// Base configuration for all files
{
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: {
'prettier': prettierPlugin,
'lingui': linguiPlugin,
'@nx': nxPlugin,
'prefer-arrow': preferArrowPlugin,
'import': importPlugin,
'unused-imports': unusedImportsPlugin,
'unicorn': unicornPlugin,
},
rules: {
// General rules
'func-style': ['error', 'declaration', { allowArrowFunctions: true }],
'no-console': ['warn', { allow: ['group', 'groupCollapsed', 'groupEnd'] }],
'no-control-regex': 0,
'no-debugger': 'error',
'no-duplicate-imports': 'error',
'no-undef': 'off',
'no-unused-vars': 'off',
// Nx rules
'@nx/enforce-module-boundaries': [
'error',
{
enforceBuildableLibDependency: true,
allow: [],
depConstraints: [
{
sourceTag: 'scope:shared',
onlyDependOnLibsWithTags: ['scope:shared'],
},
{
sourceTag: 'scope:backend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:backend'],
},
{
sourceTag: 'scope:frontend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
},
{
sourceTag: 'scope:zapier',
onlyDependOnLibsWithTags: ['scope:shared'],
},
],
},
],
// Import rules
'import/no-relative-packages': 'error',
'import/no-useless-path-segments': 'error',
'import/no-duplicates': ['error', { considerQueryString: true }],
// Prefer arrow functions
'prefer-arrow/prefer-arrow-functions': [
'error',
{
disallowPrototype: true,
singleReturnOnly: false,
classPropertiesAllowed: false,
},
],
// Unused imports
'unused-imports/no-unused-imports': 'warn',
'unused-imports/no-unused-vars': [
'warn',
{
vars: 'all',
varsIgnorePattern: '^_',
args: 'after-used',
argsIgnorePattern: '^_',
},
],
},
},
// TypeScript specific configuration
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
plugins: {
'@typescript-eslint': typescriptEslint,
},
rules: {
// TypeScript rules
'no-redeclare': 'off', // Turn off base rule for TypeScript
'@typescript-eslint/no-redeclare': 'error', // Use TypeScript-aware version
'@typescript-eslint/ban-ts-comment': 'error',
'@typescript-eslint/consistent-type-imports': [
'error',
{
prefer: 'type-imports',
fixStyle: 'inline-type-imports'
},
],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/no-empty-interface': [
'error',
{
allowSingleExtends: true,
},
],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-unused-vars': 'off',
},
},
// JavaScript specific configuration
{
files: ['*.{js,jsx}'],
rules: {
// JavaScript-specific rules if needed
},
},
// Test files
{
files: [
'*.spec.@(ts|tsx|js|jsx)',
'*.integration-spec.@(ts|tsx|js|jsx)',
'*.test.@(ts|tsx|js|jsx)',
],
languageOptions: {
globals: {
jest: true,
describe: true,
it: true,
expect: true,
beforeEach: true,
afterEach: true,
beforeAll: true,
afterAll: true,
},
},
rules: {
'@typescript-eslint/no-non-null-assertion': 'off',
},
},
// JSON files
{
files: ['**/*.json'],
languageOptions: {
parser: jsoncParser,
},
},
];
-302
View File
@@ -1,302 +0,0 @@
import js from '@eslint/js';
import nxPlugin from '@nx/eslint-plugin';
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import typescriptParser from '@typescript-eslint/parser';
import importPlugin from 'eslint-plugin-import';
import linguiPlugin from 'eslint-plugin-lingui';
import preferArrowPlugin from 'eslint-plugin-prefer-arrow';
import prettierPlugin from 'eslint-plugin-prettier';
import reactPlugin from 'eslint-plugin-react';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
import reactRefreshPlugin from 'eslint-plugin-react-refresh';
import unicornPlugin from 'eslint-plugin-unicorn';
import unusedImportsPlugin from 'eslint-plugin-unused-imports';
import jsoncParser from 'jsonc-eslint-parser';
export default [
// Base JavaScript configuration
js.configs.recommended,
// Lingui recommended rules
linguiPlugin.configs['flat/recommended'],
// Base configuration for all files
{
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: {
'react': reactPlugin,
'react-hooks': reactHooksPlugin,
'react-refresh': reactRefreshPlugin,
'prettier': prettierPlugin,
'lingui': linguiPlugin,
'@nx': nxPlugin,
'prefer-arrow': preferArrowPlugin,
'import': importPlugin,
'unused-imports': unusedImportsPlugin,
'unicorn': unicornPlugin,
},
settings: {
react: {
version: 'detect',
},
},
rules: {
// General rules
'func-style': ['error', 'declaration', { allowArrowFunctions: true }],
'no-console': ['warn', { allow: ['group', 'groupCollapsed', 'groupEnd'] }],
'no-control-regex': 0,
'no-debugger': 'error',
'no-duplicate-imports': 'error',
'no-undef': 'off',
'no-unused-vars': 'off',
'prettier/prettier': 'error',
// Nx rules
'@nx/enforce-module-boundaries': [
'error',
{
enforceBuildableLibDependency: true,
allow: [],
depConstraints: [
{
sourceTag: 'scope:shared',
onlyDependOnLibsWithTags: ['scope:shared'],
},
{
sourceTag: 'scope:backend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:backend'],
},
{
sourceTag: 'scope:frontend',
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
},
{
sourceTag: 'scope:zapier',
onlyDependOnLibsWithTags: ['scope:shared'],
},
],
},
],
// Import rules
'import/no-relative-packages': 'error',
'import/no-useless-path-segments': 'error',
'import/no-duplicates': ['error', { considerQueryString: true }],
// Prefer arrow functions
'prefer-arrow/prefer-arrow-functions': [
'error',
{
disallowPrototype: true,
singleReturnOnly: false,
classPropertiesAllowed: false,
},
],
// Unused imports
'unused-imports/no-unused-imports': 'warn',
'unused-imports/no-unused-vars': [
'warn',
{
vars: 'all',
varsIgnorePattern: '^_',
args: 'after-used',
argsIgnorePattern: '^_',
},
],
// React rules
'react/no-unescaped-entities': 'off',
'react/prop-types': 'off',
'react/jsx-key': 'off',
'react/display-name': 'off',
'react/jsx-uses-react': 'off',
'react/react-in-jsx-scope': 'off',
'react/jsx-no-useless-fragment': 'off',
'react/jsx-props-no-spreading': [
'error',
{
explicitSpread: 'ignore',
},
],
// React hooks rules
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': [
'warn',
{
additionalHooks: 'useRecoilCallback',
},
],
},
},
// TypeScript specific configuration
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
// Note: project path should be specified by each package individually
},
},
plugins: {
'@typescript-eslint': typescriptEslint,
},
rules: {
// Import restrictions
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@tabler/icons-react'],
message: 'Please import icons from `twenty-ui`',
},
{
group: ['react-hotkeys-web-hook'],
importNames: ['useHotkeys'],
message: 'Please use the custom wrapper: `useScopedHotkeys` from `twenty-ui`',
},
{
group: ['lodash'],
message: "Please use the standalone lodash package (for instance: `import groupBy from 'lodash.groupby'` instead of `import { groupBy } from 'lodash'`)",
},
],
},
],
// TypeScript rules
'no-redeclare': 'off', // Turn off base rule for TypeScript
'@typescript-eslint/no-redeclare': 'error', // Use TypeScript-aware version
'@typescript-eslint/ban-ts-comment': 'error',
'@typescript-eslint/consistent-type-imports': [
'error',
{
prefer: 'type-imports',
fixStyle: 'inline-type-imports'
},
],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/no-empty-interface': [
'error',
{
allowSingleExtends: true,
},
],
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'off',
// Custom workspace rules
'@nx/workspace-effect-components': 'error',
'@nx/workspace-no-hardcoded-colors': 'error',
'@nx/workspace-matching-state-variable': 'error',
'@nx/workspace-sort-css-properties-alphabetically': 'error',
'@nx/workspace-styled-components-prefixed-with-styled': 'error',
'@nx/workspace-no-state-useref': 'error',
'@nx/workspace-component-props-naming': 'error',
'@nx/workspace-explicit-boolean-predicates-in-if': 'error',
'@nx/workspace-use-getLoadable-and-getValue-to-get-atoms': 'error',
'@nx/workspace-useRecoilCallback-has-dependency-array': 'error',
'@nx/workspace-no-navigate-prefer-link': 'error',
},
},
// Storybook files
{
files: ['*.stories.@(ts|tsx|js|jsx)'],
rules: {
'@typescript-eslint/no-non-null-assertion': 'off',
},
},
// JavaScript specific configuration
{
files: ['*.{js,jsx}'],
rules: {
// JavaScript-specific rules if needed
},
},
// Constants files
{
files: ['**/constants/*.ts', '**/*.constants.ts'],
rules: {
'@typescript-eslint/naming-convention': [
'error',
{
selector: 'variable',
format: ['UPPER_CASE'],
},
],
'unicorn/filename-case': [
'warn',
{
cases: {
pascalCase: true,
},
},
],
'@nx/workspace-max-consts-per-file': ['error', { max: 1 }],
},
},
// Test files
{
files: [
'*.test.@(ts|tsx|js|jsx)',
],
languageOptions: {
globals: {
jest: true,
describe: true,
it: true,
expect: true,
beforeEach: true,
afterEach: true,
beforeAll: true,
afterAll: true,
},
},
rules: {
'@typescript-eslint/no-non-null-assertion': 'off',
},
},
// Constants files
{
files: ['**/*.constants.ts'],
rules: {
'@typescript-eslint/naming-convention': [
'error',
{
selector: 'variable',
format: ['UPPER_CASE'],
},
],
'unicorn/filename-case': [
'warn',
{
cases: {
pascalCase: true,
},
},
],
'@nx/workspace-max-consts-per-file': ['error', { max: 1 }],
},
},
// JSON files
{
files: ['**/*.json'],
languageOptions: {
parser: jsoncParser,
},
},
];
View File
+15 -37
View File
@@ -1,8 +1,4 @@
{
"workspaceLayout": {
"appsDir": "packages",
"libsDir": "packages"
},
"namedInputs": {
"default": ["{projectRoot}/**/*"],
"excludeStories": [
@@ -44,17 +40,14 @@
"cache": true,
"outputs": ["{options.outputFile}"],
"options": {
"eslintConfig": "{projectRoot}/eslint.config.mjs",
"eslintConfig": "{projectRoot}/.eslintrc.cjs",
"cache": true,
"cacheLocation": "{workspaceRoot}/.cache/eslint"
"cacheLocation": "{workspaceRoot}/.cache/eslint",
"ignorePath": "{workspaceRoot}/.gitignore"
},
"configurations": {
"ci": {
"cacheStrategy": "content"
},
"fix": {
"fix": true
}
"ci": { "cacheStrategy": "content" },
"fix": { "fix": true }
},
"dependsOn": ["^build"]
},
@@ -70,12 +63,8 @@
"write": false
},
"configurations": {
"ci": {
"cacheStrategy": "content"
},
"fix": {
"write": true
}
"ci": { "cacheStrategy": "content" },
"fix": { "write": true }
},
"dependsOn": ["^build"]
},
@@ -87,9 +76,7 @@
"command": "tsc -b tsconfig.json --incremental"
},
"configurations": {
"watch": {
"watch": true
}
"watch": { "watch": true }
},
"dependsOn": ["^build"]
},
@@ -104,7 +91,7 @@
],
"outputs": ["{projectRoot}/coverage"],
"options": {
"jestConfig": "{projectRoot}/jest.config.mjs",
"jestConfig": "{projectRoot}/jest.config.ts",
"coverage": true,
"coverageReporters": ["text-summary"],
"cacheDirectory": "../../.cache/jest/{projectRoot}"
@@ -114,12 +101,8 @@
"ci": true,
"maxWorkers": 3
},
"coverage": {
"coverageReporters": ["lcov", "text"]
},
"watch": {
"watch": true
}
"coverage": { "coverageReporters": ["lcov", "text"] },
"watch": { "watch": true }
}
},
"test:e2e": {
@@ -209,9 +192,7 @@
"checkCoverage": true
},
"configurations": {
"text": {
"reporter": "text"
}
"text": { "reporter": "text" }
}
},
"storybook:serve-and-test:static": {
@@ -265,7 +246,7 @@
"cache": true,
"inputs": [
"default",
"{workspaceRoot}/eslint.config.mjs",
"{workspaceRoot}/.eslintrc.js",
"{workspaceRoot}/tools/eslint-rules/**/*"
]
},
@@ -280,7 +261,7 @@
}
},
"installation": {
"version": "21.3.11"
"version": "18.3.3"
},
"generators": {
"@nx/react": {
@@ -313,8 +294,5 @@
}
},
"useInferencePlugins": false,
"defaultBase": "main",
"tui": {
"enabled": false
}
"defaultBase": "main"
}
+200 -67
View File
@@ -1,37 +1,125 @@
{
"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.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",
"@emotion/styled": "^11.11.0",
"@envelop/on-resolve": "^4.1.0",
"@floating-ui/react": "^0.24.3",
"@google-cloud/local-auth": "2.1.0",
"@graphiql/plugin-explorer": "^1.0.2",
"@graphql-tools/schema": "^10.0.0",
"@hello-pangea/dnd": "^16.2.0",
"@hookform/resolvers": "^3.1.1",
"@jsdevtools/rehype-toc": "^3.0.2",
"@linaria/core": "^6.2.0",
"@linaria/react": "^6.2.1",
"@mdx-js/react": "^3.0.0",
"@microsoft/microsoft-graph-client": "^3.0.7",
"@nestjs/apollo": "^11.0.5",
"@nestjs/axios": "^3.0.1",
"@nestjs/cli": "^9.0.0",
"@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.3.2",
"@nestjs/core": "^9.0.0",
"@nestjs/event-emitter": "^2.0.3",
"@nestjs/jwt": "^10.0.3",
"@nestjs/passport": "^9.0.3",
"@nestjs/platform-express": "^9.0.0",
"@nestjs/serve-static": "^4.0.1",
"@nestjs/terminus": "^11.0.0",
"@nestjs/typeorm": "^10.0.0",
"@nx/eslint-plugin": "^17.2.8",
"@octokit/graphql": "^7.0.2",
"@ptc-org/nestjs-query-core": "^4.2.0",
"@ptc-org/nestjs-query-typeorm": "4.2.1-alpha.2",
"@react-email/components": "0.0.35",
"@react-email/render": "0.0.17",
"@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",
"@tabler/icons-react": "^3.31.0",
"@wyw-in-js/vite": "^0.7.0",
"@types/dompurify": "^3.0.5",
"@types/facepaint": "^1.2.5",
"@types/lodash.camelcase": "^4.3.7",
"@types/lodash.chunk": "^4.2.9",
"@types/lodash.merge": "^4.6.7",
"@types/lodash.pick": "^4.3.7",
"@types/nodemailer": "^6.4.14",
"@types/passport-microsoft": "^1.0.3",
"@wyw-in-js/vite": "^0.5.3",
"@xyflow/react": "^12.4.2",
"add": "^2.0.6",
"addressparser": "^1.0.1",
"afterframe": "^1.0.2",
"apollo-server-express": "^3.12.0",
"apollo-upload-client": "^17.0.0",
"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-validate": "^1.4.5",
"cross-env": "^7.0.3",
"css-loader": "^7.1.2",
"danger-plugin-todos": "^1.3.1",
"dataloader": "^2.2.2",
"date-fns": "^2.30.0",
"date-fns-tz": "^2.0.0",
"debounce": "^2.0.0",
"deep-equal": "^2.2.2",
"dompurify": "^3.0.11",
"dotenv-cli": "^7.2.1",
"drizzle-orm": "^0.29.3",
"esbuild-plugin-svgr": "^2.1.0",
"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",
"graphql-fields": "^2.0.3",
"graphql-middleware": "^6.1.35",
"graphql-rate-limit": "^3.3.0",
"graphql-redis-subscriptions": "^2.7.0",
"graphql-scalars": "^1.23.0",
"graphql-sse": "^2.5.4",
"graphql-subscriptions": "2.0.0",
"graphql-tag": "^2.12.6",
"graphql-type-json": "^0.3.2",
"graphql-upload": "^13.0.0",
"graphql-yoga": "^4.0.4",
"hex-rgb": "^5.0.0",
"immer": "^10.1.1",
"iframe-resizer-react": "^1.1.0",
"immer": "^10.0.2",
"jest-mock-extended": "^3.0.4",
"js-cookie": "^3.0.5",
"js-levenshtein": "^1.1.6",
"json-2-csv": "^5.4.0",
"jsonwebtoken": "^9.0.0",
"libphonenumber-js": "^1.10.26",
"lodash.camelcase": "^4.3.0",
"lodash.chunk": "^4.2.0",
"lodash.compact": "^3.0.1",
"lodash.debounce": "^4.0.8",
"lodash.escaperegexp": "^4.1.2",
"lodash.groupby": "^4.6.0",
"lodash.identity": "^3.0.0",
@@ -46,28 +134,64 @@
"lodash.pickby": "^4.6.0",
"lodash.snakecase": "^4.1.1",
"lodash.upperfirst": "^4.3.1",
"luxon": "^3.3.0",
"microdiff": "^1.3.2",
"moize": "^6.1.6",
"nest-commander": "^3.12.0",
"next": "14.0.4",
"next-mdx-remote": "^4.4.1",
"nodemailer": "^6.9.8",
"openapi-types": "^12.1.3",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"passport-microsoft": "^2.0.0",
"patch-package": "^8.0.0",
"pg": "^8.11.3",
"pg-boss": "^9.0.3",
"planer": "^1.2.0",
"pluralize": "^8.0.0",
"prettier": "^3.0.3",
"prism-react-renderer": "^2.1.0",
"qs": "^6.11.2",
"react": "^18.2.0",
"react-data-grid": "7.0.0-beta.13",
"react-datepicker": "^6.7.1",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-error-boundary": "^4.0.11",
"react-helmet-async": "^1.3.0",
"react-hook-form": "^7.45.1",
"react-hotkeys-hook": "^4.4.4",
"react-icons": "^4.12.0",
"react-imask": "^7.6.0",
"react-intersection-observer": "^9.15.1",
"react-loading-skeleton": "^3.3.1",
"react-phone-number-input": "^3.3.4",
"react-responsive": "^9.0.2",
"react-router-dom": "^6.4.4",
"react-textarea-autosize": "^8.4.1",
"react-tooltip": "^5.13.1",
"recoil": "^0.7.7",
"rehype-slug": "^6.0.0",
"remark-behead": "^3.1.0",
"remark-gfm": "^3.0.1",
"rimraf": "^3.0.2",
"rxjs": "^7.2.0",
"scroll-into-view": "^1.16.2",
"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.8.1",
"tslib": "^2.3.0",
"tsup": "^8.2.4",
"type-fest": "4.10.1",
"typescript": "5.9.2",
"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",
"xlsx-ugnis": "^0.19.3",
@@ -77,57 +201,65 @@
"@babel/core": "^7.14.5",
"@babel/preset-react": "^7.14.5",
"@babel/preset-typescript": "^7.24.6",
"@chromatic-com/storybook": "^3",
"@crxjs/vite-plugin": "^1.0.14",
"@graphql-codegen/cli": "^3.3.1",
"@graphql-codegen/client-preset": "^4.1.0",
"@graphql-codegen/typescript": "^3.0.4",
"@graphql-codegen/typescript-operations": "^3.0.4",
"@graphql-codegen/typescript-react-apollo": "^3.3.7",
"@nx/eslint": "21.3.11",
"@nx/eslint-plugin": "21.3.11",
"@nx/jest": "21.3.11",
"@nx/js": "21.3.11",
"@nx/react": "21.3.11",
"@nx/storybook": "21.3.11",
"@nx/vite": "21.3.11",
"@nx/web": "21.3.11",
"@microsoft/microsoft-graph-types": "^2.40.0",
"@nestjs/cli": "^9.0.0",
"@nestjs/schematics": "^9.0.0",
"@nestjs/testing": "^9.0.0",
"@next/eslint-plugin-next": "^14.1.4",
"@nx/eslint": "18.3.3",
"@nx/eslint-plugin": "18.3.3",
"@nx/jest": "18.3.3",
"@nx/js": "18.3.3",
"@nx/react": "18.3.3",
"@nx/storybook": "18.3.3",
"@nx/vite": "18.3.3",
"@nx/web": "18.3.3",
"@playwright/test": "^1.46.0",
"@sentry/types": "^8",
"@storybook/addon-actions": "8.6.14",
"@sentry/types": "^7.109.0",
"@storybook/addon-actions": "^7.6.3",
"@storybook/addon-coverage": "^1.0.0",
"@storybook/addon-essentials": "8.6.14",
"@storybook/addon-interactions": "8.6.14",
"@storybook/addon-links": "8.6.14",
"@storybook/blocks": "8.6.14",
"@storybook/core-server": "8.6.14",
"@storybook/addon-essentials": "^7.6.7",
"@storybook/addon-interactions": "^7.6.7",
"@storybook/addon-links": "^7.6.7",
"@storybook/addon-onboarding": "^1.0.10",
"@storybook/blocks": "^7.6.3",
"@storybook/core-server": "7.6.3",
"@storybook/icons": "^1.2.9",
"@storybook/preview-api": "8.6.14",
"@storybook/react": "8.6.14",
"@storybook/react-vite": "8.6.14",
"@storybook/test": "8.6.14",
"@storybook/test-runner": "^0.23.0",
"@storybook/types": "8.6.14",
"@storybook/jest": "^0.2.3",
"@storybook/react": "^7.6.3",
"@storybook/react-vite": "^7.6.3",
"@storybook/test": "^7.6.3",
"@storybook/test-runner": "^0.16.0",
"@storybook/testing-library": "^0.2.2",
"@stylistic/eslint-plugin": "^1.5.0",
"@swc-node/register": "1.8.0",
"@swc/cli": "^0.3.12",
"@swc/core": "1.13.3",
"@swc/core": "1.7.42",
"@swc/helpers": "~0.5.2",
"@swc/jest": "^0.2.39",
"@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",
"@types/express": "^4.17.13",
"@types/fs-extra": "^11.0.4",
"@types/file-saver": "^2.0.7",
"@types/graphql-fields": "^1.3.6",
"@types/imapflow": "^1.0.21",
"@types/inquirer": "^9.0.9",
"@types/jest": "^30.0.0",
"@types/graphql-upload": "^8.0.12",
"@types/js-cookie": "^3.0.3",
"@types/js-levenshtein": "^1.1.3",
"@types/lodash.camelcase": "^4.3.7",
"@types/lodash.compact": "^3.0.9",
"@types/lodash.debounce": "^4.0.7",
"@types/lodash.escaperegexp": "^4.1.9",
"@types/lodash.groupby": "^4.6.9",
"@types/lodash.identity": "^3.0.9",
@@ -137,26 +269,26 @@
"@types/lodash.kebabcase": "^4.1.7",
"@types/lodash.mapvalues": "^4.6.9",
"@types/lodash.omit": "^4.5.9",
"@types/lodash.pick": "^4.3.7",
"@types/lodash.pickby": "^4.6.9",
"@types/lodash.snakecase": "^4.1.7",
"@types/lodash.upperfirst": "^4.3.7",
"@types/mailparser": "^3.4.6",
"@types/luxon": "^3.3.0",
"@types/ms": "^0.7.31",
"@types/node": "^24.0.0",
"@types/node": "18.19.26",
"@types/passport-google-oauth20": "^2.0.11",
"@types/passport-jwt": "^3.0.8",
"@types/passport-microsoft": "^2.1.0",
"@types/pluralize": "^0.0.33",
"@types/react": "^18.2.39",
"@types/react-datepicker": "^6.2.0",
"@types/react-dom": "^18.2.15",
"@types/scroll-into-view": "^1.16.0",
"@types/supertest": "^2.0.11",
"@types/uuid": "^9.0.2",
"@typescript-eslint/eslint-plugin": "^8.39.0",
"@typescript-eslint/parser": "^8.39.0",
"@typescript-eslint/utils": "^8.39.0",
"@vitejs/plugin-react-swc": "3.11.0",
"@typescript-eslint/eslint-plugin": "6.21.0",
"@typescript-eslint/experimental-utils": "^5.62.0",
"@typescript-eslint/parser": "6.21.0",
"@typescript-eslint/utils": "6.21.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"@vitest/ui": "1.4.0",
"@yarnpkg/types": "^4.0.0",
"chromatic": "^6.18.0",
@@ -164,67 +296,69 @@
"cross-var": "^1.1.0",
"danger": "^11.3.0",
"dotenv-cli": "^7.2.1",
"drizzle-kit": "^0.20.14",
"esbuild": "^0.23.0",
"eslint": "^9.32.0",
"eslint": "^8.53.0",
"eslint-config-next": "14.0.4",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-lingui": "^0.9.0",
"eslint-plugin-prefer-arrow": "^1.2.3",
"eslint-plugin-prettier": "^5.1.2",
"eslint-plugin-project-structure": "^3.9.1",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"eslint-plugin-simple-import-sort": "^10.0.0",
"eslint-plugin-storybook": "^0.9.0",
"eslint-plugin-unicorn": "^56.0.1",
"eslint-plugin-storybook": "^0.6.15",
"eslint-plugin-unicorn": "^51.0.1",
"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",
"msw": "^2.0.11",
"msw-storybook-addon": "^2.0.5",
"nx": "21.3.11",
"msw-storybook-addon": "2.0.0--canary.122.b3ed3b1.0",
"nx": "18.3.3",
"playwright": "^1.46.0",
"prettier": "^3.1.1",
"raw-loader": "^4.0.2",
"rimraf": "^5.0.5",
"source-map-support": "^0.5.20",
"storybook": "8.6.14",
"storybook": "^7.6.3",
"storybook-addon-cookie": "^3.2.0",
"storybook-addon-pseudo-states": "^2.1.2",
"storybook-dark-mode": "^3.0.3",
"supertest": "^6.1.3",
"ts-jest": "^29.1.1",
"ts-loader": "^9.2.3",
"ts-node": "10.9.1",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.17.0",
"vite": "^7.0.0",
"vite-plugin-checker": "^0.10.2",
"vite-plugin-cjs-interop": "^2.2.0",
"vite": "^5.4.0",
"vite-plugin-checker": "^0.6.2",
"vite-plugin-dts": "3.8.1",
"vite-plugin-svgr": "^4.2.0",
"vitest": "1.4.0"
},
"engines": {
"node": "^24.5.0",
"node": "^18.17.1",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"license": "AGPL-3.0",
"name": "twenty",
"packageManager": "yarn@4.9.2",
"packageManager": "yarn@4.4.0",
"resolutions": {
"graphql": "16.8.0",
"type-fest": "4.10.1",
"typescript": "5.9.2",
"graphql-redis-subscriptions/ioredis": "^5.6.0",
"prosemirror-view": "1.40.0",
"prosemirror-transform": "1.10.4"
"typescript": "5.3.3",
"prosemirror-model": "1.23.0",
"yjs": "13.6.18",
"graphql-redis-subscriptions/ioredis": "^5.6.0"
},
"version": "0.2.1",
"nx": {},
@@ -233,6 +367,7 @@
},
"workspaces": {
"packages": [
"packages/twenty-chrome-extension",
"packages/twenty-front",
"packages/twenty-server",
"packages/twenty-emails",
@@ -242,8 +377,6 @@
"packages/twenty-website",
"packages/twenty-e2e-testing",
"packages/twenty-shared",
"packages/twenty-apps",
"packages/twenty-cli",
"tools/eslint-rules"
]
}
-404
View File
@@ -1,404 +0,0 @@
# Contributing to Twenty Apps (WIP WIP WIP - DO NOT USE)
Thank you for your interest in contributing applications to the Twenty ecosystem! This guide will help you create, test, and submit high-quality applications.
## 🚀 Quick Start
### Prerequisites
- Twenty development environment set up
- Basic understanding of Twenty's architecture
### Create Your First Application
1. **Create Application Directory**
```bash
mkdir packages/twenty-apps/my-awesome-app
cd packages/twenty-apps/my-awesome-app
```
2. **Create Application Manifest**
```bash
cat > twenty-app.json << 'EOF'
{
"universalIdentifier": "com.yourcompany.my-awesome-app",
"label": "My Awesome App",
"description": "A brief description of what your app does",
"version": "1.0.0",
"icon": "⭐",
"roles": [],
"objects": [],
"functions": [],
"agents": [],
"views": []
}
EOF
```
3. **Create Documentation**
```bash
cat > README.md << 'EOF'
# My Awesome App
Brief description of your application.
## Features
- Feature 1
- Feature 2
## Installation
```bash
npx nx app:install twenty-server \
--source "./packages/twenty-apps/my-awesome-app" \
--workspaceId "your-workspace-id" \
--sourceType "local"
```
EOF
```
4. **Validate Your Application**
```bash
npx nx validate twenty-apps
```
5. **Test Installation**
```bash
# Build the server first
npx nx build twenty-server
# Install your app
npx nx app:install twenty-server \
--source "./packages/twenty-apps/my-awesome-app" \
--workspaceId "your-workspace-id" \
--sourceType "local" \
--verbose
```
## 📋 Application Structure
### Required Files
- `twenty-app.json` - Application manifest (required)
- `README.md` - Application documentation (required)
### Optional Files
- `DEVELOPMENT.md` - Development guide
- `functions/` - Serverless function source files
- `assets/` - Icons, screenshots, etc.
- `examples/` - Usage examples
### Manifest Schema
Your `twenty-app.json` must follow this structure:
```json
{
"universalIdentifier": "com.company.app-name",
"label": "Human Readable Name",
"description": "Brief description (max 500 chars)",
"version": "1.0.0",
"icon": "🚀",
"repositoryUrl": "https://github.com/user/repo",
"roles": [
{
"universalIdentifier": "com.company.app-name.role-name",
"label": "Role Display Name",
"description": "What this role can do",
"permissions": {
"canReadAllObjectRecords": false,
"canUpdateAllObjectRecords": false,
"canAccessAllTools": false,
"canUpdateAllSettings": false
}
}
],
"objects": [
{
"universalIdentifier": "com.company.app-name.object-name",
"nameSingular": "objectName",
"namePlural": "objectNames",
"labelSingular": "Object Name",
"labelPlural": "Object Names",
"description": "What this object represents",
"icon": "📊",
"fields": [
{
"universalIdentifier": "com.company.app-name.object-name.field-name",
"name": "fieldName",
"label": "Field Label",
"type": "TEXT|NUMBER|CURRENCY|DATE_TIME|SELECT|RELATION",
"isNullable": true,
"defaultValue": "optional"
}
]
}
],
"functions": [
{
"universalIdentifier": "com.company.app-name.function-name",
"name": "functionName",
"description": "What this function does",
"sourceCode": "export const handler = async (event) => { ... };",
"runtime": "nodejs22.x",
"timeoutSeconds": 30
}
],
"agents": [
{
"universalIdentifier": "com.company.app-name.agent-name",
"name": "agentName",
"label": "Agent Display Name",
"description": "What this agent helps with",
"prompt": "You are a helpful assistant that...",
"modelId": "gpt-4"
}
],
"views": [
{
"universalIdentifier": "com.company.app-name.view-name",
"name": "View Name",
"objectName": "objectName",
"type": "TABLE|KANBAN|CALENDAR",
"icon": "📊",
"fields": [],
"filters": [],
"sorts": []
}
]
}
```
## 🛠️ Development Workflow
### 1. Development Mode
Use development mode for rapid iteration:
```bash
# Using environment variable (recommended)
export TWENTY_API_KEY="20202020-f401-4d8a-a731-64d007c27bad" # Default dev API key
npx nx app:dev twenty-server \
--path "./packages/twenty-apps/my-awesome-app" \
--verbose
# Or using command line parameter
npx nx app:dev twenty-server \
--path "./packages/twenty-apps/my-awesome-app" \
--api-key "your-api-key-here" \
--verbose
```
**Getting an API Key:**
1. Start your Twenty development server
2. Go to Settings → APIs & Webhooks
3. Generate a new API key
4. Use the default seeded key `20202020-f401-4d8a-a731-64d007c27bad` for development
This will:
- Watch for file changes
- Automatically sync updates to Twenty
- Show detailed logs of what's happening
### 2. Testing Your Application
#### Validation
```bash
# Validate manifest structure
npx nx validate twenty-apps
# List all applications
npx nx run twenty-apps:list
# Show application info
npx nx run twenty-apps:info
```
#### Installation Testing
```bash
# Test fresh installation (uses TWENTY_API_KEY environment variable)
npx nx app:install twenty-server \
--source "./packages/twenty-apps/my-awesome-app" \
--verbose
```
#### Manual Testing
1. Install your application in a test workspace
2. Verify all objects, roles, and functions are created
3. Test the functionality works as expected
4. Check that views display correctly
5. Test any AI agents respond appropriately
### 3. Common Development Patterns
#### Field Types
- `TEXT` - String values
- `NUMBER` - Numeric values
- `CURRENCY` - Monetary amounts
- `DATE_TIME` - Timestamps
- `SELECT` - Dropdown options
- `RELATION` - Links to other objects
#### Serverless Functions
```javascript
export const handler = async (event) => {
// Access event data
const { recordId, workspaceId } = event;
// Perform your logic
const result = await processData(recordId);
// Return response
return {
statusCode: 200,
body: JSON.stringify(result)
};
};
```
#### AI Agent Prompts
```json
{
"prompt": "You are a helpful assistant for [specific domain]. You can help users with:\n\n1. Specific task 1\n2. Specific task 2\n3. Specific task 3\n\nAlways be professional and provide actionable advice."
}
```
## ✅ Quality Guidelines
### Code Quality
- ✅ Valid JSON in manifest
- ✅ All required fields present
- ✅ Unique universal identifiers
- ✅ Proper field types and constraints
- ✅ Meaningful descriptions
### Documentation Quality
- ✅ Clear README with installation instructions
- ✅ Feature list with descriptions
- ✅ Usage examples
- ✅ Screenshots or demos (if applicable)
### Functionality Quality
- ✅ Application installs without errors
- ✅ All features work as described
- ✅ No conflicts with existing Twenty functionality
- ✅ Proper error handling in functions
### User Experience
- ✅ Intuitive object and field names
- ✅ Helpful descriptions and labels
- ✅ Logical default views and sorts
- ✅ Appropriate icons and visual elements
## 🔍 Review Process
### Self-Review Checklist
Before submitting, ensure:
- [ ] Application validates successfully (`npx nx validate twenty-apps`)
- [ ] Installation works in a clean workspace
- [ ] All features function as documented
- [ ] README is complete and accurate
- [ ] Universal identifiers follow reverse domain notation
- [ ] No hardcoded values or test data
- [ ] Functions handle errors gracefully
- [ ] AI agents provide helpful responses
### Submission Process
1. **Fork the Repository**
```bash
git clone https://github.com/twentyhq/twenty.git
```
2. **Create Feature Branch**
```bash
git checkout -b feature/my-awesome-app
```
3. **Commit Your Application**
```bash
git add packages/twenty-apps/my-awesome-app/
git commit -m "feat: add My Awesome App for [specific use case]"
```
4. **Push and Create PR**
```bash
git push origin feature/my-awesome-app
# Create pull request on GitHub
```
### PR Requirements
Your pull request should include:
- **Clear Title**: "feat: add [App Name] for [use case]"
- **Description**: What the app does and why it's useful
- **Screenshots**: Show the app in action
- **Testing**: Confirm you've tested installation and functionality
- **Documentation**: Link to your app's README
### Review Criteria
Applications are reviewed for:
1. **Functionality** - Does it work as described?
2. **Code Quality** - Is the manifest well-structured?
3. **Documentation** - Is it well-documented?
4. **Uniqueness** - Does it provide unique value?
5. **Compatibility** - Works with current Twenty version?
6. **Security** - No security vulnerabilities?
## 🎯 Application Categories
### Business Applications
- **CRM Extensions** - Sales, marketing, customer service
- **Project Management** - Tasks, resources, timelines
- **Financial** - Invoicing, expenses, reporting
- **HR** - Employee management, recruiting
### Industry-Specific
- **Real Estate** - Properties, leads, transactions
- **Healthcare** - Patients, appointments, records
- **Education** - Students, courses, grades
- **Legal** - Cases, clients, documents
### Utility Applications
- **Data Integration** - Import/export, connectors
- **Automation** - Workflows, triggers, notifications
- **Reporting** - Dashboards, analytics, insights
- **Communication** - Messaging, notifications, alerts
## 🆘 Getting Help
### Resources
- [Twenty Documentation](https://docs.twenty.com)
- [Application Examples](./crm-extension/)
- [Community Discord](https://discord.gg/twenty)
### Common Issues
**"Invalid universal identifier"**
- Use reverse domain notation: `com.company.app-name`
- Ensure uniqueness across your application
- Use lowercase with hyphens
**"Validation failed"**
- Check JSON syntax with `jq . twenty-app.json`
- Ensure all required fields are present
- Verify field types match schema
**"Installation failed"**
- Check workspace ID is correct
- Ensure Twenty server is running
- Verify no conflicting applications
### Support Channels
1. Check existing applications for examples
2. Review validation error messages
3. Ask in Twenty Discord #applications channel
4. Open GitHub issue for bugs
---
**Ready to build amazing applications for Twenty?** Start with our [CRM Extension example](./crm-extension/) to see what's possible, then create your own application to solve real business problems!
-299
View File
@@ -1,299 +0,0 @@
# Twenty Apps (WIP WIP WIP - DO NOT USE)
Welcome to the Twenty Apps collection! This package will contain a curated set of applications to extend Twenty CRM's functionality.
## 🚀 Available Applications
### 📊 CRM Extension Demo
**Location**: `./crm-extension/`
**Description**: A comprehensive demo showcasing custom objects, roles, serverless functions, and AI agents for sales territory and activity management.
**Features**:
- 🗺️ Sales Territory Management
- 📈 Sales Activity Tracking
- 🤖 AI Sales Assistant
- ⚡ Automated Performance Calculations
- 👥 Role-based Access Control
## 📦 Installation
### Prerequisites
#### For Local Development (Current)
The Twenty CLI is not yet published to npm. Use it locally from the monorepo:
```bash
# From the twenty monorepo root directory
# Use nx to run CLI commands:
npx nx run twenty-cli:start -- auth login
# All CLI commands follow this pattern:
npx nx run twenty-cli:start -- [command] [options]
```
#### For Published Version (Coming Soon)
```bash
# Once published to npm
npm install -g twenty-cli
twenty auth login
```
### Installing an application
```bash
# Install any application from this package (from monorepo root)
npx nx run twenty-cli:start -- app install \
--source "./packages/twenty-apps/crm-extension" \
--type local \
--workspace-id "your-workspace-id"
```
### Developing an application
```bash
# Navigate to the application directory
cd packages/twenty-apps/crm-extension
# Start development mode with auto-sync (from monorepo root)
npx nx run twenty-cli:start -- app dev \
--verbose
```
## 🏗️ Application Structure
Each application in this package follows the standard Twenty application structure:
```
app-name/
├── twenty-app.json # Application manifest
├── README.md # Application documentation
├── DEVELOPMENT.md # Development guide (optional)
├── functions/ # Serverless functions (optional)
│ ├── function1.js
│ └── function2.js
└── assets/ # Static assets (optional)
├── icons/
└── screenshots/
```
## 📋 Application Manifest
Every Twenty application must include a `twenty-app.json` file:
```json
{
"universalIdentifier": "com.company.app-name",
"label": "App Display Name",
"description": "Brief description of the app",
"version": "1.0.0",
"icon": "🚀",
"agents": [
{
"universalIdentifier": "com.company.app-name.agent-name",
"name": "agentName",
"label": "Agent Display Name",
"description": "What this agent helps with",
"prompt": "You are a helpful assistant that...",
"modelId": "gpt-4",
"responseFormat": {
"type": "text"
}
}
]
}
```
> **Note**: Currently, Twenty applications primarily support AI agents. Support for custom objects, roles, functions, and views is planned for future releases.
## 🛠️ Development Workflow
### 1. Create a New Application
```bash
# Create application using the CLI (from monorepo root)
npx nx run twenty-cli:start -- app init my-new-app
cd my-new-app
# Or create manually in the twenty-apps package
mkdir packages/twenty-apps/my-new-app
cd packages/twenty-apps/my-new-app
# Create manifest
cat > twenty-app.json << 'EOF'
{
"universalIdentifier": "com.mycompany.my-new-app",
"label": "My New App",
"description": "Description of my application",
"version": "1.0.0",
"icon": "⭐",
"agents": []
}
EOF
```
### 2. Develop with Live Reload
```bash
# Navigate to your application directory
cd packages/twenty-apps/my-new-app
# Start development mode with file watching (from monorepo root)
npx nx run twenty-cli:start -- app dev \
--workspace-id "your-workspace-id" \
--verbose
```
### 3. Test Installation
```bash
# Test installation from local source (from monorepo root)
npx nx run twenty-cli:start -- app install \
--source "./packages/twenty-apps/my-new-app" \
--type local \
--workspace-id "test-workspace-id"
```
## 📚 Application Categories
### 🏢 Business Applications
- **CRM Extensions**: Sales, marketing, customer service enhancements
- **Project Management**: Task tracking, resource management
- **Financial**: Invoicing, expense tracking, reporting
### 🤖 AI-Powered Applications
- **Customer Support**: Intelligent chatbots and support agents
- **Sales Automation**: Lead qualification and follow-up agents
- **Data Analysis**: AI agents for reporting and insights
### 🎨 Industry-Specific Agents
- **Real Estate**: Property recommendation and client management agents
- **Healthcare**: Patient communication and scheduling assistants
- **Education**: Student support and course guidance agents
## 🤝 Contributing Applications
We welcome contributions! To add your application to this collection:
### 1. Application Requirements
- ✅ Complete `twenty-app.json` manifest
- ✅ Comprehensive README.md
- ✅ Proper universal identifier (reverse domain notation)
- ✅ Tested functionality
- ✅ Clear documentation
### 2. Submission Process
1. Fork the repository
2. Create your application in `packages/twenty-apps/your-app-name/`
3. Test thoroughly using the development workflow
4. Submit a pull request with:
- Application code and manifest
- Screenshots/demos
- Installation and usage instructions
### 3. Review Criteria
- **Functionality**: Does it work as described?
- **Documentation**: Is it well-documented?
- **Code Quality**: Is the manifest well-structured?
- **Uniqueness**: Does it provide unique value?
- **Compatibility**: Works with current Twenty version?
## ⚙️ CLI Configuration
The Twenty CLI supports both global and project-level configuration:
```bash
# Set global configuration (applies to all projects)
twenty config set apiUrl "https://your-twenty-instance.com"
twenty config set workspaceId "your-default-workspace-id"
# Set project-level configuration (applies to current directory)
twenty config set workspaceId "project-specific-workspace-id" --project
# View current configuration
twenty config list
```
Configuration files:
- **Global**: `~/.twenty/config.json`
- **Project**: `.twenty.json` in your project directory
## 🔍 Application Discovery
### Browse Applications
```bash
# List all available applications in this package
ls packages/twenty-apps/
# View application details
cat packages/twenty-apps/crm-extension/twenty-app.json
# List installed applications in your workspace (from monorepo root)
npx nx run twenty-cli:start -- app list --workspace-id "your-workspace-id"
```
### Search by Category
Applications are organized by functionality and industry. Check individual README files for detailed feature lists.
## 🚨 Troubleshooting
### Common Issues
**"Authentication failed"**
- Run `npx nx run twenty-cli:start -- auth login` to authenticate
- Check your API key is valid and not expired
- Verify you have access to the workspace
**"Application not found"**
- Verify the path to your application directory
- Ensure `twenty-app.json` exists and is valid JSON
- Check the file is in the correct location
**"Invalid workspace ID"**
- Check your workspace ID is correct
- Use `npx nx run twenty-cli:start -- config get workspaceId` to see your configured workspace
- Ensure you have access to the workspace
**"Manifest validation failed"**
- Validate your JSON syntax using a JSON validator
- Check all required fields are present (universalIdentifier, label, version)
- Ensure universal identifier follows reverse domain notation (com.company.app)
### Getting Help
1. Check the application's README.md for specific instructions
2. Review the Twenty CLI documentation: `npx nx run twenty-cli:start -- --help`
3. Check Twenty's main documentation
4. Open an issue on GitHub with detailed error messages
## 📄 License
All applications in this package are licensed under AGPL-3.0 unless otherwise specified in individual application directories.
## 🌟 Featured Applications
### 🏆 Most Popular
1. **CRM Extension Demo** - Comprehensive sales management
2. *More applications coming soon!*
### 🆕 Recently Added
1. **CRM Extension Demo** - Initial demo application
---
## 🚀 Getting Started
**Ready to extend Twenty CRM with AI agents?**
### For Local Development (Current)
1. **Clone the repo**: `git clone https://github.com/twentyhq/twenty.git && cd twenty`
2. **Authenticate**: `npx nx run twenty-cli:start -- auth login`
3. **Explore the demo**: Check out the CRM Extension demo to see what's possible
4. **Create your own**: `npx nx run twenty-cli:start -- app init my-app`
### For Published Version (Coming Soon)
1. **Install the CLI**: `npm install -g twenty-cli`
2. **Authenticate**: `twenty auth login`
3. **Explore the demo**: Check out the CRM Extension demo
4. **Create your own**: `twenty app init my-app`
Start with the CRM Extension demo to see AI agents in action, then create your own applications to solve your unique business needs!
@@ -1,60 +0,0 @@
import { promises as fs } from 'fs';
import * as path from 'path';
describe('Twenty Apps Package', () => {
describe('Package Structure', () => {
it('should have a hello-world app with manifest', async () => {
const manifestPath = path.join(__dirname, '../hello-world/twenty-app.json');
const manifestExists = await fs.access(manifestPath).then(() => true).catch(() => false);
expect(manifestExists).toBe(true);
});
it('should have a template app with manifest', async () => {
const templatePath = path.join(__dirname, '../_template/twenty-app.json');
const templateExists = await fs.access(templatePath).then(() => true).catch(() => false);
expect(templateExists).toBe(true);
});
});
describe('App Manifests', () => {
it('should have valid JSON in hello-world manifest', async () => {
const manifestPath = path.join(__dirname, '../hello-world/twenty-app.json');
const manifestContent = await fs.readFile(manifestPath, 'utf8');
expect(() => JSON.parse(manifestContent)).not.toThrow();
const manifest = JSON.parse(manifestContent);
expect(manifest).toHaveProperty('standardId');
expect(manifest).toHaveProperty('label');
expect(manifest).toHaveProperty('version');
});
it('should have valid JSON in template manifest', async () => {
const templatePath = path.join(__dirname, '../_template/twenty-app.json');
const templateContent = await fs.readFile(templatePath, 'utf8');
expect(() => JSON.parse(templateContent)).not.toThrow();
const template = JSON.parse(templateContent);
expect(template).toHaveProperty('standardId');
expect(template).toHaveProperty('label');
expect(template).toHaveProperty('version');
});
});
describe('Package Configuration', () => {
it('should have a valid package.json', async () => {
const packagePath = path.join(__dirname, '../package.json');
const packageContent = await fs.readFile(packagePath, 'utf8');
expect(() => JSON.parse(packageContent)).not.toThrow();
const packageJson = JSON.parse(packageContent);
expect(packageJson.name).toBe('twenty-apps');
expect(packageJson).toHaveProperty('version');
expect(packageJson).toHaveProperty('description');
});
});
});
-175
View File
@@ -1,175 +0,0 @@
# Your App Name
A brief description of what your application does and the problems it solves.
## 🚀 Features
- **Feature 1**: Description of what this feature does
- **Feature 2**: Description of what this feature does
- **Feature 3**: Description of what this feature does
## 📦 What Gets Installed
When you install this application, Twenty will automatically create:
### 🗂️ Objects
- **Example Object**: Description of what this object represents
### 👥 Roles
- **App Administrator**: Full access to all app features
- **App User**: Standard user access with limited permissions
### ⚡ Functions
- **Example Function**: Description of what this function does
### 🤖 AI Agents
- **App Assistant**: AI helper for app-related questions and tasks
### 📊 Views
- **All Example Objects**: Default view showing all example objects
## 🛠️ Installation
### Prerequisites
- Twenty CRM instance running
- Valid workspace ID
- Server built (`npx nx build twenty-server`)
### Install Command
```bash
npx nx app:install twenty-server \
--source "./packages/twenty-apps/your-app-name" \
--workspaceId "your-workspace-id" \
--sourceType "local" \
--verbose
```
### Development Mode
For active development with auto-sync:
```bash
npx nx app:dev twenty-server \
--appPath "./packages/twenty-apps/your-app-name" \
--workspaceId "your-workspace-id" \
--verbose
```
## 📖 Usage
### Getting Started
1. After installation, navigate to the new objects in your Twenty workspace
2. Assign users to the appropriate roles (App Administrator or App User)
3. Start creating records using the new objects
4. Use the AI assistant for help and guidance
### Common Workflows
#### Workflow 1: [Describe a common use case]
1. Step 1 description
2. Step 2 description
3. Step 3 description
#### Workflow 2: [Describe another use case]
1. Step 1 description
2. Step 2 description
3. Step 3 description
### Tips and Best Practices
- **Tip 1**: Helpful advice for users
- **Tip 2**: Another useful tip
- **Tip 3**: Best practice recommendation
## 🔧 Configuration
### Role Permissions
- **App Administrator**: Can read and update all records
- **App User**: Limited to their own records (customize as needed)
### Field Customization
You can customize the following fields after installation:
- Field 1: How to customize it
- Field 2: How to customize it
### Function Configuration
The serverless functions can be configured for:
- Setting 1: Description
- Setting 2: Description
## 🤝 Integration
### With Existing Objects
This app integrates with Twenty's core objects:
- **Companies**: How it relates to companies
- **People**: How it relates to people
- **Opportunities**: How it relates to opportunities
### API Usage
Access your app's data via Twenty's GraphQL API:
```graphql
query GetExampleObjects {
exampleObjects {
id
name
status
createdDate
}
}
```
### Webhooks
Set up webhooks to react to changes:
- When example objects are created
- When status changes
- When records are updated
## 🚨 Troubleshooting
### Common Issues
**Issue 1: [Common problem]**
- **Cause**: Why this happens
- **Solution**: How to fix it
**Issue 2: [Another problem]**
- **Cause**: Why this happens
- **Solution**: How to fix it
### Getting Help
1. Check the AI assistant for quick help
2. Review the Twenty documentation
3. Ask in the Twenty Discord community
4. Open an issue on GitHub
## 🔄 Updates
### Version History
- **v1.0.0**: Initial release with core features
### Updating
To update to a newer version:
```bash
npx nx app:install twenty-server \
--source "./packages/twenty-apps/your-app-name" \
--workspaceId "your-workspace-id" \
--sourceType "local"
```
## 📄 License
This application is licensed under [LICENSE] - see the LICENSE file for details.
## 🤝 Contributing
Contributions are welcome! Please read the [contributing guidelines](../CONTRIBUTING.md) before submitting changes.
### Development Setup
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Test thoroughly
5. Submit a pull request
---
**Need help?** The App Assistant AI agent is available in your Twenty workspace to help with questions and provide guidance on using this application effectively!
@@ -1,22 +0,0 @@
{
"standardId": "com.yourcompany.your-app-name",
"label": "Your App Name",
"description": "A brief description of what your application does",
"version": "1.0.0",
"icon": "⭐",
"agents": [
{
"standardId": "com.yourcompany.your-app-name.assistant",
"name": "assistant",
"label": "App Assistant",
"description": "AI assistant for your application",
"icon": "🤖",
"prompt": "You are a helpful assistant for [Your App Name]. Help users with:\n\n1. Understanding app features\n2. Navigating the interface\n3. Completing common tasks\n4. Troubleshooting issues\n5. Providing guidance and tips\n\nBe helpful, clear, and focused on the user's needs.",
"modelId": "gpt-4",
"responseFormat": {
"type": "text"
}
}
]
}
@@ -1,90 +0,0 @@
# Hello World 👋
A delightful Twenty CRM application that welcomes users with a friendly AI assistant!
## 🌟 What it does
This app provides a cheerful AI companion that:
- 🎉 Welcomes new users with enthusiasm
- 🚀 Helps users discover Twenty CRM features
- 💡 Provides helpful tips and best practices
- 🎯 Guides users through their first steps
- ✨ Makes the CRM experience delightful
## 🤖 Meet Your Friendly Assistant
The **Friendly Assistant** is your personal guide to Twenty CRM! This AI companion is:
- Warm and welcoming to all users
- Encouraging and supportive in every interaction
- Clear and helpful with explanations
- Excited about helping users succeed
- Full of positive energy and emojis! 🌈
## 🚀 Getting Started
### Development Mode
To start developing this app:
```bash
# From the project root
npx nx run twenty-cli:start -- app dev --path packages/twenty-apps/hello-world
# Or from this directory
cd packages/twenty-apps/hello-world
npx nx run twenty-cli:start -- app dev
```
### Deployment
To deploy this app to your Twenty instance:
```bash
npx nx run twenty-cli:start -- app deploy --path packages/twenty-apps/hello-world
```
## 🎨 Customization
Want to make this app even more fun? You can:
1. **Update the greeting style** - Modify the agent's prompt in `twenty-app.json`
2. **Change the personality** - Adjust the tone and approach
3. **Add more features** - Extend the agent's capabilities
4. **Customize the icon** - Pick your favorite emoji for the app and agent
## 💡 Example Interactions
Here's what users can expect from the Friendly Assistant:
> **User**: "I'm new to Twenty CRM, where should I start?"
>
> **Friendly Assistant**: "🎉 Welcome to Twenty CRM! I'm so excited you're here! Let's get you started on this amazing journey!
>
> Here are the perfect first steps:
> 1. 👥 Add your first contacts
> 2. 🏢 Set up your companies
> 3. 🎯 Create your first opportunities
> 4. 📊 Explore the dashboard to see your data come to life!
>
> Which one sounds most interesting to you? I'm here to guide you every step of the way! ✨"
## 🛠️ Technical Details
- **Agent Model**: Uses `auto` model selection for optimal performance
- **Response Format**: Plain text with emoji support
- **Universal ID**: `com.twenty.demo.hello-world.friendly-assistant`
- **Integration**: Seamlessly works with Twenty CRM's agent system
## 🌈 Why This App?
This Hello World app demonstrates:
- How to create engaging AI personalities
- Best practices for user onboarding
- The power of positive, helpful AI interactions
- How simple apps can make a big impact on user experience
Perfect for new developers learning Twenty's app system, or as a starting point for more complex applications!
---
*Made with ❤️ for the Twenty community*
@@ -1,15 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/agent.schema.json",
// Hello World Assistant Agent
// A cheerful AI companion that welcomes users to Twenty CRM
"standardId": "550e8400-e29b-41d4-a716-446655440001",
"name": "helloWorldAssistant",
"label": "Hello World Assistant 🤖 !",
"description": "A cheerful AI companion that welcomes users and helps them navigate Twenty CRM",
"icon": "👋",
"prompt": "You are a friendly and enthusiastic AI assistant for Twenty CRM! Your job is to:\n\n🎉 Welcome new users with excitement and positivity\n🚀 Help users discover the amazing features of Twenty CRM\n💡 Provide helpful tips and best practices\n🎯 Guide users through their first steps\n✨ Make the CRM experience delightful and engaging\n\nAlways be:\n- Warm and welcoming\n- Encouraging and supportive \n- Clear and helpful in your explanations\n- Excited about helping users succeed\n\nUse emojis to make your responses more engaging, and remember that every interaction is a chance to make someone's day better! 🌈",
"modelId": "auto",
"responseFormat": {
"type": "text"
}
}
@@ -1,11 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-cli/schemas/app-manifest.schema.json",
// Hello World Twenty App
// A friendly AI companion that greets users and helps them get started with Twenty CRM
"standardId": "550e8400-e29b-41d4-a716-446655440000",
"label": "Hello World 👋",
"description": "A friendly AI companion that greets users and helps them get started with Twenty CRM",
"icon": "🌟",
"version": "1.0.0"
// Agents are automatically discovered from the agents/ folder
}
-40
View File
@@ -1,40 +0,0 @@
const jestConfig = {
// For more information please have a look to official docs https://jestjs.io/docs/configuration/#prettierpath-string
// Prettier v3 should be supported in jest v30 https://github.com/jestjs/jest/releases/tag/v30.0.0-alpha.1
prettierPath: null,
// to enable logs, comment out the following line
silent: true,
errorOnDeprecated: true,
clearMocks: true,
displayName: 'twenty-apps',
rootDir: './',
testEnvironment: 'node',
transformIgnorePatterns: ['/node_modules/'],
testMatch: ['**/__tests__/**/*.test.ts'],
transform: {
'^.+\\.(t|j)s$': [
'@swc/jest',
{
jsc: {
parser: {
syntax: 'typescript',
tsx: false,
decorators: true,
},
transform: {
decoratorMetadata: true,
},
},
},
],
},
moduleNameMapper: {},
moduleFileExtensions: ['js', 'json', 'ts'],
modulePathIgnorePatterns: ['<rootDir>/dist'],
collectCoverageFrom: [
'__tests__/**/*.ts',
],
coverageDirectory: './coverage',
};
export default jestConfig;
-32
View File
@@ -1,32 +0,0 @@
{
"name": "twenty-apps",
"version": "0.1.0",
"description": "Collection of Twenty CRM applications and extensions",
"private": true,
"keywords": [
"twenty",
"crm",
"applications",
"extensions",
"marketplace"
],
"repository": {
"type": "git",
"url": "https://github.com/twentyhq/twenty.git",
"directory": "packages/twenty-apps"
},
"license": "AGPL-3.0",
"scripts": {
"lint": "echo 'No linting needed for application manifests'",
"test": "jest",
"test:watch": "jest --watch"
},
"devDependencies": {
"@swc/core": "^1.9.3",
"@swc/jest": "^0.2.36",
"@types/jest": "^30.0.0",
"@types/node": "^24.3.1",
"jest": "^30.1.3",
"typescript": "^5.9.2"
}
}
-35
View File
@@ -1,35 +0,0 @@
{
"name": "twenty-apps",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"tags": ["scope:apps", "type:applications"],
"targets": {
"lint": {
"executor": "nx:run-commands",
"options": {
"command": "echo 'No linting needed for application manifests'"
}
},
"list": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-apps",
"command": "find . -name 'twenty-app.json' -exec dirname {} \\; | sed 's|^\\./||' | sort"
}
},
"info": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-apps",
"command": "echo 'Available Twenty Applications:' && find . -name 'twenty-app.json' -exec sh -c 'echo \"\\n📦 $(dirname \"$1\" | sed \"s|^\\./||\"): $(jq -r \".label\" \"$1\")\"' _ {} \\;"
}
},
"test": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/twenty-apps",
"command": "yarn test"
}
}
}
}
-30
View File
@@ -1,30 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"types": ["jest", "node"]
},
"include": [
"scripts/**/*",
"__tests__/**/*"
],
"exclude": [
"node_modules",
"dist"
]
}
@@ -0,0 +1,6 @@
VITE_SERVER_BASE_URL=https://api.twenty.com
VITE_FRONT_BASE_URL=https://app.twenty.com
VITE_MODE=production
# Used to generate packages/twenty-chrome-extension/src/generated/graphql.tsx
AUTH_TOKEN=<YOUR-TOKEN-HERE>
@@ -0,0 +1,6 @@
module.exports = {
extends: ['./.eslintrc.cjs'],
rules: {
'no-console': 'error',
},
};
@@ -0,0 +1,20 @@
module.exports = {
root: true,
extends: ['../../.eslintrc.global.cjs', '../../.eslintrc.react.cjs'],
ignorePatterns: [
'node_modules',
'dist',
'**/generated/*',
],
overrides: [
{
files: ['**/*.ts', '**/*.tsx'],
parserOptions: {
project: ['packages/twenty-chrome-extension/tsconfig.*.json'],
},
rules: {
'@nx/workspace-explicit-boolean-predicates-in-if': 'warn',
},
},
],
};
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
@@ -0,0 +1 @@
src/generated
@@ -0,0 +1,60 @@
# Twenty Chrome Extension.
This extension allows you to save `company` and `people` information to your twenty workspace directly from LinkedIn.
To install the extension in development mode with hmr (hot module reload), follow these steps.
- STEP 1: Clone the repository and run `yarn install` in the root directory.
- STEP 2: Once the dependencies installation succeeds, create a file with env variables by executing the following command in the root directory.
```
cp ./packages/twenty-chrome-extension/.env.example ./packages/twenty-chrome-extension/.env
```
- STEP 3 (optional): Update values of the environment variables to match those of your instance for `twenty-front` and `twenty-server`. If you want to work on your local machine with the default setup from `Twenty Docs`, replace everything in the .env file with the following.
```
VITE_SERVER_BASE_URL=http://localhost:3000
VITE_FRONT_BASE_URL=http://localhost:3001
```
- STEP 4: Now, execute the following command in the root directory to start up the development server on Port 3002. This will create a `dist` folder in `twenty-chrome-extension`.
```
npx nx start twenty-chrome-extension
```
- STEP 5: Open Google Chrome and head to the extensions page by typing `chrome://extensions` in the address bar.
<p align="center">
<img src="../twenty-chrome-extension/public/readme-images/01-img-one.png" width="600" />
</p>
- STEP 6: Turn on the `Developer mode` from the top-right corner and click `Load unpacked`.
<p align="center">
<img src="../twenty-chrome-extension/public/readme-images/02-img-two.png" width="600" />
</p>
- STEP 7: Select the `dist` folder from `twenty-chrome-extension`.
<p align="center">
<img src="../twenty-chrome-extension/public/readme-images/03-img-three.png" width="600" />
</p>
- STEP 8: This opens up the `options` page, where you must enter your API key.
<p align="center">
<img src="../twenty-chrome-extension/public/readme-images/04-img-four.png" width="600" />
</p>
- STEP 9: Reload any LinkedIn page that you opened before installing the extension for seamless experience.
- STEP 10: Visit any individual or company profile on LinkedIn and click the `Add to Twenty` button to test.
<p align="center">
<img src="../twenty-chrome-extension/public/readme-images/05-img-five.png" width="600" />
</p>
To install the extension in production mode without hmr (hot module reload), replace the command in STEP FOUR with `npx nx build twenty-chrome-extension`. You may or may not want to execute STEP THREE based on your requirements.
@@ -0,0 +1,34 @@
import { CodegenConfig } from '@graphql-codegen/cli';
const config: CodegenConfig = {
schema: [
{
[`${import.meta.env.VITE_SERVER_BASE_URL}/graphql`]: {
// some of the mutations and queries require authorization (people or companies)
// so to regenerate the schema with types we need to pass an auth token
headers: {
Authorization: `Bearer ${import.meta.env.AUTH_TOKEN}`,
},
},
},
],
overwrite: true,
documents: ['./src/**/*.ts', '!src/generated/**/*.*'],
generates: {
'./src/generated/graphql.tsx': {
plugins: [
'typescript',
'typescript-operations',
'typescript-react-apollo',
],
config: {
skipTypename: true,
withHooks: true,
withHOC: false,
withComponent: false,
},
},
},
};
export default config;
@@ -0,0 +1,12 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<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>
<body>
<div id="app"></div>
<script type="module" src="/src/options/loading-index.tsx"></script>
</body>
</html>
@@ -0,0 +1,13 @@
{
"name": "twenty-chrome-extension",
"description": "",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"build": "npx vite build"
},
"dependencies": {
"twenty-shared": "workspace:*"
}
}
@@ -0,0 +1,12 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<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>
<body>
<div id="app"></div>
<script type="module" src="/src/options/page-inaccessible-index.tsx"></script>
</body>
</html>
@@ -0,0 +1,71 @@
{
"name": "twenty-chrome-extension",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"tags": ["scope:frontend"],
"targets": {
"build": {
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "{projectRoot}/dist"
},
"dependsOn": ["^build"]
},
"start": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"options": {
"cwd": "packages/twenty-chrome-extension",
"command": "VITE_MODE=development vite"
}
},
"preview": {
"executor": "@nx/vite:preview-server",
"options": {
"buildTarget": "twenty-chrome-extension:build",
"port": 3002,
"open": true
}
},
"reset:env": {
"executor": "nx:run-commands",
"inputs": ["{projectRoot}/.env.example"],
"outputs": ["{projectRoot}/.env"],
"cache": true,
"options": {
"cwd": "{projectRoot}",
"command": "cp .env.example .env"
}
},
"typecheck": {},
"lint": {
"options": {
"lintFilePatterns": [
"{projectRoot}/src/**/*.{ts,tsx,json}",
"{projectRoot}/package.json"
],
"maxWarnings": 0,
"reportUnusedDisableDirectives": "error"
},
"configurations": {
"ci": { "eslintConfig": "{projectRoot}/.eslintrc-ci.cjs" },
"fix": {}
}
},
"fmt": {
"options": {
"files": "src"
},
"configurations": {
"fix": {}
}
},
"graphql:generate": {
"executor": "nx:run-commands",
"options": {
"cwd": "{projectRoot}",
"command": "graphql-codegen"
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 790 B

@@ -0,0 +1,12 @@
<svg width="32" height="32" viewBox="0 0 136 136" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2343_96406)">
<path d="M136 2.28882e-05H0L0.000144482 136H136V2.28882e-05ZM27.27 50.6401C27.27 43.2101 33.3 37.1801 40.73 37.1801H66.64C67.02 37.1801 67.37 37.4101 67.53 37.7601C67.69 38.1101 67.62 38.5201 67.36 38.8101L61.68 44.9801C60.69 46.0501 59.3 46.6701 57.84 46.6701H40.8C38.57 46.6701 36.76 48.4801 36.76 50.7101V60.8901C36.76 62.2001 35.7 63.2601 34.39 63.2601H29.65C28.34 63.2601 27.28 62.2001 27.28 60.8901V50.6401H27.27ZM107.88 85.3601C107.88 92.7901 101.85 98.82 94.42 98.82H83.41C75.98 98.82 69.95 92.7901 69.95 85.3601V66.0901C69.95 64.7801 70.44 63.5201 71.33 62.5501L77.75 55.5801C78.02 55.2901 78.44 55.1901 78.82 55.3301C79.19 55.4801 79.44 55.83 79.44 56.23V85.3001C79.44 87.5301 81.25 89.3401 83.48 89.3401H94.36C96.59 89.3401 98.4 87.5301 98.4 85.3001V50.7101C98.4 48.4801 96.59 46.6701 94.36 46.6701H81.71C80.26 46.6701 78.88 47.2801 77.89 48.3401L40.16 89.3401H62.83C64.14 89.3401 65.2 90.4001 65.2 91.7101V96.4501C65.2 97.7601 64.14 98.82 62.83 98.82H32.28C29.51 98.82 27.26 96.5701 27.26 93.8001V91.29C27.26 90.03 27.73 88.8201 28.59 87.8901L70.89 41.9401C73.69 38.9001 77.62 37.1801 81.75 37.1801H94.41C101.84 37.1801 107.87 43.2101 107.87 50.6401V85.3601H107.88Z" fill="black"/>
<path d="M27.27 50.6401C27.27 43.2101 33.3 37.1801 40.73 37.1801H66.64C67.02 37.1801 67.37 37.4101 67.53 37.7601C67.69 38.1101 67.62 38.5201 67.36 38.8101L61.68 44.9801C60.69 46.0501 59.3 46.6701 57.84 46.6701H40.8C38.57 46.6701 36.76 48.4801 36.76 50.7101V60.8901C36.76 62.2001 35.7 63.2601 34.39 63.2601H29.65C28.34 63.2601 27.28 62.2001 27.28 60.8901V50.6401H27.27Z" fill="white"/>
<path d="M107.88 85.3601C107.88 92.7901 101.85 98.82 94.42 98.82H83.41C75.98 98.82 69.95 92.7901 69.95 85.3601V66.0901C69.95 64.7801 70.44 63.5201 71.33 62.5501L77.75 55.5801C78.02 55.2901 78.44 55.1901 78.82 55.3301C79.19 55.4801 79.44 55.83 79.44 56.23V85.3001C79.44 87.5301 81.25 89.3401 83.48 89.3401H94.36C96.59 89.3401 98.4 87.5301 98.4 85.3001V50.7101C98.4 48.4801 96.59 46.6701 94.36 46.6701H81.71C80.26 46.6701 78.88 47.2801 77.89 48.3401L40.16 89.3401H62.83C64.14 89.3401 65.2 90.4001 65.2 91.7101V96.4501C65.2 97.7601 64.14 98.82 62.83 98.82H32.28C29.51 98.82 27.26 96.5701 27.26 93.8001V91.29C27.26 90.03 27.73 88.8201 28.59 87.8901L70.89 41.9401C73.69 38.9001 77.62 37.1801 81.75 37.1801H94.41C101.84 37.1801 107.87 43.2101 107.87 50.6401V85.3601H107.88Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_2343_96406">
<rect width="136" height="136" rx="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 650 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 830 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,22 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<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>
/* Reset margin and padding */
html, body {
margin: 0;
padding: 0;
height: 100%; /* Ensure body takes full viewport height */
overflow: hidden; /* Prevents scrollbars from appearing */
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/options/index.tsx"></script>
</body>
</html>
@@ -0,0 +1,96 @@
import { isDefined } from 'twenty-shared/utils';
// Open options page programmatically in a new tab.
// chrome.runtime.onInstalled.addListener((details) => {
// if (details.reason === 'install') {
// openOptionsPage();
// }
// });
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
// This listens for an event from other parts of the extension, such as the content script, and performs the required tasks.
// The cases themselves are labelled such that their operations are reflected by their names.
chrome.runtime.onMessage.addListener((message, _, sendResponse) => {
switch (message.action) {
case 'getActiveTab': {
// e.g. "https://linkedin.com/company/twenty/"
chrome.tabs.query({ active: true, currentWindow: true }, ([tab]) => {
if (isDefined(tab) && isDefined(tab.id)) {
sendResponse({ tab });
}
});
break;
}
case 'openSidepanel': {
chrome.tabs.query({ active: true, currentWindow: true }, ([tab]) => {
if (isDefined(tab) && isDefined(tab.id)) {
chrome.sidePanel.open({ tabId: tab.id });
}
});
break;
}
default:
break;
}
return true;
});
chrome.tabs.onUpdated.addListener(async (tabId, _, tab) => {
const isDesiredRoute =
tab.url?.match(/^https?:\/\/(?:www\.)?linkedin\.com\/company(?:\/\S+)?/) ||
tab.url?.match(/^https?:\/\/(?:www\.)?linkedin\.com\/in(?:\/\S+)?/);
if (tab.active === true) {
if (isDefined(isDesiredRoute)) {
chrome.tabs.sendMessage(tabId, { action: 'executeContentScript' });
}
}
await chrome.sidePanel.setOptions({
tabId,
path: tab.url?.match(/^https?:\/\/(?:www\.)?linkedin\.com/)
? 'sidepanel.html'
: 'page-inaccessible.html',
enabled: true,
});
});
const setTokenStateFromCookie = (cookie: string) => {
const decodedValue = decodeURIComponent(cookie);
const tokenPair = JSON.parse(decodedValue);
if (isDefined(tokenPair)) {
chrome.storage.local.set({
isAuthenticated: true,
accessToken: tokenPair.accessToken,
refreshToken: tokenPair.refreshToken,
});
}
};
chrome.cookies.onChanged.addListener(async ({ cookie }) => {
if (cookie.name === 'tokenPair') {
const store = await chrome.storage.local.get(['clientUrl']);
const clientUrl = isDefined(store.clientUrl)
? store.clientUrl
: import.meta.env.VITE_FRONT_BASE_URL;
chrome.cookies.get({ name: 'tokenPair', url: `${clientUrl}` }, (cookie) => {
if (isDefined(cookie)) {
setTokenStateFromCookie(cookie.value);
}
});
}
});
// This will only run the very first time the extension loads, after we have stored the
// cookiesRead variable to true, this will not allow to change the token state everytime background script runs
chrome.cookies.get(
{ name: 'tokenPair', url: `${import.meta.env.VITE_FRONT_BASE_URL}` },
async (cookie) => {
const store = await chrome.storage.local.get(['cookiesRead']);
if (isDefined(cookie) && !isDefined(store.cookiesRead)) {
setTokenStateFromCookie(cookie.value);
chrome.storage.local.set({ cookiesRead: true });
}
},
);
@@ -0,0 +1,77 @@
import { isDefined } from 'twenty-shared/utils';
interface CustomDiv extends HTMLDivElement {
onClickHandler: (newHandler: () => void) => void;
}
export const createDefaultButton = (
buttonId: string,
buttonText = '',
): CustomDiv => {
const btn = document.getElementById(buttonId) as CustomDiv;
if (isDefined(btn)) return btn;
const div = document.createElement('div') as CustomDiv;
const img = document.createElement('img');
const span = document.createElement('span');
span.textContent = buttonText;
img.src =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAA7EAAAOxAGVKw4bAAACb0lEQVR4nO2VO4taQRTHr3AblbjxEVlwCwVhg7BoqqCIjy/gAyyFWNlYBOxsfH0KuxgQGwXRUkGuL2S7i1barGAgiwbdW93SnGOc4BonPiKahf3DwXFmuP/fPM4ZlvmlTxAhCBdzHnEQWYiv7Mr4C3NeuVYhQYDPzOUUQgDLBQGcLHNhvQK8DACPx8PTxiqVyvISG43GbyaT6Qfpn06n0m63e/tPAPF4vJ1MJu8kEsnWTCkWi1yr1RKGw+GDRqPBOTfr44vFQvD7/Q/lcpmaaVQAr9fLp1IpO22c47hGOBz+MB6PH+Vy+VYDAL8qlUoGtVotzOfzq4MAgsHgE/6KojiQyWR/bKVSqbSszHFM8Pl8z1YK48JsNltCOBwOnrYLO+8AAIjb+nHbycoTiUQfDJ7tFq4YAHiVSmXBxcD41u8flQU8z7fhzO0r83atVns3Go3u9Xr9x0O/RQXo9/tsIBBg6vX606a52Wz+bZ7P5/WwG29gxSJzhKgA6XTaDoFNF+krFAocmC//4yWEcSf2wTm7mCO19xFgSsKOLI16vV7b7XY7mRNoLwA0JymJ5uQIzgIAuX5PzDElT2m+E8BqtQ4ymcx7Yq7T6a6ZE4sKgOadTucaCwkxp1UzlEKh0GDxIXOwDWHAdi6Xe3swQDQa/Q7mywoolUpvsaptymazDWKxmBHTlWXZm405BFZoNpuGgwEmk4mE2SGtVivii4f1AO7J3ZopkQCQj7Ar1FeRChCJRJzVapX6DKNIfSc1Ax+wtQWQ55h6bH8FWDfYV4fO3wlwDr0C/BcADYiTPCxHqIEA2QsCZAkAKnRGkMbKN/sTX5YHPQ1e7SkAAAAASUVORK5CYII=';
img.height = 16;
img.width = 16;
img.alt = 'Twenty logo';
// Write universal styles for the button
const divStyles = {
border: '1px solid black',
borderRadius: '20px',
backgroundColor: 'black',
color: 'white',
fontWeight: '600',
fontSize: '1.5rem',
display: 'flex',
alignItems: 'center',
gap: '5px',
justifyContent: 'center',
padding: '0 1rem',
cursor: 'pointer',
height: '32px',
width: 'max-content',
};
Object.assign(div.style, divStyles);
// Apply common styles to specifc states of a button.
div.addEventListener('mouseenter', () => {
const hoverStyles = {
//eslint-disable-next-line @nx/workspace-no-hardcoded-colors
backgroundColor: '#5e5e5e',
//eslint-disable-next-line @nx/workspace-no-hardcoded-colors
borderColor: '#5e5e5e',
};
Object.assign(div.style, hoverStyles);
});
div.addEventListener('mouseleave', () => {
Object.assign(div.style, divStyles);
});
div.onClickHandler = (newHandler) => {
div.onclick = async () => {
const store = await chrome.storage.local.get();
// If an api key is not set, the options page opens up to allow the user to configure an api key.
if (!store.accessToken) {
chrome.runtime.sendMessage({ action: 'openSidepanel' });
return;
}
newHandler();
};
};
div.id = buttonId;
div.appendChild(img);
div.appendChild(span);
return div;
};
@@ -0,0 +1,126 @@
import { createDefaultButton } from '~/contentScript/createButton';
import changeSidePanelUrl from '~/contentScript/utils/changeSidepanelUrl';
import extractCompanyLinkedinLink from '~/contentScript/utils/extractCompanyLinkedinLink';
import extractDomain from '~/contentScript/utils/extractDomain';
import { createCompany, fetchCompany } from '~/db/company.db';
import { CompanyInput } from '~/db/types/company.types';
import { isDefined } from 'twenty-shared/utils';
export const checkIfCompanyExists = async () => {
const { tab: activeTab } = await chrome.runtime.sendMessage({
action: 'getActiveTab',
});
const companyURL = extractCompanyLinkedinLink(activeTab.url);
return await fetchCompany({
linkedinLink: {
url: { eq: companyURL },
label: { eq: companyURL },
},
});
};
export const addCompany = async () => {
// Extract company-specific data from the DOM
const companyNameElement = document.querySelector(
'.org-top-card-summary__title',
);
const domainNameElement = document.querySelector(
'.org-top-card-primary-actions__inner a',
);
const addressElement = document.querySelectorAll(
'.org-top-card-summary-info-list__info-item',
)[1];
const employeesNumberElement = document.querySelectorAll(
'.org-top-card-summary-info-list__info-item',
)[3];
// Get the text content or other necessary data from the DOM elements
const companyName = companyNameElement
? companyNameElement.getAttribute('title')
: '';
const domainName = extractDomain(
domainNameElement && domainNameElement.getAttribute('href'),
);
const address = addressElement
? addressElement.textContent?.trim().replace(/\s+/g, ' ')
: '';
const employees = employeesNumberElement
? Number(
employeesNumberElement.textContent
?.trim()
.replace(/\s+/g, ' ')
.split('-')[0],
)
: 0;
// Prepare company data to send to the backend
const companyInputData: CompanyInput = {
name: companyName ?? '',
domainName: domainName,
address: address ?? '',
employees: employees,
};
// Extract active tab url using chrome API - an event is triggered here and is caught by background script.
const { tab: activeTab } = await chrome.runtime.sendMessage({
action: 'getActiveTab',
});
// Convert URLs like https://www.linkedin.com/company/twenty/about/ to https://www.linkedin.com/company/twenty
const companyURL = extractCompanyLinkedinLink(activeTab.url);
companyInputData.linkedinLink = { url: companyURL, label: companyURL };
const companyId = await createCompany(companyInputData);
if (isDefined(companyId)) {
await changeSidePanelUrl(`/object/company/${companyId}`);
}
return companyId;
};
export const insertButtonForCompany = async () => {
const companyButtonDiv = createDefaultButton('twenty-company-btn');
const companyDiv: HTMLDivElement | null = document.querySelector(
'.org-top-card__primary-content',
);
if (isDefined(companyDiv)) {
Object.assign(companyButtonDiv.style, {
marginTop: '.8rem',
});
companyDiv.parentElement?.append(companyButtonDiv);
}
const companyButtonSpan = companyButtonDiv.getElementsByTagName('span')[0];
const company = await checkIfCompanyExists();
const openCompanyOnSidePanel = (companyId: string) => {
companyButtonSpan.textContent = 'View in Twenty';
companyButtonDiv.onClickHandler(async () => {
await changeSidePanelUrl(`/object/company/${companyId}`);
chrome.runtime.sendMessage({ action: 'openSidepanel' });
});
};
if (isDefined(company)) {
await changeSidePanelUrl(`/object/company/${company.id}`);
if (isDefined(company.id)) openCompanyOnSidePanel(company.id);
} else {
await changeSidePanelUrl(`/objects/companies`);
companyButtonSpan.textContent = 'Add to Twenty';
companyButtonDiv.onClickHandler(async () => {
companyButtonSpan.textContent = 'Saving...';
const companyId = await addCompany();
if (isDefined(companyId)) {
openCompanyOnSidePanel(companyId);
} else {
companyButtonSpan.textContent = 'Try again';
}
});
}
};
@@ -0,0 +1,133 @@
import { createDefaultButton } from '~/contentScript/createButton';
import changeSidePanelUrl from '~/contentScript/utils/changeSidepanelUrl';
import extractFirstAndLastName from '~/contentScript/utils/extractFirstAndLastName';
import { createPerson, fetchPerson } from '~/db/person.db';
import { PersonInput } from '~/db/types/person.types';
import { isDefined } from 'twenty-shared/utils';
export const checkIfPersonExists = async () => {
const { tab: activeTab } = await chrome.runtime.sendMessage({
action: 'getActiveTab',
});
let activeTabUrl = '';
if (isDefined(activeTab.url.endsWith('/'))) {
activeTabUrl = activeTab.url.slice(0, -1);
}
const personNameElement = document.querySelector('.text-heading-xlarge');
const personName = personNameElement ? personNameElement.textContent : '';
const { firstName, lastName } = extractFirstAndLastName(String(personName));
const person = await fetchPerson({
name: {
firstName: { eq: firstName },
lastName: { eq: lastName },
},
linkedinLink: { url: { eq: activeTabUrl }, label: { eq: activeTabUrl } },
});
return person;
};
export const addPerson = async () => {
const personNameElement = document.querySelector('.text-heading-xlarge');
const separatorElement = document.querySelector(
'.pv-text-details__separator',
);
const personCityElement = separatorElement?.previousElementSibling;
const profilePictureElement = document.querySelector(
'.pv-top-card-profile-picture__image',
);
const firstListItem = document.querySelector(
'div[data-view-name="profile-component-entity"]',
);
const secondDivElement = firstListItem?.querySelector('div:nth-child(2)');
const ariaHiddenSpan = secondDivElement?.querySelector(
'span[aria-hidden="true"]',
);
// Get the text content or other necessary data from the DOM elements.
const personName = personNameElement ? personNameElement.textContent : '';
const personCity = personCityElement
? personCityElement.textContent?.trim().replace(/\s+/g, ' ').split(',')[0]
: '';
const profilePicture = profilePictureElement
? profilePictureElement?.getAttribute('src')
: '';
const jobTitle = ariaHiddenSpan ? ariaHiddenSpan.textContent?.trim() : '';
const { firstName, lastName } = extractFirstAndLastName(String(personName));
// Prepare person data to send to the backend.
const personData: PersonInput = {
name: { firstName, lastName },
city: personCity ?? '',
avatarUrl: profilePicture ?? '',
jobTitle: jobTitle ?? '',
linkedinLink: { url: '', label: '' },
};
// Extract active tab url using chrome API - an event is triggered here and is caught by background script.
const { tab: activeTab } = await chrome.runtime.sendMessage({
action: 'getActiveTab',
});
let activeTabUrl = '';
// Remove last slash from the URL for consistency when saving usernames.
if (isDefined(activeTab.url.endsWith('/'))) {
activeTabUrl = activeTab.url.slice(0, -1);
}
personData.linkedinLink = { url: activeTabUrl, label: activeTabUrl };
const personId = await createPerson(personData);
if (isDefined(personId)) {
await changeSidePanelUrl(`/object/person/${personId}`);
}
return personId;
};
export const insertButtonForPerson = async () => {
const personButtonDiv = createDefaultButton('twenty-person-btn');
if (isDefined(personButtonDiv)) {
const addedProfileDiv = document.querySelector('.artdeco-card > .ph5');
if (isDefined(addedProfileDiv)) {
Object.assign(personButtonDiv.style, {
marginTop: '.8rem',
});
addedProfileDiv.append(personButtonDiv);
}
const personButtonSpan = personButtonDiv.getElementsByTagName('span')[0];
const person = await checkIfPersonExists();
const openPersonOnSidePanel = (personId: string) => {
personButtonSpan.textContent = 'View in Twenty';
personButtonDiv.onClickHandler(async () => {
await changeSidePanelUrl(`/object/person/${personId}`);
chrome.runtime.sendMessage({ action: 'openSidepanel' });
});
};
if (isDefined(person)) {
await changeSidePanelUrl(`/object/person/${person.id}`);
if (isDefined(person.id)) openPersonOnSidePanel(person.id);
} else {
await changeSidePanelUrl(`/objects/people`);
personButtonSpan.textContent = 'Add to Twenty';
personButtonDiv.onClickHandler(async () => {
personButtonSpan.textContent = 'Saving...';
const personId = await addPerson();
if (isDefined(personId)) openPersonOnSidePanel(personId);
else personButtonSpan.textContent = 'Try again';
});
}
}
};
@@ -0,0 +1,44 @@
import { insertButtonForCompany } from '~/contentScript/extractCompanyProfile';
import { insertButtonForPerson } from '~/contentScript/extractPersonProfile';
import { isDefined } from 'twenty-shared/utils';
// Inject buttons into the DOM when SPA is reloaded on the resource url.
// e.g. reload the page when on https://www.linkedin.com/in/mabdullahabaid/
// await insertButtonForCompany();
const companyRoute = /^https?:\/\/(?:www\.)?linkedin\.com\/company(?:\/\S+)?/;
const personRoute = /^https?:\/\/(?:www\.)?linkedin\.com\/in(?:\/\S+)?/;
const executeScript = async () => {
const loc = window.location.href;
switch (true) {
case companyRoute.test(loc):
await insertButtonForCompany();
break;
case personRoute.test(loc):
await insertButtonForPerson();
break;
default:
break;
}
};
// The content script gets executed upon load, so the the content script is executed when a user visits https://www.linkedin.com/feed/.
// However, there would never be another reload in a single page application unless triggered manually.
// Therefore, if the user navigates to a person or a company page, we must manually re-execute the content script to create the "Add to Twenty" button.
// e.g. create "Add to Twenty" button when a user navigates to https://www.linkedin.com/in/mabdullahabaid/ from https://www.linkedin.com/feed/
chrome.runtime.onMessage.addListener(async (message, _, sendResponse) => {
if (message.action === 'executeContentScript') {
await executeScript();
}
sendResponse('Executing!');
});
chrome.storage.local.onChanged.addListener(async (store) => {
if (isDefined(store.accessToken)) {
if (isDefined(store.accessToken.newValue)) {
await executeScript();
}
}
});

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