Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b45e44fb85 | ||
|
|
bed46f12df | ||
|
|
26709bcc43 | ||
|
|
35817d5fae | ||
|
|
0a75cbd06c | ||
|
|
3e731da69c |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -1,137 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Twenty Development Rules
|
||||
|
||||
This directory contains Twenty's development guidelines and best practices in the modern Cursor Rules format (MDC). These rules are automatically applied based on file patterns and provide context-aware guidance to AI assistants.
|
||||
|
||||
## Rules Overview
|
||||
|
||||
### Core Guidelines
|
||||
- **architecture.mdc** - Project overview, technology stack, and infrastructure setup (Always Applied)
|
||||
- **nx-rules.mdc** - Nx workspace guidelines and best practices (Auto-attached to Nx files)
|
||||
|
||||
### Code Quality
|
||||
- **typescript-guidelines.mdc** - TypeScript best practices and conventions (Auto-attached to .ts/.tsx files)
|
||||
- **code-style.mdc** - General coding standards and style guide (Auto-attached to code files)
|
||||
- **file-structure.mdc** - File and directory organization patterns (Auto-attached to config files)
|
||||
|
||||
### React Development
|
||||
- **react-general-guidelines.mdc** - Core React development principles (Auto-attached to React files)
|
||||
- **react-state-management.mdc** - State management approaches with Recoil (Auto-attached to state files)
|
||||
|
||||
### Testing & Quality
|
||||
- **testing-guidelines.mdc** - Testing strategies and best practices (Auto-attached to test files)
|
||||
|
||||
### Internationalization
|
||||
- **translations.mdc** - Translation workflow and i18n setup (Auto-attached to locale files)
|
||||
|
||||
## How Rules Work
|
||||
|
||||
### Automatic Attachment
|
||||
Rules are automatically included in your AI context based on file patterns (globs). When you work on TypeScript files, the TypeScript guidelines are automatically loaded.
|
||||
|
||||
### Manual Reference
|
||||
You can manually reference any rule using the `@ruleName` syntax:
|
||||
- `@nx-rules` - Include Nx-specific guidance
|
||||
- `@react-general-guidelines` - Load React best practices
|
||||
- `@testing-guidelines` - Get testing recommendations
|
||||
|
||||
### Rule Types Used
|
||||
- **Always Applied** - Loaded in every context (architecture.mdc, README.mdc)
|
||||
- **Auto Attached** - Loaded when matching file patterns are referenced
|
||||
- **Agent Requested** - Available for AI to include when relevant
|
||||
- **Manual** - Only included when explicitly mentioned
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Frontend Commands
|
||||
```bash
|
||||
# Testing
|
||||
npx nx test twenty-front # Run unit tests
|
||||
npx nx storybook:build twenty-front # Build Storybook
|
||||
npx nx storybook:serve-and-test:static # Run Storybook tests
|
||||
|
||||
# Development
|
||||
npx nx lint twenty-front # Run linter
|
||||
npx nx typecheck twenty-front # Type checking
|
||||
npx nx run twenty-front:graphql:generate # Generate GraphQL types
|
||||
```
|
||||
|
||||
### Backend Commands
|
||||
```bash
|
||||
# Database
|
||||
npx nx database:reset twenty-server # Reset database
|
||||
npx nx run twenty-server:database:init:prod # Initialize database
|
||||
npx nx run twenty-server:database:migrate:prod # Run migrations
|
||||
|
||||
# Development
|
||||
npx nx run twenty-server:start # Start the server
|
||||
npx nx run twenty-server:lint # Run linter (add --fix to auto-fix)
|
||||
npx nx run twenty-server:typecheck # Type checking
|
||||
npx nx run twenty-server:test # Run unit tests
|
||||
npx nx run twenty-server:test:integration:with-db-reset # Run integration tests
|
||||
|
||||
# Migrations
|
||||
npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/migrations/[name] -d src/database/typeorm/core/core.datasource.ts
|
||||
|
||||
# Workspace
|
||||
npx nx run twenty-server:command workspace:sync-metadata -f # Sync metadata
|
||||
```
|
||||
|
||||
## Usage Guidelines
|
||||
|
||||
### For Developers
|
||||
- Rules are automatically applied based on file context
|
||||
- Check rule descriptions to understand when they're activated
|
||||
- Use manual references (`@ruleName`) for additional context
|
||||
- Keep rules updated as the codebase evolves
|
||||
|
||||
### For AI Assistants
|
||||
- Rules provide consistent guidance across conversations
|
||||
- Use rule context to maintain coding standards
|
||||
- Reference specific rules when making recommendations
|
||||
- Apply rule principles in code suggestions and reviews
|
||||
|
||||
## Contributing to Rules
|
||||
|
||||
### Adding New Rules
|
||||
1. Create a new `.mdc` file in this directory
|
||||
2. Include proper metadata headers with description and globs
|
||||
3. Write clear, actionable guidelines with examples
|
||||
4. Test the rule with relevant file patterns
|
||||
5. Update this README if needed
|
||||
|
||||
### Updating Existing Rules
|
||||
1. Modify the rule content while preserving metadata
|
||||
2. Test changes with affected file patterns
|
||||
3. Ensure consistency with other rules
|
||||
4. Update examples and best practices as needed
|
||||
|
||||
## Rule Format Reference
|
||||
|
||||
Each rule file uses the MDC format with metadata:
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Brief description of the rule's purpose
|
||||
globs: ["**/*.ts", "**/*.tsx"] # File patterns for auto-attachment
|
||||
alwaysApply: false # Whether to always include this rule
|
||||
---
|
||||
|
||||
# Rule Title
|
||||
|
||||
Rule content in Markdown format...
|
||||
```
|
||||
|
||||
## Migration from Legacy Format
|
||||
|
||||
The rules have been migrated from the legacy `.md` format to the modern `.mdc` format, providing:
|
||||
- Better context awareness through file pattern matching
|
||||
- Improved organization with metadata headers
|
||||
- More flexible rule application strategies
|
||||
- Enhanced integration with Cursor's AI features
|
||||
|
||||
For the most up-to-date version of these guidelines, always refer to the files in this directory.
|
||||
@@ -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
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
---
|
||||
description: Twenty CRM architecture overview - monorepo structure, tech stack, and development principles
|
||||
globs: []
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Twenty Architecture
|
||||
|
||||
## Tech Stack
|
||||
- **Frontend**: React 18, TypeScript, Recoil, Styled Components, Vite
|
||||
- **Backend**: NestJS, TypeORM, PostgreSQL, Redis, GraphQL
|
||||
- **Monorepo**: Nx workspace with yarn
|
||||
|
||||
## Package Structure
|
||||
```
|
||||
packages/
|
||||
├── twenty-front/ # React app
|
||||
├── twenty-server/ # NestJS API
|
||||
├── twenty-ui/ # Shared components
|
||||
├── twenty-shared/ # Common types/utils
|
||||
└── twenty-emails/ # Email templates
|
||||
```
|
||||
|
||||
## Key Principles
|
||||
- **Functional components only** (no classes)
|
||||
- **Named exports only** (no default exports)
|
||||
- **Types over interfaces** (except for extending third-party)
|
||||
- **String literals over enums** (except GraphQL)
|
||||
- **No 'any' type allowed**
|
||||
- **Event handlers over useEffect** for state updates
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,103 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Code Style Guidelines
|
||||
|
||||
## Formatting Standards
|
||||
- **Prettier**: 2-space indentation, single quotes, trailing commas, semicolons
|
||||
- **Print width**: 80 characters
|
||||
- **ESLint**: No unused imports, consistent import ordering, prefer const over let
|
||||
|
||||
## Naming Conventions
|
||||
```typescript
|
||||
// ✅ Variables and functions - camelCase
|
||||
const userAccountBalance = 1000;
|
||||
const calculateMonthlyPayment = () => {};
|
||||
|
||||
// ✅ Constants - SCREAMING_SNAKE_CASE
|
||||
const API_ENDPOINTS = {
|
||||
USERS: '/api/users',
|
||||
ORDERS: '/api/orders',
|
||||
} as const;
|
||||
|
||||
// ✅ Types and Classes - PascalCase
|
||||
class UserService {}
|
||||
type UserAccountData = {};
|
||||
type ButtonProps = {}; // Component props suffix with 'Props'
|
||||
|
||||
// ✅ Files and directories - kebab-case
|
||||
// user-profile.component.tsx
|
||||
// user-profile.styles.ts
|
||||
```
|
||||
|
||||
## Import Organization
|
||||
```typescript
|
||||
// ✅ Correct import order
|
||||
// 1. External libraries
|
||||
import React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
// 2. Internal modules (absolute paths)
|
||||
import { Button } from '@/components/ui';
|
||||
import { UserService } from '@/services';
|
||||
|
||||
// 3. Relative imports
|
||||
import { UserCardProps } from './types';
|
||||
```
|
||||
|
||||
## Function Structure
|
||||
```typescript
|
||||
// ✅ Small, focused functions
|
||||
// ✅ Required parameters first, optional last
|
||||
const processUserData = (
|
||||
user: User,
|
||||
options: ProcessingOptions,
|
||||
callback?: (result: ProcessedUser) => void
|
||||
): ProcessedUser => {
|
||||
const processedUser = transformUserData(user);
|
||||
applyOptions(processedUser, options);
|
||||
|
||||
if (callback) {
|
||||
callback(processedUser);
|
||||
}
|
||||
|
||||
return processedUser;
|
||||
};
|
||||
```
|
||||
|
||||
## Comments
|
||||
```typescript
|
||||
// ✅ Explain business logic and non-obvious intentions
|
||||
// Apply 15% discount for premium users with orders > $100
|
||||
const discount = isPremiumUser && orderTotal > 100 ? 0.15 : 0;
|
||||
|
||||
// TODO: Replace with proper authentication service
|
||||
const isAuthenticated = localStorage.getItem('token') !== null;
|
||||
|
||||
/**
|
||||
* JSDoc for public APIs
|
||||
* @param basePrice - The base price before modifications
|
||||
* @returns The final price after tax and discount
|
||||
*/
|
||||
const calculateTotalPrice = (basePrice: number): number => {
|
||||
// Implementation
|
||||
};
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
```typescript
|
||||
// ✅ Proper error types and meaningful messages
|
||||
try {
|
||||
const user = await userService.findById(userId);
|
||||
if (!user) {
|
||||
throw new UserNotFoundError(`User with ID ${userId} not found`);
|
||||
}
|
||||
return user;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch user', { userId, error });
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -1,107 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# File Structure Guidelines
|
||||
|
||||
## Directory Organization
|
||||
```
|
||||
packages/twenty-front/src/
|
||||
├── components/ # Reusable UI components
|
||||
├── pages/ # Route components
|
||||
├── modules/ # Feature modules
|
||||
├── hooks/ # Custom hooks
|
||||
├── services/ # API services
|
||||
└── types/ # Type definitions
|
||||
|
||||
packages/twenty-server/src/
|
||||
├── modules/ # Feature modules
|
||||
├── entities/ # Database entities
|
||||
├── dto/ # Data transfer objects
|
||||
└── utils/ # Helper functions
|
||||
```
|
||||
|
||||
## File Naming
|
||||
- **kebab-case** for all files and directories
|
||||
- **Descriptive suffixes** for clarity
|
||||
```
|
||||
// ✅ Correct naming
|
||||
user-profile.component.tsx
|
||||
user-profile.styles.ts
|
||||
user-profile.test.tsx
|
||||
user.service.ts
|
||||
user.entity.ts
|
||||
create-user.dto.ts
|
||||
```
|
||||
|
||||
## Index Files & Barrel Exports
|
||||
```typescript
|
||||
// ✅ Clean barrel exports in index.ts
|
||||
export { UserCard } from './user-card.component';
|
||||
export { UserList } from './user-list.component';
|
||||
export type { UserCardProps, UserListProps } from './types';
|
||||
|
||||
// ✅ Usage - clean imports
|
||||
import { UserCard, UserList } from '@/components/user';
|
||||
```
|
||||
|
||||
## Module Structure
|
||||
```
|
||||
src/modules/user/
|
||||
├── components/ # Module-specific components
|
||||
├── hooks/ # Module hooks
|
||||
├── services/ # API services
|
||||
├── types/ # Type definitions
|
||||
└── index.ts # Module exports
|
||||
```
|
||||
|
||||
## Import/Export Patterns
|
||||
```typescript
|
||||
// ✅ Import organization
|
||||
// 1. External libraries
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
// 2. Internal modules (absolute paths)
|
||||
import { Button } from '@/components/ui';
|
||||
import { UserService } from '@/services';
|
||||
|
||||
// 3. Relative imports
|
||||
import { UserCardProps } from './types';
|
||||
|
||||
// ✅ Named exports only (no default exports)
|
||||
export const UserComponent = ({ user }: UserProps) => {
|
||||
// Component implementation
|
||||
};
|
||||
```
|
||||
|
||||
## File Size Guidelines
|
||||
- **Components**: Under 300 lines
|
||||
- **Services**: Under 500 lines
|
||||
- **Extract logic** into hooks/utilities when files grow large
|
||||
- **Use composition** over large monolithic components
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### Project Configuration
|
||||
```
|
||||
.vscode/ # VSCode settings
|
||||
├── settings.json
|
||||
├── extensions.json
|
||||
└── launch.json
|
||||
|
||||
.github/ # GitHub workflows
|
||||
├── workflows/
|
||||
└── templates/
|
||||
|
||||
.cursor/ # Cursor rules
|
||||
├── rules/
|
||||
└── environment.json
|
||||
```
|
||||
|
||||
### Build Configuration
|
||||
- Keep build configs in root or package directories
|
||||
- Use consistent naming for config files
|
||||
- Comment complex configurations
|
||||
- Version control all configuration files
|
||||
@@ -1,83 +0,0 @@
|
||||
---
|
||||
description: Guidelines and best practices for working with Nx in the Twenty workspace, including workspace architecture understanding, configuration management, and generator usage.
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
---
|
||||
description: Guidelines and best practices for working with Nx in the Twenty workspace, including workspace architecture understanding, configuration management, and generator usage.
|
||||
globs: ["**/nx.json", "**/project.json", "**/workspace.json"]
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Nx Guidelines
|
||||
|
||||
## Core Commands
|
||||
```bash
|
||||
# Run target for specific project
|
||||
npx nx run twenty-front:build
|
||||
npx nx run twenty-server:test
|
||||
|
||||
# Run target for all projects
|
||||
npx nx run-many --target=build --all
|
||||
npx nx run-many --target=test --projects=twenty-front,twenty-server
|
||||
|
||||
# Generate/modify projects
|
||||
npx nx g @nx/react:app my-app
|
||||
npx nx g @nx/react:component my-component
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
- Each package has a `project.json` with targets
|
||||
- Dependencies managed through `tsconfig.json` path mappings
|
||||
- Shared libraries in `packages/` directory
|
||||
|
||||
## Build Targets
|
||||
```json
|
||||
// project.json
|
||||
{
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/vite:build",
|
||||
"options": { "outputPath": "dist/packages/twenty-front" }
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"options": { "jestConfig": "packages/twenty-front/jest.config.ts" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Dependency Graph
|
||||
```bash
|
||||
# View project dependencies
|
||||
npx nx graph
|
||||
|
||||
# Check what's affected by changes
|
||||
npx nx affected --target=test
|
||||
npx nx affected --target=build --base=main
|
||||
```
|
||||
|
||||
## Library Management
|
||||
- Use `npx nx g @nx/workspace:library` generator for shared libs
|
||||
- Internal imports use `@/` path mapping
|
||||
- Libraries must export through index.ts barrel files
|
||||
|
||||
## Cache Configuration
|
||||
- Nx caches build outputs and test results
|
||||
- Configure `outputs` in project.json targets
|
||||
- Use `inputs` to define what invalidates cache
|
||||
|
||||
```json
|
||||
{
|
||||
"build": {
|
||||
"outputs": ["dist/packages/my-app"],
|
||||
"inputs": ["source", "^source"],
|
||||
"cache": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
# React Guidelines
|
||||
|
||||
## Core React Principles
|
||||
Twenty follows modern React best practices with a focus on functional components and clean, maintainable code. This document outlines our React conventions and best practices.
|
||||
|
||||
## Component Structure
|
||||
|
||||
### Functional Components Only
|
||||
- Use functional components exclusively
|
||||
- No class components allowed
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
export const UserProfile = ({ user }: UserProfileProps) => {
|
||||
return (
|
||||
<StyledContainer>
|
||||
<h1>{user.name}</h1>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
// ❌ Incorrect
|
||||
export class UserProfile extends React.Component<UserProfileProps> {
|
||||
render() {
|
||||
return (
|
||||
<StyledContainer>
|
||||
<h1>{this.props.user.name}</h1>
|
||||
</StyledContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Named Exports
|
||||
- Use named exports exclusively
|
||||
- No default exports
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
export const Button = ({ label }: ButtonProps) => {
|
||||
return <button>{label}</button>;
|
||||
};
|
||||
|
||||
// ❌ Incorrect
|
||||
export default function Button({ label }: ButtonProps) {
|
||||
return <button>{label}</button>;
|
||||
}
|
||||
```
|
||||
|
||||
## State and Effects
|
||||
|
||||
### Event Handlers Over useEffect
|
||||
- Prefer event handlers for state updates
|
||||
- Avoid useEffect for state synchronization
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
const UserForm = () => {
|
||||
const handleSubmit = async (data: FormData) => {
|
||||
await updateUser(data);
|
||||
refreshUserList();
|
||||
};
|
||||
|
||||
return <Form onSubmit={handleSubmit} />;
|
||||
};
|
||||
|
||||
// ❌ Incorrect
|
||||
const UserForm = () => {
|
||||
useEffect(() => {
|
||||
if (formData) {
|
||||
updateUser(formData);
|
||||
}
|
||||
}, [formData]);
|
||||
|
||||
return <Form />;
|
||||
};
|
||||
```
|
||||
|
||||
## Component Design
|
||||
|
||||
### Small, Focused Components
|
||||
- Keep components small and single-purpose
|
||||
- Extract reusable logic into custom hooks
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
const UserCard = ({ user }: UserCardProps) => {
|
||||
return (
|
||||
<StyledCard>
|
||||
<UserAvatar user={user} />
|
||||
<UserInfo user={user} />
|
||||
<UserActions user={user} />
|
||||
</StyledCard>
|
||||
);
|
||||
};
|
||||
|
||||
// ❌ Incorrect
|
||||
const UserCard = ({ user }: UserCardProps) => {
|
||||
return (
|
||||
<StyledCard>
|
||||
{/* Too much logic in one component */}
|
||||
<img src={user.avatar} />
|
||||
<div>{user.name}</div>
|
||||
<div>{user.email}</div>
|
||||
<button onClick={() => handleEdit(user)}>Edit</button>
|
||||
<button onClick={() => handleDelete(user)}>Delete</button>
|
||||
{/* More complex logic... */}
|
||||
</StyledCard>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
### Prop Naming
|
||||
- Use clear, descriptive prop names
|
||||
- Follow React conventions (onClick, isActive, etc.)
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
type ButtonProps = {
|
||||
onClick: () => void;
|
||||
isDisabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
// ❌ Incorrect
|
||||
type ButtonProps = {
|
||||
clickHandler: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
};
|
||||
```
|
||||
|
||||
### Prop Destructuring
|
||||
- Destructure props with proper typing
|
||||
- Use TypeScript for prop types
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
const Button = ({ onClick, isDisabled, children }: ButtonProps) => {
|
||||
return (
|
||||
<button onClick={onClick} disabled={isDisabled}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// ❌ Incorrect
|
||||
const Button = (props: ButtonProps) => {
|
||||
return (
|
||||
<button onClick={props.onClick} disabled={props.isDisabled}>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Memoization
|
||||
- Use memo for expensive computations
|
||||
- Avoid premature optimization
|
||||
```typescript
|
||||
// ✅ Correct - Complex computation
|
||||
const MemoizedChart = memo(({ data }: ChartProps) => {
|
||||
// Complex rendering logic
|
||||
return <ComplexChart data={data} />;
|
||||
});
|
||||
|
||||
// ❌ Incorrect - Unnecessary memoization
|
||||
const MemoizedText = memo(({ text }: { text: string }) => {
|
||||
return <span>{text}</span>;
|
||||
});
|
||||
```
|
||||
|
||||
### Event Handlers
|
||||
- Use callback refs for DOM manipulation
|
||||
- Memoize callbacks when needed
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
const UserList = () => {
|
||||
const handleScroll = useCallback((event: UIEvent) => {
|
||||
// Complex scroll handling
|
||||
}, []);
|
||||
|
||||
return <div onScroll={handleScroll}>{/* content */}</div>;
|
||||
};
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Error Boundaries
|
||||
- Use error boundaries for component error handling
|
||||
- Provide meaningful fallback UIs
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
const ErrorFallback = ({ error }: { error: Error }) => (
|
||||
<StyledError>
|
||||
<h2>Something went wrong</h2>
|
||||
<pre>{error.message}</pre>
|
||||
</StyledError>
|
||||
);
|
||||
|
||||
const SafeComponent = () => (
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
<ComponentThatMightError />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
```
|
||||
|
||||
### Loading States
|
||||
- Handle loading states gracefully
|
||||
- Provide meaningful loading indicators
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
const UserProfile = () => {
|
||||
const { data: user, isLoading, error } = useUser();
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (error) return <ErrorMessage error={error} />;
|
||||
if (!user) return <NotFound />;
|
||||
|
||||
return <UserProfileContent user={user} />;
|
||||
};
|
||||
```
|
||||
@@ -1,86 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# React Guidelines
|
||||
|
||||
## Core Rules
|
||||
- **Functional components only** (no classes)
|
||||
- **Named exports only** (no default exports)
|
||||
- **Event handlers over useEffect** for state updates
|
||||
|
||||
## Component Structure
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
export const UserProfile = ({ user, onEdit }: UserProfileProps) => {
|
||||
const handleEdit = () => onEdit(user.id);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<h1>{user.name}</h1>
|
||||
<Button onClick={handleEdit}>Edit</Button>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Props & Event Handlers
|
||||
```typescript
|
||||
// ✅ Correct - Destructure props
|
||||
const Button = ({ onClick, isDisabled, children }: ButtonProps) => (
|
||||
<button onClick={onClick} disabled={isDisabled}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
// ✅ Correct - Event handlers over useEffect
|
||||
const UserForm = ({ onSubmit }: UserFormProps) => {
|
||||
const handleSubmit = async (data: FormData) => {
|
||||
await onSubmit(data);
|
||||
// Direct event handling, not useEffect
|
||||
};
|
||||
|
||||
return <Form onSubmit={handleSubmit} />;
|
||||
};
|
||||
```
|
||||
|
||||
## Component Design
|
||||
- **Small, focused components** - Single responsibility
|
||||
- **Composition over inheritance** - Combine simple components
|
||||
- **Extract complex logic** into custom hooks
|
||||
|
||||
```typescript
|
||||
// ✅ Good - Composed from smaller components
|
||||
const UserCard = ({ user }: UserCardProps) => (
|
||||
<StyledCard>
|
||||
<UserAvatar user={user} />
|
||||
<UserInfo user={user} />
|
||||
<UserActions user={user} />
|
||||
</StyledCard>
|
||||
);
|
||||
```
|
||||
|
||||
## Performance
|
||||
```typescript
|
||||
// ✅ Use memo for expensive components only
|
||||
const ExpensiveChart = memo(({ data }: ChartProps) => {
|
||||
// Complex rendering logic
|
||||
return <ComplexChart data={data} />;
|
||||
});
|
||||
|
||||
// ✅ Memoize callbacks when needed
|
||||
const UserList = ({ users, onUserSelect }: UserListProps) => {
|
||||
const handleUserSelect = useCallback((user: User) => {
|
||||
onUserSelect(user);
|
||||
}, [onUserSelect]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{users.map(user => (
|
||||
<UserItem key={user.id} user={user} onSelect={handleUserSelect} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,219 @@
|
||||
# State Management Guidelines
|
||||
|
||||
## Core State Management Principles
|
||||
Twenty uses a combination of Recoil for global state and Apollo Client for server state management. This document outlines our state management conventions and best practices.
|
||||
|
||||
## Global State Management
|
||||
|
||||
### Recoil Usage
|
||||
- Use Recoil for global application state
|
||||
- Keep atoms small and focused
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
// states/theme.ts
|
||||
export const themeState = atom<'light' | 'dark'>({
|
||||
key: 'themeState',
|
||||
default: 'light',
|
||||
});
|
||||
|
||||
// states/user.ts
|
||||
export const userState = atom<User | null>({
|
||||
key: 'userState',
|
||||
default: null,
|
||||
});
|
||||
|
||||
// ❌ Incorrect
|
||||
// states/globalState.ts
|
||||
export const globalState = atom({
|
||||
key: 'globalState',
|
||||
default: {
|
||||
theme: 'light',
|
||||
user: null,
|
||||
settings: {},
|
||||
// ... many other unrelated pieces of state
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Atom Organization
|
||||
- Place atoms in the `states/` directory
|
||||
- Group related atoms in feature-specific files
|
||||
```typescript
|
||||
// states/workspace/atoms.ts
|
||||
export const workspaceIdState = atom<string>({
|
||||
key: 'workspaceIdState',
|
||||
default: '',
|
||||
});
|
||||
|
||||
export const workspaceSettingsState = atom<WorkspaceSettings>({
|
||||
key: 'workspaceSettingsState',
|
||||
default: defaultSettings,
|
||||
});
|
||||
```
|
||||
|
||||
## Server State Management
|
||||
|
||||
### Apollo Client Usage
|
||||
- Use Apollo Client for all GraphQL operations
|
||||
- Leverage Apollo's caching capabilities
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
const { data, loading } = useQuery(GET_USER_QUERY, {
|
||||
variables: { id },
|
||||
fetchPolicy: 'cache-first',
|
||||
});
|
||||
|
||||
// ❌ Incorrect
|
||||
const [user, setUser] = useState(null);
|
||||
useEffect(() => {
|
||||
fetch('/api/user/' + id).then(setUser);
|
||||
}, [id]);
|
||||
```
|
||||
|
||||
### Query Organization
|
||||
- Separate operation files
|
||||
- Use fragments for shared fields
|
||||
```typescript
|
||||
// queries/user.ts
|
||||
export const UserFragment = gql`
|
||||
fragment UserFields on User {
|
||||
id
|
||||
name
|
||||
email
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_USER = gql`
|
||||
query GetUser($id: ID!) {
|
||||
user(id: $id) {
|
||||
...UserFields
|
||||
}
|
||||
}
|
||||
${UserFragment}
|
||||
`;
|
||||
```
|
||||
|
||||
## State Management Best Practices
|
||||
|
||||
### Multiple Small Atoms
|
||||
- Prefer multiple small atoms over prop drilling
|
||||
- Keep atoms focused on specific features
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
export const selectedViewState = atom<string>({
|
||||
key: 'selectedViewState',
|
||||
default: '',
|
||||
});
|
||||
|
||||
export const viewFiltersState = atom<ViewFilters>({
|
||||
key: 'viewFiltersState',
|
||||
default: {},
|
||||
});
|
||||
|
||||
// ❌ Incorrect - Prop drilling
|
||||
const ViewContainer = ({ selectedView, filters, onViewChange }) => {
|
||||
return (
|
||||
<ViewHeader view={selectedView} onViewChange={onViewChange}>
|
||||
<ViewContent>
|
||||
<ViewFilters filters={filters} />
|
||||
</ViewContent>
|
||||
</ViewHeader>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### No useRef for State
|
||||
- Never use useRef for state management
|
||||
- Use proper state management tools
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
const [count, setCount] = useState(0);
|
||||
// or
|
||||
const [count, setCount] = useRecoilState(countState);
|
||||
|
||||
// ❌ Incorrect
|
||||
const countRef = useRef(0);
|
||||
```
|
||||
|
||||
### Data Fetching
|
||||
- Extract data fetching to sibling components
|
||||
- Keep components focused on presentation
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
const UserProfileContainer = () => {
|
||||
const { data, loading } = useQuery(GET_USER);
|
||||
if (loading) return <LoadingSpinner />;
|
||||
return <UserProfile user={data.user} />;
|
||||
};
|
||||
|
||||
const UserProfile = ({ user }: UserProfileProps) => {
|
||||
return <div>{user.name}</div>;
|
||||
};
|
||||
|
||||
// ❌ Incorrect
|
||||
const UserProfile = () => {
|
||||
const { data, loading } = useQuery(GET_USER);
|
||||
if (loading) return <LoadingSpinner />;
|
||||
return <div>{data.user.name}</div>;
|
||||
};
|
||||
```
|
||||
|
||||
### Hook Usage
|
||||
- Use appropriate hooks for state access
|
||||
- Choose between useRecoilValue and useRecoilState based on needs
|
||||
```typescript
|
||||
// ✅ Correct - Read-only access
|
||||
const theme = useRecoilValue(themeState);
|
||||
|
||||
// ✅ Correct - Read-write access
|
||||
const [theme, setTheme] = useRecoilState(themeState);
|
||||
|
||||
// ❌ Incorrect - Using state setter when only reading
|
||||
const [theme, _] = useRecoilState(themeState);
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Selector Usage
|
||||
- Use selectors for derived state
|
||||
- Memoize complex calculations
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
const filteredUsersState = selector({
|
||||
key: 'filteredUsersState',
|
||||
get: ({ get }) => {
|
||||
const users = get(usersState);
|
||||
const filter = get(userFilterState);
|
||||
return users.filter(user =>
|
||||
user.name.toLowerCase().includes(filter.toLowerCase())
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// ❌ Incorrect - Calculating in component
|
||||
const UserList = () => {
|
||||
const users = useRecoilValue(usersState);
|
||||
const filter = useRecoilValue(userFilterState);
|
||||
const filteredUsers = users.filter(user =>
|
||||
user.name.toLowerCase().includes(filter.toLowerCase())
|
||||
);
|
||||
return <List users={filteredUsers} />;
|
||||
};
|
||||
```
|
||||
|
||||
### Cache Management
|
||||
- Configure appropriate cache policies
|
||||
- Handle cache invalidation properly
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
const [updateUser] = useMutation(UPDATE_USER, {
|
||||
update: (cache, { data }) => {
|
||||
cache.modify({
|
||||
id: cache.identify(data.updateUser),
|
||||
fields: {
|
||||
name: () => data.updateUser.name,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -1,81 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# React State Management
|
||||
|
||||
## Recoil Patterns
|
||||
```typescript
|
||||
// ✅ Atoms for primitive state
|
||||
export const currentUserState = atom<User | null>({
|
||||
key: 'currentUserState',
|
||||
default: null,
|
||||
});
|
||||
|
||||
// ✅ Selectors for derived state
|
||||
export const userDisplayNameSelector = selector({
|
||||
key: 'userDisplayNameSelector',
|
||||
get: ({ get }) => {
|
||||
const user = get(currentUserState);
|
||||
return user ? `${user.firstName} ${user.lastName}` : 'Guest';
|
||||
},
|
||||
});
|
||||
|
||||
// ✅ Atom families for dynamic atoms
|
||||
export const userByIdState = atomFamily<User | null, string>({
|
||||
key: 'userByIdState',
|
||||
default: null,
|
||||
});
|
||||
```
|
||||
|
||||
## Local State Guidelines
|
||||
```typescript
|
||||
// ✅ Multiple useState for unrelated state
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [data, setData] = useState<User[]>([]);
|
||||
|
||||
// ✅ useReducer for complex state logic
|
||||
type FormAction =
|
||||
| { type: 'SET_FIELD'; field: string; value: string }
|
||||
| { type: 'SET_ERRORS'; errors: Record<string, string> }
|
||||
| { type: 'RESET' };
|
||||
|
||||
const formReducer = (state: FormState, action: FormAction): FormState => {
|
||||
switch (action.type) {
|
||||
case 'SET_FIELD':
|
||||
return { ...state, [action.field]: action.value };
|
||||
case 'SET_ERRORS':
|
||||
return { ...state, errors: action.errors };
|
||||
case 'RESET':
|
||||
return initialFormState;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Data Flow Rules
|
||||
- **Props down, events up** - Unidirectional data flow
|
||||
- **Avoid bidirectional binding** - Use callback functions
|
||||
- **Normalize complex data** - Use lookup tables over nested objects
|
||||
|
||||
```typescript
|
||||
// ✅ Normalized state structure
|
||||
type UsersState = {
|
||||
byId: Record<string, User>;
|
||||
allIds: string[];
|
||||
};
|
||||
|
||||
// ✅ Functional state updates
|
||||
const increment = useCallback(() => {
|
||||
setCount(prev => prev + 1);
|
||||
}, []);
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
- Use atom families for dynamic data collections
|
||||
- Implement proper selector caching
|
||||
- Avoid heavy computations in selectors
|
||||
- Batch state updates when possible
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
@@ -1,89 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Testing Guidelines
|
||||
|
||||
## Test Structure (AAA Pattern)
|
||||
```typescript
|
||||
describe('UserService', () => {
|
||||
describe('when getting user by ID', () => {
|
||||
it('should return user data for valid ID', async () => {
|
||||
// Arrange
|
||||
const userId = '123';
|
||||
const expectedUser = { id: '123', name: 'John' };
|
||||
mockUserRepository.findById.mockResolvedValue(expectedUser);
|
||||
|
||||
// Act
|
||||
const result = await userService.getUserById(userId);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(expectedUser);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## React Component Testing
|
||||
```typescript
|
||||
// ✅ Test user behavior, not implementation
|
||||
describe('LoginForm', () => {
|
||||
it('should display error message for invalid credentials', async () => {
|
||||
const mockOnSubmit = jest.fn().mockRejectedValue(new Error('Invalid credentials'));
|
||||
render(<LoginForm onSubmit={mockOnSubmit} />);
|
||||
|
||||
await user.type(screen.getByLabelText(/email/i), 'invalid@example.com');
|
||||
await user.type(screen.getByLabelText(/password/i), 'wrongpassword');
|
||||
await user.click(screen.getByRole('button', { name: /sign in/i }));
|
||||
|
||||
expect(await screen.findByText(/invalid credentials/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Mocking Patterns
|
||||
```typescript
|
||||
// ✅ Service mocking
|
||||
const mockEmailService = {
|
||||
sendEmail: jest.fn().mockResolvedValue({ success: true }),
|
||||
validateEmail: jest.fn().mockReturnValue(true),
|
||||
};
|
||||
|
||||
// ✅ Test data factories
|
||||
const createTestUser = (overrides = {}) => ({
|
||||
id: uuid(),
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Principles
|
||||
- **Test behavior, not implementation** - Focus on what users see/do
|
||||
- **Use descriptive test names** - "should [behavior] when [condition]"
|
||||
- **Query by user-visible elements** - text, roles, labels over test IDs
|
||||
- **Keep tests isolated** - Independent and repeatable
|
||||
- **70% unit, 20% integration, 10% E2E** - Test pyramid
|
||||
|
||||
## Common Patterns
|
||||
```typescript
|
||||
// Async testing
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Loading...')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// User interactions
|
||||
await user.click(screen.getByRole('button'));
|
||||
await user.type(screen.getByLabelText(/search/i), 'query');
|
||||
|
||||
// API integration tests
|
||||
const response = await request(app)
|
||||
.post('/api/users')
|
||||
.send(userData)
|
||||
.expect(201);
|
||||
```
|
||||
@@ -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
|
||||
@@ -1,291 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Translation Guidelines
|
||||
|
||||
## Internationalization (i18n) Overview
|
||||
|
||||
### Supported Languages
|
||||
- English (en) - Primary language
|
||||
- French (fr) - Secondary language
|
||||
- German (de) - Planned
|
||||
- Spanish (es) - Planned
|
||||
- Additional languages based on community contributions
|
||||
|
||||
### i18n Architecture
|
||||
- Use react-i18next for React components
|
||||
- Store translations in JSON files
|
||||
- Implement namespace-based organization
|
||||
- Support for interpolation and pluralization
|
||||
|
||||
## File Structure
|
||||
|
||||
### Translation Files
|
||||
```
|
||||
src/locales/
|
||||
├── en/ # English translations
|
||||
│ ├── common.json # Common UI strings
|
||||
│ ├── auth.json # Authentication strings
|
||||
│ ├── dashboard.json # Dashboard specific
|
||||
│ ├── forms.json # Form labels and validation
|
||||
│ └── errors.json # Error messages
|
||||
├── fr/ # French translations
|
||||
│ ├── common.json
|
||||
│ ├── auth.json
|
||||
│ └── ...
|
||||
└── index.ts # i18n configuration
|
||||
```
|
||||
|
||||
### Translation Keys
|
||||
- Use nested objects for organization
|
||||
- Follow consistent naming patterns
|
||||
- Include context in key names
|
||||
```json
|
||||
{
|
||||
"auth": {
|
||||
"login": {
|
||||
"title": "Sign In",
|
||||
"email": "Email Address",
|
||||
"password": "Password",
|
||||
"submit": "Sign In",
|
||||
"forgotPassword": "Forgot Password?"
|
||||
},
|
||||
"register": {
|
||||
"title": "Create Account",
|
||||
"confirmPassword": "Confirm Password"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Translation Implementation
|
||||
|
||||
### React Components
|
||||
- Use useTranslation hook
|
||||
- Specify namespaces for better organization
|
||||
- Handle loading states properly
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const LoginForm = () => {
|
||||
const { t } = useTranslation('auth');
|
||||
|
||||
return (
|
||||
<form>
|
||||
<h1>{t('login.title')}</h1>
|
||||
<input
|
||||
placeholder={t('login.email')}
|
||||
type="email"
|
||||
/>
|
||||
<input
|
||||
placeholder={t('login.password')}
|
||||
type="password"
|
||||
/>
|
||||
<button type="submit">
|
||||
{t('login.submit')}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Interpolation
|
||||
- Use interpolation for dynamic content
|
||||
- Pass variables through t() function
|
||||
- Keep interpolation simple and readable
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
const WelcomeMessage = ({ userName }: { userName: string }) => {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
return (
|
||||
<h1>{t('welcome.message', { name: userName })}</h1>
|
||||
);
|
||||
};
|
||||
|
||||
// Translation file
|
||||
{
|
||||
"welcome": {
|
||||
"message": "Welcome back, {{name}}!"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pluralization
|
||||
- Handle singular/plural forms correctly
|
||||
- Use count-based pluralization
|
||||
- Support different plural rules per language
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
const ItemCount = ({ count }: { count: number }) => {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
return (
|
||||
<span>{t('items.count', { count })}</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Translation file
|
||||
{
|
||||
"items": {
|
||||
"count_one": "{{count}} item",
|
||||
"count_other": "{{count}} items"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Translation Management
|
||||
|
||||
### Adding New Strings
|
||||
1. Add English translation first
|
||||
2. Use descriptive keys that indicate context
|
||||
3. Include comments for translators when needed
|
||||
4. Test with long translations to ensure UI flexibility
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"profile": {
|
||||
// Displayed in user profile header
|
||||
"displayName": "Display Name",
|
||||
// Used in forms when editing profile
|
||||
"editDisplayName": "Edit Display Name",
|
||||
// Confirmation message after profile update
|
||||
"updateSuccess": "Profile updated successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Translation Validation
|
||||
- Use TypeScript for translation key validation
|
||||
- Implement automated checks for missing translations
|
||||
- Validate interpolation parameters
|
||||
```typescript
|
||||
// ✅ Correct - Type-safe translations
|
||||
type TranslationKey =
|
||||
| 'auth.login.title'
|
||||
| 'auth.login.email'
|
||||
| 'auth.login.password'
|
||||
| 'common.welcome.message';
|
||||
|
||||
const t = (key: TranslationKey, options?: any) => {
|
||||
// Translation implementation
|
||||
};
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Key Naming
|
||||
- Use descriptive, hierarchical keys
|
||||
- Avoid abbreviations
|
||||
- Group related translations
|
||||
- Keep keys consistent across languages
|
||||
```json
|
||||
// ✅ Correct
|
||||
{
|
||||
"dashboard": {
|
||||
"header": {
|
||||
"title": "Dashboard",
|
||||
"subtitle": "Welcome to your workspace"
|
||||
},
|
||||
"actions": {
|
||||
"createNew": "Create New",
|
||||
"refresh": "Refresh Data",
|
||||
"export": "Export"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ Incorrect
|
||||
{
|
||||
"dash_title": "Dashboard",
|
||||
"newBtn": "New",
|
||||
"refreshData": "Refresh"
|
||||
}
|
||||
```
|
||||
|
||||
### String Guidelines
|
||||
- Write clear, concise text
|
||||
- Use consistent terminology
|
||||
- Consider character limits for UI elements
|
||||
- Avoid concatenating translated strings
|
||||
```json
|
||||
// ✅ Correct
|
||||
{
|
||||
"user": {
|
||||
"status": {
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"away": "Away"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ Incorrect - Don't concatenate
|
||||
{
|
||||
"user": {
|
||||
"statusPrefix": "User is ",
|
||||
"statusOnline": "online"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Context Information
|
||||
- Provide context for translators
|
||||
- Include character limits when relevant
|
||||
- Explain when/where text appears
|
||||
- Note any technical constraints
|
||||
```json
|
||||
{
|
||||
"button": {
|
||||
// Primary action button, max 20 characters
|
||||
"save": "Save Changes",
|
||||
// Secondary button in modal footer
|
||||
"cancel": "Cancel",
|
||||
// Destructive action, should sound cautious
|
||||
"delete": "Delete Permanently"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### Development Process
|
||||
1. Develop features with English translations
|
||||
2. Use placeholder keys during development
|
||||
3. Finalize translation keys before feature completion
|
||||
4. Add translations to all supported languages
|
||||
5. Test with different language strings
|
||||
|
||||
### Translation Updates
|
||||
1. Create translation tasks for new features
|
||||
2. Provide context and screenshots to translators
|
||||
3. Review translations for consistency
|
||||
4. Test UI with translated strings
|
||||
5. Update documentation when needed
|
||||
|
||||
### Quality Assurance
|
||||
- Review translations in context
|
||||
- Test with longest expected translations
|
||||
- Verify formatting with interpolation
|
||||
- Check for cultural appropriateness
|
||||
- Ensure accessibility with screen readers
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Regular Tasks
|
||||
- Review and update outdated translations
|
||||
- Check for unused translation keys
|
||||
- Maintain consistency across languages
|
||||
- Update translation documentation
|
||||
- Monitor for missing translations in new features
|
||||
|
||||
### Tools and Automation
|
||||
- Use automated translation validation
|
||||
- Implement missing translation detection
|
||||
- Set up continuous integration checks
|
||||
- Maintain translation coverage reports
|
||||
- Use translation management platforms when needed
|
||||
@@ -1,14 +1,3 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
---
|
||||
description: TypeScript best practices and conventions for the Twenty codebase, including strict typing, naming conventions, and type safety guidelines.
|
||||
globs: ["**/*.ts", "**/*.tsx"]
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# TypeScript Guidelines
|
||||
|
||||
## Core TypeScript Principles
|
||||
@@ -180,4 +169,4 @@ Twenty enforces strict TypeScript usage to ensure type safety and maintainable c
|
||||
type NonNullableProperties<T> = {
|
||||
[P in keyof T]: NonNullable<T[P]>;
|
||||
};
|
||||
```
|
||||
```
|
||||
@@ -2,4 +2,3 @@
|
||||
.env
|
||||
node_modules
|
||||
.nx/cache
|
||||
packages/twenty-server/.env
|
||||
|
||||
@@ -10,8 +10,7 @@ inputs:
|
||||
configuration:
|
||||
required: false
|
||||
default: 'ci'
|
||||
args:
|
||||
required: false
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
@@ -19,4 +18,4 @@ runs:
|
||||
uses: nrwl/nx-set-shas@v4
|
||||
- name: Run affected command
|
||||
shell: bash
|
||||
run: npx nx affected --nxBail --configuration=${{ inputs.configuration }} -t=${{ inputs.tasks }} --parallel=${{ inputs.parallel }} --exclude='*,!tag:${{ inputs.tag }}' ${{ inputs.args }}
|
||||
run: npx nx affected --nxBail --configuration=${{ inputs.configuration }} -t=${{ inputs.tasks }} --parallel=${{ inputs.parallel }} --exclude='*,!tag:${{ inputs.tag }}'
|
||||
@@ -2,7 +2,7 @@ name: Yarn Install
|
||||
inputs:
|
||||
node-version:
|
||||
required: false
|
||||
default: '22'
|
||||
default: '18'
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
|
||||
@@ -1,777 +0,0 @@
|
||||
name: GraphQL and OpenAPI Breaking Changes Detection
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, edited]
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
MAIN_SERVER_PORT: 3000
|
||||
CURRENT_SERVER_PORT: 3002
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
checks: write
|
||||
|
||||
jobs:
|
||||
changed-files-check:
|
||||
uses: ./.github/workflows/changed-files.yaml
|
||||
with:
|
||||
files: |
|
||||
package.json
|
||||
packages/twenty-server/**
|
||||
packages/twenty-emails/**
|
||||
packages/twenty-shared/**
|
||||
|
||||
api-breaking-changes:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 45
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
env:
|
||||
NX_REJECT_UNKNOWN_LOCAL_CACHE: 0
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
env:
|
||||
PGUSER_SUPERUSER: postgres
|
||||
PGPASSWORD_SUPERUSER: postgres
|
||||
ALLOW_NOSSL: 'true'
|
||||
SPILO_PROVIDER: 'local'
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server:latest
|
||||
env:
|
||||
CLICKHOUSE_PASSWORD: clickhousePassword
|
||||
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
|
||||
ports:
|
||||
- 8123:8123
|
||||
- 9000:9000
|
||||
options: >-
|
||||
--health-cmd "clickhouse-client --host=localhost --port=9000 --user=default --password=clickhousePassword --query='SELECT 1'"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Checkout current branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
|
||||
- name: Build shared dependencies
|
||||
run: |
|
||||
npx nx build twenty-shared
|
||||
npx nx build twenty-emails
|
||||
|
||||
- name: Build current branch server
|
||||
run: npx nx build twenty-server
|
||||
|
||||
- name: Setup databases
|
||||
run: |
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "current_branch";'
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "main_branch";'
|
||||
|
||||
- name: Run ClickHouse migrations
|
||||
run: npx nx clickhouse:migrate twenty-server
|
||||
env:
|
||||
CLICKHOUSE_URL: http://default:clickhousePassword@localhost:8123/twenty
|
||||
CLICKHOUSE_PASSWORD: clickhousePassword
|
||||
|
||||
- name: Setup current branch database
|
||||
run: |
|
||||
npx nx reset:env twenty-server
|
||||
# Function to set or update environment variable
|
||||
set_env_var() {
|
||||
local var_name="$1"
|
||||
local var_value="$2"
|
||||
local env_file="packages/twenty-server/.env"
|
||||
|
||||
if grep -q "^${var_name}=" "$env_file"; then
|
||||
sed -i "s|^${var_name}=.*|${var_name}=${var_value}|" "$env_file"
|
||||
else
|
||||
echo "${var_name}=${var_value}" >> "$env_file"
|
||||
fi
|
||||
}
|
||||
|
||||
set_env_var "PG_DATABASE_URL" "postgres://postgres:postgres@localhost:5432/current_branch"
|
||||
set_env_var "NODE_PORT" "${{ env.CURRENT_SERVER_PORT }}"
|
||||
set_env_var "REDIS_URL" "redis://localhost:6379"
|
||||
set_env_var "CLICKHOUSE_URL" "http://default:clickhousePassword@localhost:8123/twenty"
|
||||
set_env_var "CLICKHOUSE_PASSWORD" "clickhousePassword"
|
||||
|
||||
npx nx run twenty-server:database:init:prod
|
||||
npx nx run twenty-server:database:migrate:prod
|
||||
|
||||
- name: Seed current branch database with test data
|
||||
run: |
|
||||
npx nx command-no-deps twenty-server -- workspace:seed:dev
|
||||
|
||||
- name: Start current branch server in background
|
||||
run: |
|
||||
echo "=== Current branch .env file contents ==="
|
||||
cat packages/twenty-server/.env
|
||||
echo "=== Starting current branch server ==="
|
||||
nohup npx nx run twenty-server:start:prod > /tmp/current-server.log 2>&1 &
|
||||
echo $! > /tmp/current-server.pid
|
||||
echo "Current server PID: $(cat /tmp/current-server.pid)"
|
||||
|
||||
- name: Wait for current branch server to be ready
|
||||
run: |
|
||||
echo "Waiting for current branch server to start..."
|
||||
timeout=300
|
||||
interval=5
|
||||
elapsed=0
|
||||
|
||||
while [ $elapsed -lt $timeout ]; do
|
||||
if curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/graphql" > /dev/null 2>&1 && \
|
||||
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/core" > /dev/null 2>&1; then
|
||||
echo "Current branch server is ready!"
|
||||
break
|
||||
fi
|
||||
|
||||
echo "Current branch server not ready yet, waiting ${interval}s..."
|
||||
sleep $interval
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
|
||||
if [ $elapsed -ge $timeout ]; then
|
||||
echo "Timeout waiting for current branch server to start"
|
||||
echo "Current server log:"
|
||||
cat /tmp/current-server.log || echo "No current server log found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Download GraphQL and REST responses from current branch
|
||||
run: |
|
||||
# Admin token from jest-integration.config.ts
|
||||
ADMIN_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwiaWF0IjoxNzM5NTQ3NjYxLCJleHAiOjMzMjk3MTQ3NjYxfQ.fbOM9yhr3jWDicPZ1n771usUURiPGmNdeFApsgrbxOw"
|
||||
|
||||
# Load introspection query from file
|
||||
INTROSPECTION_QUERY=$(cat packages/twenty-utils/graphql-introspection-query.graphql)
|
||||
|
||||
# Prepare the query payload
|
||||
QUERY_PAYLOAD=$(echo "$INTROSPECTION_QUERY" | tr '\n' ' ' | sed 's/"/\\"/g')
|
||||
|
||||
echo "Downloading GraphQL schema from current server..."
|
||||
curl -X POST "http://localhost:${{ env.CURRENT_SERVER_PORT }}/graphql" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
|
||||
-d "{\"query\":\"${QUERY_PAYLOAD}\"}" \
|
||||
-o current-schema-introspection.json \
|
||||
-w "HTTP Status: %{http_code}\n" \
|
||||
-s
|
||||
|
||||
echo "Downloading GraphQL metadata schema from current server..."
|
||||
curl -X POST "http://localhost:${{ env.CURRENT_SERVER_PORT }}/metadata" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
|
||||
-d "{\"query\":\"${QUERY_PAYLOAD}\"}" \
|
||||
-o current-metadata-schema-introspection.json \
|
||||
-w "HTTP Status: %{http_code}\n" \
|
||||
-s
|
||||
|
||||
# Download current branch OpenAPI specs
|
||||
echo "Downloading OpenAPI specifications from current server..."
|
||||
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/core" \
|
||||
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
|
||||
-o current-rest-api.json \
|
||||
-w "HTTP Status: %{http_code}\n"
|
||||
|
||||
curl -s "http://localhost:${{ env.CURRENT_SERVER_PORT }}/rest/open-api/metadata" \
|
||||
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
|
||||
-o current-rest-metadata-api.json \
|
||||
-w "HTTP Status: %{http_code}\n"
|
||||
|
||||
# Verify the downloads
|
||||
echo "Current branch files downloaded:"
|
||||
ls -la current-*
|
||||
|
||||
|
||||
- name: Preserve current branch files
|
||||
run: |
|
||||
# Create a temp directory to store current branch files
|
||||
mkdir -p /tmp/current-branch-files
|
||||
|
||||
# Move current branch files to temp directory
|
||||
mv current-* /tmp/current-branch-files/ 2>/dev/null || echo "No current-* files to preserve"
|
||||
|
||||
echo "Preserved current branch files for later restoration"
|
||||
|
||||
- name: Stop current branch server
|
||||
run: |
|
||||
if [ -f /tmp/current-server.pid ]; then
|
||||
echo "Stopping current branch server..."
|
||||
kill $(cat /tmp/current-server.pid) || true
|
||||
# Wait a bit for graceful shutdown
|
||||
sleep 5
|
||||
# Force kill if still running
|
||||
kill -9 $(cat /tmp/current-server.pid) 2>/dev/null || true
|
||||
rm -f /tmp/current-server.pid
|
||||
fi
|
||||
|
||||
- name: Checkout main branch
|
||||
run: |
|
||||
git stash
|
||||
git checkout origin/main
|
||||
git clean -fd
|
||||
|
||||
- name: Install dependencies for main branch
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
|
||||
- name: Build main branch dependencies
|
||||
run: |
|
||||
npx nx build twenty-shared
|
||||
npx nx build twenty-emails
|
||||
|
||||
- name: Build main branch server
|
||||
run: npx nx build twenty-server
|
||||
|
||||
- name: Setup main branch database
|
||||
run: |
|
||||
# Function to set or update environment variable
|
||||
set_env_var() {
|
||||
local var_name="$1"
|
||||
local var_value="$2"
|
||||
local env_file="packages/twenty-server/.env"
|
||||
|
||||
if grep -q "^${var_name}=" "$env_file"; then
|
||||
sed -i "s|^${var_name}=.*|${var_name}=${var_value}|" "$env_file"
|
||||
else
|
||||
echo "${var_name}=${var_value}" >> "$env_file"
|
||||
fi
|
||||
}
|
||||
|
||||
set_env_var "PG_DATABASE_URL" "postgres://postgres:postgres@localhost:5432/main_branch"
|
||||
set_env_var "NODE_PORT" "${{ env.MAIN_SERVER_PORT }}"
|
||||
|
||||
npx nx run twenty-server:database:init:prod
|
||||
npx nx run twenty-server:database:migrate:prod
|
||||
|
||||
- name: Seed main branch database with test data
|
||||
run: |
|
||||
npx nx command-no-deps twenty-server -- workspace:seed:dev
|
||||
|
||||
- name: Start main branch server in background
|
||||
run: |
|
||||
echo "=== Main branch .env file contents ==="
|
||||
cat packages/twenty-server/.env
|
||||
echo "=== Starting main branch server ==="
|
||||
nohup npx nx run twenty-server:start:prod > /tmp/main-server.log 2>&1 &
|
||||
echo $! > /tmp/main-server.pid
|
||||
echo "Main server PID: $(cat /tmp/main-server.pid)"
|
||||
|
||||
- name: Wait for main branch server to be ready
|
||||
run: |
|
||||
echo "Waiting for main branch server to start..."
|
||||
timeout=300
|
||||
interval=5
|
||||
elapsed=0
|
||||
|
||||
while [ $elapsed -lt $timeout ]; do
|
||||
if curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/graphql" > /dev/null 2>&1 && \
|
||||
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/core" > /dev/null 2>&1; then
|
||||
echo "Main branch server is ready!"
|
||||
break
|
||||
fi
|
||||
|
||||
echo "Main branch server not ready yet, waiting ${interval}s..."
|
||||
sleep $interval
|
||||
elapsed=$((elapsed + interval))
|
||||
done
|
||||
|
||||
if [ $elapsed -ge $timeout ]; then
|
||||
echo "Timeout waiting for main branch server to start"
|
||||
echo "Main server log:"
|
||||
cat /tmp/main-server.log || echo "No main server log found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Download GraphQL and REST responses from main branch
|
||||
run: |
|
||||
# Admin token from jest-integration.config.ts
|
||||
ADMIN_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwiaWF0IjoxNzM5NTQ3NjYxLCJleHAiOjMzMjk3MTQ3NjYxfQ.fbOM9yhr3jWDicPZ1n771usUURiPGmNdeFApsgrbxOw"
|
||||
|
||||
# Load introspection query from file
|
||||
INTROSPECTION_QUERY=$(cat packages/twenty-utils/graphql-introspection-query.graphql)
|
||||
|
||||
# Prepare the query payload
|
||||
QUERY_PAYLOAD=$(echo "$INTROSPECTION_QUERY" | tr '\n' ' ' | sed 's/"/\\"/g')
|
||||
|
||||
echo "Downloading GraphQL schema from main server..."
|
||||
curl -X POST "http://localhost:${{ env.MAIN_SERVER_PORT }}/graphql" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
|
||||
-d "{\"query\":\"${QUERY_PAYLOAD}\"}" \
|
||||
-o main-schema-introspection.json \
|
||||
-w "HTTP Status: %{http_code}\n" \
|
||||
-s
|
||||
|
||||
echo "Downloading GraphQL metadata schema from main server..."
|
||||
curl -X POST "http://localhost:${{ env.MAIN_SERVER_PORT }}/metadata" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
|
||||
-d "{\"query\":\"${QUERY_PAYLOAD}\"}" \
|
||||
-o main-metadata-schema-introspection.json \
|
||||
-w "HTTP Status: %{http_code}\n" \
|
||||
-s
|
||||
|
||||
# Download main branch OpenAPI specs
|
||||
echo "Downloading OpenAPI specifications from main server..."
|
||||
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/core" \
|
||||
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
|
||||
-o main-rest-api.json \
|
||||
-w "HTTP Status: %{http_code}\n"
|
||||
|
||||
curl -s "http://localhost:${{ env.MAIN_SERVER_PORT }}/rest/open-api/metadata" \
|
||||
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
|
||||
-o main-rest-metadata-api.json \
|
||||
-w "HTTP Status: %{http_code}\n"
|
||||
|
||||
# Verify the downloads
|
||||
echo "Main branch files downloaded:"
|
||||
ls -la main-*
|
||||
|
||||
|
||||
- name: Restore current branch files
|
||||
run: |
|
||||
# Move current branch files back to working directory
|
||||
mv /tmp/current-branch-files/* . 2>/dev/null || echo "No files to restore"
|
||||
|
||||
# Verify all files are present
|
||||
echo "All API files restored:"
|
||||
ls -la current-* main-* 2>/dev/null || echo "Some files may be missing"
|
||||
|
||||
# Clean up temp directory
|
||||
rm -rf /tmp/current-branch-files
|
||||
|
||||
- name: Install OpenAPI Diff Tool
|
||||
run: |
|
||||
# Using the Java-based OpenAPITools/openapi-diff via Docker
|
||||
echo "Using OpenAPITools/openapi-diff via Docker"
|
||||
|
||||
- name: Generate GraphQL Schema Diff Reports
|
||||
run: |
|
||||
echo "=== INSTALLING GRAPHQL INSPECTOR CLI ==="
|
||||
npm install -g @graphql-inspector/cli
|
||||
|
||||
echo "=== GENERATING GRAPHQL DIFF REPORTS ==="
|
||||
|
||||
# Check if GraphQL schema has changes
|
||||
echo "Checking GraphQL schema for changes..."
|
||||
if graphql-inspector diff main-schema-introspection.json current-schema-introspection.json >/dev/null 2>&1; then
|
||||
echo "✅ No changes in GraphQL schema"
|
||||
# Don't create a diff file for no changes
|
||||
else
|
||||
echo "⚠️ Changes detected in GraphQL schema, generating report..."
|
||||
echo "# GraphQL Schema Changes" > graphql-schema-diff.md
|
||||
echo "" >> graphql-schema-diff.md
|
||||
graphql-inspector diff main-schema-introspection.json current-schema-introspection.json >> graphql-schema-diff.md 2>&1 || {
|
||||
echo "⚠️ **Breaking changes or errors detected in GraphQL schema**" >> graphql-schema-diff.md
|
||||
echo "" >> graphql-schema-diff.md
|
||||
echo "\`\`\`" >> graphql-schema-diff.md
|
||||
graphql-inspector diff main-schema-introspection.json current-schema-introspection.json 2>&1 >> graphql-schema-diff.md || echo "Error generating diff" >> graphql-schema-diff.md
|
||||
echo "\`\`\`" >> graphql-schema-diff.md
|
||||
}
|
||||
fi
|
||||
|
||||
# Check if GraphQL metadata schema has changes
|
||||
echo "Checking GraphQL metadata schema for changes..."
|
||||
if graphql-inspector diff main-metadata-schema-introspection.json current-metadata-schema-introspection.json >/dev/null 2>&1; then
|
||||
echo "✅ No changes in GraphQL metadata schema"
|
||||
# Don't create a diff file for no changes
|
||||
else
|
||||
echo "⚠️ Changes detected in GraphQL metadata schema, generating report..."
|
||||
echo "# GraphQL Metadata Schema Changes" > graphql-metadata-diff.md
|
||||
echo "" >> graphql-metadata-diff.md
|
||||
graphql-inspector diff main-metadata-schema-introspection.json current-metadata-schema-introspection.json >> graphql-metadata-diff.md 2>&1 || {
|
||||
echo "⚠️ **Breaking changes or errors detected in GraphQL metadata schema**" >> graphql-metadata-diff.md
|
||||
echo "" >> graphql-metadata-diff.md
|
||||
echo "\`\`\`" >> graphql-metadata-diff.md
|
||||
graphql-inspector diff main-metadata-schema-introspection.json current-metadata-schema-introspection.json 2>&1 >> graphql-metadata-diff.md || echo "Error generating diff" >> graphql-metadata-diff.md
|
||||
echo "\`\`\`" >> graphql-metadata-diff.md
|
||||
}
|
||||
fi
|
||||
|
||||
# Show summary
|
||||
echo "Generated diff files:"
|
||||
ls -la *-diff.md 2>/dev/null || echo "No diff files generated (no changes detected)"
|
||||
|
||||
- name: Check REST API Breaking Changes
|
||||
run: |
|
||||
echo "=== CHECKING REST API FOR BREAKING CHANGES ==="
|
||||
|
||||
# Use the Java-based openapi-diff via Docker
|
||||
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest \
|
||||
--json /specs/rest-api-diff.json \
|
||||
/specs/main-rest-api.json /specs/current-rest-api.json || echo "OpenAPI diff completed with exit code $?"
|
||||
|
||||
# Check if the output file was created and is valid JSON
|
||||
if [ -f "rest-api-diff.json" ] && jq empty rest-api-diff.json 2>/dev/null; then
|
||||
# Check for breaking changes using Java openapi-diff JSON structure
|
||||
incompatible=$(jq -r '.incompatible // false' rest-api-diff.json)
|
||||
different=$(jq -r '.different // false' rest-api-diff.json)
|
||||
|
||||
# Count changes
|
||||
new_endpoints=$(jq -r '.newEndpoints | length' rest-api-diff.json 2>/dev/null || echo "0")
|
||||
missing_endpoints=$(jq -r '.missingEndpoints | length' rest-api-diff.json 2>/dev/null || echo "0")
|
||||
changed_operations=$(jq -r '.changedOperations | length' rest-api-diff.json 2>/dev/null || echo "0")
|
||||
|
||||
if [ "$incompatible" = "true" ]; then
|
||||
echo "❌ Breaking changes detected in REST API"
|
||||
|
||||
# Generate breaking changes report
|
||||
echo "# REST API Breaking Changes" > rest-api-diff.md
|
||||
echo "" >> rest-api-diff.md
|
||||
echo "⚠️ **Breaking changes detected that may affect existing API consumers**" >> rest-api-diff.md
|
||||
echo "" >> rest-api-diff.md
|
||||
|
||||
# Parse and format the changes from Java openapi-diff
|
||||
jq -r '
|
||||
if (.missingEndpoints | length) > 0 then
|
||||
"## 🚨 Removed Endpoints (" + (.missingEndpoints | length | tostring) + ")\n" +
|
||||
(.missingEndpoints | map("- **" + .method + " " + .pathUrl + "**: " + (.summary // "")) | join("\n"))
|
||||
else "" end,
|
||||
if (.changedOperations | length) > 0 then
|
||||
"\n## ⚠️ Changed Operations (" + (.changedOperations | length | tostring) + ")\n" +
|
||||
(.changedOperations | map("- **" + .method + " " + .pathUrl + "**: " + (.summary // "Modified operation")) | join("\n"))
|
||||
else "" end,
|
||||
if (.newEndpoints | length) > 0 then
|
||||
"\n## ✅ New Endpoints (" + (.newEndpoints | length | tostring) + ")\n" +
|
||||
(.newEndpoints | map("- " + .method + " " + .pathUrl + ": " + (.summary // "")) | join("\n"))
|
||||
else "" end
|
||||
' rest-api-diff.json >> rest-api-diff.md
|
||||
|
||||
elif [ "$different" = "true" ]; then
|
||||
echo "📝 Non-breaking changes detected ($new_endpoints new endpoints, $missing_endpoints removed, $changed_operations changed)"
|
||||
|
||||
# Generate non-breaking changes report
|
||||
echo "# REST API Changes" > rest-api-diff.md
|
||||
echo "" >> rest-api-diff.md
|
||||
echo "## Summary" >> rest-api-diff.md
|
||||
|
||||
jq -r '
|
||||
if (.newEndpoints | length) > 0 then
|
||||
"### ✅ New Endpoints (" + (.newEndpoints | length | tostring) + ")\n" +
|
||||
(.newEndpoints | map("- " + .method + " " + .pathUrl + ": " + (.summary // "")) | join("\n"))
|
||||
else "" end,
|
||||
if (.changedOperations | length) > 0 then
|
||||
"\n### 🔄 Changed Operations (" + (.changedOperations | length | tostring) + ")\n" +
|
||||
(.changedOperations | map("- " + .method + " " + .pathUrl + ": " + (.summary // "Modified operation")) | join("\n"))
|
||||
else "" end
|
||||
' rest-api-diff.json >> rest-api-diff.md
|
||||
else
|
||||
echo "✅ No changes detected in REST API"
|
||||
# Don't create diff file for no changes
|
||||
fi
|
||||
else
|
||||
echo "⚠️ OpenAPI diff tool could not process the files"
|
||||
|
||||
echo "# REST API Analysis Error" > rest-api-diff.md
|
||||
echo "" >> rest-api-diff.md
|
||||
echo "⚠️ **Error occurred while analyzing REST API changes**" >> rest-api-diff.md
|
||||
echo "" >> rest-api-diff.md
|
||||
echo "## Error Output" >> rest-api-diff.md
|
||||
echo "\`\`\`" >> rest-api-diff.md
|
||||
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest /specs/main-rest-api.json /specs/current-rest-api.json 2>&1 >> rest-api-diff.md || echo "Could not capture error output"
|
||||
echo "\`\`\`" >> rest-api-diff.md
|
||||
|
||||
# Don't fail the workflow for tool errors
|
||||
echo "::warning::REST API analysis tool error - continuing workflow"
|
||||
fi
|
||||
|
||||
- name: Check REST Metadata API Breaking Changes
|
||||
run: |
|
||||
echo "=== CHECKING REST METADATA API FOR BREAKING CHANGES ==="
|
||||
|
||||
# Use the Java-based openapi-diff for metadata API as well
|
||||
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest \
|
||||
--json /specs/rest-metadata-api-diff.json \
|
||||
/specs/main-rest-metadata-api.json /specs/current-rest-metadata-api.json || echo "OpenAPI diff completed with exit code $?"
|
||||
|
||||
# Check if the output file was created and is valid JSON
|
||||
if [ -f "rest-metadata-api-diff.json" ] && jq empty rest-metadata-api-diff.json 2>/dev/null; then
|
||||
# Check for breaking changes using Java openapi-diff JSON structure
|
||||
incompatible=$(jq -r '.incompatible // false' rest-metadata-api-diff.json)
|
||||
different=$(jq -r '.different // false' rest-metadata-api-diff.json)
|
||||
|
||||
# Count changes
|
||||
new_endpoints=$(jq -r '.newEndpoints | length' rest-metadata-api-diff.json 2>/dev/null || echo "0")
|
||||
missing_endpoints=$(jq -r '.missingEndpoints | length' rest-metadata-api-diff.json 2>/dev/null || echo "0")
|
||||
changed_operations=$(jq -r '.changedOperations | length' rest-metadata-api-diff.json 2>/dev/null || echo "0")
|
||||
|
||||
if [ "$incompatible" = "true" ]; then
|
||||
echo "❌ Breaking changes detected in REST Metadata API"
|
||||
|
||||
# Generate breaking changes report
|
||||
echo "# REST Metadata API Breaking Changes" > rest-metadata-api-diff.md
|
||||
echo "" >> rest-metadata-api-diff.md
|
||||
echo "⚠️ **Breaking changes detected that may affect existing API consumers**" >> rest-metadata-api-diff.md
|
||||
echo "" >> rest-metadata-api-diff.md
|
||||
|
||||
# Parse and format the changes from Java openapi-diff
|
||||
jq -r '
|
||||
if (.missingEndpoints | length) > 0 then
|
||||
"## 🚨 Removed Endpoints (" + (.missingEndpoints | length | tostring) + ")\n" +
|
||||
(.missingEndpoints | map("- **" + .method + " " + .pathUrl + "**: " + (.summary // "")) | join("\n"))
|
||||
else "" end,
|
||||
if (.changedOperations | length) > 0 then
|
||||
"\n## ⚠️ Changed Operations (" + (.changedOperations | length | tostring) + ")\n" +
|
||||
(.changedOperations | map("- **" + .method + " " + .pathUrl + "**: " + (.summary // "Modified operation")) | join("\n"))
|
||||
else "" end,
|
||||
if (.newEndpoints | length) > 0 then
|
||||
"\n## ✅ New Endpoints (" + (.newEndpoints | length | tostring) + ")\n" +
|
||||
(.newEndpoints | map("- " + .method + " " + .pathUrl + ": " + (.summary // "")) | join("\n"))
|
||||
else "" end
|
||||
' rest-metadata-api-diff.json >> rest-metadata-api-diff.md
|
||||
|
||||
elif [ "$different" = "true" ]; then
|
||||
echo "📝 Non-breaking changes detected ($new_endpoints new endpoints, $missing_endpoints removed, $changed_operations changed)"
|
||||
|
||||
# Generate non-breaking changes report
|
||||
echo "# REST Metadata API Changes" > rest-metadata-api-diff.md
|
||||
echo "" >> rest-metadata-api-diff.md
|
||||
echo "## Summary" >> rest-metadata-api-diff.md
|
||||
|
||||
jq -r '
|
||||
if (.newEndpoints | length) > 0 then
|
||||
"### ✅ New Endpoints (" + (.newEndpoints | length | tostring) + ")\n" +
|
||||
(.newEndpoints | map("- " + .method + " " + .pathUrl + ": " + (.summary // "")) | join("\n"))
|
||||
else "" end,
|
||||
if (.changedOperations | length) > 0 then
|
||||
"\n### 🔄 Changed Operations (" + (.changedOperations | length | tostring) + ")\n" +
|
||||
(.changedOperations | map("- " + .method + " " + .pathUrl + ": " + (.summary // "Modified operation")) | join("\n"))
|
||||
else "" end
|
||||
' rest-metadata-api-diff.json >> rest-metadata-api-diff.md
|
||||
else
|
||||
echo "✅ No changes detected in REST Metadata API"
|
||||
# Don't create diff file for no changes
|
||||
fi
|
||||
else
|
||||
echo "⚠️ OpenAPI diff tool could not process the metadata API files"
|
||||
|
||||
echo "# REST Metadata API Analysis Error" > rest-metadata-api-diff.md
|
||||
echo "" >> rest-metadata-api-diff.md
|
||||
echo "⚠️ **Error occurred while analyzing REST Metadata API changes**" >> rest-metadata-api-diff.md
|
||||
echo "" >> rest-metadata-api-diff.md
|
||||
echo "## Error Output" >> rest-metadata-api-diff.md
|
||||
echo "\`\`\`" >> rest-metadata-api-diff.md
|
||||
docker run --rm -v "$(pwd):/specs" openapitools/openapi-diff:latest /specs/main-rest-metadata-api.json /specs/current-rest-metadata-api.json 2>&1 >> rest-metadata-api-diff.md || echo "Could not capture error output"
|
||||
echo "\`\`\`" >> rest-metadata-api-diff.md
|
||||
|
||||
# Don't fail the workflow for tool errors
|
||||
echo "::warning::REST Metadata API analysis tool error - continuing workflow"
|
||||
fi
|
||||
|
||||
- name: Comment API Changes on PR
|
||||
if: always()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
let hasChanges = false;
|
||||
let comment = '';
|
||||
|
||||
try {
|
||||
if (fs.existsSync('graphql-schema-diff.md')) {
|
||||
const graphqlDiff = fs.readFileSync('graphql-schema-diff.md', 'utf8');
|
||||
if (graphqlDiff.trim()) {
|
||||
if (!hasChanges) {
|
||||
comment = '## 📊 API Changes Report\n\n';
|
||||
hasChanges = true;
|
||||
}
|
||||
comment += '### GraphQL Schema Changes\n' + graphqlDiff + '\n\n';
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync('graphql-metadata-diff.md')) {
|
||||
const graphqlMetadataDiff = fs.readFileSync('graphql-metadata-diff.md', 'utf8');
|
||||
if (graphqlMetadataDiff.trim()) {
|
||||
if (!hasChanges) {
|
||||
comment = '## 📊 API Changes Report\n\n';
|
||||
hasChanges = true;
|
||||
}
|
||||
comment += '### GraphQL Metadata Schema Changes\n' + graphqlMetadataDiff + '\n\n';
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync('rest-api-diff.md')) {
|
||||
const restDiff = fs.readFileSync('rest-api-diff.md', 'utf8');
|
||||
if (restDiff.trim()) {
|
||||
if (!hasChanges) {
|
||||
comment = '## 📊 API Changes Report\n\n';
|
||||
hasChanges = true;
|
||||
}
|
||||
comment += restDiff + '\n\n';
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync('rest-metadata-api-diff.md')) {
|
||||
const metadataDiff = fs.readFileSync('rest-metadata-api-diff.md', 'utf8');
|
||||
if (metadataDiff.trim()) {
|
||||
if (!hasChanges) {
|
||||
comment = '## 📊 API Changes Report\n\n';
|
||||
hasChanges = true;
|
||||
}
|
||||
comment += metadataDiff + '\n\n';
|
||||
}
|
||||
}
|
||||
|
||||
// Only post comment if there are changes
|
||||
if (hasChanges) {
|
||||
// Check if there are any breaking changes detected
|
||||
let hasBreakingChanges = false;
|
||||
let breakingChangeNote = '';
|
||||
|
||||
// Check for breaking changes in any of the diff files
|
||||
if (fs.existsSync('rest-api-diff.md')) {
|
||||
const restDiff = fs.readFileSync('rest-api-diff.md', 'utf8');
|
||||
if (restDiff.includes('Breaking Changes') || restDiff.includes('🚨') ||
|
||||
restDiff.includes('Removed Endpoints') || restDiff.includes('Changed Operations')) {
|
||||
hasBreakingChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync('rest-metadata-api-diff.md')) {
|
||||
const metadataDiff = fs.readFileSync('rest-metadata-api-diff.md', 'utf8');
|
||||
if (metadataDiff.includes('Breaking Changes') || metadataDiff.includes('🚨') ||
|
||||
metadataDiff.includes('Removed Endpoints') || metadataDiff.includes('Changed Operations')) {
|
||||
hasBreakingChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check GraphQL changes for breaking changes indicators
|
||||
if (fs.existsSync('graphql-schema-diff.md')) {
|
||||
const graphqlDiff = fs.readFileSync('graphql-schema-diff.md', 'utf8');
|
||||
if (graphqlDiff.includes('Breaking changes') || graphqlDiff.includes('BREAKING')) {
|
||||
hasBreakingChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync('graphql-metadata-diff.md')) {
|
||||
const graphqlMetadataDiff = fs.readFileSync('graphql-metadata-diff.md', 'utf8');
|
||||
if (graphqlMetadataDiff.includes('Breaking changes') || graphqlMetadataDiff.includes('BREAKING')) {
|
||||
hasBreakingChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check PR title for "breaking"
|
||||
const prTitle = "${{ github.event.pull_request.title }}";
|
||||
const titleContainsBreaking = prTitle.toLowerCase().includes('breaking');
|
||||
|
||||
if (hasBreakingChanges) {
|
||||
if (titleContainsBreaking) {
|
||||
breakingChangeNote = '\n\n## ✅ Breaking Change Protocol\n\n' +
|
||||
'**This PR title contains "breaking" and breaking changes were detected - the CI will fail as expected.**\n\n' +
|
||||
'📝 **Action Required**: Please add `BREAKING CHANGE:` to your commit message to trigger a major version bump.\n\n' +
|
||||
'Example:\n```\nfeat: add new API endpoint\n\nBREAKING CHANGE: removed deprecated field from User schema\n```';
|
||||
} else {
|
||||
breakingChangeNote = '\n\n## ⚠️ Breaking Change Protocol\n\n' +
|
||||
'**Breaking changes detected but PR title does not contain "breaking" - CI will pass but action needed.**\n\n' +
|
||||
'🔄 **Options**:\n' +
|
||||
'1. **If this IS a breaking change**: Add "breaking" to your PR title and add `BREAKING CHANGE:` to your commit message\n' +
|
||||
'2. **If this is NOT a breaking change**: The API diff tool may have false positives - please review carefully\n\n' +
|
||||
'For breaking changes, add to commit message:\n```\nfeat: add new API endpoint\n\nBREAKING CHANGE: removed deprecated field from User schema\n```';
|
||||
}
|
||||
}
|
||||
|
||||
const COMMENT_MARKER = '<!-- API_CHANGES_REPORT -->';
|
||||
const commentBody = COMMENT_MARKER + '\n' + comment + '⚠️ **Please review these API changes carefully before merging.**' + breakingChangeNote;
|
||||
|
||||
// Get all comments to find existing API changes comment
|
||||
const {data: comments} = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
// Find our existing comment
|
||||
const botComment = comments.find(comment => comment.body.includes(COMMENT_MARKER));
|
||||
|
||||
if (botComment) {
|
||||
// Update existing comment
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id,
|
||||
body: commentBody
|
||||
});
|
||||
console.log('Updated existing API changes comment');
|
||||
} else {
|
||||
// Create new comment
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: commentBody
|
||||
});
|
||||
console.log('Created new API changes comment');
|
||||
}
|
||||
} else {
|
||||
console.log('No API changes detected - skipping PR comment');
|
||||
|
||||
// Check if there's an existing comment to remove
|
||||
const {data: comments} = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const COMMENT_MARKER = '<!-- API_CHANGES_REPORT -->';
|
||||
const botComment = comments.find(comment => comment.body.includes(COMMENT_MARKER));
|
||||
|
||||
if (botComment) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id,
|
||||
});
|
||||
console.log('Deleted existing API changes comment (no changes detected)');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Could not post comment:', error);
|
||||
}
|
||||
|
||||
- name: Cleanup servers
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f /tmp/current-server.pid ]; then
|
||||
kill $(cat /tmp/current-server.pid) || true
|
||||
fi
|
||||
if [ -f /tmp/main-server.pid ]; then
|
||||
kill $(cat /tmp/main-server.pid) || true
|
||||
fi
|
||||
|
||||
- name: Upload API specifications and diffs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: api-specifications-and-diffs
|
||||
path: |
|
||||
/tmp/main-server.log
|
||||
/tmp/current-server.log
|
||||
*-api.json
|
||||
*-schema-introspection.json
|
||||
*-diff.md
|
||||
*-diff.json
|
||||
|
||||
@@ -89,15 +89,19 @@ jobs:
|
||||
fi
|
||||
- name: Server / Check for Pending Migrations
|
||||
run: |
|
||||
METADATA_MIGRATION_OUTPUT=$(npx nx run twenty-server:typeorm migration:generate metadata-migration-check -d src/database/typeorm/metadata/metadata.datasource.ts || true)
|
||||
|
||||
CORE_MIGRATION_OUTPUT=$(npx nx run twenty-server:typeorm migration:generate core-migration-check -d src/database/typeorm/core/core.datasource.ts || true)
|
||||
|
||||
METADATA_MIGRATION_FILE=$(ls packages/twenty-server/*metadata-migration-check.ts 2>/dev/null || echo "")
|
||||
CORE_MIGRATION_FILE=$(ls packages/twenty-server/*core-migration-check.ts 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$CORE_MIGRATION_FILE" ]; then
|
||||
if [ -n "$METADATA_MIGRATION_FILE" ] || [ -n "$CORE_MIGRATION_FILE" ]; then
|
||||
echo "::error::Unexpected migration files were generated. Please create a proper migration manually."
|
||||
echo "$METADATA_MIGRATION_OUTPUT"
|
||||
echo "$CORE_MIGRATION_OUTPUT"
|
||||
|
||||
rm -f packages/twenty-server/*core-migration-check.ts
|
||||
rm -f packages/twenty-server/*metadata-migration-check.ts packages/twenty-server/*core-migration-check.ts
|
||||
|
||||
exit 1
|
||||
fi
|
||||
@@ -145,10 +149,6 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
needs: server-setup
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
services:
|
||||
postgres:
|
||||
image: twentycrm/twenty-postgres-spilo
|
||||
@@ -187,7 +187,6 @@ jobs:
|
||||
ANALYTICS_ENABLED: true
|
||||
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
|
||||
CLICKHOUSE_PASSWORD: clickhousePassword
|
||||
SHARD_COUNTER: 4
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
@@ -225,7 +224,12 @@ 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
|
||||
|
||||
+1
-3
@@ -43,6 +43,4 @@ dump.rdb
|
||||
/flake.nix
|
||||
|
||||
.crowdin.yml
|
||||
.react-email/
|
||||
|
||||
mcp.json
|
||||
.react-email/
|
||||
Vendored
+1
-2
@@ -5,7 +5,6 @@
|
||||
{
|
||||
"name": "twenty-server - start debug",
|
||||
"type": "node",
|
||||
"runtimeVersion": "22.12",
|
||||
"request": "launch",
|
||||
"runtimeExecutable": "npx",
|
||||
"runtimeArgs": [
|
||||
@@ -38,7 +37,7 @@
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"runtimeExecutable": "npx",
|
||||
"runtimeVersion": "22.12",
|
||||
"runtimeVersion": "18",
|
||||
"runtimeArgs": [
|
||||
"nx",
|
||||
"run",
|
||||
|
||||
Vendored
+2
-1
@@ -43,11 +43,12 @@
|
||||
],
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"search.exclude": {
|
||||
"**/.yarn": true
|
||||
"**/.yarn": true,
|
||||
},
|
||||
"eslint.debug": true,
|
||||
"files.associations": {
|
||||
".cursorrules": "markdown"
|
||||
},
|
||||
"jestrunner.codeLensSelector": "**/*.{test,spec,integration-spec}.{js,jsx,ts,tsx}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
postgres-on-docker:
|
||||
docker run -d \
|
||||
--name twenty_pg \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-e PGUSER_SUPERUSER=postgres \
|
||||
-e PGPASSWORD_SUPERUSER=postgres \
|
||||
-e ALLOW_NOSSL=true \
|
||||
-v twenty_db_data:/var/lib/postgresql/data \
|
||||
-v twenty_db_data:/home/postgres/pgdata \
|
||||
-p 5432:5432 \
|
||||
postgres:16
|
||||
twentycrm/twenty-postgres-spilo:latest
|
||||
@echo "Waiting for PostgreSQL to be ready..."
|
||||
@until docker exec twenty_pg psql -U postgres -d postgres \
|
||||
-c 'SELECT pg_is_in_recovery();' 2>/dev/null | grep -q 'f'; do \
|
||||
|
||||
+23
-20
@@ -1,13 +1,16 @@
|
||||
{
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@air/react-drag-to-select": "^5.0.8",
|
||||
"@apollo/client": "^3.7.17",
|
||||
"@apollo/server": "^4.7.3",
|
||||
"@aws-sdk/client-lambda": "^3.700.0",
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
"@aws-sdk/client-sts": "^3.700.0",
|
||||
"@aws-sdk/credential-providers": "^3.700.0",
|
||||
"@calcom/embed-react": "^1.5.3",
|
||||
"@aws-sdk/client-lambda": "^3.614.0",
|
||||
"@aws-sdk/client-s3": "^3.363.0",
|
||||
"@aws-sdk/client-sts": "^3.744.0",
|
||||
"@aws-sdk/credential-providers": "^3.363.0",
|
||||
"@blocknote/mantine": "^0.22.0",
|
||||
"@blocknote/react": "^0.22.0",
|
||||
"@blocknote/server-util": "0.17.1",
|
||||
"@codesandbox/sandpack-react": "^2.13.5",
|
||||
"@dagrejs/dagre": "^1.1.2",
|
||||
"@emotion/react": "^11.11.1",
|
||||
@@ -43,9 +46,9 @@
|
||||
"@ptc-org/nestjs-query-typeorm": "4.2.1-alpha.2",
|
||||
"@react-email/components": "0.0.35",
|
||||
"@react-email/render": "0.0.17",
|
||||
"@sentry/node": "^9.26.0",
|
||||
"@sentry/profiling-node": "^9.26.0",
|
||||
"@sentry/react": "^9.26.0",
|
||||
"@sentry/node": "^8",
|
||||
"@sentry/profiling-node": "^8",
|
||||
"@sentry/react": "^8",
|
||||
"@sniptt/guards": "^0.2.0",
|
||||
"@stoplight/elements": "^8.0.5",
|
||||
"@swc/jest": "^0.2.29",
|
||||
@@ -68,6 +71,7 @@
|
||||
"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",
|
||||
@@ -90,7 +94,6 @@
|
||||
"facepaint": "^1.2.1",
|
||||
"file-type": "16.5.4",
|
||||
"framer-motion": "^11.18.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"googleapis": "105",
|
||||
"graphiql": "^3.1.1",
|
||||
"graphql": "16.8.0",
|
||||
@@ -218,7 +221,7 @@
|
||||
"@nx/vite": "18.3.3",
|
||||
"@nx/web": "18.3.3",
|
||||
"@playwright/test": "^1.46.0",
|
||||
"@sentry/types": "^8",
|
||||
"@sentry/types": "^7.109.0",
|
||||
"@storybook/addon-actions": "^7.6.3",
|
||||
"@storybook/addon-coverage": "^1.0.0",
|
||||
"@storybook/addon-essentials": "^7.6.7",
|
||||
@@ -239,11 +242,12 @@
|
||||
"@swc/cli": "^0.3.12",
|
||||
"@swc/core": "1.7.42",
|
||||
"@swc/helpers": "~0.5.2",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/jest-dom": "^6.1.5",
|
||||
"@testing-library/react": "14.0.0",
|
||||
"@types/addressparser": "^1.0.3",
|
||||
"@types/apollo-upload-client": "^17.0.2",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"@types/better-sqlite3": "^7.6.8",
|
||||
"@types/bytes": "^3.1.1",
|
||||
"@types/chrome": "^0.0.267",
|
||||
"@types/deep-equal": "^1.0.1",
|
||||
@@ -270,7 +274,7 @@
|
||||
"@types/lodash.upperfirst": "^4.3.7",
|
||||
"@types/luxon": "^3.3.0",
|
||||
"@types/ms": "^0.7.31",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/node": "18.19.26",
|
||||
"@types/passport-google-oauth20": "^2.0.11",
|
||||
"@types/passport-jwt": "^3.0.8",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
@@ -312,7 +316,7 @@
|
||||
"eslint-plugin-unused-imports": "^3.0.0",
|
||||
"http-server": "^14.1.1",
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "30.0.0-beta.3",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"jest-environment-node": "^29.4.1",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"jsdom": "~22.1.0",
|
||||
@@ -334,15 +338,14 @@
|
||||
"ts-node": "10.9.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.17.0",
|
||||
"vite": "^6.3.5",
|
||||
"vite": "^5.4.0",
|
||||
"vite-plugin-checker": "^0.6.2",
|
||||
"vite-plugin-cjs-interop": "^2.2.0",
|
||||
"vite-plugin-dts": "3.8.1",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vitest": "1.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.12.0",
|
||||
"node": "^18.17.1",
|
||||
"npm": "please-use-yarn",
|
||||
"yarn": ">=4.0.2"
|
||||
},
|
||||
@@ -353,9 +356,9 @@
|
||||
"graphql": "16.8.0",
|
||||
"type-fest": "4.10.1",
|
||||
"typescript": "5.3.3",
|
||||
"graphql-redis-subscriptions/ioredis": "^5.6.0",
|
||||
"prosemirror-view": "1.40.0",
|
||||
"prosemirror-transform": "1.10.4"
|
||||
"prosemirror-model": "1.23.0",
|
||||
"yjs": "13.6.18",
|
||||
"graphql-redis-subscriptions/ioredis": "^5.6.0"
|
||||
},
|
||||
"version": "0.2.1",
|
||||
"nx": {},
|
||||
|
||||
@@ -6358,7 +6358,7 @@ export type RelationConnection = {
|
||||
};
|
||||
|
||||
export type RelationDefinition = {
|
||||
direction: RelationType;
|
||||
direction: RelationDefinitionType;
|
||||
relationId: Scalars['UUID'];
|
||||
sourceFieldMetadata: Field;
|
||||
sourceObjectMetadata: Object;
|
||||
@@ -6367,7 +6367,7 @@ export type RelationDefinition = {
|
||||
};
|
||||
|
||||
/** Relation definition type */
|
||||
export enum RelationType {
|
||||
export enum RelationDefinitionType {
|
||||
ManyToMany = 'MANY_TO_MANY',
|
||||
ManyToOne = 'MANY_TO_ONE',
|
||||
OneToMany = 'ONE_TO_MANY',
|
||||
|
||||
@@ -7,6 +7,7 @@ TAG=latest
|
||||
#REDIS_URL=redis://redis:6379
|
||||
|
||||
SERVER_URL=http://localhost:3000
|
||||
SIGN_IN_PREFILLED=false
|
||||
|
||||
# Use openssl rand -base64 32 for each secret
|
||||
# APP_SECRET=replace_me_with_a_random_string
|
||||
|
||||
@@ -12,8 +12,6 @@ services:
|
||||
PG_DATABASE_URL: postgres://${PG_DATABASE_USER:-postgres}:${PG_DATABASE_PASSWORD:-postgres}@${PG_DATABASE_HOST:-db}:${PG_DATABASE_PORT:-5432}/default
|
||||
SERVER_URL: ${SERVER_URL}
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||
DISABLE_DB_MIGRATIONS: ${DISABLE_DB_MIGRATIONS}
|
||||
DISABLE_CRON_JOBS_REGISTRATION: ${DISABLE_CRON_JOBS_REGISTRATION}
|
||||
|
||||
STORAGE_TYPE: ${STORAGE_TYPE}
|
||||
STORAGE_S3_REGION: ${STORAGE_S3_REGION}
|
||||
@@ -58,14 +56,13 @@ services:
|
||||
worker:
|
||||
image: twentycrm/twenty:${TAG:-latest}
|
||||
volumes:
|
||||
- server-local-data:/app/packages/twenty-server/.local-storage
|
||||
- server-local-data:/app/packages/twenty-server/${STORAGE_LOCAL_PATH:-.local-storage}
|
||||
command: ["yarn", "worker:prod"]
|
||||
environment:
|
||||
PG_DATABASE_URL: postgres://${PG_DATABASE_USER:-postgres}:${PG_DATABASE_PASSWORD:-postgres}@${PG_DATABASE_HOST:-db}:${PG_DATABASE_PORT:-5432}/default
|
||||
SERVER_URL: ${SERVER_URL}
|
||||
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
||||
DISABLE_DB_MIGRATIONS: "true" # it already runs on the server
|
||||
DISABLE_CRON_JOBS_REGISTRATION: "true" # it already runs on the server
|
||||
|
||||
STORAGE_TYPE: ${STORAGE_TYPE}
|
||||
STORAGE_S3_REGION: ${STORAGE_S3_REGION}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22-alpine as twenty-website-build
|
||||
FROM node:18.17.1-alpine as twenty-website-build
|
||||
|
||||
|
||||
WORKDIR /app
|
||||
@@ -23,7 +23,7 @@ COPY ./packages/twenty-ui /app/packages/twenty-ui
|
||||
COPY ./packages/twenty-website /app/packages/twenty-website
|
||||
RUN npx nx build twenty-website
|
||||
|
||||
FROM node:22-alpine as twenty-website
|
||||
FROM node:18.17.1-alpine as twenty-website
|
||||
|
||||
WORKDIR /app/packages/twenty-website
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Base image for common dependencies
|
||||
FROM node:22-alpine as common-deps
|
||||
FROM node:18.17.1-alpine as common-deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -49,7 +49,7 @@ RUN npx nx build twenty-front
|
||||
|
||||
|
||||
# Final stage: Run the application
|
||||
FROM node:22-alpine as twenty
|
||||
FROM node:18.17.1-alpine as twenty
|
||||
|
||||
# Used to run healthcheck in docker
|
||||
RUN apk add --no-cache curl jq
|
||||
|
||||
@@ -28,23 +28,7 @@ setup_and_migrate_db() {
|
||||
yarn command:prod upgrade
|
||||
echo "Successfully migrated DB!"
|
||||
}
|
||||
|
||||
register_background_jobs() {
|
||||
if [ "${DISABLE_CRON_JOBS_REGISTRATION}" = "true" ]; then
|
||||
echo "Cron job registration is disabled, skipping..."
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Registering background sync jobs..."
|
||||
if yarn command:prod cron:register:all; then
|
||||
echo "Successfully registered all background sync jobs!"
|
||||
else
|
||||
echo "Warning: Failed to register background jobs, but continuing startup..."
|
||||
fi
|
||||
}
|
||||
|
||||
setup_and_migrate_db
|
||||
register_background_jobs
|
||||
|
||||
# Continue with the original Docker command
|
||||
exec "$@"
|
||||
|
||||
@@ -19,7 +19,7 @@ export const deleteWorkflow = async ({
|
||||
operationName: 'DeleteOneWorkflow',
|
||||
variables: { idToDelete: workflowId },
|
||||
query:
|
||||
'mutation DeleteOneWorkflow($idToDelete: UUID!) {\n deleteWorkflow(id: $idToDelete) {\n __typename\n deletedAt\n id\n }\n}',
|
||||
'mutation DeleteOneWorkflow($idToDelete: ID!) {\n deleteWorkflow(id: $idToDelete) {\n __typename\n deletedAt\n id\n }\n}',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@ export const destroyWorkflow = async ({
|
||||
operationName: 'DestroyOneWorkflow',
|
||||
variables: { idToDestroy: workflowId },
|
||||
query:
|
||||
'mutation DestroyOneWorkflow($idToDestroy: UUID!) {\n destroyWorkflow(id: $idToDestroy) {\n id\n __typename\n }\n}',
|
||||
'mutation DestroyOneWorkflow($idToDestroy: ID!) {\n destroyWorkflow(id: $idToDestroy) {\n id\n __typename\n }\n}',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8,12 +8,14 @@ test('The workflow run visualizer shows the executed draft version without the l
|
||||
await workflowVisualizer.createInitialTrigger('manual');
|
||||
|
||||
const manualTriggerAvailabilitySelect = page.getByRole('button', {
|
||||
name: 'When record is selected',
|
||||
name: 'When record(s) are selected',
|
||||
});
|
||||
|
||||
await manualTriggerAvailabilitySelect.click();
|
||||
|
||||
const alwaysAvailableOption = page.getByText('When no record is selected');
|
||||
const alwaysAvailableOption = page.getByText(
|
||||
'When no record(s) are selected',
|
||||
);
|
||||
|
||||
await alwaysAvailableOption.click();
|
||||
|
||||
@@ -28,31 +30,21 @@ test('The workflow run visualizer shows the executed draft version without the l
|
||||
|
||||
await launchTestButton.click();
|
||||
|
||||
await workflowVisualizer.closeSidePanel();
|
||||
const goToExecutionPageLink = page.getByRole('link', {
|
||||
name: 'View execution details',
|
||||
});
|
||||
const executionPageUrl = await goToExecutionPageLink.getAttribute('href');
|
||||
expect(executionPageUrl).not.toBeNull();
|
||||
|
||||
await workflowVisualizer.deleteStep(firstStepId);
|
||||
|
||||
await page.goto('/objects/workflowRuns');
|
||||
await page.goto(executionPageUrl!);
|
||||
|
||||
const recordTableRowForWorkflowRun = page
|
||||
.getByRole('row', {
|
||||
name: workflowVisualizer.workflowName,
|
||||
})
|
||||
.first();
|
||||
|
||||
const linkToWorkflowRun = recordTableRowForWorkflowRun
|
||||
.getByRole('link', {
|
||||
name: workflowVisualizer.workflowName,
|
||||
})
|
||||
.first();
|
||||
|
||||
await linkToWorkflowRun.click({ force: true });
|
||||
|
||||
const workflowRunNameElement = page
|
||||
const workflowRunName = page
|
||||
.getByText(`#1 - ${workflowVisualizer.workflowName}`)
|
||||
.nth(1);
|
||||
|
||||
await expect(workflowRunNameElement).toBeVisible();
|
||||
await expect(workflowRunName).toBeVisible();
|
||||
|
||||
const executedFirstStepNode = workflowVisualizer.getStepNode(firstStepId);
|
||||
|
||||
@@ -72,12 +64,14 @@ test('Workflow Runs with a pending form step can be opened in the side panel and
|
||||
await workflowVisualizer.createInitialTrigger('manual');
|
||||
|
||||
const manualTriggerAvailabilitySelect = page.getByRole('button', {
|
||||
name: 'When record is selected',
|
||||
name: 'When record(s) are selected',
|
||||
});
|
||||
|
||||
await manualTriggerAvailabilitySelect.click();
|
||||
|
||||
const alwaysAvailableOption = page.getByText('When no record is selected');
|
||||
const alwaysAvailableOption = page.getByText(
|
||||
'When no record(s) are selected',
|
||||
);
|
||||
|
||||
await alwaysAvailableOption.click();
|
||||
|
||||
@@ -86,23 +80,35 @@ test('Workflow Runs with a pending form step can be opened in the side panel and
|
||||
const { createdStepId: firstStepId } =
|
||||
await workflowVisualizer.createStep('form');
|
||||
|
||||
const addFormFieldButton = page.getByText('Add Field', { exact: true });
|
||||
|
||||
await addFormFieldButton.click();
|
||||
|
||||
await workflowVisualizer.closeSidePanel();
|
||||
|
||||
const launchTestButton = page.getByLabel(workflowVisualizer.workflowName);
|
||||
|
||||
await launchTestButton.click();
|
||||
|
||||
const goToExecutionPageLink = page.getByRole('link', {
|
||||
name: 'View execution details',
|
||||
});
|
||||
|
||||
await expect(goToExecutionPageLink).toBeVisible();
|
||||
|
||||
await workflowVisualizer.seeRunsButton.click();
|
||||
|
||||
const workflowRunName = `#1 - ${workflowVisualizer.workflowName}`;
|
||||
|
||||
const workflowRunNameCell = page.getByRole('cell', { name: workflowRunName });
|
||||
|
||||
await expect(workflowRunNameCell).toBeVisible();
|
||||
|
||||
await workflowVisualizer.setWorkflowsOpenInMode('side-panel');
|
||||
|
||||
// 1. Exit the dropdown
|
||||
await workflowRunNameCell.click();
|
||||
// 2. Actually open the workflow run in the side panel
|
||||
await workflowRunNameCell.click();
|
||||
|
||||
await expect(workflowVisualizer.stepHeaderInCommandMenu).toContainText(
|
||||
'Form',
|
||||
{
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
|
||||
await workflowVisualizer.goBackInCommandMenu.click();
|
||||
@@ -112,9 +118,7 @@ test('Workflow Runs with a pending form step can be opened in the side panel and
|
||||
|
||||
await expect(workflowRunNameInCommandMenu).toBeVisible();
|
||||
|
||||
await workflowVisualizer.commandMenu
|
||||
.locator(workflowVisualizer.triggerNode)
|
||||
.click();
|
||||
await workflowVisualizer.triggerNode.click();
|
||||
|
||||
await expect(workflowVisualizer.stepHeaderInCommandMenu).toContainText(
|
||||
'Launch manually',
|
||||
@@ -122,9 +126,7 @@ test('Workflow Runs with a pending form step can be opened in the side panel and
|
||||
|
||||
await workflowVisualizer.goBackInCommandMenu.click();
|
||||
|
||||
const formStep = workflowVisualizer.commandMenu.locator(
|
||||
workflowVisualizer.getStepNode(firstStepId),
|
||||
);
|
||||
const formStep = workflowVisualizer.getStepNode(firstStepId);
|
||||
|
||||
await formStep.click();
|
||||
|
||||
|
||||
@@ -27,13 +27,13 @@
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.js"
|
||||
"require": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.12.0",
|
||||
"node": "^18.17.1",
|
||||
"npm": "please-use-yarn",
|
||||
"yarn": "^4.0.2"
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ const logoStyle = {
|
||||
export const Logo = () => {
|
||||
return (
|
||||
<Img
|
||||
src="https://app.twenty.com/images/icons/windows11/Square150x150Logo.scale-100.png"
|
||||
src="https://app.twenty.com/icons/windows11/Square150x150Logo.scale-100.png"
|
||||
alt="Twenty logo"
|
||||
width="40"
|
||||
height="40"
|
||||
|
||||
@@ -11,5 +11,4 @@ VITE_DISABLE_ESLINT_CHECKER=true
|
||||
# VITE_ENABLE_SSL=false
|
||||
# VITE_HOST=localhost.com
|
||||
# SSL_KEY_PATH="./certs/your-cert.key"
|
||||
# SSL_CERT_PATH="./certs/your-cert.crt"
|
||||
# IS_DEBUG_MODE=false
|
||||
# SSL_CERT_PATH="./certs/your-cert.crt"
|
||||
@@ -7,10 +7,10 @@ import { useDarkMode } from 'storybook-dark-mode';
|
||||
import { RootDecorator } from '../src/testing/decorators/RootDecorator';
|
||||
import { mockedUserJWT } from '../src/testing/mock-data/jwt';
|
||||
|
||||
import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext';
|
||||
import 'react-loading-skeleton/dist/skeleton.css';
|
||||
import 'twenty-ui/style.css';
|
||||
import { THEME_DARK, THEME_LIGHT, ThemeContextProvider } from 'twenty-ui/theme';
|
||||
import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext';
|
||||
|
||||
initialize({
|
||||
onUnhandledRequest: async (request: Request) => {
|
||||
@@ -48,7 +48,7 @@ const preview: Preview = {
|
||||
<ThemeProvider theme={theme}>
|
||||
<ThemeContextProvider theme={theme}>
|
||||
<ClickOutsideListenerContext.Provider
|
||||
value={{ excludedClickOutsideId: undefined }}
|
||||
value={{ excludeClassName: undefined }}
|
||||
>
|
||||
<Story />
|
||||
</ClickOutsideListenerContext.Provider>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||
|
||||
module.exports = {
|
||||
schema:
|
||||
(process.env.REACT_APP_SERVER_BASE_URL ?? 'http://localhost:3000') +
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||
|
||||
module.exports = {
|
||||
schema:
|
||||
(process.env.REACT_APP_SERVER_BASE_URL ?? 'http://localhost:3000') +
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/x-icon"
|
||||
href="/images/icons/android/android-launchericon-48-48.png"
|
||||
href="images/icons/android/android-launchericon-48-48.png"
|
||||
data-rh="true"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="/images/icons/ios/192.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="images/icons/ios/192.png" />
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="A modern open-source CRM" />
|
||||
|
||||
@@ -56,7 +56,7 @@ const jestConfig: JestConfigWithTsJest = {
|
||||
global: {
|
||||
statements: 57,
|
||||
lines: 55,
|
||||
functions: 46,
|
||||
functions: 47,
|
||||
},
|
||||
},
|
||||
collectCoverageFrom: ['<rootDir>/src/**/*.ts'],
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "NODE_ENV=production VITE_DISABLE_TYPESCRIPT_CHECKER=true VITE_DISABLE_ESLINT_CHECKER=true NODE_OPTIONS=--max-old-space-size=8192 npx vite build && sh ./scripts/inject-runtime-env.sh",
|
||||
"build:sourcemaps": "NODE_ENV=production VITE_BUILD_SOURCEMAP=true VITE_DISABLE_TYPESCRIPT_CHECKER=true VITE_DISABLE_ESLINT_CHECKER=true NODE_OPTIONS=--max-old-space-size=8192 npx vite build && sh ./scripts/inject-runtime-env.sh",
|
||||
"start:prod": "NODE_ENV=production npx serve -s build",
|
||||
"build": "VITE_DISABLE_TYPESCRIPT_CHECKER=true VITE_DISABLE_ESLINT_CHECKER=true NODE_OPTIONS=--max-old-space-size=4500 npx vite build && sh ./scripts/inject-runtime-env.sh",
|
||||
"build:sourcemaps": "VITE_BUILD_SOURCEMAP=true VITE_DISABLE_TYPESCRIPT_CHECKER=true VITE_DISABLE_ESLINT_CHECKER=true NODE_OPTIONS=--max-old-space-size=7000 npx vite build && sh ./scripts/inject-runtime-env.sh",
|
||||
"start:prod": "NODE_ENV=production npx vite --host",
|
||||
"tsup": "npx tsup"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.12.0",
|
||||
"node": "^18.17.1",
|
||||
"npm": "please-use-yarn",
|
||||
"yarn": "^4.0.2"
|
||||
},
|
||||
@@ -29,10 +29,8 @@
|
||||
"workerDirectory": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@blocknote/mantine": "^0.31.1",
|
||||
"@blocknote/react": "^0.31.1",
|
||||
"@blocknote/xl-docx-exporter": "^0.31.1",
|
||||
"@blocknote/xl-pdf-exporter": "^0.31.1",
|
||||
"@blocknote/xl-docx-exporter": "^0.22.0",
|
||||
"@blocknote/xl-pdf-exporter": "^0.22.0",
|
||||
"@cyntler/react-doc-viewer": "^1.17.0",
|
||||
"@lingui/core": "^5.1.2",
|
||||
"@lingui/detect-locale": "^5.2.0",
|
||||
@@ -84,7 +82,6 @@
|
||||
"eslint-plugin-storybook": "^0.6.15",
|
||||
"eslint-plugin-unicorn": "^51.0.1",
|
||||
"eslint-plugin-unused-imports": "^3.0.0",
|
||||
"optionator": "^0.9.1",
|
||||
"rollup-plugin-visualizer": "^5.14.0"
|
||||
"optionator": "^0.9.1"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 627 KiB |
@@ -26,12 +26,14 @@ const documents = {
|
||||
"\n \n query GetOneDatabaseConnection($input: RemoteServerIdInput!) {\n findOneRemoteServerById(input: $input) {\n ...RemoteServerFields\n }\n }\n": types.GetOneDatabaseConnectionDocument,
|
||||
"\n mutation CreateOneObjectMetadataItem($input: CreateOneObjectInput!) {\n createOneObject(input: $input) {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isActive\n isSearchable\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n isLabelSyncedWithName\n }\n }\n": types.CreateOneObjectMetadataItemDocument,
|
||||
"\n mutation CreateOneFieldMetadataItem($input: CreateOneFieldMetadataInput!) {\n createOneField(input: $input) {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isNullable\n createdAt\n updatedAt\n settings\n defaultValue\n options\n isLabelSyncedWithName\n }\n }\n": types.CreateOneFieldMetadataItemDocument,
|
||||
"\n mutation CreateOneRelationMetadataItem(\n $input: CreateOneRelationMetadataInput!\n ) {\n createOneRelationMetadata(input: $input) {\n id\n relationType\n fromObjectMetadataId\n toObjectMetadataId\n fromFieldMetadataId\n toFieldMetadataId\n createdAt\n updatedAt\n }\n }\n": types.CreateOneRelationMetadataItemDocument,
|
||||
"\n mutation UpdateOneFieldMetadataItem(\n $idToUpdate: UUID!\n $updatePayload: UpdateFieldInput!\n ) {\n updateOneField(input: { id: $idToUpdate, update: $updatePayload }) {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isNullable\n createdAt\n updatedAt\n settings\n isLabelSyncedWithName\n }\n }\n": types.UpdateOneFieldMetadataItemDocument,
|
||||
"\n mutation UpdateOneObjectMetadataItem(\n $idToUpdate: UUID!\n $updatePayload: UpdateObjectPayload!\n ) {\n updateOneObject(input: { id: $idToUpdate, update: $updatePayload }) {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isActive\n isSearchable\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n isLabelSyncedWithName\n }\n }\n": types.UpdateOneObjectMetadataItemDocument,
|
||||
"\n mutation DeleteOneObjectMetadataItem($idToDelete: UUID!) {\n deleteOneObject(input: { id: $idToDelete }) {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isActive\n isSearchable\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n isLabelSyncedWithName\n }\n }\n": types.DeleteOneObjectMetadataItemDocument,
|
||||
"\n mutation DeleteOneFieldMetadataItem($idToDelete: UUID!) {\n deleteOneField(input: { id: $idToDelete }) {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isNullable\n createdAt\n updatedAt\n settings\n }\n }\n": types.DeleteOneFieldMetadataItemDocument,
|
||||
"\n query ObjectMetadataItems {\n objects(paging: { first: 1000 }) {\n edges {\n node {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isRemote\n isActive\n isSystem\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n shortcut\n isLabelSyncedWithName\n isSearchable\n duplicateCriteria\n indexMetadataList {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n }\n fieldsList {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isSystem\n isNullable\n isUnique\n createdAt\n updatedAt\n defaultValue\n options\n settings\n isLabelSyncedWithName\n relation {\n type\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetFieldMetadata {\n id\n name\n }\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n": types.ObjectMetadataItemsDocument,
|
||||
"\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n timeoutSeconds\n latestVersion\n latestVersionInputSchema\n publishedVersions\n createdAt\n updatedAt\n }\n": types.ServerlessFunctionFieldsFragmentDoc,
|
||||
"\n mutation DeleteOneRelationMetadataItem($idToDelete: UUID!) {\n deleteOneRelation(input: { id: $idToDelete }) {\n id\n }\n }\n": types.DeleteOneRelationMetadataItemDocument,
|
||||
"\n query ObjectMetadataItems {\n objects(paging: { first: 1000 }) {\n edges {\n node {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isRemote\n isActive\n isSystem\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n shortcut\n isLabelSyncedWithName\n isSearchable\n duplicateCriteria\n indexMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n indexFieldMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n order\n fieldMetadataId\n }\n }\n }\n }\n }\n }\n fieldsList {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isSystem\n isNullable\n isUnique\n createdAt\n updatedAt\n defaultValue\n options\n settings\n isLabelSyncedWithName\n relationDefinition {\n relationId\n direction\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\n }\n targetFieldMetadata {\n id\n name\n }\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n": types.ObjectMetadataItemsDocument,
|
||||
"\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n timeoutSeconds\n syncStatus\n latestVersion\n latestVersionInputSchema\n publishedVersions\n createdAt\n updatedAt\n }\n": types.ServerlessFunctionFieldsFragmentDoc,
|
||||
"\n \n mutation CreateOneServerlessFunctionItem(\n $input: CreateServerlessFunctionInput!\n ) {\n createOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.CreateOneServerlessFunctionItemDocument,
|
||||
"\n \n mutation DeleteOneServerlessFunction($input: ServerlessFunctionIdInput!) {\n deleteOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.DeleteOneServerlessFunctionDocument,
|
||||
"\n mutation ExecuteOneServerlessFunction(\n $input: ExecuteServerlessFunctionInput!\n ) {\n executeOneServerlessFunction(input: $input) {\n data\n logs\n duration\n status\n error\n }\n }\n": types.ExecuteOneServerlessFunctionDocument,
|
||||
@@ -109,6 +111,10 @@ export function graphql(source: "\n mutation CreateOneObjectMetadataItem($input
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n mutation CreateOneFieldMetadataItem($input: CreateOneFieldMetadataInput!) {\n createOneField(input: $input) {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isNullable\n createdAt\n updatedAt\n settings\n defaultValue\n options\n isLabelSyncedWithName\n }\n }\n"): (typeof documents)["\n mutation CreateOneFieldMetadataItem($input: CreateOneFieldMetadataInput!) {\n createOneField(input: $input) {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isNullable\n createdAt\n updatedAt\n settings\n defaultValue\n options\n isLabelSyncedWithName\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n mutation CreateOneRelationMetadataItem(\n $input: CreateOneRelationMetadataInput!\n ) {\n createOneRelationMetadata(input: $input) {\n id\n relationType\n fromObjectMetadataId\n toObjectMetadataId\n fromFieldMetadataId\n toFieldMetadataId\n createdAt\n updatedAt\n }\n }\n"): (typeof documents)["\n mutation CreateOneRelationMetadataItem(\n $input: CreateOneRelationMetadataInput!\n ) {\n createOneRelationMetadata(input: $input) {\n id\n relationType\n fromObjectMetadataId\n toObjectMetadataId\n fromFieldMetadataId\n toFieldMetadataId\n createdAt\n updatedAt\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@@ -128,11 +134,15 @@ export function graphql(source: "\n mutation DeleteOneFieldMetadataItem($idToDe
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query ObjectMetadataItems {\n objects(paging: { first: 1000 }) {\n edges {\n node {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isRemote\n isActive\n isSystem\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n shortcut\n isLabelSyncedWithName\n isSearchable\n duplicateCriteria\n indexMetadataList {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n }\n fieldsList {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isSystem\n isNullable\n isUnique\n createdAt\n updatedAt\n defaultValue\n options\n settings\n isLabelSyncedWithName\n relation {\n type\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetFieldMetadata {\n id\n name\n }\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n"): (typeof documents)["\n query ObjectMetadataItems {\n objects(paging: { first: 1000 }) {\n edges {\n node {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isRemote\n isActive\n isSystem\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n shortcut\n isLabelSyncedWithName\n isSearchable\n duplicateCriteria\n indexMetadataList {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n }\n fieldsList {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isSystem\n isNullable\n isUnique\n createdAt\n updatedAt\n defaultValue\n options\n settings\n isLabelSyncedWithName\n relation {\n type\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetFieldMetadata {\n id\n name\n }\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n"];
|
||||
export function graphql(source: "\n mutation DeleteOneRelationMetadataItem($idToDelete: UUID!) {\n deleteOneRelation(input: { id: $idToDelete }) {\n id\n }\n }\n"): (typeof documents)["\n mutation DeleteOneRelationMetadataItem($idToDelete: UUID!) {\n deleteOneRelation(input: { id: $idToDelete }) {\n id\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n timeoutSeconds\n latestVersion\n latestVersionInputSchema\n publishedVersions\n createdAt\n updatedAt\n }\n"): (typeof documents)["\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n timeoutSeconds\n latestVersion\n latestVersionInputSchema\n publishedVersions\n createdAt\n updatedAt\n }\n"];
|
||||
export function graphql(source: "\n query ObjectMetadataItems {\n objects(paging: { first: 1000 }) {\n edges {\n node {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isRemote\n isActive\n isSystem\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n shortcut\n isLabelSyncedWithName\n isSearchable\n duplicateCriteria\n indexMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n indexFieldMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n order\n fieldMetadataId\n }\n }\n }\n }\n }\n }\n fieldsList {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isSystem\n isNullable\n isUnique\n createdAt\n updatedAt\n defaultValue\n options\n settings\n isLabelSyncedWithName\n relationDefinition {\n relationId\n direction\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\n }\n targetFieldMetadata {\n id\n name\n }\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n"): (typeof documents)["\n query ObjectMetadataItems {\n objects(paging: { first: 1000 }) {\n edges {\n node {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isRemote\n isActive\n isSystem\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n shortcut\n isLabelSyncedWithName\n isSearchable\n duplicateCriteria\n indexMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n indexFieldMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n order\n fieldMetadataId\n }\n }\n }\n }\n }\n }\n fieldsList {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isSystem\n isNullable\n isUnique\n createdAt\n updatedAt\n defaultValue\n options\n settings\n isLabelSyncedWithName\n relationDefinition {\n relationId\n direction\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\n }\n targetFieldMetadata {\n id\n name\n }\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n timeoutSeconds\n syncStatus\n latestVersion\n latestVersionInputSchema\n publishedVersions\n createdAt\n updatedAt\n }\n"): (typeof documents)["\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n timeoutSeconds\n syncStatus\n latestVersion\n latestVersionInputSchema\n publishedVersions\n createdAt\n updatedAt\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useIsMatchingLocation } from '../useIsMatchingLocation';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { AppBasePath } from '@/types/AppBasePath';
|
||||
|
||||
const Wrapper =
|
||||
(initialIndex = 0) =>
|
||||
({ children }: { children: React.ReactNode }) => (
|
||||
<MemoryRouter
|
||||
initialEntries={['/example', '/other', `${AppBasePath.Settings}/example`]}
|
||||
initialIndex={initialIndex}
|
||||
>
|
||||
{children}
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
describe('useIsMatchingLocation', () => {
|
||||
it('returns true when paths match with no basePath', () => {
|
||||
const { result } = renderHook(() => useIsMatchingLocation(), {
|
||||
wrapper: Wrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isMatchingLocation('/example')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when paths do not match with no basePath', () => {
|
||||
const { result } = renderHook(() => useIsMatchingLocation(), {
|
||||
wrapper: Wrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isMatchingLocation('/non-match')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when paths match with basePath', () => {
|
||||
const { result } = renderHook(() => useIsMatchingLocation(), {
|
||||
wrapper: Wrapper(2),
|
||||
});
|
||||
|
||||
expect(
|
||||
result.current.isMatchingLocation('example', AppBasePath.Settings),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when paths do not match with basePath', () => {
|
||||
const { result } = renderHook(() => useIsMatchingLocation(), {
|
||||
wrapper: Wrapper(),
|
||||
});
|
||||
|
||||
expect(
|
||||
result.current.isMatchingLocation('non-match', AppBasePath.Settings),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('handles trailing slashes in basePath correctly', () => {
|
||||
const { result } = renderHook(() => useIsMatchingLocation(), {
|
||||
wrapper: Wrapper(2),
|
||||
});
|
||||
|
||||
expect(
|
||||
result.current.isMatchingLocation(
|
||||
'example',
|
||||
(AppBasePath.Settings + '/') as AppBasePath,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('handles without basePath correctly', () => {
|
||||
const { result } = renderHook(() => useIsMatchingLocation(), {
|
||||
wrapper: Wrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isMatchingLocation('example')).toBe(true);
|
||||
});
|
||||
});
|
||||
+10
-67
@@ -3,16 +3,14 @@ import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePat
|
||||
import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
|
||||
import { AppPath } from '@/types/AppPath';
|
||||
import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo';
|
||||
import { expect } from '@storybook/test';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
|
||||
import { OnboardingStatus } from '~/generated/graphql';
|
||||
|
||||
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
|
||||
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
|
||||
import { usePageChangeEffectNavigateLocation } from '~/hooks/usePageChangeEffectNavigateLocation';
|
||||
import { UNTESTED_APP_PATHS } from '~/testing/constants/UntestedAppPaths';
|
||||
import { isMatchingLocation } from '~/utils/isMatchingLocation';
|
||||
|
||||
jest.mock('@/onboarding/hooks/useOnboardingStatus');
|
||||
const setupMockOnboardingStatus = (
|
||||
@@ -30,13 +28,13 @@ const setupMockIsWorkspaceActivationStatusEqualsTo = (
|
||||
.mockReturnValueOnce(isWorkspaceSuspended);
|
||||
};
|
||||
|
||||
jest.mock('~/utils/isMatchingLocation');
|
||||
const mockIsMatchingLocation = jest.mocked(isMatchingLocation);
|
||||
jest.mock('~/hooks/useIsMatchingLocation');
|
||||
const mockUseIsMatchingLocation = jest.mocked(useIsMatchingLocation);
|
||||
|
||||
const setupMockIsMatchingLocation = (pathname: string) => {
|
||||
mockIsMatchingLocation.mockImplementation(
|
||||
(_location, path) => path === pathname,
|
||||
);
|
||||
mockUseIsMatchingLocation.mockReturnValueOnce({
|
||||
isMatchingLocation: (path: string) => path === pathname,
|
||||
});
|
||||
};
|
||||
|
||||
jest.mock('@/auth/hooks/useIsLogged');
|
||||
@@ -51,11 +49,6 @@ jest.mocked(useDefaultHomePagePath).mockReturnValue({
|
||||
defaultHomePagePath,
|
||||
});
|
||||
|
||||
jest.mock('@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace');
|
||||
jest.mocked(useIsCurrentLocationOnAWorkspace).mockReturnValue({
|
||||
isOnAWorkspace: true,
|
||||
});
|
||||
|
||||
jest.mock('react-router-dom');
|
||||
const setupMockUseParams = (objectNamePlural?: string) => {
|
||||
jest
|
||||
@@ -64,14 +57,10 @@ const setupMockUseParams = (objectNamePlural?: string) => {
|
||||
};
|
||||
|
||||
jest.mock('recoil');
|
||||
const setupMockRecoil = (
|
||||
objectNamePlural?: string,
|
||||
verifyEmailNextPath?: string,
|
||||
) => {
|
||||
const setupMockRecoil = (objectNamePlural?: string) => {
|
||||
jest
|
||||
.mocked(useRecoilValue)
|
||||
.mockReturnValueOnce([{ namePlural: objectNamePlural ?? '' }])
|
||||
.mockReturnValueOnce(verifyEmailNextPath);
|
||||
.mockReturnValueOnce([{ namePlural: objectNamePlural ?? '' }]);
|
||||
};
|
||||
|
||||
// prettier-ignore
|
||||
@@ -83,7 +72,6 @@ const testCases: {
|
||||
res: string | undefined;
|
||||
objectNamePluralFromParams?: string;
|
||||
objectNamePluralFromMetadata?: string;
|
||||
verifyEmailNextPath?: string;
|
||||
}[] = [
|
||||
{ loc: AppPath.Verify, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
{ loc: AppPath.Verify, isLoggedIn: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: '/settings/billing' },
|
||||
@@ -92,7 +80,6 @@ const testCases: {
|
||||
{ loc: AppPath.Verify, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.Verify, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.Verify, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.Verify, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.Verify, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
|
||||
{ loc: AppPath.SignInUp, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -102,7 +89,6 @@ const testCases: {
|
||||
{ loc: AppPath.SignInUp, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.SignInUp, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.SignInUp, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.SignInUp, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.SignInUp, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
|
||||
{ loc: AppPath.Invite, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: '/plan-required' },
|
||||
@@ -112,7 +98,6 @@ const testCases: {
|
||||
{ loc: AppPath.Invite, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: '/create/profile' },
|
||||
{ loc: AppPath.Invite, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: '/sync/emails' },
|
||||
{ loc: AppPath.Invite, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: '/invite-team' },
|
||||
{ loc: AppPath.Invite, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.Invite, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
|
||||
{ loc: AppPath.ResetPassword, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: '/plan-required' },
|
||||
@@ -122,19 +107,15 @@ const testCases: {
|
||||
{ loc: AppPath.ResetPassword, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: '/create/profile' },
|
||||
{ loc: AppPath.ResetPassword, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: '/sync/emails' },
|
||||
{ loc: AppPath.ResetPassword, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: '/invite-team' },
|
||||
{ loc: AppPath.ResetPassword, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.ResetPassword, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
|
||||
{ loc: AppPath.VerifyEmail, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
{ loc: AppPath.VerifyEmail, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, verifyEmailNextPath: '/nextPath?key=value', res: '/nextPath?key=value' },
|
||||
{ loc: AppPath.VerifyEmail, isLoggedIn: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: '/settings/billing' },
|
||||
{ loc: AppPath.VerifyEmail, isLoggedIn: false, isWorkspaceSuspended: false, onboardingStatus: undefined, verifyEmailNextPath: '/nextPath?key=value', res: undefined },
|
||||
{ loc: AppPath.VerifyEmail, isLoggedIn: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: undefined },
|
||||
{ loc: AppPath.VerifyEmail, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: AppPath.CreateWorkspace },
|
||||
{ loc: AppPath.VerifyEmail, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.VerifyEmail, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.VerifyEmail, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.VerifyEmail, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.VerifyEmail, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
|
||||
{ loc: AppPath.CreateWorkspace, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -144,7 +125,6 @@ const testCases: {
|
||||
{ loc: AppPath.CreateWorkspace, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.CreateWorkspace, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.CreateWorkspace, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.CreateWorkspace, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.CreateWorkspace, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
|
||||
{ loc: AppPath.CreateProfile, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -154,7 +134,6 @@ const testCases: {
|
||||
{ loc: AppPath.CreateProfile, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: undefined },
|
||||
{ loc: AppPath.CreateProfile, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.CreateProfile, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.CreateProfile, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.CreateProfile, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
|
||||
{ loc: AppPath.SyncEmails, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -164,7 +143,6 @@ const testCases: {
|
||||
{ loc: AppPath.SyncEmails, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.SyncEmails, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: undefined },
|
||||
{ loc: AppPath.SyncEmails, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.SyncEmails, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.SyncEmails, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
|
||||
{ loc: AppPath.InviteTeam, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -174,29 +152,8 @@ const testCases: {
|
||||
{ loc: AppPath.InviteTeam, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.InviteTeam, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.InviteTeam, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: undefined },
|
||||
{ loc: AppPath.InviteTeam, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.InviteTeam, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
|
||||
{ loc: AppPath.BookCallDecision, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: undefined },
|
||||
{ loc: AppPath.BookCallDecision, isLoggedIn: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: '/settings/billing' },
|
||||
{ loc: AppPath.BookCallDecision, isLoggedIn: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp },
|
||||
{ loc: AppPath.BookCallDecision, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: undefined },
|
||||
{ loc: AppPath.BookCallDecision, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.BookCallDecision, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.BookCallDecision, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.BookCallDecision, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: undefined },
|
||||
{ loc: AppPath.BookCallDecision, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
|
||||
{ loc: AppPath.BookCall, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: undefined },
|
||||
{ loc: AppPath.BookCall, isLoggedIn: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: '/settings/billing' },
|
||||
{ loc: AppPath.BookCall, isLoggedIn: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp },
|
||||
{ loc: AppPath.BookCall, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.WORKSPACE_ACTIVATION, res: undefined },
|
||||
{ loc: AppPath.BookCall, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.BookCall, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.BookCall, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.BookCall, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: undefined },
|
||||
{ loc: AppPath.BookCall, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
|
||||
{ loc: AppPath.PlanRequired, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: undefined },
|
||||
{ loc: AppPath.PlanRequired, isLoggedIn: true, isWorkspaceSuspended: true, onboardingStatus: OnboardingStatus.COMPLETED, res: '/settings/billing' },
|
||||
{ loc: AppPath.PlanRequired, isLoggedIn: false, isWorkspaceSuspended: false, onboardingStatus: undefined, res: AppPath.SignInUp },
|
||||
@@ -204,7 +161,6 @@ const testCases: {
|
||||
{ loc: AppPath.PlanRequired, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.PlanRequired, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.PlanRequired, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.PlanRequired, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.PlanRequired, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
|
||||
{ loc: AppPath.PlanRequiredSuccess, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: undefined },
|
||||
@@ -214,7 +170,6 @@ const testCases: {
|
||||
{ loc: AppPath.PlanRequiredSuccess, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.PlanRequiredSuccess, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.PlanRequiredSuccess, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.PlanRequiredSuccess, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.PlanRequiredSuccess, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
|
||||
{ loc: AppPath.Index, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -224,7 +179,6 @@ const testCases: {
|
||||
{ loc: AppPath.Index, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.Index, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.Index, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.Index, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.Index, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: defaultHomePagePath },
|
||||
|
||||
{ loc: AppPath.TasksPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -234,7 +188,6 @@ const testCases: {
|
||||
{ loc: AppPath.TasksPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.TasksPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.TasksPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.TasksPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.TasksPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
|
||||
{ loc: AppPath.OpportunitiesPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -244,7 +197,6 @@ const testCases: {
|
||||
{ loc: AppPath.OpportunitiesPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.OpportunitiesPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.OpportunitiesPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.OpportunitiesPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.OpportunitiesPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
|
||||
{ loc: AppPath.RecordIndexPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -254,7 +206,6 @@ const testCases: {
|
||||
{ loc: AppPath.RecordIndexPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.RecordIndexPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.RecordIndexPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.RecordIndexPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.RecordIndexPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
{ loc: AppPath.RecordIndexPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined, objectNamePluralFromParams: 'existing-object', objectNamePluralFromMetadata: 'existing-object' },
|
||||
{ loc: AppPath.RecordIndexPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: AppPath.NotFound, objectNamePluralFromParams: 'non-existing-object', objectNamePluralFromMetadata: 'existing-object' },
|
||||
@@ -266,7 +217,6 @@ const testCases: {
|
||||
{ loc: AppPath.RecordShowPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.RecordShowPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.RecordShowPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.RecordShowPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.RecordShowPage, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
|
||||
{ loc: AppPath.SettingsCatchAll, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -276,7 +226,6 @@ const testCases: {
|
||||
{ loc: AppPath.SettingsCatchAll, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.SettingsCatchAll, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.SettingsCatchAll, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.SettingsCatchAll, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.SettingsCatchAll, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
|
||||
{ loc: AppPath.DevelopersCatchAll, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -286,7 +235,6 @@ const testCases: {
|
||||
{ loc: AppPath.DevelopersCatchAll, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.DevelopersCatchAll, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.DevelopersCatchAll, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.DevelopersCatchAll, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.DevelopersCatchAll, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
|
||||
{ loc: AppPath.Authorize, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -296,7 +244,6 @@ const testCases: {
|
||||
{ loc: AppPath.Authorize, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.Authorize, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.Authorize, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.Authorize, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.Authorize, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
|
||||
{ loc: AppPath.NotFoundWildcard, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -306,7 +253,6 @@ const testCases: {
|
||||
{ loc: AppPath.NotFoundWildcard, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.NotFoundWildcard, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.NotFoundWildcard, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.NotFoundWildcard, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.NotFoundWildcard, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
|
||||
{ loc: AppPath.NotFound, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PLAN_REQUIRED, res: AppPath.PlanRequired },
|
||||
@@ -316,7 +262,6 @@ const testCases: {
|
||||
{ loc: AppPath.NotFound, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.PROFILE_CREATION, res: AppPath.CreateProfile },
|
||||
{ loc: AppPath.NotFound, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.SYNC_EMAIL, res: AppPath.SyncEmails },
|
||||
{ loc: AppPath.NotFound, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.INVITE_TEAM, res: AppPath.InviteTeam },
|
||||
{ loc: AppPath.NotFound, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.BOOK_ONBOARDING, res: AppPath.BookCallDecision },
|
||||
{ loc: AppPath.NotFound, isLoggedIn: true, isWorkspaceSuspended: false, onboardingStatus: OnboardingStatus.COMPLETED, res: undefined },
|
||||
];
|
||||
|
||||
@@ -330,7 +275,6 @@ describe('usePageChangeEffectNavigateLocation', () => {
|
||||
isLoggedIn,
|
||||
objectNamePluralFromParams,
|
||||
objectNamePluralFromMetadata,
|
||||
verifyEmailNextPath,
|
||||
res,
|
||||
}) => {
|
||||
setupMockIsMatchingLocation(loc);
|
||||
@@ -338,7 +282,7 @@ describe('usePageChangeEffectNavigateLocation', () => {
|
||||
setupMockIsWorkspaceActivationStatusEqualsTo(isWorkspaceSuspended);
|
||||
setupMockIsLogged(isLoggedIn);
|
||||
setupMockUseParams(objectNamePluralFromParams);
|
||||
setupMockRecoil(objectNamePluralFromMetadata, verifyEmailNextPath);
|
||||
setupMockRecoil(objectNamePluralFromMetadata);
|
||||
|
||||
expect(usePageChangeEffectNavigateLocation()).toEqual(res);
|
||||
},
|
||||
@@ -350,8 +294,7 @@ describe('usePageChangeEffectNavigateLocation', () => {
|
||||
(Object.keys(OnboardingStatus).length +
|
||||
['isWorkspaceSuspended:true', 'isWorkspaceSuspended:false']
|
||||
.length) +
|
||||
['nonExistingObjectInParam', 'existingObjectInParam:false'].length +
|
||||
['caseWithRedirectionToVerifyEmailNextPath', 'caseWithout'].length,
|
||||
['nonExistingObjectInParam', 'existingObjectInParam:false'].length,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { matchPath, useLocation } from 'react-router-dom';
|
||||
|
||||
import { AppBasePath } from '@/types/AppBasePath';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useCallback } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const addTrailingSlash = (path: string) =>
|
||||
path.endsWith('/') ? path : path + '/';
|
||||
|
||||
const getConstructedPath = (path: string, basePath?: AppBasePath) => {
|
||||
if (!isNonEmptyString(basePath)) return path;
|
||||
|
||||
return addTrailingSlash(basePath) + path;
|
||||
};
|
||||
|
||||
export const useIsMatchingLocation = () => {
|
||||
const location = useLocation();
|
||||
|
||||
const isMatchingLocation = useCallback(
|
||||
(path: string, basePath?: AppBasePath) => {
|
||||
const match = matchPath(
|
||||
getConstructedPath(path, basePath),
|
||||
location.pathname,
|
||||
);
|
||||
return isDefined(match);
|
||||
},
|
||||
[location.pathname],
|
||||
);
|
||||
|
||||
return {
|
||||
isMatchingLocation,
|
||||
};
|
||||
};
|
||||
@@ -1,31 +1,28 @@
|
||||
import { verifyEmailNextPathState } from '@/app/states/verifyEmailNextPathState';
|
||||
import { useIsLogged } from '@/auth/hooks/useIsLogged';
|
||||
import { useIsCurrentLocationOnAWorkspace } from '@/domain-manager/hooks/useIsCurrentLocationOnAWorkspace';
|
||||
import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath';
|
||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||
import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus';
|
||||
import { AppPath } from '@/types/AppPath';
|
||||
import { SettingsPath } from '@/types/SettingsPath';
|
||||
import { useIsWorkspaceActivationStatusEqualsTo } from '@/workspace/hooks/useIsWorkspaceActivationStatusEqualsTo';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { OnboardingStatus } from '~/generated/graphql';
|
||||
import { isMatchingLocation } from '~/utils/isMatchingLocation';
|
||||
import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation';
|
||||
|
||||
export const usePageChangeEffectNavigateLocation = () => {
|
||||
const { isMatchingLocation } = useIsMatchingLocation();
|
||||
const isLoggedIn = useIsLogged();
|
||||
const { isOnAWorkspace } = useIsCurrentLocationOnAWorkspace();
|
||||
const onboardingStatus = useOnboardingStatus();
|
||||
const isWorkspaceSuspended = useIsWorkspaceActivationStatusEqualsTo(
|
||||
WorkspaceActivationStatus.SUSPENDED,
|
||||
);
|
||||
const { defaultHomePagePath } = useDefaultHomePagePath();
|
||||
const location = useLocation();
|
||||
|
||||
const someMatchingLocationOf = (appPaths: AppPath[]): boolean =>
|
||||
appPaths.some((appPath) => isMatchingLocation(location, appPath));
|
||||
appPaths.some((appPath) => isMatchingLocation(appPath));
|
||||
const onGoingUserCreationPaths = [
|
||||
AppPath.Invite,
|
||||
AppPath.SignInUp,
|
||||
@@ -39,19 +36,16 @@ export const usePageChangeEffectNavigateLocation = () => {
|
||||
AppPath.InviteTeam,
|
||||
AppPath.PlanRequired,
|
||||
AppPath.PlanRequiredSuccess,
|
||||
AppPath.BookCallDecision,
|
||||
AppPath.BookCall,
|
||||
];
|
||||
|
||||
const objectNamePlural = useParams().objectNamePlural ?? '';
|
||||
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
|
||||
const objectMetadataItem = objectMetadataItems?.find(
|
||||
const objectMetadataItem = objectMetadataItems.find(
|
||||
(objectMetadataItem) => objectMetadataItem.namePlural === objectNamePlural,
|
||||
);
|
||||
const verifyEmailNextPath = useRecoilValue(verifyEmailNextPathState);
|
||||
|
||||
if (
|
||||
(!isLoggedIn || (isLoggedIn && !isOnAWorkspace)) &&
|
||||
!isLoggedIn &&
|
||||
!someMatchingLocationOf([
|
||||
...onGoingUserCreationPaths,
|
||||
AppPath.ResetPassword,
|
||||
@@ -62,30 +56,15 @@ export const usePageChangeEffectNavigateLocation = () => {
|
||||
|
||||
if (
|
||||
onboardingStatus === OnboardingStatus.PLAN_REQUIRED &&
|
||||
!someMatchingLocationOf([
|
||||
AppPath.PlanRequired,
|
||||
AppPath.PlanRequiredSuccess,
|
||||
AppPath.BookCall,
|
||||
AppPath.BookCallDecision,
|
||||
])
|
||||
!someMatchingLocationOf([AppPath.PlanRequired, AppPath.PlanRequiredSuccess])
|
||||
) {
|
||||
if (
|
||||
isMatchingLocation(location, AppPath.VerifyEmail) &&
|
||||
isDefined(verifyEmailNextPath)
|
||||
) {
|
||||
return verifyEmailNextPath;
|
||||
}
|
||||
return AppPath.PlanRequired;
|
||||
}
|
||||
|
||||
if (isWorkspaceSuspended) {
|
||||
if (!isMatchingLocation(location, AppPath.SettingsCatchAll)) {
|
||||
return `${AppPath.SettingsCatchAll.replace('/*', '')}/${
|
||||
SettingsPath.Billing
|
||||
}`;
|
||||
}
|
||||
|
||||
return;
|
||||
if (isWorkspaceSuspended && !isMatchingLocation(AppPath.SettingsCatchAll)) {
|
||||
return `${AppPath.SettingsCatchAll.replace('/*', '')}/${
|
||||
SettingsPath.Billing
|
||||
}`;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -93,8 +72,6 @@ export const usePageChangeEffectNavigateLocation = () => {
|
||||
!someMatchingLocationOf([
|
||||
AppPath.CreateWorkspace,
|
||||
AppPath.PlanRequiredSuccess,
|
||||
AppPath.BookCallDecision,
|
||||
AppPath.BookCall,
|
||||
])
|
||||
) {
|
||||
return AppPath.CreateWorkspace;
|
||||
@@ -102,47 +79,40 @@ export const usePageChangeEffectNavigateLocation = () => {
|
||||
|
||||
if (
|
||||
onboardingStatus === OnboardingStatus.PROFILE_CREATION &&
|
||||
!isMatchingLocation(location, AppPath.CreateProfile)
|
||||
!isMatchingLocation(AppPath.CreateProfile)
|
||||
) {
|
||||
return AppPath.CreateProfile;
|
||||
}
|
||||
|
||||
if (
|
||||
onboardingStatus === OnboardingStatus.SYNC_EMAIL &&
|
||||
!isMatchingLocation(location, AppPath.SyncEmails)
|
||||
!isMatchingLocation(AppPath.SyncEmails)
|
||||
) {
|
||||
return AppPath.SyncEmails;
|
||||
}
|
||||
|
||||
if (
|
||||
onboardingStatus === OnboardingStatus.INVITE_TEAM &&
|
||||
!isMatchingLocation(location, AppPath.InviteTeam)
|
||||
!isMatchingLocation(AppPath.InviteTeam)
|
||||
) {
|
||||
return AppPath.InviteTeam;
|
||||
}
|
||||
|
||||
if (
|
||||
onboardingStatus === OnboardingStatus.BOOK_ONBOARDING &&
|
||||
!someMatchingLocationOf([AppPath.BookCallDecision, AppPath.BookCall])
|
||||
) {
|
||||
return AppPath.BookCallDecision;
|
||||
}
|
||||
|
||||
if (
|
||||
onboardingStatus === OnboardingStatus.COMPLETED &&
|
||||
someMatchingLocationOf([...onboardingPaths, ...onGoingUserCreationPaths]) &&
|
||||
!isMatchingLocation(location, AppPath.ResetPassword) &&
|
||||
!isMatchingLocation(AppPath.ResetPassword) &&
|
||||
isLoggedIn
|
||||
) {
|
||||
return defaultHomePagePath;
|
||||
}
|
||||
|
||||
if (isMatchingLocation(location, AppPath.Index) && isLoggedIn) {
|
||||
if (isMatchingLocation(AppPath.Index) && isLoggedIn) {
|
||||
return defaultHomePagePath;
|
||||
}
|
||||
|
||||
if (
|
||||
isMatchingLocation(location, AppPath.RecordIndexPage) &&
|
||||
isMatchingLocation(AppPath.RecordIndexPage) &&
|
||||
!isDefined(objectMetadataItem)
|
||||
) {
|
||||
return AppPath.NotFound;
|
||||
|
||||
+9
-5
@@ -2,12 +2,12 @@ import { getOperationName } from '@apollo/client/utilities';
|
||||
import { expect } from '@storybook/jest';
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { within } from '@storybook/test';
|
||||
import { HttpResponse, graphql, http } from 'msw';
|
||||
import { HttpResponse, graphql } from 'msw';
|
||||
|
||||
import { GET_PUBLIC_WORKSPACE_DATA_BY_DOMAIN } from '@/auth/graphql/queries/getPublicWorkspaceDataByDomain';
|
||||
import { GET_CLIENT_CONFIG } from '@/client-config/graphql/queries/getClientConfig';
|
||||
import { FIND_MANY_OBJECT_METADATA_ITEMS } from '@/object-metadata/graphql/queries';
|
||||
import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser';
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
import { RecordIndexPage } from '~/pages/object-record/RecordIndexPage';
|
||||
import {
|
||||
PageDecorator,
|
||||
@@ -21,9 +21,6 @@ import { mockedUserData } from '~/testing/mock-data/users';
|
||||
const userMetadataLoaderMocks = {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get(`${REACT_APP_SERVER_BASE_URL}/client-config`, () => {
|
||||
return HttpResponse.json(mockedClientConfig);
|
||||
}),
|
||||
graphql.query(getOperationName(GET_CURRENT_USER) ?? '', () => {
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
@@ -31,6 +28,13 @@ const userMetadataLoaderMocks = {
|
||||
},
|
||||
});
|
||||
}),
|
||||
graphql.query(getOperationName(GET_CLIENT_CONFIG) ?? '', () => {
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
clientConfig: mockedClientConfig,
|
||||
},
|
||||
});
|
||||
}),
|
||||
graphql.query(
|
||||
getOperationName(GET_PUBLIC_WORKSPACE_DATA_BY_DOMAIN) ?? '',
|
||||
() => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user