Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d37451aeb | ||
|
|
526408c094 | ||
|
|
63f399330a |
@@ -1,65 +0,0 @@
|
||||
# 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,97 +0,0 @@
|
||||
# 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,259 +0,0 @@
|
||||
# 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,207 +0,0 @@
|
||||
# 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,220 +0,0 @@
|
||||
# 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,219 +0,0 @@
|
||||
# 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,253 +0,0 @@
|
||||
# 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,162 +0,0 @@
|
||||
# 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,172 +0,0 @@
|
||||
# TypeScript Guidelines
|
||||
|
||||
## Core TypeScript Principles
|
||||
Twenty enforces strict TypeScript usage to ensure type safety and maintainable code. This document outlines our TypeScript conventions and best practices.
|
||||
|
||||
## Type Safety
|
||||
|
||||
### Strict Typing
|
||||
- **No 'any' type allowed**
|
||||
- TypeScript strict mode enabled
|
||||
- noImplicitAny enabled
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
function processUser(user: User) {
|
||||
return user.name;
|
||||
}
|
||||
|
||||
// ❌ Incorrect
|
||||
function processUser(user: any) {
|
||||
return user.name;
|
||||
}
|
||||
```
|
||||
|
||||
### Type Definitions
|
||||
|
||||
#### Types over Interfaces
|
||||
- Use `type` for all type definitions
|
||||
- Exception: When extending third-party interfaces
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
type User = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
// ❌ Incorrect
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
```
|
||||
|
||||
### String Literals over Enums
|
||||
- Use string literal unions instead of enums
|
||||
- Exception: GraphQL enums
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
type UserRole = 'admin' | 'user' | 'guest';
|
||||
|
||||
// ❌ Incorrect
|
||||
enum UserRole {
|
||||
Admin = 'admin',
|
||||
User = 'user',
|
||||
Guest = 'guest',
|
||||
}
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Component Props
|
||||
- Suffix component prop types with 'Props'
|
||||
- Keep props focused and single-purpose
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
type ButtonProps = {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: 'primary' | 'secondary';
|
||||
};
|
||||
|
||||
// ❌ Incorrect
|
||||
type ButtonParameters = {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: 'primary' | 'secondary';
|
||||
};
|
||||
```
|
||||
|
||||
## Type Inference
|
||||
|
||||
### Leverage TypeScript Inference
|
||||
- Use type inference when types are clear
|
||||
- Explicitly type when inference is ambiguous
|
||||
```typescript
|
||||
// ✅ Correct - Clear inference
|
||||
const users = ['John', 'Jane']; // inferred as string[]
|
||||
|
||||
// ✅ Correct - Explicit typing needed
|
||||
const processUser = (user: User): UserResponse => {
|
||||
// Complex processing
|
||||
return response;
|
||||
};
|
||||
|
||||
// ❌ Incorrect - Unnecessary explicit typing
|
||||
const users: string[] = ['John', 'Jane'];
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Type Guards
|
||||
- Use type guards for runtime type checking
|
||||
- Prefer discriminated unions
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
type Success = {
|
||||
type: 'success';
|
||||
data: User;
|
||||
};
|
||||
|
||||
type Error = {
|
||||
type: 'error';
|
||||
message: string;
|
||||
};
|
||||
|
||||
type Result = Success | Error;
|
||||
|
||||
function handleResult(result: Result) {
|
||||
if (result.type === 'success') {
|
||||
// TypeScript knows result.data exists
|
||||
console.log(result.data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Generics
|
||||
- Use generics for reusable type patterns
|
||||
- Keep generic names descriptive
|
||||
```typescript
|
||||
// ✅ Correct
|
||||
type ApiResponse<TData> = {
|
||||
data: TData;
|
||||
status: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
// ❌ Incorrect
|
||||
type ApiResponse<T> = {
|
||||
data: T;
|
||||
status: number;
|
||||
message: string;
|
||||
};
|
||||
```
|
||||
|
||||
### Type Exports
|
||||
- Export types when they're used across files
|
||||
- Keep type definitions close to their usage
|
||||
```typescript
|
||||
// types.ts
|
||||
export type User = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
// UserComponent.tsx
|
||||
import { type User } from './types';
|
||||
```
|
||||
|
||||
### Utility Types
|
||||
- Leverage TypeScript utility types
|
||||
- Create custom utility types for repeated patterns
|
||||
```typescript
|
||||
// Built-in utility types
|
||||
type UserPartial = Partial<User>;
|
||||
type UserReadonly = Readonly<User>;
|
||||
|
||||
// Custom utility types
|
||||
type NonNullableProperties<T> = {
|
||||
[P in keyof T]: NonNullable<T[P]>;
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,99 @@
|
||||
# Twenty Development Rules
|
||||
|
||||
## General
|
||||
- Twenty is an open source CRM built with Typescript (React, NestJS)
|
||||
|
||||
### Main Packages / folders
|
||||
- packages/twenty-front: Main Frontend (React)
|
||||
- packages/twenty-server: Main Backend (NestJS)
|
||||
- packages/twenty-website: Marketing website + docs (NextJS)
|
||||
- packages/twenty-ui: UI library (React)
|
||||
- packages/twenty-shared: Shared utils, constants, types
|
||||
|
||||
### Development Stack
|
||||
- Package Manager: yarn
|
||||
- Monorepo: nx
|
||||
- Database: PostgreSQL + TypeORM (core, metadata schemas)
|
||||
- State Management: Recoil
|
||||
- Data Management: Apollo / GraphQL
|
||||
- Cache: redis
|
||||
- Auth: JWT
|
||||
- Queue: BullMQ
|
||||
- Storage: S3/local filesystem
|
||||
- Testing Backend: Jest, Supertest
|
||||
- Testing Frontend: Jest, Storybook, MSW
|
||||
- Testing E2E: Playwright
|
||||
|
||||
## Styling
|
||||
- Use @emotion/styled, never CSS classes/Tailwind
|
||||
- Prefix styled components with 'Styled'
|
||||
- Keep styled components at top of file
|
||||
- Use Theme object for colors/spacing/typography
|
||||
- Use Theme values instead of px/rem
|
||||
- Use mq helper for media queries
|
||||
|
||||
## TypeScript
|
||||
- No 'any' type - use proper typing
|
||||
- Use type over interface
|
||||
- String literals over enums (except GraphQL)
|
||||
- Props suffix for component props types
|
||||
- Use type inference when possible
|
||||
- Enable strict mode and noImplicitAny
|
||||
|
||||
## React
|
||||
- Only functional components
|
||||
- Named exports only
|
||||
- No useEffect, prefer event handlers
|
||||
- Small focused components
|
||||
- Proper prop naming (onClick, isActive)
|
||||
- Destructure props with types
|
||||
|
||||
## State Management
|
||||
- Recoil for global state
|
||||
- Apollo Client for GraphQL/server state
|
||||
- Atoms in states/ directory
|
||||
- Multiple small atoms over prop drilling
|
||||
- No useRef for state
|
||||
- Extract data fetching to siblings
|
||||
- useRecoilValue/useRecoilState appropriately
|
||||
|
||||
## File Structure
|
||||
- One component per file
|
||||
- Features in modules/
|
||||
- Hooks in hooks/
|
||||
- States in states/
|
||||
- Types in types/
|
||||
- PascalCase components, camelCase others
|
||||
|
||||
## Translation
|
||||
- Use @lingui/react/macro
|
||||
- Use <Trans> within components
|
||||
- Use t`string` elsewhere (from useLingui hook)
|
||||
- Don't translate metadata (field names, object names, etc)
|
||||
- Don't translate mocks
|
||||
|
||||
## Code Style
|
||||
- Early returns
|
||||
- No nested ternaries
|
||||
- No else-if
|
||||
- Optional chaining over &&
|
||||
- Small focused functions
|
||||
- Clear variable names
|
||||
- No console.logs in commits
|
||||
- Few comments, prefer code readability
|
||||
|
||||
## GraphQL
|
||||
- Use gql tag
|
||||
- Separate operation files
|
||||
- Proper codegen typing
|
||||
- Consistent naming (getX, updateX)
|
||||
- Use fragments
|
||||
- Use generated types
|
||||
|
||||
## Testing
|
||||
- Test every feature
|
||||
- React Testing Library
|
||||
- Test behavior not implementation
|
||||
- Mock external deps
|
||||
- Use data-testid
|
||||
- Follow naming conventions
|
||||
@@ -1,7 +1,6 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['plugin:prettier/recommended', 'plugin:lingui/recommended'],
|
||||
ignorePatterns: ['node_modules'],
|
||||
plugins: [
|
||||
'@nx',
|
||||
'prefer-arrow',
|
||||
+5
-114
@@ -6,74 +6,14 @@ module.exports = {
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:storybook/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
'plugin:lingui/recommended',
|
||||
'plugin:@nx/typescript'
|
||||
],
|
||||
plugins: ['react-hooks', 'react-refresh', '@nx', 'prefer-arrow', 'import', 'unused-imports', 'unicorn', 'lingui'],
|
||||
rules: {
|
||||
'lingui/no-single-variables-to-translate': 'off',
|
||||
'func-style': ['error', 'declaration', { allowArrowFunctions: true }],
|
||||
'no-console': ['warn', { allow: ['group', 'groupCollapsed', 'groupEnd'] }],
|
||||
'no-control-regex': 0,
|
||||
'no-debugger': 'error',
|
||||
'no-duplicate-imports': 'error',
|
||||
'no-undef': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
|
||||
'@nx/enforce-module-boundaries': [
|
||||
'error',
|
||||
{
|
||||
enforceBuildableLibDependency: true,
|
||||
allow: [],
|
||||
depConstraints: [
|
||||
{
|
||||
sourceTag: 'scope:shared',
|
||||
onlyDependOnLibsWithTags: ['scope:shared'],
|
||||
},
|
||||
{
|
||||
sourceTag: 'scope:backend',
|
||||
onlyDependOnLibsWithTags: ['scope:shared', 'scope:backend'],
|
||||
},
|
||||
{
|
||||
sourceTag: 'scope:frontend',
|
||||
onlyDependOnLibsWithTags: ['scope:shared', 'scope:frontend'],
|
||||
},
|
||||
{
|
||||
sourceTag: 'scope:zapier',
|
||||
onlyDependOnLibsWithTags: ['scope:shared'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
'import/no-relative-packages': 'error',
|
||||
'import/no-useless-path-segments': 'error',
|
||||
'import/no-duplicates': ['error', { considerQueryString: true }],
|
||||
|
||||
'prefer-arrow/prefer-arrow-functions': [
|
||||
'error',
|
||||
{
|
||||
disallowPrototype: true,
|
||||
singleReturnOnly: false,
|
||||
classPropertiesAllowed: false,
|
||||
},
|
||||
],
|
||||
|
||||
'unused-imports/no-unused-imports': 'warn',
|
||||
'unused-imports/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
vars: 'all',
|
||||
varsIgnorePattern: '^_',
|
||||
args: 'after-used',
|
||||
argsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: ['react-hooks', 'react-refresh'],
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
files: ['*.ts', '*.tsx'],
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.base.{json,*.json}'],
|
||||
},
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
@@ -97,14 +37,6 @@ module.exports = {
|
||||
],
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-empty-interface': [
|
||||
'error',
|
||||
{
|
||||
allowSingleExtends: true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@nx/workspace-effect-components': 'error',
|
||||
'@nx/workspace-no-hardcoded-colors': 'error',
|
||||
'@nx/workspace-matching-state-variable': 'error',
|
||||
@@ -154,46 +86,5 @@ module.exports = {
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['*.js', '*.jsx'],
|
||||
extends: ['plugin:@nx/javascript'],
|
||||
rules: {},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'*.test.@(ts|tsx|js|jsx)',
|
||||
],
|
||||
env: {
|
||||
jest: true,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.constants.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/naming-convention': [
|
||||
'error',
|
||||
{
|
||||
selector: 'variable',
|
||||
format: ['UPPER_CASE'],
|
||||
},
|
||||
],
|
||||
'unicorn/filename-case': [
|
||||
'warn',
|
||||
{
|
||||
cases: {
|
||||
pascalCase: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
'@nx/workspace-max-consts-per-file': ['error', { max: 1 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['*.json'],
|
||||
parser: 'jsonc-eslint-parser',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -13,10 +13,14 @@ Good first issues are a great way to start contributing and get familiar with th
|
||||
|
||||
## Issue assignment
|
||||
|
||||
To avoid conflicts, we follow these guidelines:
|
||||
Having multiple contributors address the same issue can cause frustration.
|
||||
|
||||
1. For `Good First Issue` and `Experienced Contributor` issues without `size: long` labels, we'll merge the first PRs that meet our [code quality standards](https://twenty.com/developers). **We don't assign contributors to these issues**. For `priority: high` issues, our core team will step in within days if no adequate contributions are received.
|
||||
2. For `size: long` Issues, assigned contributors have one week to submit their first draft PR.
|
||||
To avoid conflicts, we follow these guidelines:
|
||||
1. If a core team member assigned you the issue within the last three days, your PR takes priority.
|
||||
2. Otherwise, the first submitted PR is prioritized.
|
||||
3. For "size: long" PRs, the assignment period extends to one week.
|
||||
|
||||
Please ensure you're assigned to an issue before starting work.
|
||||
|
||||
## How to Contribute
|
||||
|
||||
@@ -63,4 +67,4 @@ git push origin your-branch-name
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
If you face any issues or have suggestions, please feel free to (create an issue on Twenty's GitHub repository)[https://github.com/twentyhq/twenty/issues/new]. Please provide as much detail as possible.
|
||||
If you face any issues or have suggestions, please feel free to (create an issue on Twenty's GitHub repository)[https://github.com/twentyhq/twenty/issues/new]. Please provide as much detail as possible.
|
||||
@@ -7,9 +7,6 @@ inputs:
|
||||
required: false
|
||||
tasks:
|
||||
required: true
|
||||
configuration:
|
||||
required: false
|
||||
default: 'ci'
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
@@ -18,4 +15,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 }}'
|
||||
run: npx nx affected --nxBail --configuration=ci -t=${{ inputs.tasks }} --parallel=${{ inputs.parallel }} --exclude='*,!tag:${{ inputs.tag }}'
|
||||
@@ -20,7 +20,7 @@ runs:
|
||||
id: cache-primary-key-builder
|
||||
shell: bash
|
||||
run: |
|
||||
echo "CACHE_PRIMARY_KEY_PREFIX=v3-${{ inputs.key }}-${{ github.ref_name }}" >> "${GITHUB_OUTPUT}"
|
||||
echo "CACHE_PRIMARY_KEY_PREFIX=${{ inputs.key }}-${{ github.ref_name }}" >> "${GITHUB_OUTPUT}"
|
||||
- name: Restore cache
|
||||
uses: actions/cache/restore@v4
|
||||
id: restore-cache
|
||||
|
||||
@@ -25,8 +25,8 @@ runs:
|
||||
id: cache-node-modules
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
key: v3-${{ steps.globals.outputs.CACHE_KEY_PREFIX }}-${{github.sha}}
|
||||
restore-keys: v3-${{ steps.globals.outputs.CACHE_KEY_PREFIX }}-
|
||||
key: ${{ steps.globals.outputs.CACHE_KEY_PREFIX }}-${{github.sha}}
|
||||
restore-keys: ${{ steps.globals.outputs.CACHE_KEY_PREFIX }}-
|
||||
path: ${{ steps.globals.outputs.PATH_TO_CACHE }}
|
||||
- name: Install Dependencies
|
||||
if: ${{ steps.cache-node-modules.outputs.cache-hit != 'true' && steps.cache-node-modules.outputs.cache-matched-key == '' }}
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
name: CI Emails
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changed-files-check:
|
||||
uses: ./.github/workflows/changed-files.yaml
|
||||
with:
|
||||
files: |
|
||||
package.json
|
||||
packages/twenty-emails/**
|
||||
emails-test:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 10
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
env:
|
||||
NX_REJECT_UNKNOWN_LOCAL_CACHE: 0
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Build twenty-emails
|
||||
run: npx nx build twenty-emails
|
||||
- name: Run email tests
|
||||
run: |
|
||||
# Start the email server in the background
|
||||
npx nx run twenty-emails:start &
|
||||
SERVER_PID=$!
|
||||
|
||||
# Wait for server to start
|
||||
sleep 20
|
||||
|
||||
# Check if server is running
|
||||
if ! curl -s http://localhost:4001/preview/test.email > /dev/null; then
|
||||
echo "Email server failed to start"
|
||||
kill $SERVER_PID
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Kill the server
|
||||
kill $SERVER_PID
|
||||
ci-emails-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 1
|
||||
runs-on: depot-ubuntu-24.04-8
|
||||
needs: [changed-files-check, emails-test]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
@@ -108,14 +108,15 @@ jobs:
|
||||
- name: GraphQL / Check for Pending Generation
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
# Run GraphQL generation commands
|
||||
npx nx run twenty-front:graphql:generate
|
||||
npx nx run twenty-front:graphql:generate --configuration=metadata
|
||||
|
||||
# Check if any files were modified
|
||||
if ! git diff --quiet; then
|
||||
echo "::error::GraphQL schema changes detected. Please run 'npx nx run twenty-front:graphql:generate' and 'npx nx run twenty-front:graphql:generate --configuration=metadata' and commit the changes."
|
||||
exit 1
|
||||
GRAPHQL_GENERATE_OUTPUT=$(npx nx run twenty-front:graphql:generate || true)
|
||||
GRAPHQL_METADATA_OUTPUT=$(npx nx run twenty-front:graphql:generate --configuration=metadata || true)
|
||||
if [[ $GRAPHQL_GENERATE_OUTPUT == *"No changes detected"* && $GRAPHQL_METADATA_OUTPUT == *"No changes detected"* ]]; then
|
||||
echo "GraphQL generation check passed."
|
||||
else
|
||||
echo "::error::Unexpected GraphQL changes detected. Please run the required commands and commit the changes."
|
||||
echo "$GRAPHQL_GENERATE_OUTPUT"
|
||||
echo "$GRAPHQL_METADATA_OUTPUT"
|
||||
exit 1
|
||||
fi
|
||||
- name: Save server setup
|
||||
uses: ./.github/workflows/actions/save-cache
|
||||
@@ -168,25 +169,8 @@ jobs:
|
||||
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
|
||||
env:
|
||||
NX_REJECT_UNKNOWN_LOCAL_CACHE: 0
|
||||
NODE_ENV: test
|
||||
ANALYTICS_ENABLED: true
|
||||
CLICKHOUSE_URL: "http://default:clickhousePassword@localhost:8123/twenty"
|
||||
CLICKHOUSE_PASSWORD: clickhousePassword
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
@@ -194,13 +178,12 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- name: Install dependencies
|
||||
uses: ./.github/workflows/actions/yarn-install
|
||||
- name: Update .env.test for integrations tests
|
||||
- name: Update .env.test for billing
|
||||
run: |
|
||||
echo "IS_BILLING_ENABLED=true" >> .env.test
|
||||
echo "BILLING_STRIPE_API_KEY=test-api-key" >> .env.test
|
||||
echo "BILLING_STRIPE_BASE_PLAN_PRODUCT_ID=test-base-plan-product-id" >> .env.test
|
||||
echo "BILLING_STRIPE_WEBHOOK_SECRET=test-webhook-secret" >> .env.test
|
||||
echo "BILLING_PLAN_REQUIRED_LINK=http://localhost:3001/stripe-redirection" >> .env.test
|
||||
- name: Restore server setup
|
||||
uses: ./.github/workflows/actions/restore-cache
|
||||
with:
|
||||
@@ -212,18 +195,15 @@ jobs:
|
||||
npx nx build twenty-shared
|
||||
npx nx build twenty-emails
|
||||
- name: Server / Create Test DB
|
||||
env:
|
||||
NODE_ENV: test
|
||||
run: |
|
||||
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
|
||||
- name: Run ClickHouse migrations
|
||||
run: npx nx clickhouse:migrate twenty-server
|
||||
- name: Run ClickHouse seeds
|
||||
run: npx nx clickhouse:seed twenty-server
|
||||
- name: Server / Run Integration Tests
|
||||
uses: ./.github/workflows/actions/nx-affected
|
||||
with:
|
||||
tag: scope:backend
|
||||
tasks: 'test:integration'
|
||||
configuration: 'with-db-reset'
|
||||
tasks: 'test:integration:with-db-reset'
|
||||
- name: Server / Upload reset-logs file
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -30,6 +30,10 @@ jobs:
|
||||
yq eval '.services.server.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i docker-compose.yml
|
||||
yq eval '.services.server.restart = "no"' -i docker-compose.yml
|
||||
|
||||
yq eval 'del(.services.db.image)' -i docker-compose.yml
|
||||
yq eval '.services.db.build.context = "../../"' -i docker-compose.yml
|
||||
yq eval '.services.db.build.dockerfile = "./packages/twenty-docker/twenty-postgres-spilo/Dockerfile"' -i docker-compose.yml
|
||||
|
||||
echo "Setting up .env file..."
|
||||
cp .env.example .env
|
||||
echo "Generating secrets..."
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
name: CI Tinybird
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'package.json'
|
||||
- 'packages/twenty-tinybird/**'
|
||||
|
||||
pull_request:
|
||||
paths:
|
||||
- 'package.json'
|
||||
- 'packages/twenty-tinybird/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
uses: tinybirdco/ci/.github/workflows/ci.yml@main
|
||||
with:
|
||||
data_project_dir: packages/twenty-tinybird
|
||||
secrets:
|
||||
tb_admin_token: ${{ secrets.TB_ADMIN_TOKEN }}
|
||||
tb_host: https://api.eu-central-1.aws.tinybird.co
|
||||
@@ -21,7 +21,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
danger-js:
|
||||
timeout-minutes: 5
|
||||
timeout-minutes: 3
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.action != 'closed'
|
||||
steps:
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
website-build:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 3
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
@@ -56,13 +56,9 @@ jobs:
|
||||
run: npx nx build twenty-website
|
||||
env:
|
||||
DATABASE_PG_URL: postgres://postgres:postgres@localhost:5432/default
|
||||
KEYSTATIC_GITHUB_CLIENT_ID: xxx
|
||||
KEYSTATIC_GITHUB_CLIENT_SECRET: xxx
|
||||
KEYSTATIC_SECRET: xxx
|
||||
NEXT_PUBLIC_KEYSTATIC_GITHUB_APP_SLUG: xxx
|
||||
ci-website-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 1
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, website-build]
|
||||
steps:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Pull down translations from Crowdin every two hours or when triggered manually.
|
||||
# When force_pull input is true, translations will be pulled regardless of compilation status.
|
||||
|
||||
name: 'Pull translations from Crowdin'
|
||||
name: 'Pull translations'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
# Strict mode fails if there are missing translations.
|
||||
- name: Compile translations
|
||||
id: compile_translations_strict
|
||||
id: compile_translations
|
||||
run: |
|
||||
npx nx run twenty-server:lingui:compile --strict
|
||||
npx nx run twenty-emails:lingui:compile --strict
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
git stash
|
||||
|
||||
- name: Pull translations from Crowdin
|
||||
if: inputs.force_pull || steps.compile_translations_strict.outcome == 'failure'
|
||||
if: inputs.force_pull || steps.compile_translations.outcome == 'failure'
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
upload_sources: false
|
||||
@@ -98,13 +98,18 @@ jobs:
|
||||
run: sudo chown -R runner:docker .
|
||||
|
||||
- name: Compile translations
|
||||
id: compile_translations
|
||||
if: inputs.force_pull || steps.compile_translations_strict.outcome == 'failure'
|
||||
if: inputs.force_pull || steps.compile_translations.outcome == 'failure'
|
||||
run: |
|
||||
npx nx run twenty-server:lingui:compile
|
||||
npx nx run twenty-emails:lingui:compile
|
||||
npx nx run twenty-front:lingui:compile
|
||||
git status
|
||||
|
||||
- name: Debug git status
|
||||
run: git status
|
||||
|
||||
- name: Check and commit compiled files
|
||||
id: check_changes
|
||||
run: |
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@twenty.com'
|
||||
git add .
|
||||
@@ -116,11 +121,11 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Push changes
|
||||
if: steps.compile_translations.outputs.changes_detected == 'true'
|
||||
if: steps.check_changes.outputs.changes_detected == 'true'
|
||||
run: git push origin HEAD:i18n
|
||||
|
||||
- name: Create pull request
|
||||
if: steps.compile_translations.outputs.changes_detected == 'true'
|
||||
if: steps.check_changes.outputs.changes_detected == 'true'
|
||||
run: |
|
||||
if git diff --name-only origin/main..HEAD | grep -q .; then
|
||||
gh pr create -B main -H i18n --title 'i18n - translations' --body 'Created by Github action' || true
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: 'Push translations to Crowdin'
|
||||
name: 'Extract translations when there is a push to main, and upload them to Crowdin'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -16,7 +16,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
name: 'Preview Environment Dispatch'
|
||||
|
||||
on:
|
||||
# Using pull_request_target instead of pull_request to have access to secrets for external contributors
|
||||
# Security note: This is safe because we're only using the repository-dispatch action with limited scope
|
||||
# and not checking out or running any code from the external contributor's PR
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
changed-files-check:
|
||||
uses: ./.github/workflows/changed-files.yaml
|
||||
with:
|
||||
files: |
|
||||
.github/workflows/preview-env-dispatch.yaml
|
||||
.github/workflows/preview-env-keepalive.yaml
|
||||
packages/twenty-docker/**
|
||||
docker-compose.yml
|
||||
packages/twenty-server/**
|
||||
packages/twenty-front/**
|
||||
|
||||
trigger-preview:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true' || contains(github.event.pull_request.labels.*.name, 'preview')
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger preview environment workflow
|
||||
uses: peter-evans/repository-dispatch@v2
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: ${{ github.repository }}
|
||||
event-type: preview-environment
|
||||
client-payload: '{"pr_number": "${{ github.event.pull_request.number }}", "pr_head_sha": "${{ github.event.pull_request.head.sha }}", "repo_full_name": "${{ github.repository }}"}'
|
||||
@@ -1,141 +0,0 @@
|
||||
name: 'Preview Environment Keep Alive'
|
||||
|
||||
on:
|
||||
repository_dispatch:
|
||||
types: [preview-environment]
|
||||
|
||||
jobs:
|
||||
preview-environment:
|
||||
timeout-minutes: 310
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.client_payload.pr_head_sha }}
|
||||
|
||||
- name: Run compose setup
|
||||
run: |
|
||||
echo "Patching docker-compose.yml..."
|
||||
# change image to localbuild using yq
|
||||
yq eval 'del(.services.server.image)' -i packages/twenty-docker/docker-compose.yml
|
||||
yq eval '.services.server.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
|
||||
yq eval '.services.server.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
|
||||
|
||||
yq eval 'del(.services.worker.image)' -i packages/twenty-docker/docker-compose.yml
|
||||
yq eval '.services.worker.build.context = "../../"' -i packages/twenty-docker/docker-compose.yml
|
||||
yq eval '.services.worker.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i packages/twenty-docker/docker-compose.yml
|
||||
|
||||
echo "Setting up .env file..."
|
||||
cp packages/twenty-docker/.env.example packages/twenty-docker/.env
|
||||
|
||||
echo "Generating secrets..."
|
||||
echo "# === Randomly generated secrets ===" >> packages/twenty-docker/.env
|
||||
echo "APP_SECRET=$(openssl rand -base64 32)" >> packages/twenty-docker/.env
|
||||
echo "PG_DATABASE_PASSWORD=$(openssl rand -hex 16)" >> packages/twenty-docker/.env
|
||||
# Remove line below when true becomes the default value (soon)
|
||||
echo "CONFIG_VARIABLES_IN_DB_ENABLED=true" >> packages/twenty-docker/.env
|
||||
|
||||
echo "Docker compose build..."
|
||||
cd packages/twenty-docker/
|
||||
docker compose build
|
||||
working-directory: ./
|
||||
|
||||
- name: Create Tunnel
|
||||
id: expose-tunnel
|
||||
uses: codetalkio/expose-tunnel@v1.5.0
|
||||
with:
|
||||
service: bore.pub
|
||||
port: 3000
|
||||
|
||||
- name: Start services with correct SERVER_URL
|
||||
run: |
|
||||
cd packages/twenty-docker/
|
||||
|
||||
# Update the SERVER_URL with the tunnel URL
|
||||
echo "Setting SERVER_URL to ${{ steps.expose-tunnel.outputs.tunnel-url }}"
|
||||
sed -i '/SERVER_URL=/d' .env
|
||||
echo "SERVER_URL=${{ steps.expose-tunnel.outputs.tunnel-url }}" >> .env
|
||||
|
||||
# Start the services
|
||||
echo "Docker compose up..."
|
||||
docker compose up -d || {
|
||||
echo "Docker compose failed to start"
|
||||
docker compose logs
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "Waiting for services to be ready..."
|
||||
count=0
|
||||
while [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-db-1) = "healthy" ] || [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-server-1) = "healthy" ]; do
|
||||
sleep 5
|
||||
count=$((count+1))
|
||||
if [ $count -gt 60 ]; then
|
||||
echo "Timeout waiting for services to be ready"
|
||||
docker compose logs
|
||||
exit 1
|
||||
fi
|
||||
echo "Still waiting for services... ($count/60)"
|
||||
done
|
||||
|
||||
echo "All services are up and running!"
|
||||
working-directory: ./
|
||||
|
||||
- name: Output tunnel URL to logs
|
||||
run: |
|
||||
echo "✅ Preview Environment Ready!"
|
||||
echo "🔗 Preview URL: ${{ steps.expose-tunnel.outputs.tunnel-url }}"
|
||||
echo "⏱️ This environment will be available for 5 hours"
|
||||
|
||||
- name: Post comment on PR
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
const COMMENT_MARKER = '<!-- PR_PREVIEW_ENV -->';
|
||||
const commentBody = `${COMMENT_MARKER}
|
||||
🚀 **Preview Environment Ready!**
|
||||
|
||||
Your preview environment is available at: ${{ steps.expose-tunnel.outputs.tunnel-url }}
|
||||
|
||||
This environment will automatically shut down when the PR is closed or after 5 hours.`;
|
||||
|
||||
// Get all comments
|
||||
const {data: comments} = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: ${{ github.event.client_payload.pr_number }},
|
||||
});
|
||||
|
||||
// Find our comment
|
||||
const botComment = comments.find(comment => comment.body.includes(COMMENT_MARKER));
|
||||
|
||||
if (botComment) {
|
||||
// Update existing comment
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id,
|
||||
body: commentBody
|
||||
});
|
||||
console.log('Updated existing comment');
|
||||
} else {
|
||||
// Create new comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: ${{ github.event.client_payload.pr_number }},
|
||||
body: commentBody
|
||||
});
|
||||
console.log('Created new comment');
|
||||
}
|
||||
|
||||
- name: Keep tunnel alive for 5 hours
|
||||
run: timeout 300m sleep 18000 # Stop on whichever we reach first (300m or 5hour sleep)
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
cd packages/twenty-docker/
|
||||
docker compose down -v
|
||||
working-directory: ./
|
||||
+1
-2
@@ -42,5 +42,4 @@ dump.rdb
|
||||
/flake.lock
|
||||
/flake.nix
|
||||
|
||||
.crowdin.yml
|
||||
.react-email/
|
||||
.crowdin.yml
|
||||
@@ -1,5 +1,3 @@
|
||||
enableConstraintsChecks: true
|
||||
|
||||
enableHardenedMode: true
|
||||
|
||||
enableInlineHunks: true
|
||||
|
||||
@@ -17,7 +17,4 @@ postgres-on-docker:
|
||||
-c "CREATE DATABASE \"test\" WITH OWNER postgres;"
|
||||
|
||||
redis-on-docker:
|
||||
docker run -d --name twenty_redis -p 6379:6379 redis/redis-stack-server:latest
|
||||
|
||||
clickhouse-on-docker:
|
||||
docker run -d --name twenty_clickhouse -p 8123:8123 -p 9000:9000 -e CLICKHOUSE_PASSWORD=devPassword clickhouse/clickhouse-server:latest
|
||||
docker run -d --name twenty_redis -p 6379:6379 redis/redis-stack-server:latest
|
||||
@@ -1,28 +1,28 @@
|
||||
|
||||
<br />
|
||||
<br>
|
||||
<p align="center">
|
||||
<a href="https://www.twenty.com">
|
||||
<img src="./packages/twenty-website/public/images/core/logo.svg" width="100px" alt="Twenty logo" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h2 align="center" >The #1 Open-Source CRM </h2>
|
||||
<h2 align="center" >The #1 Open-Source CRM </h3>
|
||||
|
||||
<p align="center"><a href="https://twenty.com">🌐 Website</a> · <a href="https://twenty.com/developers">📚 Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website/public/images/readme/figma-icon.png" width="12" height="12"/> Figma</a></p>
|
||||
<p align="center"><a href="https://twenty.com">🌐 Website</a> · <a href="https://twenty.com/developers">📚 Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website/public/images/readme/figma-icon.png" width="12" height="12"/> Figma</a><p>
|
||||
<br />
|
||||
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.twenty.com">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/preview-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/preview-light.png" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/preview-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/preview-light.png">
|
||||
<img src="./packages/twenty-docs/static/img/preview-light.png" alt="Companies view" />
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<br />
|
||||
<br>
|
||||
|
||||
# Installation
|
||||
|
||||
@@ -40,7 +40,7 @@ We built Twenty for three reasons:
|
||||
|
||||
**We believe in Open-source and community.** Hundreds of developers are already building Twenty together. Once we have plugin capabilities, a whole ecosystem will grow around it.
|
||||
|
||||
<br />
|
||||
<br>
|
||||
|
||||
# What You Can Do With Twenty
|
||||
We're currently developing Twenty's beta version.
|
||||
@@ -60,8 +60,8 @@ Below are a few features we have implemented to date:
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/index-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/index-light.png" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/index-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/index-light.png">
|
||||
<img src="./packages/twenty-docs/static/img/visualise-customer-light.png" alt="Companies view" />
|
||||
</picture>
|
||||
</p>
|
||||
@@ -70,9 +70,9 @@ Below are a few features we have implemented to date:
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/kanban-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/kanban-light.png" />
|
||||
<img src="./packages/twenty-docs/static/img/follow-your-deals-light.png" alt="Opportunities view" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/kanban-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/kanban-light.png">
|
||||
<img src="./packages/twenty-docs/static/img/follow-your-deals-light.png" alt="Companies view" />
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
@@ -80,9 +80,9 @@ Below are a few features we have implemented to date:
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/emails-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/emails-light.png" />
|
||||
<img src="./packages/twenty-docs/static/img/emails-light.png" alt="Emails" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/emails-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/emails-light.png">
|
||||
<img src="./packages/twenty-docs/static/img/rich-notes-light.png" alt="Companies view" />
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
@@ -90,9 +90,9 @@ Below are a few features we have implemented to date:
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/data-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/data-light.png" />
|
||||
<img src="./packages/twenty-docs/static/img/data-light.png" alt="Data model" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/data-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/data-light.png">
|
||||
<img src="./packages/twenty-docs/static/img/rich-notes-light.png" alt="Companies view" />
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
@@ -100,9 +100,9 @@ Below are a few features we have implemented to date:
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/notes-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/notes-light.png" />
|
||||
<img src="./packages/twenty-docs/static/img/notes-light.png" alt="Rich notes" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/notes-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/notes-light.png">
|
||||
<img src="./packages/twenty-docs/static/img/rich-notes-light.png" alt="Companies view" />
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
@@ -110,9 +110,9 @@ Below are a few features we have implemented to date:
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/tasks-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/tasks-light.png" />
|
||||
<img src="./packages/twenty-docs/static/img/create-tasks-light.png" alt="Tasks" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/tasks-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/tasks-light.png">
|
||||
<img src="./packages/twenty-docs/static/img/create-tasks-light.png" alt="Companies view" />
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
@@ -120,9 +120,9 @@ Below are a few features we have implemented to date:
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/keyboard-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/keyboard-light.png" />
|
||||
<img src="./packages/twenty-docs/static/img/keyboard-dark.png" alt="Keyboard shortcuts" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/keyboard-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/keyboard-light.png">
|
||||
<img src="./packages/twenty-docs/static/img/shortcut-navigation-light.png" alt="Companies view" />
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
@@ -130,33 +130,22 @@ Below are a few features we have implemented to date:
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/api-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/api-light.png" />
|
||||
<img src="./packages/twenty-docs/static/img/api-light.png" alt="API" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/api-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/v0.12.0/packages/twenty-docs/static/img/api-light.png">
|
||||
<img src="./packages/twenty-docs/static/img/shortcut-navigation-light.png" alt="Companies view" />
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
|
||||
<br />
|
||||
<br>
|
||||
|
||||
# Stack
|
||||
- [TypeScript](https://www.typescriptlang.org/)
|
||||
- [Nx](https://nx.dev/)
|
||||
- [NestJS](https://nestjs.com/), with [BullMQ](https://bullmq.io/), [PostgreSQL](https://www.postgresql.org/), [Redis](https://redis.io/)
|
||||
- [React](https://reactjs.org/), with [Recoil](https://recoiljs.org/), [Emotion](https://emotion.sh/) and [Lingui](https://lingui.dev/)
|
||||
|
||||
|
||||
|
||||
# Thanks
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website/public/images/readme/chromatic.png" height="30" alt="Chromatic" /></a>
|
||||
<a href="https://greptile.com"><img src="./packages/twenty-website/public/images/readme/greptile.png" height="30" alt="Greptile" /></a>
|
||||
<a href="https://sentry.io/"><img src="./packages/twenty-website/public/images/readme/sentry.png" height="30" alt="Sentry" /></a>
|
||||
<a href="https://crowdin.com/"><img src="./packages/twenty-website/public/images/readme/crowdin.png" height="30" alt="Crowdin" /></a>
|
||||
</p>
|
||||
|
||||
Thanks to these amazing services that we use and recommend for UI testing (Chromatic), code review (Greptile), catching bugs (Sentry) and translating (Crowdin).
|
||||
- [React](https://reactjs.org/), with [Recoil](https://recoiljs.org/) and [Emotion](https://emotion.sh/)
|
||||
- [Greptile](https://greptile.com) for code reviews.
|
||||
- [Lingui](https://lingui.dev/) and [Crowdin](https://twenty.crowdin.com/twenty) for translations.
|
||||
|
||||
|
||||
# Join the Community
|
||||
|
||||
+10
-11
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@air/react-drag-to-select": "^5.0.8",
|
||||
"@apollo/client": "^3.7.17",
|
||||
@@ -25,6 +24,8 @@
|
||||
"@jsdevtools/rehype-toc": "^3.0.2",
|
||||
"@linaria/core": "^6.2.0",
|
||||
"@linaria/react": "^6.2.1",
|
||||
"@lingui/core": "^5.1.2",
|
||||
"@lingui/react": "^5.1.2",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
"@nestjs/apollo": "^11.0.5",
|
||||
@@ -44,7 +45,7 @@
|
||||
"@octokit/graphql": "^7.0.2",
|
||||
"@ptc-org/nestjs-query-core": "^4.2.0",
|
||||
"@ptc-org/nestjs-query-typeorm": "4.2.1-alpha.2",
|
||||
"@react-email/components": "0.0.35",
|
||||
"@react-email/components": "0.0.32",
|
||||
"@react-email/render": "0.0.17",
|
||||
"@sentry/node": "^8",
|
||||
"@sentry/profiling-node": "^8",
|
||||
@@ -52,7 +53,7 @@
|
||||
"@sniptt/guards": "^0.2.0",
|
||||
"@stoplight/elements": "^8.0.5",
|
||||
"@swc/jest": "^0.2.29",
|
||||
"@tabler/icons-react": "^3.31.0",
|
||||
"@tabler/icons-react": "^2.44.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/facepaint": "^1.2.5",
|
||||
"@types/lodash.camelcase": "^4.3.7",
|
||||
@@ -77,7 +78,6 @@
|
||||
"bytes": "^3.1.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cron-parser": "^5.1.1",
|
||||
"cron-validate": "^1.4.5",
|
||||
"cross-env": "^7.0.3",
|
||||
"css-loader": "^7.1.2",
|
||||
@@ -100,9 +100,7 @@
|
||||
"graphql-fields": "^2.0.3",
|
||||
"graphql-middleware": "^6.1.35",
|
||||
"graphql-rate-limit": "^3.3.0",
|
||||
"graphql-redis-subscriptions": "^2.7.0",
|
||||
"graphql-scalars": "^1.23.0",
|
||||
"graphql-sse": "^2.5.4",
|
||||
"graphql-subscriptions": "2.0.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"graphql-type-json": "^0.3.2",
|
||||
@@ -143,6 +141,8 @@
|
||||
"next-mdx-remote": "^4.4.1",
|
||||
"nodemailer": "^6.9.8",
|
||||
"openapi-types": "^12.1.3",
|
||||
"overlayscrollbars": "^2.6.1",
|
||||
"overlayscrollbars-react": "^0.5.4",
|
||||
"passport": "^0.7.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
@@ -184,7 +184,6 @@
|
||||
"semver": "^7.5.4",
|
||||
"sharp": "^0.32.1",
|
||||
"slash": "^5.1.0",
|
||||
"storybook-addon-mock-date": "^0.6.0",
|
||||
"stripe": "^17.3.1",
|
||||
"ts-key-enum": "^2.0.12",
|
||||
"tslib": "^2.3.0",
|
||||
@@ -208,6 +207,9 @@
|
||||
"@graphql-codegen/typescript": "^3.0.4",
|
||||
"@graphql-codegen/typescript-operations": "^3.0.4",
|
||||
"@graphql-codegen/typescript-react-apollo": "^3.3.7",
|
||||
"@lingui/cli": "^5.1.2",
|
||||
"@lingui/swc-plugin": "^5.1.0",
|
||||
"@lingui/vite-plugin": "^5.1.2",
|
||||
"@microsoft/microsoft-graph-types": "^2.40.0",
|
||||
"@nestjs/cli": "^9.0.0",
|
||||
"@nestjs/schematics": "^9.0.0",
|
||||
@@ -291,7 +293,6 @@
|
||||
"@typescript-eslint/utils": "6.21.0",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"@vitest/ui": "1.4.0",
|
||||
"@yarnpkg/types": "^4.0.0",
|
||||
"chromatic": "^6.18.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"cross-var": "^1.1.0",
|
||||
@@ -357,9 +358,7 @@
|
||||
"graphql": "16.8.0",
|
||||
"type-fest": "4.10.1",
|
||||
"typescript": "5.3.3",
|
||||
"prosemirror-model": "1.23.0",
|
||||
"yjs": "13.6.18",
|
||||
"graphql-redis-subscriptions/ioredis": "^5.6.0"
|
||||
"prosemirror-model": "1.23.0"
|
||||
},
|
||||
"version": "0.2.1",
|
||||
"nx": {},
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['../../.eslintrc.global.cjs', '../../.eslintrc.react.cjs'],
|
||||
ignorePatterns: [
|
||||
'node_modules',
|
||||
'dist',
|
||||
'**/generated/*',
|
||||
],
|
||||
extends: ['../../.eslintrc.cjs', '../../.eslintrc.react.cjs'],
|
||||
ignorePatterns: ['!**/*', 'node_modules', 'dist', '**/generated/*'],
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
files: ['*.ts', '*.tsx'],
|
||||
parserOptions: {
|
||||
project: ['packages/twenty-chrome-extension/tsconfig.*.json'],
|
||||
project: ['packages/twenty-chrome-extension/tsconfig.{json,*.json}'],
|
||||
},
|
||||
rules: {
|
||||
'@nx/workspace-explicit-boolean-predicates-in-if': 'warn',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="images/icons/android/android-launchericon-48-48.png" />
|
||||
<link rel="icon" href="/icons/android/android-launchericon-48-48.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Twenty</title>
|
||||
</head>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="images/icons/android/android-launchericon-48-48.png" />
|
||||
<link rel="icon" href="/icons/android/android-launchericon-48-48.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Twenty</title>
|
||||
</head>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="images/icons/android/android-launchericon-48-48.png" />
|
||||
<link rel="icon" href="/icons/android/android-launchericon-48-48.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Twenty</title>
|
||||
<style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isDefined } from 'twenty-shared';
|
||||
|
||||
// Open options page programmatically in a new tab.
|
||||
// chrome.runtime.onInstalled.addListener((details) => {
|
||||
// if (details.reason === 'install') {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isDefined } from 'twenty-shared';
|
||||
|
||||
interface CustomDiv extends HTMLDivElement {
|
||||
onClickHandler: (newHandler: () => void) => void;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { isDefined } from 'twenty-shared';
|
||||
import { createDefaultButton } from '~/contentScript/createButton';
|
||||
import changeSidePanelUrl from '~/contentScript/utils/changeSidepanelUrl';
|
||||
import extractCompanyLinkedinLink from '~/contentScript/utils/extractCompanyLinkedinLink';
|
||||
import extractDomain from '~/contentScript/utils/extractDomain';
|
||||
import { createCompany, fetchCompany } from '~/db/company.db';
|
||||
import { CompanyInput } from '~/db/types/company.types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const checkIfCompanyExists = async () => {
|
||||
const { tab: activeTab } = await chrome.runtime.sendMessage({
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { isDefined } from 'twenty-shared';
|
||||
import { createDefaultButton } from '~/contentScript/createButton';
|
||||
import changeSidePanelUrl from '~/contentScript/utils/changeSidepanelUrl';
|
||||
import extractFirstAndLastName from '~/contentScript/utils/extractFirstAndLastName';
|
||||
import { createPerson, fetchPerson } from '~/db/person.db';
|
||||
import { PersonInput } from '~/db/types/person.types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const checkIfPersonExists = async () => {
|
||||
const { tab: activeTab } = await chrome.runtime.sendMessage({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isDefined } from 'twenty-shared';
|
||||
import { insertButtonForCompany } from '~/contentScript/extractCompanyProfile';
|
||||
import { insertButtonForPerson } from '~/contentScript/extractPersonProfile';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
// Inject buttons into the DOM when SPA is reloaded on the resource url.
|
||||
// e.g. reload the page when on https://www.linkedin.com/in/mabdullahabaid/
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isDefined } from 'twenty-shared';
|
||||
|
||||
const btn = document.getElementById('twenty-settings-btn');
|
||||
if (!isDefined(btn)) {
|
||||
const div = document.createElement('div');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isDefined } from 'twenty-shared';
|
||||
|
||||
const changeSidePanelUrl = async (url: string) => {
|
||||
if (isDefined(url)) {
|
||||
chrome.storage.local.set({ navigateSidepanel: 'sidepanel' });
|
||||
|
||||
+4
-1
@@ -1,4 +1,7 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
// Extract "https://www.linkedin.com/company/twenty/" from any of the following urls, which the user can visit while on the company page.
|
||||
|
||||
import { isDefined } from 'twenty-shared';
|
||||
|
||||
// "https://www.linkedin.com/company/twenty/" "https://www.linkedin.com/company/twenty/about/" "https://www.linkedin.com/company/twenty/people/".
|
||||
const extractCompanyLinkedinLink = (activeTabUrl: string) => {
|
||||
// Regular expression to match the company ID
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isDefined } from 'twenty-shared';
|
||||
import {
|
||||
ExchangeAuthCodeInput,
|
||||
ExchangeAuthCodeResponse,
|
||||
@@ -5,7 +6,6 @@ import {
|
||||
} from '~/db/types/auth.types';
|
||||
import { EXCHANGE_AUTHORIZATION_CODE } from '~/graphql/auth/mutations';
|
||||
import { callMutation } from '~/utils/requestDb';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const exchangeAuthorizationCode = async (
|
||||
exchangeAuthCodeInput: ExchangeAuthCodeInput,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isDefined } from 'twenty-shared';
|
||||
import {
|
||||
CompanyInput,
|
||||
CreateCompanyResponse,
|
||||
@@ -8,7 +9,6 @@ import { CREATE_COMPANY } from '~/graphql/company/mutations';
|
||||
import { FIND_COMPANY } from '~/graphql/company/queries';
|
||||
|
||||
import { callMutation, callQuery } from '../utils/requestDb';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const fetchCompany = async (
|
||||
companyfilerInput: CompanyFilterInput,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isDefined } from 'twenty-shared';
|
||||
import {
|
||||
CreatePersonResponse,
|
||||
FindPersonResponse,
|
||||
@@ -8,7 +9,6 @@ import { CREATE_PERSON } from '~/graphql/person/mutations';
|
||||
import { FIND_PERSON } from '~/graphql/person/queries';
|
||||
|
||||
import { callMutation, callQuery } from '../utils/requestDb';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const fetchPerson = async (
|
||||
personFilterData: PersonFilterInput,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ApolloClient, InMemoryCache } from '@apollo/client';
|
||||
|
||||
import { isDefined } from 'twenty-shared';
|
||||
import { Tokens } from '~/db/types/auth.types';
|
||||
import { RENEW_TOKEN } from '~/graphql/auth/mutations';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const renewToken = async (
|
||||
appToken: string,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { isDefined } from 'twenty-shared';
|
||||
import Settings from '~/options/Settings';
|
||||
import Sidepanel from '~/options/Sidepanel';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const App = () => {
|
||||
const [currentScreen, setCurrentScreen] = useState('');
|
||||
|
||||
@@ -3,8 +3,8 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import { MainButton } from '@/ui/input/button/MainButton';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { isDefined } from 'twenty-shared';
|
||||
import { clearStore } from '~/utils/apolloClient';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
align-items: center;
|
||||
|
||||
@@ -2,7 +2,7 @@ import styled from '@emotion/styled';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { MainButton } from '@/ui/input/button/MainButton';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isDefined } from 'twenty-shared';
|
||||
|
||||
const StyledIframe = styled.iframe`
|
||||
display: block;
|
||||
|
||||
@@ -42,6 +42,7 @@ const StyledInput = styled.input`
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
|
||||
&::placeholder {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { isDefined } from 'twenty-shared';
|
||||
|
||||
export type ToggleSize = 'small' | 'medium';
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ApolloClient, from, HttpLink, InMemoryCache } from '@apollo/client';
|
||||
import { setContext } from '@apollo/client/link/context';
|
||||
import { onError } from '@apollo/client/link/error';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { isDefined } from 'twenty-shared';
|
||||
|
||||
export const clearStore = () => {
|
||||
chrome.storage.local.remove([
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { OperationVariables } from '@apollo/client';
|
||||
import { DocumentNode } from 'graphql';
|
||||
|
||||
import { isDefined } from 'twenty-shared';
|
||||
import getApolloClient from '~/utils/apolloClient';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const callQuery = async <T>(
|
||||
query: DocumentNode,
|
||||
|
||||
@@ -1,27 +1,14 @@
|
||||
# Makefile for building Twenty CRM docker images.
|
||||
# Set the tag and/or target build platform using make command-line variables assignments.
|
||||
#
|
||||
# Optional make variables:
|
||||
# PLATFORM - defaults to 'linux/amd64'
|
||||
# TAG - defaults to 'latest'
|
||||
#
|
||||
# Example: make
|
||||
# Example: make PLATFORM=linux/aarch64 TAG=my-tag
|
||||
|
||||
PLATFORM ?= linux/amd64
|
||||
TAG ?= latest
|
||||
|
||||
prod-build:
|
||||
@cd ../.. && docker build -f ./packages/twenty-docker/twenty/Dockerfile --platform $(PLATFORM) --tag twenty:$(TAG) . && cd -
|
||||
@cd ../.. && docker build -f ./packages/twenty-docker/twenty/Dockerfile --tag twenty . && cd -
|
||||
|
||||
prod-run:
|
||||
@docker run -d -p 3000:3000 --name twenty twenty:$(TAG)
|
||||
@docker run -d -p 3000:3000 --name twenty twenty
|
||||
|
||||
prod-postgres-run:
|
||||
@docker run -d -p 5432:5432 -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres --name twenty-postgres twenty-postgres:$(TAG)
|
||||
@docker run -d -p 5432:5432 -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres --name twenty-postgres twenty-postgres
|
||||
|
||||
prod-website-build:
|
||||
@cd ../.. && docker build -f ./packages/twenty-docker/twenty-website/Dockerfile --platform $(PLATFORM) --tag twenty-website:$(TAG) . && cd -
|
||||
@cd ../.. && docker build -f ./packages/twenty-docker/twenty-website/Dockerfile --tag twenty-website . && cd -
|
||||
|
||||
prod-website-run:
|
||||
@docker run -d -p 3000:3000 --name twenty-website twenty-website:$(TAG)
|
||||
@docker run -d -p 3000:3000 --name twenty-website twenty-website
|
||||
|
||||
@@ -30,32 +30,7 @@ services:
|
||||
STORAGE_S3_NAME: ${STORAGE_S3_NAME}
|
||||
STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT}
|
||||
|
||||
APP_SECRET: ${APP_SECRET:-replace_me_with_a_random_string}
|
||||
|
||||
# MESSAGING_PROVIDER_GMAIL_ENABLED: ${MESSAGING_PROVIDER_GMAIL_ENABLED}
|
||||
# CALENDAR_PROVIDER_GOOGLE_ENABLED: ${CALENDAR_PROVIDER_GOOGLE_ENABLED}
|
||||
# AUTH_GOOGLE_CLIENT_ID: ${AUTH_GOOGLE_CLIENT_ID}
|
||||
# AUTH_GOOGLE_CLIENT_SECRET: ${AUTH_GOOGLE_CLIENT_SECRET}
|
||||
# AUTH_GOOGLE_CALLBACK_URL: ${AUTH_GOOGLE_CALLBACK_URL}
|
||||
# AUTH_GOOGLE_APIS_CALLBACK_URL: ${AUTH_GOOGLE_APIS_CALLBACK_URL}
|
||||
|
||||
# CALENDAR_PROVIDER_MICROSOFT_ENABLED: ${CALENDAR_PROVIDER_MICROSOFT_ENABLED}
|
||||
# MESSAGING_PROVIDER_MICROSOFT_ENABLED: ${MESSAGING_PROVIDER_MICROSOFT_ENABLED}
|
||||
# AUTH_MICROSOFT_ENABLED: ${AUTH_MICROSOFT_ENABLED}
|
||||
# AUTH_MICROSOFT_CLIENT_ID: ${AUTH_MICROSOFT_CLIENT_ID}
|
||||
# AUTH_MICROSOFT_CLIENT_SECRET: ${AUTH_MICROSOFT_CLIENT_SECRET}
|
||||
# AUTH_MICROSOFT_CALLBACK_URL: ${AUTH_MICROSOFT_CALLBACK_URL}
|
||||
# AUTH_MICROSOFT_APIS_CALLBACK_URL: ${AUTH_MICROSOFT_APIS_CALLBACK_URL}
|
||||
|
||||
# EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:-contact@yourdomain.com}
|
||||
# EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:-"John from YourDomain"}
|
||||
# EMAIL_SYSTEM_ADDRESS: ${EMAIL_SYSTEM_ADDRESS:-system@yourdomain.com}
|
||||
# EMAIL_DRIVER: ${EMAIL_DRIVER:-smtp}
|
||||
# EMAIL_SMTP_HOST: ${EMAIL_SMTP_HOST:-smtp.gmail.com}
|
||||
# EMAIL_SMTP_PORT: ${EMAIL_SMTP_PORT:-465}
|
||||
# EMAIL_SMTP_USER: ${EMAIL_SMTP_USER:-}
|
||||
# EMAIL_SMTP_PASSWORD: ${EMAIL_SMTP_PASSWORD:-}
|
||||
|
||||
APP_SECRET: ${APP_SECRET}
|
||||
depends_on:
|
||||
change-vol-ownership:
|
||||
condition: service_completed_successfully
|
||||
@@ -70,8 +45,6 @@ services:
|
||||
|
||||
worker:
|
||||
image: twentycrm/twenty:${TAG:-latest}
|
||||
volumes:
|
||||
- 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
|
||||
@@ -84,32 +57,7 @@ services:
|
||||
STORAGE_S3_NAME: ${STORAGE_S3_NAME}
|
||||
STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT}
|
||||
|
||||
APP_SECRET: ${APP_SECRET:-replace_me_with_a_random_string}
|
||||
|
||||
# MESSAGING_PROVIDER_GMAIL_ENABLED: ${MESSAGING_PROVIDER_GMAIL_ENABLED}
|
||||
# CALENDAR_PROVIDER_GOOGLE_ENABLED: ${CALENDAR_PROVIDER_GOOGLE_ENABLED}
|
||||
# AUTH_GOOGLE_CLIENT_ID: ${AUTH_GOOGLE_CLIENT_ID}
|
||||
# AUTH_GOOGLE_CLIENT_SECRET: ${AUTH_GOOGLE_CLIENT_SECRET}
|
||||
# AUTH_GOOGLE_CALLBACK_URL: ${AUTH_GOOGLE_CALLBACK_URL}
|
||||
# AUTH_GOOGLE_APIS_CALLBACK_URL: ${AUTH_GOOGLE_APIS_CALLBACK_URL}
|
||||
|
||||
# CALENDAR_PROVIDER_MICROSOFT_ENABLED: ${CALENDAR_PROVIDER_MICROSOFT_ENABLED}
|
||||
# MESSAGING_PROVIDER_MICROSOFT_ENABLED: ${MESSAGING_PROVIDER_MICROSOFT_ENABLED}
|
||||
# AUTH_MICROSOFT_ENABLED: ${AUTH_MICROSOFT_ENABLED}
|
||||
# AUTH_MICROSOFT_CLIENT_ID: ${AUTH_MICROSOFT_CLIENT_ID}
|
||||
# AUTH_MICROSOFT_CLIENT_SECRET: ${AUTH_MICROSOFT_CLIENT_SECRET}
|
||||
# AUTH_MICROSOFT_CALLBACK_URL: ${AUTH_MICROSOFT_CALLBACK_URL}
|
||||
# AUTH_MICROSOFT_APIS_CALLBACK_URL: ${AUTH_MICROSOFT_APIS_CALLBACK_URL}
|
||||
|
||||
# EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:-contact@yourdomain.com}
|
||||
# EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:-"John from YourDomain"}
|
||||
# EMAIL_SYSTEM_ADDRESS: ${EMAIL_SYSTEM_ADDRESS:-system@yourdomain.com}
|
||||
# EMAIL_DRIVER: ${EMAIL_DRIVER:-smtp}
|
||||
# EMAIL_SMTP_HOST: ${EMAIL_SMTP_HOST:-smtp.gmail.com}
|
||||
# EMAIL_SMTP_PORT: ${EMAIL_SMTP_PORT:-465}
|
||||
# EMAIL_SMTP_USER: ${EMAIL_SMTP_USER:-}
|
||||
# EMAIL_SMTP_PASSWORD: ${EMAIL_SMTP_PASSWORD:-}
|
||||
|
||||
APP_SECRET: ${APP_SECRET}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -118,12 +66,14 @@ services:
|
||||
restart: always
|
||||
|
||||
db:
|
||||
image: postgres:16
|
||||
image: twentycrm/twenty-postgres-spilo:${TAG:-latest}
|
||||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
- db-data:/home/postgres/pgdata
|
||||
environment:
|
||||
POSTGRES_USER: ${PG_DATABASE_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${PG_DATABASE_PASSWORD:-postgres}
|
||||
PGUSER_SUPERUSER: ${PG_DATABASE_USER:-postgres}
|
||||
PGPASSWORD_SUPERUSER: ${PG_DATABASE_PASSWORD:-postgres}
|
||||
ALLOW_NOSSL: "true"
|
||||
SPILO_PROVIDER: "local"
|
||||
healthcheck:
|
||||
test: pg_isready -U ${PG_DATABASE_USER:-postgres} -h localhost -d postgres
|
||||
interval: 5s
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
# README
|
||||
DISCLAIMER: The k8s and podman deployments are not maintained by the core team.
|
||||
These files are provided and maintained by the community. Twenty core team
|
||||
maintains support for docker deployment.
|
||||
|
||||
## Overview
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
# How to deploy twenty on podman
|
||||
|
||||
DISCLAIMER: The k8s and podman deployments are not maintained by the core team.
|
||||
These files are provided and maintained by the community. Twenty core team
|
||||
maintains support for docker deployment.
|
||||
|
||||
|
||||
## How to use
|
||||
|
||||
1. Edit `.env` file. At the minimum set `POSTGRES_PASSWORD`, `SERVER_URL`, and `APP_SECRET`.
|
||||
2. Start twenty by running `podman-compose up -d`.
|
||||
|
||||
If you need to stop twenty, you can do so by running `podman-compose down`.
|
||||
|
||||
|
||||
### Install systemd service (optional)
|
||||
|
||||
If you want to install a systemd service to run twenty, you can use the provided systemd service.
|
||||
|
||||
Edit `twentycrm.service` and change these two variables:
|
||||
|
||||
|
||||
WorkingDirectory=/opt/apps/twenty
|
||||
EnvironmentFile=/opt/apps/twenty/.env
|
||||
|
||||
`WorkingDirectory` should be changed to the path in which `podman-compose.yml` is located.
|
||||
|
||||
`EnvironmentFile` should be changed to the path in which your `.env`file is located.
|
||||
|
||||
You can run the script `install-systemd-user-service` to install the systemd service under the current user.
|
||||
|
||||
|
||||
./install-systemd-user-service
|
||||
|
||||
Note: this script will enable the service and also start it. So it will assume that twenty is not currently running.
|
||||
If you started it previously, bring it down using:
|
||||
|
||||
podman-compose down
|
||||
|
||||
|
||||
|
||||
## Compatibility
|
||||
|
||||
These files should be compatible with podman 4.3+.
|
||||
|
||||
I have tested this on Debian GNU/Linux 12 (bookworm) and with the podman that is distributed with the official Debian stable mirrors (podman v4.3.1+ds1-8+deb12u1, podman-compose v1.0.3-3).
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
mkdir -p ~/.config/systemd/user
|
||||
[ -f twentycrm.service ] || { echo "Error: twentycrm.service not found"; exit 1; }
|
||||
cp twentycrm.service ~/.config/systemd/user
|
||||
|
||||
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable twentycrm.service
|
||||
systemctl --user start twentycrm.service
|
||||
@@ -1,71 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
if [ ! -f "$(dirname "$0")/.env" ]; then
|
||||
echo "Error: .env file not found"
|
||||
exit 1
|
||||
fi
|
||||
source "$(dirname "$0")/.env"
|
||||
|
||||
## steps to cleanup and start over from scratch - useful when testing
|
||||
#podman pod stop twenty-pod
|
||||
#podman pod rm twenty-pod
|
||||
#podman volume rm twenty-db-data twenty-server-data twenty-redis-data
|
||||
# end of cleanup
|
||||
|
||||
podman volume create twenty-db-data
|
||||
podman volume create twenty-server-data
|
||||
podman volume create twenty-redis-data
|
||||
|
||||
podman pod create --name twenty-pod -p 127.0.0.1:8080:3000
|
||||
|
||||
podman run -d --pod twenty-pod --name twenty-db \
|
||||
-e POSTGRES_DB=twenty \
|
||||
-e POSTGRES_USER=twenty \
|
||||
-e POSTGRES_PASSWORD="$POSTGRES_PASSWORD" \
|
||||
-v twenty-db-data:/var/lib/postgresql/data:Z \
|
||||
docker.io/library/postgres:16
|
||||
|
||||
podman run -d --pod twenty-pod --name twenty-redis \
|
||||
-v twenty-redis-data:/data:Z \
|
||||
docker.io/library/redis:latest
|
||||
|
||||
podman run -d --pod twenty-pod --name twenty-server \
|
||||
-e NODE_PORT=3000 \
|
||||
-e SERVER_URL="$SERVER_URL" \
|
||||
-e PG_DATABASE_URL="postgresql://twenty:$POSTGRES_PASSWORD@twenty-db:5432/twenty" \
|
||||
-e REDIS_URL="redis://twenty-redis:6379" \
|
||||
-e APP_SECRET="$APP_SECRET" \
|
||||
-e NODE_ENV=production \
|
||||
-e LOG_LEVEL=info \
|
||||
-v twenty-server-data:/app/docker-data:Z \
|
||||
docker.io/twentycrm/twenty:latest
|
||||
|
||||
podman run -d --pod twenty-pod --name twenty-worker \
|
||||
--init \
|
||||
-e SERVER_URL="$SERVER_URL" \
|
||||
-e PG_DATABASE_URL="postgresql://twenty:$POSTGRES_PASSWORD@twenty-db:5432/twenty" \
|
||||
-e REDIS_URL="redis://twenty-redis:6379" \
|
||||
-e APP_SECRET="$APP_SECRET" \
|
||||
-e DISABLE_DB_MIGRATIONS=true \
|
||||
-e NODE_ENV=production \
|
||||
-e LOG_LEVEL=info \
|
||||
-v twenty-server-data:/app/docker-data:Z \
|
||||
docker.io/twentycrm/twenty:latest \
|
||||
yarn worker:prod
|
||||
|
||||
# wait some time, check status
|
||||
sleep 30
|
||||
podman ps --pod -f name=twenty-pod
|
||||
|
||||
|
||||
mkdir -p ~/.config/systemd/user
|
||||
if [ ! -f "twentycrm.service" ]; then
|
||||
echo "Error: twentycrm.service file not found"
|
||||
exit 1
|
||||
fi
|
||||
cp twentycrm.service ~/.config/systemd/user
|
||||
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable twentycrm.service
|
||||
systemctl --user start twentycrm.service
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
db:
|
||||
container_name: twenty-db
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_DB: twenty
|
||||
POSTGRES_USER: twenty
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- twenty-db-data:/var/lib/postgresql/data:Z
|
||||
|
||||
redis:
|
||||
container_name: twenty-redis
|
||||
image: redis:latest
|
||||
volumes:
|
||||
- twenty-redis-data:/data:Z
|
||||
|
||||
server:
|
||||
container_name: twenty-server
|
||||
image: twentycrm/twenty:latest
|
||||
environment:
|
||||
NODE_PORT: "3000"
|
||||
SERVER_URL: ${SERVER_URL}
|
||||
PG_DATABASE_URL: "postgresql://twenty:${POSTGRES_PASSWORD}@twenty-db:5432/twenty"
|
||||
REDIS_URL: "redis://twenty-redis:6379"
|
||||
APP_SECRET: ${APP_SECRET}
|
||||
NODE_ENV: production
|
||||
LOG_LEVEL: info
|
||||
volumes:
|
||||
- twenty-server-data:/app/docker-data:Z
|
||||
ports:
|
||||
- 127.0.0.1:8080:3000
|
||||
|
||||
worker:
|
||||
container_name: twenty-worker
|
||||
image: twentycrm/twenty:latest
|
||||
command: yarn worker:prod
|
||||
environment:
|
||||
SERVER_URL: ${SERVER_URL}
|
||||
PG_DATABASE_URL: "postgresql://twenty:${POSTGRES_PASSWORD}@twenty-db:5432/twenty"
|
||||
REDIS_URL: "redis://twenty-redis:6379"
|
||||
APP_SECRET: ${APP_SECRET}
|
||||
DISABLE_DB_MIGRATIONS: "true"
|
||||
NODE_ENV: production
|
||||
LOG_LEVEL: info
|
||||
volumes:
|
||||
- twenty-server-data:/app/docker-data:Z
|
||||
init: true
|
||||
|
||||
volumes:
|
||||
twenty-db-data:
|
||||
twenty-server-data:
|
||||
twenty-redis-data:
|
||||
@@ -1,15 +0,0 @@
|
||||
[Unit]
|
||||
Description=TwentyCRM Container Stack via Podman-Compose
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/apps/twenty
|
||||
EnvironmentFile=/opt/apps/twenty/.env
|
||||
ExecStart=/usr/bin/podman-compose -f podman-compose.yml up -d
|
||||
ExecStop=/usr/bin/podman-compose -f podman-compose.yml down
|
||||
RemainAfterExit=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
@@ -8,18 +8,10 @@ COPY ./yarn.lock .
|
||||
COPY ./.yarnrc.yml .
|
||||
COPY ./.yarn/releases /app/.yarn/releases
|
||||
COPY ./tools/eslint-rules /app/tools/eslint-rules
|
||||
COPY ./packages/twenty-ui/package.json /app/packages/twenty-ui/
|
||||
COPY ./packages/twenty-shared/package.json /app/packages/twenty-shared/
|
||||
COPY ./packages/twenty-website/package.json /app/packages/twenty-website/package.json
|
||||
|
||||
RUN yarn
|
||||
|
||||
ENV KEYSTATIC_GITHUB_CLIENT_ID="<fake build value>"
|
||||
ENV KEYSTATIC_GITHUB_CLIENT_SECRET="<fake build value>"
|
||||
ENV KEYSTATIC_SECRET="<fake build value>"
|
||||
ENV NEXT_PUBLIC_KEYSTATIC_GITHUB_APP_SLUG="<fake build value>"
|
||||
|
||||
COPY ./packages/twenty-ui /app/packages/twenty-ui
|
||||
COPY ./packages/twenty-website /app/packages/twenty-website
|
||||
RUN npx nx build twenty-website
|
||||
|
||||
@@ -34,9 +26,4 @@ WORKDIR /app/packages/twenty-website
|
||||
LABEL org.opencontainers.image.source=https://github.com/twentyhq/twenty
|
||||
LABEL org.opencontainers.image.description="This image provides a consistent and reproducible environment for the website."
|
||||
|
||||
RUN chown -R 1000 /app
|
||||
|
||||
# Use non root user with uid 1000
|
||||
USER 1000
|
||||
|
||||
CMD ["/bin/sh", "-c", "npx nx start"]
|
||||
@@ -7,7 +7,6 @@ WORKDIR /app
|
||||
COPY ./package.json ./yarn.lock ./.yarnrc.yml ./tsconfig.base.json ./nx.json /app/
|
||||
COPY ./.yarn/releases /app/.yarn/releases
|
||||
|
||||
COPY ./.prettierrc /app/
|
||||
COPY ./packages/twenty-emails/package.json /app/packages/twenty-emails/
|
||||
COPY ./packages/twenty-server/package.json /app/packages/twenty-server/
|
||||
COPY ./packages/twenty-server/patches /app/packages/twenty-server/patches
|
||||
@@ -64,9 +63,8 @@ WORKDIR /app/packages/twenty-server
|
||||
|
||||
ARG REACT_APP_SERVER_BASE_URL
|
||||
ENV REACT_APP_SERVER_BASE_URL $REACT_APP_SERVER_BASE_URL
|
||||
|
||||
ARG APP_VERSION
|
||||
ENV APP_VERSION $APP_VERSION
|
||||
ARG SENTRY_RELEASE
|
||||
ENV SENTRY_RELEASE $SENTRY_RELEASE
|
||||
|
||||
# Copy built applications from previous stages
|
||||
COPY --chown=1000 --from=twenty-server-build /app /app
|
||||
|
||||
@@ -10,14 +10,12 @@ if [ "${DISABLE_DB_MIGRATIONS}" != "true" ] && [ ! -f /app/docker-data/db_status
|
||||
PGPASS=$(echo $PG_DATABASE_URL | awk -F ':' '{print $3}' | awk -F '@' '{print $1}')
|
||||
PGHOST=$(echo $PG_DATABASE_URL | awk -F '@' '{print $2}' | awk -F ':' '{print $1}')
|
||||
PGPORT=$(echo $PG_DATABASE_URL | awk -F ':' '{print $4}' | awk -F '/' '{print $1}')
|
||||
PGDATABASE=$(echo $PG_DATABASE_URL | awk -F ':' '{print $4}' | awk -F '/' '{print $2}')
|
||||
PGPASSWORD=${PGPASS} psql -h ${PGHOST} -p ${PGPORT} -U ${PGUSER} -d postgres -tc "SELECT 1 FROM pg_database WHERE datname = '${PGDATABASE}'" | grep -q 1 || \
|
||||
PGPASSWORD=${PGPASS} psql -h ${PGHOST} -p ${PGPORT} -U ${PGUSER} -d postgres -c "CREATE DATABASE \"${PGDATABASE}\""
|
||||
PGPASSWORD=${PGPASS} psql -h ${PGHOST} -p ${PGPORT} -U ${PGUSER} -d postgres -tc "SELECT 1 FROM pg_database WHERE datname = 'default'" | grep -q 1 || \
|
||||
PGPASSWORD=${PGPASS} psql -h ${PGHOST} -p ${PGPORT} -U ${PGUSER} -d postgres -c "CREATE DATABASE \"default\""
|
||||
|
||||
# Run setup and migration scripts
|
||||
NODE_OPTIONS="--max-old-space-size=1500" tsx ./scripts/setup-db.ts
|
||||
yarn database:migrate:prod
|
||||
yarn command:prod upgrade
|
||||
|
||||
# Mark initialization as done
|
||||
echo "Successfuly migrated DB!"
|
||||
|
||||
@@ -3,4 +3,18 @@ FRONTEND_BASE_URL=http://localhost:3001
|
||||
BACKEND_BASE_URL=http://localhost:3000
|
||||
DEFAULT_LOGIN=tim@apple.dev
|
||||
DEFAULT_PASSWORD=Applecar2025
|
||||
WEBSITE_URL=https://twenty.com
|
||||
WEBSITE_URL=https://twenty.com
|
||||
|
||||
# === DO NOT USE, WORK IN PROGRESS ===
|
||||
# This URL must have trailing forward slash as all REST API endpoints have object after it
|
||||
# Documentation for REST API: https://twenty.com/developers/rest-api/core#/
|
||||
# REST_API_BASE_URL=http://localhost:3000/rest/
|
||||
|
||||
# Documentation for GraphQL API: https://twenty.com/developers/graphql/core
|
||||
# GRAPHQL_BASE_URL=http://localhost:3000/graphql
|
||||
|
||||
# Without this key, all API tests will fail, to generate this key
|
||||
# Log in to Twenty workspace, go to Settings > Developers, generate new key and paste it here
|
||||
# In order to use it, header Authorization: Bearer token must be used
|
||||
# This key works for both REST and GraphQL API
|
||||
# API_DEV_KEY=fill_with_proper_key
|
||||
@@ -17,15 +17,12 @@ export class WorkflowVisualizerPage {
|
||||
readonly deactivateWorkflowButton: Locator;
|
||||
readonly addTriggerButton: Locator;
|
||||
readonly commandMenu: Locator;
|
||||
readonly stepHeaderInCommandMenu: Locator;
|
||||
readonly workflowNameLabel: Locator;
|
||||
readonly triggerNode: Locator;
|
||||
readonly background: Locator;
|
||||
readonly useAsDraftButton: Locator;
|
||||
readonly overrideDraftButton: Locator;
|
||||
readonly discardDraftButton: Locator;
|
||||
readonly seeRunsButton: Locator;
|
||||
readonly goBackInCommandMenu: Locator;
|
||||
|
||||
#actionNames: Record<WorkflowActionType, string> = {
|
||||
'create-record': 'Create Record',
|
||||
@@ -33,7 +30,6 @@ export class WorkflowVisualizerPage {
|
||||
'delete-record': 'Delete Record',
|
||||
code: 'Code',
|
||||
'send-email': 'Send Email',
|
||||
form: 'Form',
|
||||
};
|
||||
|
||||
#createdActionNames: Record<WorkflowActionType, string> = {
|
||||
@@ -42,7 +38,6 @@ export class WorkflowVisualizerPage {
|
||||
'delete-record': 'Delete Record',
|
||||
code: 'Code - Serverless Function',
|
||||
'send-email': 'Send Email',
|
||||
form: 'Form',
|
||||
};
|
||||
|
||||
#triggerNames: Record<WorkflowTriggerType, string> = {
|
||||
@@ -56,7 +51,7 @@ export class WorkflowVisualizerPage {
|
||||
'record-created': 'Record is Created',
|
||||
'record-updated': 'Record is Updated',
|
||||
'record-deleted': 'Record is Deleted',
|
||||
manual: 'Launch manually',
|
||||
manual: 'Manual Trigger',
|
||||
};
|
||||
|
||||
constructor({ page, workflowName }: { page: Page; workflowName: string }) {
|
||||
@@ -73,9 +68,6 @@ export class WorkflowVisualizerPage {
|
||||
});
|
||||
this.addTriggerButton = page.getByText('Add a Trigger');
|
||||
this.commandMenu = page.getByTestId('command-menu');
|
||||
this.stepHeaderInCommandMenu = this.commandMenu.getByTestId(
|
||||
'workflow-step-header',
|
||||
);
|
||||
this.workflowNameLabel = page
|
||||
.getByTestId('top-bar-title')
|
||||
.getByText(this.workflowName);
|
||||
@@ -88,10 +80,6 @@ export class WorkflowVisualizerPage {
|
||||
this.discardDraftButton = page.getByRole('button', {
|
||||
name: 'Discard Draft',
|
||||
});
|
||||
this.seeRunsButton = page.getByRole('link', { name: 'See runs' });
|
||||
this.goBackInCommandMenu = this.commandMenu
|
||||
.getByRole('button')
|
||||
.and(this.commandMenu.getByTestId('command-menu-go-back-button'));
|
||||
}
|
||||
|
||||
async createOneWorkflow() {
|
||||
@@ -116,19 +104,11 @@ export class WorkflowVisualizerPage {
|
||||
}
|
||||
|
||||
async goToWorkflowVisualizerPage() {
|
||||
await this.#page.goto(`/`);
|
||||
await Promise.all([
|
||||
this.#page.goto(`/object/workflow/${this.workflowId}`),
|
||||
|
||||
const workflowsLink = this.#page.getByRole('link', { name: 'Workflows' });
|
||||
|
||||
await workflowsLink.click();
|
||||
|
||||
const workflowLink = this.#page
|
||||
.getByTestId(`row-id-${this.workflowId}`)
|
||||
.getByRole('link', { name: this.workflowName });
|
||||
|
||||
await workflowLink.click({ force: true });
|
||||
|
||||
await this.waitForWorkflowVisualizerLoad();
|
||||
this.waitForWorkflowVisualizerLoad(),
|
||||
]);
|
||||
}
|
||||
|
||||
async createInitialTrigger(trigger: WorkflowTriggerType) {
|
||||
@@ -250,72 +230,35 @@ export class WorkflowVisualizerPage {
|
||||
}
|
||||
|
||||
async closeSidePanel() {
|
||||
const closeButton = this.#page.getByRole('button', {
|
||||
name: 'Close command menu',
|
||||
});
|
||||
const closeButton = this.#page.getByTestId(
|
||||
'page-header-command-menu-button',
|
||||
);
|
||||
|
||||
await closeButton.click();
|
||||
|
||||
await expect(this.#page.getByTestId('command-menu')).not.toBeVisible();
|
||||
}
|
||||
|
||||
async goToWorkflowsIndexPage() {
|
||||
await this.#page.goto('/objects/workflows');
|
||||
}
|
||||
|
||||
async setWorkflowsOpenInMode(mode: 'side-panel' | 'record-page') {
|
||||
const recordTableOptionsButton = this.#page.getByText('Options');
|
||||
await recordTableOptionsButton.click();
|
||||
|
||||
const layoutButton = this.#page.getByText('Layout');
|
||||
await layoutButton.click();
|
||||
|
||||
const openInButton = this.#page.getByText('Open in');
|
||||
await openInButton.click();
|
||||
|
||||
if (mode === 'side-panel') {
|
||||
const openInSidePanelOption = this.#page.getByRole('option', {
|
||||
name: 'Side Panel',
|
||||
});
|
||||
|
||||
await openInSidePanelOption.click();
|
||||
} else {
|
||||
const openInRecordPageOption = this.#page.getByRole('option', {
|
||||
name: 'Record Page',
|
||||
});
|
||||
|
||||
await openInRecordPageOption.click();
|
||||
}
|
||||
|
||||
// Close the dropdown
|
||||
await recordTableOptionsButton.click();
|
||||
}
|
||||
}
|
||||
|
||||
export const test = base.extend<{
|
||||
workflowVisualizer: WorkflowVisualizerPage;
|
||||
}>({
|
||||
workflowVisualizer: async ({ page }, use) => {
|
||||
const workflowVisualizer = new WorkflowVisualizerPage({
|
||||
page,
|
||||
workflowName: 'Test Workflow',
|
||||
});
|
||||
export const test = base.extend<{ workflowVisualizer: WorkflowVisualizerPage }>(
|
||||
{
|
||||
workflowVisualizer: async ({ page }, use) => {
|
||||
const workflowVisualizer = new WorkflowVisualizerPage({
|
||||
page,
|
||||
workflowName: 'Test Workflow',
|
||||
});
|
||||
|
||||
await workflowVisualizer.createOneWorkflow();
|
||||
await workflowVisualizer.goToWorkflowVisualizerPage();
|
||||
await workflowVisualizer.createOneWorkflow();
|
||||
await workflowVisualizer.goToWorkflowVisualizerPage();
|
||||
|
||||
await use(workflowVisualizer);
|
||||
await use(workflowVisualizer);
|
||||
|
||||
await deleteWorkflow({
|
||||
page,
|
||||
workflowId: workflowVisualizer.workflowId,
|
||||
});
|
||||
await destroyWorkflow({
|
||||
page,
|
||||
workflowId: workflowVisualizer.workflowId,
|
||||
});
|
||||
|
||||
await workflowVisualizer.goToWorkflowsIndexPage();
|
||||
await workflowVisualizer.setWorkflowsOpenInMode('record-page');
|
||||
await deleteWorkflow({
|
||||
page,
|
||||
workflowId: workflowVisualizer.workflowId,
|
||||
});
|
||||
await destroyWorkflow({
|
||||
page,
|
||||
workflowId: workflowVisualizer.workflowId,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ export const test = base.extend<{ screenshotHook: void }>({
|
||||
),
|
||||
});
|
||||
},
|
||||
{ auto: true },
|
||||
{ auto: true }, // automatic fixture runs with every test
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ export class InsertFieldData {
|
||||
'//label[contains(., "POST CODE")]/../div[last()]/input',
|
||||
);
|
||||
this.countrySelect = page.locator(
|
||||
'//span[contains(., "COUNTRY")]/../div[last()]/div',
|
||||
'//span[contains(., "COUNTRY")]/../div[last()]/input',
|
||||
);
|
||||
this.arrayValueInput = page.locator("//input[@placeholder='Enter value']");
|
||||
this.arrayAddValueButton = page.locator(
|
||||
|
||||
@@ -1,55 +1,5 @@
|
||||
import { Locator, Page } from '@playwright/test';
|
||||
|
||||
export class StripePage {
|
||||
private readonly cardNumberInput: Locator;
|
||||
private readonly cardExpiryInput: Locator;
|
||||
private readonly cardCvcInput: Locator;
|
||||
private readonly cardholderNameInput: Locator;
|
||||
private readonly startTrialButton: Locator;
|
||||
private readonly cancelSubscriptionButton: Locator;
|
||||
private readonly confirmButton: Locator;
|
||||
private readonly returnToTwentyLink: Locator;
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.cardNumberInput = page.getByPlaceholder('1234 1234 1234 1234');
|
||||
this.cardExpiryInput = page.getByPlaceholder('MM / YY');
|
||||
this.cardCvcInput = page.getByPlaceholder('CVC');
|
||||
this.cardholderNameInput = page.getByPlaceholder('Full name on card');
|
||||
this.startTrialButton = page.getByTestId('hosted-payment-submit-button');
|
||||
this.cancelSubscriptionButton = page.locator(
|
||||
'a[data-test="cancel-subscription"]',
|
||||
);
|
||||
this.confirmButton = page.getByTestId('confirm');
|
||||
this.returnToTwentyLink = page.getByTestId('return-to-business-link');
|
||||
}
|
||||
|
||||
async fillCardNumber(number: string) {
|
||||
await this.cardNumberInput.fill(number);
|
||||
}
|
||||
|
||||
async fillCardExpiry(expiry: string) {
|
||||
await this.cardExpiryInput.fill(expiry);
|
||||
}
|
||||
|
||||
async fillCardCvc(cvc: string) {
|
||||
await this.cardCvcInput.fill(cvc);
|
||||
}
|
||||
|
||||
async fillCardholderName(name: string) {
|
||||
await this.cardholderNameInput.fill(name);
|
||||
}
|
||||
|
||||
async startTrial() {
|
||||
await this.startTrialButton.click();
|
||||
}
|
||||
|
||||
async clickCancelSubscription() {
|
||||
await this.cancelSubscriptionButton.click();
|
||||
await this.confirmButton.click();
|
||||
await this.page.getByTestId('cancellation_reason_cancel').click();
|
||||
}
|
||||
|
||||
async returnToTwenty() {
|
||||
await this.returnToTwentyLink.click();
|
||||
}
|
||||
// TODO: implement all necessary methods (staging/sandbox page - does it differ anyhow from normal page?)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export class MainPage {
|
||||
private readonly hiddenFieldsButton: Locator;
|
||||
private readonly editFieldsButton: Locator;
|
||||
private readonly importButton: Locator;
|
||||
private readonly exportButton: Locator;
|
||||
private readonly deletedRecordsButton: Locator;
|
||||
private readonly createNewRecordButton: Locator;
|
||||
private readonly addToFavoritesButton: Locator;
|
||||
@@ -66,12 +67,16 @@ export class MainPage {
|
||||
this.importButton = page
|
||||
.getByTestId('tooltip')
|
||||
.filter({ hasText: /^Import$/ });
|
||||
this.exportButton = page
|
||||
.getByTestId('tooltip')
|
||||
.filter({ hasText: /^Export$/ });
|
||||
this.deletedRecordsButton = page
|
||||
.getByTestId('tooltip')
|
||||
.filter({ hasText: /^Deleted */ });
|
||||
this.createNewRecordButton = page.getByTestId('add-button');
|
||||
this.addToFavoritesButton = page.getByText('Add to favorites');
|
||||
this.deleteFromFavoritesButton = page.getByText('Delete from favorites');
|
||||
this.exportBottomBarButton = page.getByText('Export');
|
||||
this.deleteRecordsButton = page.getByText('Delete');
|
||||
}
|
||||
|
||||
@@ -161,6 +166,10 @@ export class MainPage {
|
||||
await this.importButton.click();
|
||||
}
|
||||
|
||||
async clickExportButton() {
|
||||
await this.exportButton.click();
|
||||
}
|
||||
|
||||
async clickDeletedRecordsButton() {
|
||||
await this.deletedRecordsButton.click();
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ export class AccountsSection {
|
||||
private readonly addBlocklistField: Locator;
|
||||
private readonly addBlocklistButton: Locator;
|
||||
private readonly connectWithGoogleButton: Locator;
|
||||
private readonly connectWithMicrosoftButton: Locator;
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.page = page;
|
||||
@@ -23,9 +22,6 @@ export class AccountsSection {
|
||||
this.connectWithGoogleButton = page.getByRole('button', {
|
||||
name: 'Connect with Google',
|
||||
});
|
||||
this.connectWithMicrosoftButton = page.getByRole('button', {
|
||||
name: 'Connect with Microsoft',
|
||||
});
|
||||
}
|
||||
|
||||
async clickAddAccount() {
|
||||
@@ -55,8 +51,4 @@ export class AccountsSection {
|
||||
async linkGoogleAccount() {
|
||||
await this.connectWithGoogleButton.click();
|
||||
}
|
||||
|
||||
async linkMicrosoftAccount() {
|
||||
await this.connectWithMicrosoftButton.click();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { Locator, Page } from '@playwright/test';
|
||||
|
||||
export class APIsSection {
|
||||
private readonly createAPIKeyButton: Locator;
|
||||
private readonly regenerateAPIKeyButton: Locator;
|
||||
private readonly nameOfAPIKeyInput: Locator;
|
||||
private readonly expirationDateAPIKeySelect: Locator;
|
||||
private readonly cancelButton: Locator;
|
||||
private readonly saveButton: Locator;
|
||||
private readonly deleteButton: Locator;
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.page = page;
|
||||
this.createAPIKeyButton = page.getByRole('link', {
|
||||
name: 'Create API Key',
|
||||
});
|
||||
this.nameOfAPIKeyInput = page
|
||||
.getByPlaceholder('E.g. backoffice integration')
|
||||
.first();
|
||||
this.expirationDateAPIKeySelect = page.locator(
|
||||
'div[aria-controls="object-field-type-select-options"]',
|
||||
);
|
||||
this.regenerateAPIKeyButton = page.getByRole('button', {
|
||||
name: 'Regenerate Key',
|
||||
});
|
||||
this.cancelButton = page.getByRole('button', { name: 'Cancel' });
|
||||
this.saveButton = page.getByRole('button', { name: 'Save' });
|
||||
this.deleteButton = page.getByRole('button', { name: 'Delete' });
|
||||
}
|
||||
|
||||
async createAPIKey() {
|
||||
await this.createAPIKeyButton.click();
|
||||
}
|
||||
|
||||
async typeAPIKeyName(name: string) {
|
||||
await this.nameOfAPIKeyInput.clear();
|
||||
await this.nameOfAPIKeyInput.fill(name);
|
||||
}
|
||||
|
||||
async selectAPIExpirationDate(date: string) {
|
||||
await this.expirationDateAPIKeySelect.click();
|
||||
await this.page.getByText(date, { exact: true }).click();
|
||||
}
|
||||
|
||||
async regenerateAPIKey() {
|
||||
await this.regenerateAPIKeyButton.click();
|
||||
}
|
||||
|
||||
async deleteAPIKey() {
|
||||
await this.deleteButton.click();
|
||||
}
|
||||
|
||||
async clickCancelButton() {
|
||||
await this.cancelButton.click();
|
||||
}
|
||||
|
||||
async clickSaveButton() {
|
||||
await this.saveButton.click();
|
||||
}
|
||||
|
||||
async checkAPIKeyDetails(name: string) {
|
||||
await this.page.locator(`//a/div[contains(.,'${name}')][first()]`).click();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Locator, Page } from '@playwright/test';
|
||||
|
||||
export class DevelopersSection {
|
||||
private readonly readDocumentationButton: Locator;
|
||||
private readonly createAPIKeyButton: Locator;
|
||||
private readonly regenerateAPIKeyButton: Locator;
|
||||
private readonly nameOfAPIKeyInput: Locator;
|
||||
private readonly expirationDateAPIKeySelect: Locator;
|
||||
private readonly createWebhookButton: Locator;
|
||||
private readonly webhookURLInput: Locator;
|
||||
private readonly webhookDescription: Locator;
|
||||
private readonly webhookFilterObjectSelect: Locator;
|
||||
private readonly webhookFilterActionSelect: Locator;
|
||||
private readonly cancelButton: Locator;
|
||||
private readonly saveButton: Locator;
|
||||
private readonly deleteButton: Locator;
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.page = page;
|
||||
this.readDocumentationButton = page.getByRole('link', {
|
||||
name: 'Read documentation',
|
||||
});
|
||||
this.createAPIKeyButton = page.getByRole('link', {
|
||||
name: 'Create API Key',
|
||||
});
|
||||
this.createWebhookButton = page.getByRole('link', {
|
||||
name: 'Create Webhook',
|
||||
});
|
||||
this.nameOfAPIKeyInput = page
|
||||
.getByPlaceholder('E.g. backoffice integration')
|
||||
.first();
|
||||
this.expirationDateAPIKeySelect = page
|
||||
.locator('div')
|
||||
.filter({ hasText: /^Never$/ })
|
||||
.nth(3); // good enough if expiration date will change only 1 time
|
||||
this.regenerateAPIKeyButton = page.getByRole('button', {
|
||||
name: 'Regenerate Key',
|
||||
});
|
||||
this.webhookURLInput = page.getByPlaceholder('URL');
|
||||
this.webhookDescription = page.getByPlaceholder('Write a description');
|
||||
this.webhookFilterObjectSelect = page
|
||||
.locator('div')
|
||||
.filter({ hasText: /^All Objects$/ })
|
||||
.nth(3); // works only for first filter
|
||||
this.webhookFilterActionSelect = page
|
||||
.locator('div')
|
||||
.filter({ hasText: /^All Actions$/ })
|
||||
.nth(3); // works only for first filter
|
||||
this.cancelButton = page.getByRole('button', { name: 'Cancel' });
|
||||
this.saveButton = page.getByRole('button', { name: 'Save' });
|
||||
this.deleteButton = page.getByRole('button', { name: 'Delete' });
|
||||
}
|
||||
|
||||
async openDocumentation() {
|
||||
await this.readDocumentationButton.click();
|
||||
}
|
||||
|
||||
async createAPIKey() {
|
||||
await this.createAPIKeyButton.click();
|
||||
}
|
||||
|
||||
async typeAPIKeyName(name: string) {
|
||||
await this.nameOfAPIKeyInput.clear();
|
||||
await this.nameOfAPIKeyInput.fill(name);
|
||||
}
|
||||
|
||||
async selectAPIExpirationDate(date: string) {
|
||||
await this.expirationDateAPIKeySelect.click();
|
||||
await this.page.getByText(date, { exact: true }).click();
|
||||
}
|
||||
|
||||
async regenerateAPIKey() {
|
||||
await this.regenerateAPIKeyButton.click();
|
||||
}
|
||||
|
||||
async deleteAPIKey() {
|
||||
await this.deleteButton.click();
|
||||
}
|
||||
|
||||
async deleteWebhook() {
|
||||
await this.deleteButton.click();
|
||||
}
|
||||
|
||||
async createWebhook() {
|
||||
await this.createWebhookButton.click();
|
||||
}
|
||||
|
||||
async typeWebhookURL(url: string) {
|
||||
await this.webhookURLInput.fill(url);
|
||||
}
|
||||
|
||||
async typeWebhookDescription(description: string) {
|
||||
await this.webhookDescription.fill(description);
|
||||
}
|
||||
|
||||
async selectWebhookObject(object: string) {
|
||||
// TODO: finish
|
||||
}
|
||||
|
||||
async selectWebhookAction(action: string) {
|
||||
// TODO: finish
|
||||
}
|
||||
|
||||
async deleteWebhookFilter() {
|
||||
// TODO: finish
|
||||
}
|
||||
|
||||
async clickCancelButton() {
|
||||
await this.cancelButton.click();
|
||||
}
|
||||
|
||||
async clickSaveButton() {
|
||||
await this.saveButton.click();
|
||||
}
|
||||
|
||||
async checkAPIKeyDetails(name: string) {
|
||||
await this.page.locator(`//a/div[contains(.,'${name}')][first()]`).click();
|
||||
}
|
||||
|
||||
async checkWebhookDetails(name: string) {
|
||||
await this.page.locator(`//a/div[contains(.,'${name}')][first()]`).click();
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,11 @@ export class ExperienceSection {
|
||||
private readonly dateFormatDropdown: Locator;
|
||||
private readonly timeFormatDropdown: Locator;
|
||||
private readonly searchInput: Locator;
|
||||
private readonly languageDropdown: Locator;
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.page = page;
|
||||
this.lightThemeButton = page.locator('div[variant="Light"]').first();
|
||||
this.darkThemeButton = page.locator('div[variant="Dark"]').first();
|
||||
this.lightThemeButton = page.getByText('AaLight'); // it works
|
||||
this.darkThemeButton = page.getByText('AaDark');
|
||||
this.timezoneDropdown = page.locator(
|
||||
'//span[contains(., "Time zone")]/../div/div/div',
|
||||
);
|
||||
@@ -23,9 +22,6 @@ export class ExperienceSection {
|
||||
'//span[contains(., "Time format")]/../div/div/div',
|
||||
);
|
||||
this.searchInput = page.getByPlaceholder('Search');
|
||||
this.languageDropdown = page.locator(
|
||||
'//h2[contains(., "Language")]/../../../div/div/div',
|
||||
);
|
||||
}
|
||||
|
||||
async changeThemeToLight() {
|
||||
@@ -56,9 +52,4 @@ export class ExperienceSection {
|
||||
await this.timeFormatDropdown.click();
|
||||
await this.page.getByText(timeFormat, { exact: true }).click();
|
||||
}
|
||||
|
||||
async selectLanguage(language: string) {
|
||||
await this.languageDropdown.click();
|
||||
await this.page.getByText(language, { exact: true }).click();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Locator, Page } from '@playwright/test';
|
||||
|
||||
export class FunctionsSection {
|
||||
private readonly newFunctionButton: Locator;
|
||||
private readonly functionNameInput: Locator;
|
||||
private readonly functionDescriptionInput: Locator;
|
||||
private readonly editorTab: Locator;
|
||||
private readonly codeEditorField: Locator;
|
||||
private readonly resetButton: Locator;
|
||||
private readonly publishButton: Locator;
|
||||
private readonly testButton: Locator;
|
||||
private readonly testTab: Locator;
|
||||
private readonly runFunctionButton: Locator;
|
||||
private readonly inputField: Locator;
|
||||
private readonly settingsTab: Locator;
|
||||
private readonly searchVariableInput: Locator;
|
||||
private readonly addVariableButton: Locator;
|
||||
private readonly nameVariableInput: Locator;
|
||||
private readonly valueVariableInput: Locator;
|
||||
private readonly cancelVariableButton: Locator;
|
||||
private readonly saveVariableButton: Locator;
|
||||
private readonly editVariableButton: Locator;
|
||||
private readonly deleteVariableButton: Locator;
|
||||
private readonly cancelButton: Locator;
|
||||
private readonly saveButton: Locator;
|
||||
private readonly deleteButton: Locator;
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.newFunctionButton = page.getByRole('button', { name: 'New Function' });
|
||||
this.functionNameInput = page.getByPlaceholder('Name');
|
||||
this.functionDescriptionInput = page.getByPlaceholder('Description');
|
||||
this.editorTab = page.getByTestId('tab-editor');
|
||||
this.codeEditorField = page.getByTestId('dummyInput'); // TODO: fix
|
||||
this.resetButton = page.getByRole('button', { name: 'Reset' });
|
||||
this.publishButton = page.getByRole('button', { name: 'Publish' });
|
||||
this.testButton = page.getByRole('button', { name: 'Test' });
|
||||
this.testTab = page.getByTestId('tab-test');
|
||||
this.runFunctionButton = page.getByRole('button', { name: 'Run Function' });
|
||||
this.inputField = page.getByTestId('dummyInput'); // TODO: fix
|
||||
this.settingsTab = page.getByTestId('tab-settings');
|
||||
this.searchVariableInput = page.getByPlaceholder('Search a variable');
|
||||
this.addVariableButton = page.getByRole('button', { name: 'Add Variable' });
|
||||
this.nameVariableInput = page.getByPlaceholder('Name').nth(1);
|
||||
this.valueVariableInput = page.getByPlaceholder('Value');
|
||||
this.cancelVariableButton = page.locator('.css-uwqduk').first(); // TODO: fix
|
||||
this.saveVariableButton = page.locator('.css-uwqduk').nth(1); // TODO: fix
|
||||
this.editVariableButton = page.getByText('Edit', { exact: true });
|
||||
this.deleteVariableButton = page.getByText('Delete', { exact: true });
|
||||
this.cancelButton = page.getByRole('button', { name: 'Cancel' });
|
||||
this.saveButton = page.getByRole('button', { name: 'Save' });
|
||||
this.deleteButton = page.getByRole('button', { name: 'Delete function' });
|
||||
}
|
||||
|
||||
async clickNewFunction() {
|
||||
await this.newFunctionButton.click();
|
||||
}
|
||||
|
||||
async typeFunctionName(name: string) {
|
||||
await this.functionNameInput.fill(name);
|
||||
}
|
||||
|
||||
async typeFunctionDescription(description: string) {
|
||||
await this.functionDescriptionInput.fill(description);
|
||||
}
|
||||
|
||||
async checkFunctionDetails(name: string) {
|
||||
await this.page.getByRole('link', { name: `${name} nodejs18.x` }).click();
|
||||
}
|
||||
|
||||
async clickEditorTab() {
|
||||
await this.editorTab.click();
|
||||
}
|
||||
|
||||
async clickResetButton() {
|
||||
await this.resetButton.click();
|
||||
}
|
||||
|
||||
async clickPublishButton() {
|
||||
await this.publishButton.click();
|
||||
}
|
||||
|
||||
async clickTestButton() {
|
||||
await this.testButton.click();
|
||||
}
|
||||
|
||||
async typeFunctionCode() {
|
||||
// TODO: finish once utils are merged
|
||||
}
|
||||
|
||||
async clickTestTab() {
|
||||
await this.testTab.click();
|
||||
}
|
||||
|
||||
async runFunction() {
|
||||
await this.runFunctionButton.click();
|
||||
}
|
||||
|
||||
async typeFunctionInput() {
|
||||
// TODO: finish once utils are merged
|
||||
}
|
||||
|
||||
async clickSettingsTab() {
|
||||
await this.settingsTab.click();
|
||||
}
|
||||
|
||||
async searchVariable(name: string) {
|
||||
await this.searchVariableInput.fill(name);
|
||||
}
|
||||
|
||||
async addVariable() {
|
||||
await this.addVariableButton.click();
|
||||
}
|
||||
|
||||
async typeVariableName(name: string) {
|
||||
await this.nameVariableInput.fill(name);
|
||||
}
|
||||
|
||||
async typeVariableValue(value: string) {
|
||||
await this.valueVariableInput.fill(value);
|
||||
}
|
||||
|
||||
async editVariable(name: string) {
|
||||
await this.page
|
||||
.locator(
|
||||
`//div[@data-testid='tooltip' and contains(., '${name}')]/../../div[last()]/div/div/button`,
|
||||
)
|
||||
.click();
|
||||
await this.editVariableButton.click();
|
||||
}
|
||||
|
||||
async deleteVariable(name: string) {
|
||||
await this.page
|
||||
.locator(
|
||||
`//div[@data-testid='tooltip' and contains(., '${name}')]/../../div[last()]/div/div/button`,
|
||||
)
|
||||
.click();
|
||||
await this.deleteVariableButton.click();
|
||||
}
|
||||
|
||||
async cancelVariable() {
|
||||
await this.cancelVariableButton.click();
|
||||
}
|
||||
|
||||
async saveVariable() {
|
||||
await this.saveVariableButton.click();
|
||||
}
|
||||
|
||||
async clickCancelButton() {
|
||||
await this.cancelButton.click();
|
||||
}
|
||||
|
||||
async clickSaveButton() {
|
||||
await this.saveButton.click();
|
||||
}
|
||||
|
||||
async clickDeleteButton() {
|
||||
await this.deleteButton.click();
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ export class GeneralSection {
|
||||
private readonly workspaceNameField: Locator;
|
||||
private readonly supportSwitch: Locator;
|
||||
private readonly deleteWorkspaceButton: Locator;
|
||||
private readonly customizeDomainButton: Locator;
|
||||
private readonly subdomainInput: Locator;
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.page = page;
|
||||
@@ -14,12 +12,6 @@ export class GeneralSection {
|
||||
this.deleteWorkspaceButton = page.getByRole('button', {
|
||||
name: 'Delete workspace',
|
||||
});
|
||||
this.customizeDomainButton = page.getByRole('button', {
|
||||
name: 'Customize domain',
|
||||
});
|
||||
this.subdomainInput = page.locator(
|
||||
'//div[contains(.,".twenty-main.com")]/../input',
|
||||
);
|
||||
}
|
||||
|
||||
async changeWorkspaceName(workspaceName: string) {
|
||||
@@ -34,10 +26,4 @@ export class GeneralSection {
|
||||
async clickDeleteWorkSpaceButton() {
|
||||
await this.deleteWorkspaceButton.click();
|
||||
}
|
||||
|
||||
async changeSubdomain(subdomain: string) {
|
||||
await this.customizeDomainButton.click();
|
||||
await this.subdomainInput.fill(subdomain);
|
||||
await this.page.getByRole('button', { name: 'Save' }).click();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ export class NewFieldSection {
|
||||
private readonly relationFieldLink: Locator;
|
||||
private readonly relationTypeSelect: Locator;
|
||||
private readonly objectDestinationSelect: Locator;
|
||||
private readonly relationIconSelect: Locator;
|
||||
private readonly relationFieldNameInput: Locator;
|
||||
private readonly fullNameFieldLink: Locator;
|
||||
private readonly UUIDFieldLink: Locator;
|
||||
@@ -226,11 +225,6 @@ export class NewFieldSection {
|
||||
await this.page.getByTestId('tooltip').filter({ hasText: name }).click();
|
||||
}
|
||||
|
||||
async selectRelationIcon(name: string) {
|
||||
await this.relationIconSelect.click();
|
||||
await this.page.getByTitle(name).click();
|
||||
}
|
||||
|
||||
async typeRelationName(name: string) {
|
||||
await this.relationFieldNameInput.clear();
|
||||
await this.relationFieldNameInput.fill(name);
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Locator, Page } from '@playwright/test';
|
||||
|
||||
export class RolesSection {
|
||||
private readonly page: Page;
|
||||
private readonly createRoleButton: Locator;
|
||||
private readonly defaultRoleDropdown: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.createRoleButton = page.getByRole('button', { name: 'Create Role' });
|
||||
this.defaultRoleDropdown = page.getByTestId('tooltip');
|
||||
}
|
||||
|
||||
async clickCreateRoleButton() {
|
||||
await this.createRoleButton.click();
|
||||
}
|
||||
|
||||
async selectDefaultRole(role: string) {
|
||||
await this.defaultRoleDropdown.click();
|
||||
await this.page.getByTestId('tooltip').getByText(role).click();
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,10 @@
|
||||
import { Locator, Page } from '@playwright/test';
|
||||
|
||||
export class SecuritySection {
|
||||
private readonly googleToggle: Locator;
|
||||
private readonly microsoftToggle: Locator;
|
||||
private readonly passwordToggle: Locator;
|
||||
private readonly inviteByLinkToggle: Locator;
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.googleToggle = page.getByLabel('Google');
|
||||
this.microsoftToggle = page.getByLabel('Microsoft');
|
||||
this.passwordToggle = page.getByLabel('Password');
|
||||
this.inviteByLinkToggle = page.getByLabel('Invite by Link');
|
||||
}
|
||||
|
||||
async toggleGoogle() {
|
||||
await this.googleToggle.click();
|
||||
}
|
||||
|
||||
async toggleMicrosoft() {
|
||||
await this.microsoftToggle.click();
|
||||
}
|
||||
|
||||
async togglePassword() {
|
||||
await this.passwordToggle.click();
|
||||
this.inviteByLinkToggle = page.locator('input[type="checkbox"]').nth(1);
|
||||
}
|
||||
|
||||
async toggleInviteByLink() {
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Locator, Page } from '@playwright/test';
|
||||
|
||||
export class WebhooksSection {
|
||||
private readonly createWebhookButton: Locator;
|
||||
private readonly webhookURLInput: Locator;
|
||||
private readonly webhookDescription: Locator;
|
||||
private readonly deleteButton: Locator;
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.page = page;
|
||||
this.createWebhookButton = page.getByRole('link', {
|
||||
name: 'Create Webhook',
|
||||
});
|
||||
this.webhookURLInput = page.getByPlaceholder('URL');
|
||||
this.webhookDescription = page.getByPlaceholder('Write a description');
|
||||
this.deleteButton = page.getByRole('button', { name: 'Delete' });
|
||||
}
|
||||
|
||||
async deleteWebhook() {
|
||||
await this.deleteButton.click();
|
||||
}
|
||||
|
||||
async createWebhook() {
|
||||
await this.createWebhookButton.click();
|
||||
}
|
||||
|
||||
async typeWebhookURL(url: string) {
|
||||
await this.webhookURLInput.clear();
|
||||
await this.webhookURLInput.fill(url);
|
||||
}
|
||||
|
||||
async typeWebhookDescription(description: string) {
|
||||
await this.webhookDescription.fill(description);
|
||||
}
|
||||
|
||||
async selectWebhookObject(index: number, object: string) {
|
||||
await this.page
|
||||
.locator(
|
||||
`//div[aria-controls="object-webhook-type-select-${index}-options"]`,
|
||||
)
|
||||
.click();
|
||||
await this.page.getByRole('option').getByText(object).click();
|
||||
}
|
||||
|
||||
async selectWebhookAction(index: number, action: string) {
|
||||
await this.page
|
||||
.locator(
|
||||
`//div[aria-controls="operation-webhook-type-select-${index}-options"]`,
|
||||
)
|
||||
.click();
|
||||
await this.page.getByRole('option').getByText(action).click();
|
||||
}
|
||||
|
||||
async deleteWebhookFilter(index: number) {
|
||||
await this.page
|
||||
.locator(
|
||||
`//div[aria-controls="object-webhook-type-select-${index}-options"]/../..//button`,
|
||||
)
|
||||
.click();
|
||||
}
|
||||
|
||||
async checkWebhookDetails(name: string) {
|
||||
await this.page.locator(`//a/div[contains(.,'${name}')][first()]`).click();
|
||||
}
|
||||
}
|
||||
@@ -9,15 +9,13 @@ export class SettingsPage {
|
||||
private readonly calendarsLink: Locator;
|
||||
private readonly generalLink: Locator;
|
||||
private readonly membersLink: Locator;
|
||||
private readonly rolesLink: Locator;
|
||||
private readonly dataModelLink: Locator;
|
||||
private readonly integrationsLink: Locator;
|
||||
private readonly developersLink: Locator;
|
||||
private readonly functionsLink: Locator;
|
||||
private readonly securityLink: Locator;
|
||||
private readonly apisLink: Locator;
|
||||
private readonly webhooksLink: Locator;
|
||||
private readonly adminPanelLink: Locator;
|
||||
private readonly labLink: Locator;
|
||||
private readonly integrationsLink: Locator;
|
||||
private readonly releasesLink: Locator;
|
||||
private readonly logoutLink: Locator;
|
||||
private readonly advancedToggle: Locator;
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
@@ -30,15 +28,13 @@ export class SettingsPage {
|
||||
this.calendarsLink = page.getByRole('link', { name: 'Calendars' });
|
||||
this.generalLink = page.getByRole('link', { name: 'General' });
|
||||
this.membersLink = page.getByRole('link', { name: 'Members' });
|
||||
this.rolesLink = page.getByRole('link', { name: 'Roles' });
|
||||
this.dataModelLink = page.getByRole('link', { name: 'Data model' });
|
||||
this.developersLink = page.getByRole('link', { name: 'Developers' });
|
||||
this.functionsLink = page.getByRole('link', { name: 'Functions' });
|
||||
this.integrationsLink = page.getByRole('link', { name: 'Integrations' });
|
||||
this.securityLink = page.getByRole('link', { name: 'Security' });
|
||||
this.apisLink = page.getByRole('link', { name: 'APIs' });
|
||||
this.webhooksLink = page.getByRole('link', { name: 'Webhooks' });
|
||||
this.adminPanelLink = page.getByRole('link', { name: 'Admin Panel' });
|
||||
this.labLink = page.getByRole('link', { name: 'Lab' });
|
||||
this.releasesLink = page.getByRole('link', { name: 'Releases' });
|
||||
this.logoutLink = page.getByText('Logout');
|
||||
this.advancedToggle = page.locator('input[type="checkbox"]').first();
|
||||
}
|
||||
|
||||
@@ -74,36 +70,24 @@ export class SettingsPage {
|
||||
await this.membersLink.click();
|
||||
}
|
||||
|
||||
async goToRolesSection() {
|
||||
await this.rolesLink.click();
|
||||
}
|
||||
|
||||
async goToDataModelSection() {
|
||||
await this.dataModelLink.click();
|
||||
}
|
||||
|
||||
async goToIntegrationsSection() {
|
||||
await this.integrationsLink.click();
|
||||
async goToDevelopersSection() {
|
||||
await this.developersLink.click();
|
||||
}
|
||||
|
||||
async goToFunctionsSection() {
|
||||
await this.functionsLink.click();
|
||||
}
|
||||
|
||||
async goToSecuritySection() {
|
||||
await this.securityLink.click();
|
||||
}
|
||||
|
||||
async goToAPIsSection() {
|
||||
await this.apisLink.click();
|
||||
}
|
||||
|
||||
async goToWebhooksSection() {
|
||||
await this.webhooksLink.click();
|
||||
}
|
||||
|
||||
async goToAdminPanelSection() {
|
||||
await this.adminPanelLink.click();
|
||||
}
|
||||
|
||||
async goToLabSection() {
|
||||
await this.labLink.click();
|
||||
async goToIntegrationsSection() {
|
||||
await this.integrationsLink.click();
|
||||
}
|
||||
|
||||
async goToReleasesIntegration() {
|
||||
@@ -111,7 +95,7 @@ export class SettingsPage {
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await this.page.getByText('Logout').click();
|
||||
await this.logoutLink.click();
|
||||
}
|
||||
|
||||
async toggleAdvancedSettings() {
|
||||
|
||||
@@ -9,5 +9,4 @@ export type WorkflowActionType =
|
||||
| 'update-record'
|
||||
| 'delete-record'
|
||||
| 'code'
|
||||
| 'send-email'
|
||||
| 'form';
|
||||
| 'send-email';
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "twenty-e2e-testing",
|
||||
"version": "0.42.11",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.51.0"
|
||||
"@playwright/test": "^1.49.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,7 @@ test('Create workflow', async ({ page }) => {
|
||||
const workflowsLink = page.getByRole('link', { name: 'Workflows' });
|
||||
await workflowsLink.click();
|
||||
|
||||
const createWorkflowButton = page.getByRole('button', {
|
||||
name: 'Create new workflow',
|
||||
});
|
||||
const createWorkflowButton = page.getByRole('button', { name: 'New record' });
|
||||
|
||||
const [createWorkflowResponse] = await Promise.all([
|
||||
page.waitForResponse(async (response) => {
|
||||
@@ -28,7 +26,7 @@ test('Create workflow', async ({ page }) => {
|
||||
createWorkflowButton.click(),
|
||||
]);
|
||||
|
||||
const recordName = page.getByTestId('top-bar-title').getByText('Untitled');
|
||||
const recordName = page.getByTestId('top-bar-title').getByTestId('tooltip');
|
||||
await recordName.click();
|
||||
|
||||
const nameInput = page.getByTestId('top-bar-title').getByRole('textbox');
|
||||
|
||||
@@ -26,7 +26,7 @@ test('The workflow run visualizer shows the executed draft version without the l
|
||||
|
||||
await workflowVisualizer.closeSidePanel();
|
||||
|
||||
const launchTestButton = page.getByLabel(workflowVisualizer.workflowName);
|
||||
const launchTestButton = page.getByRole('button', { name: 'Test' });
|
||||
|
||||
await launchTestButton.click();
|
||||
|
||||
@@ -40,114 +40,21 @@ test('The workflow run visualizer shows the executed draft version without the l
|
||||
|
||||
await page.goto(executionPageUrl!);
|
||||
|
||||
const workflowRunName = page
|
||||
.getByText(`#1 - ${workflowVisualizer.workflowName}`)
|
||||
.nth(1);
|
||||
const workflowRunName = page.getByText('Execution of v1');
|
||||
|
||||
await expect(workflowRunName).toBeVisible();
|
||||
|
||||
const flowTab = page.getByText('Flow', { exact: true });
|
||||
|
||||
await flowTab.click();
|
||||
|
||||
const executedFirstStepNode = workflowVisualizer.getStepNode(firstStepId);
|
||||
|
||||
await expect(executedFirstStepNode).toBeVisible();
|
||||
|
||||
await executedFirstStepNode.click();
|
||||
|
||||
await expect(workflowVisualizer.stepHeaderInCommandMenu).toContainText(
|
||||
'Create Record',
|
||||
);
|
||||
});
|
||||
|
||||
// FIXME: Documented bug. See https://github.com/twentyhq/core-team-issues/issues/921
|
||||
test.fail('Workflow Runs with a pending form step can be opened in the side panel and then in full screen', async ({
|
||||
workflowVisualizer,
|
||||
page,
|
||||
}) => {
|
||||
await workflowVisualizer.createInitialTrigger('manual');
|
||||
|
||||
const manualTriggerAvailabilitySelect = page.getByRole('button', {
|
||||
name: 'When record(s) are selected',
|
||||
});
|
||||
|
||||
await manualTriggerAvailabilitySelect.click();
|
||||
|
||||
const alwaysAvailableOption = page.getByText(
|
||||
'When no record(s) are selected',
|
||||
);
|
||||
|
||||
await alwaysAvailableOption.click();
|
||||
|
||||
await workflowVisualizer.closeSidePanel();
|
||||
|
||||
const { createdStepId: firstStepId } =
|
||||
await workflowVisualizer.createStep('form');
|
||||
|
||||
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',
|
||||
);
|
||||
|
||||
await workflowVisualizer.goBackInCommandMenu.click();
|
||||
|
||||
const workflowRunNameInCommandMenu =
|
||||
workflowVisualizer.commandMenu.getByText(workflowRunName);
|
||||
|
||||
await expect(workflowRunNameInCommandMenu).toBeVisible();
|
||||
|
||||
await workflowVisualizer.triggerNode.click();
|
||||
|
||||
await expect(workflowVisualizer.stepHeaderInCommandMenu).toContainText(
|
||||
'Launch manually',
|
||||
);
|
||||
|
||||
await workflowVisualizer.goBackInCommandMenu.click();
|
||||
|
||||
const formStep = workflowVisualizer.getStepNode(firstStepId);
|
||||
|
||||
await formStep.click();
|
||||
|
||||
await workflowVisualizer.goBackInCommandMenu.click();
|
||||
|
||||
const openInFullScreenButton = workflowVisualizer.commandMenu.getByRole(
|
||||
'button',
|
||||
{ name: 'Open' },
|
||||
);
|
||||
|
||||
await openInFullScreenButton.click();
|
||||
|
||||
const workflowRunNameInShowPage = page
|
||||
.getByText(`#1 - ${workflowVisualizer.workflowName}`)
|
||||
.nth(1);
|
||||
|
||||
await expect(workflowRunNameInShowPage).toBeVisible();
|
||||
|
||||
// Expect the side panel to be opened by default on the form.
|
||||
await expect(workflowVisualizer.stepHeaderInCommandMenu).toContainText(
|
||||
'Form',
|
||||
);
|
||||
await expect(
|
||||
workflowVisualizer.commandMenu.getByRole('textbox').first(),
|
||||
).toHaveValue('Create Record');
|
||||
});
|
||||
|
||||
@@ -8,19 +8,25 @@ test('Use an old version as draft', async ({ workflowVisualizer, page }) => {
|
||||
|
||||
await workflowVisualizer.background.click();
|
||||
|
||||
await workflowVisualizer.activateWorkflowButton.click();
|
||||
await Promise.all([
|
||||
expect(workflowVisualizer.workflowStatus).toHaveText('Active'),
|
||||
|
||||
await expect(workflowVisualizer.workflowStatus).toHaveText('Active');
|
||||
workflowVisualizer.activateWorkflowButton.click(),
|
||||
]);
|
||||
|
||||
await workflowVisualizer.createStep('delete-record');
|
||||
await Promise.all([
|
||||
expect(workflowVisualizer.workflowStatus).toHaveText('Draft'),
|
||||
|
||||
await expect(workflowVisualizer.workflowStatus).toHaveText('Draft');
|
||||
workflowVisualizer.createStep('delete-record'),
|
||||
]);
|
||||
|
||||
await workflowVisualizer.closeSidePanel();
|
||||
await workflowVisualizer.background.click();
|
||||
|
||||
await workflowVisualizer.activateWorkflowButton.click();
|
||||
await Promise.all([
|
||||
expect(workflowVisualizer.workflowStatus).toHaveText('Active'),
|
||||
|
||||
await expect(workflowVisualizer.workflowStatus).toHaveText('Active');
|
||||
workflowVisualizer.activateWorkflowButton.click(),
|
||||
]);
|
||||
|
||||
await expect(workflowVisualizer.triggerNode).toContainText(
|
||||
'Record is Created',
|
||||
@@ -35,8 +41,6 @@ test('Use an old version as draft', async ({ workflowVisualizer, page }) => {
|
||||
const workflowsLink = page.getByRole('link', { name: 'Workflows' });
|
||||
await workflowsLink.click();
|
||||
|
||||
await workflowVisualizer.setWorkflowsOpenInMode('record-page');
|
||||
|
||||
const recordTableRowForWorkflow = page.getByRole('row', {
|
||||
name: workflowVisualizer.workflowName,
|
||||
});
|
||||
@@ -44,7 +48,7 @@ test('Use an old version as draft', async ({ workflowVisualizer, page }) => {
|
||||
const linkToWorkflow = recordTableRowForWorkflow.getByRole('link', {
|
||||
name: workflowVisualizer.workflowName,
|
||||
});
|
||||
await expect(linkToWorkflow).toBeVisible();
|
||||
expect(linkToWorkflow).toBeVisible();
|
||||
|
||||
const linkToFirstWorkflowVersion = recordTableRowForWorkflow.getByRole(
|
||||
'link',
|
||||
@@ -53,7 +57,7 @@ test('Use an old version as draft', async ({ workflowVisualizer, page }) => {
|
||||
},
|
||||
);
|
||||
|
||||
await linkToFirstWorkflowVersion.click({ force: true });
|
||||
await linkToFirstWorkflowVersion.click();
|
||||
|
||||
await expect(workflowVisualizer.workflowStatus).toHaveText('Archived');
|
||||
await expect(workflowVisualizer.useAsDraftButton).toBeVisible();
|
||||
@@ -65,9 +69,11 @@ test('Use an old version as draft', async ({ workflowVisualizer, page }) => {
|
||||
]);
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(1);
|
||||
|
||||
await workflowVisualizer.useAsDraftButton.click();
|
||||
await Promise.all([
|
||||
page.waitForURL(`/object/workflow/${workflowVisualizer.workflowId}`),
|
||||
|
||||
await page.waitForURL(`/object/workflow/${workflowVisualizer.workflowId}`);
|
||||
workflowVisualizer.useAsDraftButton.click(),
|
||||
]);
|
||||
|
||||
await expect(workflowVisualizer.workflowStatus).toHaveText('Draft');
|
||||
await expect(workflowVisualizer.useAsDraftButton).not.toBeVisible();
|
||||
@@ -90,13 +96,17 @@ test('Use an old version as draft while having a pending draft version', async (
|
||||
|
||||
await workflowVisualizer.background.click();
|
||||
|
||||
await workflowVisualizer.activateWorkflowButton.click();
|
||||
await Promise.all([
|
||||
expect(workflowVisualizer.workflowStatus).toHaveText('Active'),
|
||||
|
||||
await expect(workflowVisualizer.workflowStatus).toHaveText('Active');
|
||||
workflowVisualizer.activateWorkflowButton.click(),
|
||||
]);
|
||||
|
||||
await workflowVisualizer.createStep('delete-record');
|
||||
await Promise.all([
|
||||
expect(workflowVisualizer.workflowStatus).toHaveText('Draft'),
|
||||
|
||||
await expect(workflowVisualizer.workflowStatus).toHaveText('Draft');
|
||||
workflowVisualizer.createStep('delete-record'),
|
||||
]);
|
||||
|
||||
await expect(workflowVisualizer.triggerNode).toContainText(
|
||||
'Record is Created',
|
||||
@@ -120,7 +130,7 @@ test('Use an old version as draft while having a pending draft version', async (
|
||||
const linkToWorkflow = recordTableRowForWorkflow.getByRole('link', {
|
||||
name: workflowVisualizer.workflowName,
|
||||
});
|
||||
await expect(linkToWorkflow).toBeVisible();
|
||||
expect(linkToWorkflow).toBeVisible();
|
||||
|
||||
const linkToFirstWorkflowVersion = recordTableRowForWorkflow.getByRole(
|
||||
'link',
|
||||
@@ -129,7 +139,7 @@ test('Use an old version as draft while having a pending draft version', async (
|
||||
},
|
||||
);
|
||||
|
||||
await linkToFirstWorkflowVersion.click({ force: true });
|
||||
await linkToFirstWorkflowVersion.click();
|
||||
|
||||
await expect(workflowVisualizer.workflowStatus).toHaveText('Active');
|
||||
await expect(workflowVisualizer.useAsDraftButton).toBeVisible();
|
||||
@@ -141,13 +151,17 @@ test('Use an old version as draft while having a pending draft version', async (
|
||||
]);
|
||||
await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(1);
|
||||
|
||||
await workflowVisualizer.useAsDraftButton.click();
|
||||
await Promise.all([
|
||||
expect(workflowVisualizer.overrideDraftButton).toBeVisible(),
|
||||
|
||||
await expect(workflowVisualizer.overrideDraftButton).toBeVisible();
|
||||
workflowVisualizer.useAsDraftButton.click(),
|
||||
]);
|
||||
|
||||
await workflowVisualizer.overrideDraftButton.click();
|
||||
await Promise.all([
|
||||
page.waitForURL(`/object/workflow/${workflowVisualizer.workflowId}`),
|
||||
|
||||
await page.waitForURL(`/object/workflow/${workflowVisualizer.workflowId}`);
|
||||
workflowVisualizer.overrideDraftButton.click(),
|
||||
]);
|
||||
|
||||
await expect(workflowVisualizer.workflowStatus).toHaveText('Draft');
|
||||
await expect(workflowVisualizer.useAsDraftButton).not.toBeVisible();
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@nx/react/babel",
|
||||
{
|
||||
"runtime": "automatic",
|
||||
"useBuiltIns": "usage"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": []
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
module.exports = {
|
||||
extends: ['../../.eslintrc.global.cjs', '../../.eslintrc.react.cjs'],
|
||||
extends: ['../../.eslintrc.cjs', '../../.eslintrc.react.cjs'],
|
||||
ignorePatterns: ['!**/*'],
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
files: ['*.ts', '*.tsx'],
|
||||
parserOptions: {
|
||||
project: ['packages/twenty-emails/tsconfig.*.json'],
|
||||
project: ['packages/twenty-emails/tsconfig.{json,*.json}'],
|
||||
},
|
||||
rules: {
|
||||
'@nx/dependency-checks': 'error',
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
dist
|
||||
.react-email/
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# Twenty Emails
|
||||
|
||||
This package contains the email templates used by Twenty.
|
||||
|
||||
## Features
|
||||
|
||||
- Email templates built with [React Email](https://react.email/)
|
||||
- Internationalization (i18n) support via [@lingui/react](https://lingui.dev/)
|
||||
- Local preview server for testing email templates
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Starting the Local Preview Server
|
||||
|
||||
To start the local preview server for email development:
|
||||
|
||||
```bash
|
||||
npx nx start twenty-emails
|
||||
```
|
||||
|
||||
This will run the development server on port 4001. You can then view your email templates at [http://localhost:4001](http://localhost:4001).
|
||||
|
||||
### Building Emails
|
||||
|
||||
To build the email templates:
|
||||
|
||||
```bash
|
||||
npx nx build twenty-emails
|
||||
```
|
||||
|
||||
## Email Structure
|
||||
|
||||
Each email template is located in the `src/emails` directory. The templates use various components from the `src/components` directory to maintain consistent styling and functionality.
|
||||
@@ -1,13 +1,13 @@
|
||||
import { defineConfig } from '@lingui/conf';
|
||||
import { defineConfig } from '@lingui/cli';
|
||||
import { formatter } from '@lingui/format-po';
|
||||
import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
import { APP_LOCALES } from 'twenty-shared';
|
||||
|
||||
export default defineConfig({
|
||||
sourceLocale: 'en',
|
||||
locales: Object.values(APP_LOCALES),
|
||||
pseudoLocale: 'pseudo-en',
|
||||
fallbackLocales: {
|
||||
default: SOURCE_LOCALE,
|
||||
'pseudo-en': 'en',
|
||||
},
|
||||
extractorParserOptions: {
|
||||
tsExperimentalDecorators: true,
|
||||
@@ -20,5 +20,5 @@ export default defineConfig({
|
||||
],
|
||||
catalogsMergePath: '<rootDir>/src/locales/generated/{locale}',
|
||||
compileNamespace: 'ts',
|
||||
format: formatter({ lineNumbers: false, printLinguiId: true }),
|
||||
format: formatter({ lineNumbers: false }),
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "twenty-emails",
|
||||
"version": "0.42.11",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -9,22 +10,8 @@
|
||||
"build": "npx vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lingui/core": "^5.1.2",
|
||||
"@lingui/react": "^5.1.2",
|
||||
"twenty-shared": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0 || ^19.0.0",
|
||||
"react-dom": "^18.2.0 || ^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lingui/cli": "^5.1.2",
|
||||
"@lingui/swc-plugin": "^5.1.0",
|
||||
"@lingui/vite-plugin": "^5.1.2",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"react-email": "4.0.3"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.mjs",
|
||||
|
||||
@@ -11,13 +11,6 @@
|
||||
},
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"start": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"cwd": "{projectRoot}",
|
||||
"command": "email dev -d src/emails -p 4001"
|
||||
}
|
||||
},
|
||||
"typecheck": {},
|
||||
"lint": {
|
||||
"options": {
|
||||
|
||||
@@ -54,7 +54,8 @@ export const emailTheme = {
|
||||
},
|
||||
background: {
|
||||
colors: { highlight: grayScale.gray15 },
|
||||
button: grayScale.gray60,
|
||||
radialGradient: `radial-gradient(50% 62.62% at 50% 0%, #505050 0%, ${grayScale.gray60} 100%)`,
|
||||
radialGradientHover: `radial-gradient(76.32% 95.59% at 50% 0%, #505050 0%, ${grayScale.gray60} 100%)`,
|
||||
transparent: {
|
||||
medium: 'rgba(0, 0, 0, 0.08)',
|
||||
light: 'rgba(0, 0, 0, 0.04)',
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { i18n, Messages } from '@lingui/core';
|
||||
import { I18nProvider } from '@lingui/react';
|
||||
import { Container, Html } from '@react-email/components';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { BaseHead } from 'src/components/BaseHead';
|
||||
import { Footer } from 'src/components/Footer';
|
||||
import { Logo } from 'src/components/Logo';
|
||||
import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
|
||||
import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared';
|
||||
import { messages as afMessages } from '../locales/generated/af-ZA';
|
||||
import { messages as arMessages } from '../locales/generated/ar-SA';
|
||||
import { messages as caMessages } from '../locales/generated/ca-ES';
|
||||
@@ -38,11 +38,10 @@ import { messages as viMessages } from '../locales/generated/vi-VN';
|
||||
import { messages as zhHansMessages } from '../locales/generated/zh-CN';
|
||||
import { messages as zhHantMessages } from '../locales/generated/zh-TW';
|
||||
|
||||
type BaseEmailProps = {
|
||||
children: JSX.Element | JSX.Element[] | string;
|
||||
type BaseEmailProps = PropsWithChildren<{
|
||||
width?: number;
|
||||
locale: keyof typeof APP_LOCALES;
|
||||
};
|
||||
}>;
|
||||
|
||||
const messages: Record<keyof typeof APP_LOCALES, Messages> = {
|
||||
en: enMessages,
|
||||
@@ -96,7 +95,6 @@ export const BaseEmail = ({ children, width, locale }: BaseEmailProps) => {
|
||||
<Container width={width || 290}>
|
||||
<Logo />
|
||||
{children}
|
||||
<Footer />
|
||||
</Container>
|
||||
</Html>
|
||||
</I18nProvider>
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { Button } from '@react-email/components';
|
||||
import { ReactNode } from 'react';
|
||||
import { Button } from '@react-email/button';
|
||||
|
||||
import { emailTheme } from 'src/common-style';
|
||||
|
||||
const callToActionStyle = {
|
||||
display: 'inline-flex',
|
||||
display: 'flex',
|
||||
padding: '8px 32px',
|
||||
borderRadius: emailTheme.border.radius.md,
|
||||
border: `1px solid ${emailTheme.background.transparent.light}`,
|
||||
background: emailTheme.background.button,
|
||||
background: emailTheme.background.radialGradient,
|
||||
boxShadow: `0px 2px 4px 0px ${emailTheme.background.transparent.light}, 0px 0px 4px 0px ${emailTheme.background.transparent.medium}`,
|
||||
color: emailTheme.font.colors.inverted,
|
||||
fontSize: emailTheme.font.size.md,
|
||||
fontWeight: emailTheme.font.weight.bold,
|
||||
width: 'auto',
|
||||
};
|
||||
|
||||
type CallToActionProps = {
|
||||
href: string;
|
||||
value: JSX.Element | JSX.Element[] | string;
|
||||
value: ReactNode;
|
||||
};
|
||||
|
||||
export const CallToAction = ({ value, href }: CallToActionProps) => {
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
import { Column, Container, Row } from '@react-email/components';
|
||||
import { Column, Row } from '@react-email/components';
|
||||
import { Link } from 'src/components/Link';
|
||||
import { ShadowText } from 'src/components/ShadowText';
|
||||
|
||||
const footerContainerStyle = {
|
||||
marginTop: '12px',
|
||||
};
|
||||
|
||||
export const Footer = () => {
|
||||
return (
|
||||
<Container style={footerContainerStyle}>
|
||||
<>
|
||||
<Row>
|
||||
<Column>
|
||||
<ShadowText>
|
||||
<Link
|
||||
href="https://twenty.com/"
|
||||
value={i18n._('Website')}
|
||||
aria-label={i18n._("Visit Twenty's website")}
|
||||
value="Website"
|
||||
aria-label="Visit Twenty's website"
|
||||
/>
|
||||
</ShadowText>
|
||||
</Column>
|
||||
@@ -24,8 +19,8 @@ export const Footer = () => {
|
||||
<ShadowText>
|
||||
<Link
|
||||
href="https://github.com/twentyhq/twenty"
|
||||
value={i18n._('Github')}
|
||||
aria-label={i18n._("Visit Twenty's GitHub repository")}
|
||||
value="Github"
|
||||
aria-label="Visit Twenty's GitHub repository"
|
||||
/>
|
||||
</ShadowText>
|
||||
</Column>
|
||||
@@ -33,8 +28,8 @@ export const Footer = () => {
|
||||
<ShadowText>
|
||||
<Link
|
||||
href="https://twenty.com/user-guide"
|
||||
value={i18n._('User guide')}
|
||||
aria-label={i18n._("Read Twenty's user guide")}
|
||||
value="User guide"
|
||||
aria-label="Read Twenty's user guide"
|
||||
/>
|
||||
</ShadowText>
|
||||
</Column>
|
||||
@@ -42,19 +37,19 @@ export const Footer = () => {
|
||||
<ShadowText>
|
||||
<Link
|
||||
href="https://docs.twenty.com/"
|
||||
value={i18n._('Developers')}
|
||||
aria-label={i18n._("Visit Twenty's developer documentation")}
|
||||
value="Developers"
|
||||
aria-label="Visit Twenty's developer documentation"
|
||||
/>
|
||||
</ShadowText>
|
||||
</Column>
|
||||
</Row>
|
||||
<ShadowText>
|
||||
<>
|
||||
{i18n._('Twenty.com, Public Benefit Corporation')}
|
||||
<br />
|
||||
{i18n._('San Francisco / Paris')}
|
||||
</>
|
||||
Twenty.com Public Benefit Corporation
|
||||
<br />
|
||||
2261 Market Street #5275
|
||||
<br />
|
||||
San Francisco, CA 94114
|
||||
</ShadowText>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Column, Container, Row } from '@react-email/components';
|
||||
import React from 'react';
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
|
||||
import { emailTheme } from 'src/common-style';
|
||||
|
||||
type HighlightedContainerProps = {
|
||||
children: JSX.Element | JSX.Element[] | string;
|
||||
};
|
||||
type HighlightedContainerProps = PropsWithChildren;
|
||||
|
||||
const highlightedContainerStyle = {
|
||||
background: emailTheme.background.colors.highlight,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user