mirror of
https://github.com/documenso/documenso.git
synced 2025-11-09 20:12:31 +10:00
chore: add code styleguide (#2089)
Co-authored-by: Ephraim Atta-Duncan <ephraimduncan68@gmail.com>
This commit is contained in:
692
CODE_STYLE.md
Normal file
692
CODE_STYLE.md
Normal file
@ -0,0 +1,692 @@
|
||||
# Documenso Code Style Guide
|
||||
|
||||
This document captures the code style, patterns, and conventions used in the Documenso codebase. It covers both enforceable rules and subjective "taste" elements that make our code consistent and maintainable.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [General Principles](#general-principles)
|
||||
2. [TypeScript Conventions](#typescript-conventions)
|
||||
3. [Imports & Dependencies](#imports--dependencies)
|
||||
4. [Functions & Methods](#functions--methods)
|
||||
5. [React & Components](#react--components)
|
||||
6. [Error Handling](#error-handling)
|
||||
7. [Async/Await Patterns](#asyncawait-patterns)
|
||||
8. [Whitespace & Formatting](#whitespace--formatting)
|
||||
9. [Naming Conventions](#naming-conventions)
|
||||
10. [Pattern Matching](#pattern-matching)
|
||||
11. [Database & Prisma](#database--prisma)
|
||||
12. [TRPC Patterns](#trpc-patterns)
|
||||
|
||||
---
|
||||
|
||||
## General Principles
|
||||
|
||||
- **Functional over Object-Oriented**: Prefer functional programming patterns over classes
|
||||
- **Explicit over Implicit**: Be explicit about types, return values, and error cases
|
||||
- **Early Returns**: Use guard clauses and early returns to reduce nesting
|
||||
- **Immutability**: Favor `const` over `let`; avoid mutation where possible
|
||||
|
||||
---
|
||||
|
||||
## TypeScript Conventions
|
||||
|
||||
### Type Definitions
|
||||
|
||||
```typescript
|
||||
// ✅ Prefer `type` over `interface`
|
||||
type CreateDocumentOptions = {
|
||||
templateId: number;
|
||||
userId: number;
|
||||
recipients: Recipient[];
|
||||
};
|
||||
|
||||
// ❌ Avoid interfaces unless absolutely necessary
|
||||
interface CreateDocumentOptions {
|
||||
templateId: number;
|
||||
}
|
||||
```
|
||||
|
||||
### Type Imports
|
||||
|
||||
```typescript
|
||||
// ✅ Use `type` keyword for type-only imports
|
||||
import type { Document, Recipient } from '@prisma/client';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
|
||||
// Types in function signatures
|
||||
export const findDocuments = async ({ userId, teamId }: FindDocumentsOptions) => {
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
### Inline Types for Function Parameters
|
||||
|
||||
```typescript
|
||||
// ✅ Extract inline types to named types
|
||||
type FinalRecipient = Pick<Recipient, 'name' | 'email' | 'role' | 'authOptions'> & {
|
||||
templateRecipientId: number;
|
||||
fields: Field[];
|
||||
};
|
||||
|
||||
const finalRecipients: FinalRecipient[] = [];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Imports & Dependencies
|
||||
|
||||
### Import Organization
|
||||
|
||||
Imports should be organized in the following order with blank lines between groups:
|
||||
|
||||
```typescript
|
||||
// 1. React imports
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
// 2. Third-party library imports (alphabetically)
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Document, Recipient } from '@prisma/client';
|
||||
import { DocumentStatus, RecipientRole } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
// 3. Internal package imports (from @documenso/*)
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
// 4. Relative imports
|
||||
import { getTeamById } from '../team/get-team';
|
||||
import type { FindResultResponse } from './types';
|
||||
```
|
||||
|
||||
### Destructuring Imports
|
||||
|
||||
```typescript
|
||||
// ✅ Destructure specific exports
|
||||
// ✅ Use type imports for types
|
||||
import type { Document } from '@prisma/client';
|
||||
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Functions & Methods
|
||||
|
||||
### Arrow Functions
|
||||
|
||||
```typescript
|
||||
// ✅ Always use arrow functions for functions
|
||||
export const createDocument = async ({
|
||||
userId,
|
||||
title,
|
||||
}: CreateDocumentOptions) => {
|
||||
// ...
|
||||
};
|
||||
|
||||
// ✅ Callbacks and handlers
|
||||
const onSubmit = useCallback(async () => {
|
||||
// ...
|
||||
}, [dependencies]);
|
||||
|
||||
// ❌ Avoid regular function declarations
|
||||
function createDocument() {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Function Parameters
|
||||
|
||||
```typescript
|
||||
// ✅ Use destructured object parameters for multiple params
|
||||
export const findDocuments = async ({
|
||||
userId,
|
||||
teamId,
|
||||
status = ExtendedDocumentStatus.ALL,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
}: FindDocumentsOptions) => {
|
||||
// ...
|
||||
};
|
||||
|
||||
// ✅ Destructure on separate line when needed
|
||||
const onFormSubmit = form.handleSubmit(onSubmit);
|
||||
|
||||
// ✅ Deconstruct nested properties explicitly
|
||||
const { user } = ctx;
|
||||
const { templateId } = input;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## React & Components
|
||||
|
||||
### Component Definition
|
||||
|
||||
```typescript
|
||||
// ✅ Use const with arrow function
|
||||
export const AddSignersFormPartial = ({
|
||||
documentFlow,
|
||||
recipients,
|
||||
fields,
|
||||
onSubmit,
|
||||
}: AddSignersFormProps) => {
|
||||
// ...
|
||||
};
|
||||
|
||||
// ❌ Never use classes
|
||||
class MyComponent extends React.Component {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Hooks
|
||||
|
||||
```typescript
|
||||
// ✅ Group related hooks together with blank line separation
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { currentStep, totalSteps, previousStep } = useStep();
|
||||
|
||||
const form = useForm<TFormSchema>({
|
||||
resolver: zodResolver(ZFormSchema),
|
||||
defaultValues: {
|
||||
// ...
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Event Handlers
|
||||
|
||||
```typescript
|
||||
// ✅ Use arrow functions with descriptive names
|
||||
const onFormSubmit = async () => {
|
||||
await form.trigger();
|
||||
// ...
|
||||
};
|
||||
|
||||
const onFieldCopy = useCallback(
|
||||
(event?: KeyboardEvent | null) => {
|
||||
event?.preventDefault();
|
||||
// ...
|
||||
},
|
||||
[dependencies],
|
||||
);
|
||||
|
||||
// ✅ Inline handlers for simple operations
|
||||
<Button onClick={() => setOpen(false)}>Close</Button>
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
```typescript
|
||||
// ✅ Descriptive state names with auxiliary verbs
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||
|
||||
// ✅ Complex state in single useState when related
|
||||
const [coords, setCoords] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Try-Catch Blocks
|
||||
|
||||
```typescript
|
||||
// ✅ Use try-catch for operations that might fail
|
||||
try {
|
||||
const document = await getDocumentById({
|
||||
documentId: Number(documentId),
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: document,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
status: 404,
|
||||
body: {
|
||||
message: 'Document not found',
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Throwing Errors
|
||||
|
||||
```typescript
|
||||
// ✅ Use AppError for application errors
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Template not found',
|
||||
});
|
||||
|
||||
// ✅ Use descriptive error messages
|
||||
if (!template) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: `Template with ID ${templateId} not found`,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Error Parsing on Frontend
|
||||
|
||||
```typescript
|
||||
// ✅ Parse errors on the frontend
|
||||
try {
|
||||
await updateOrganisation({ organisationId, data });
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
console.error(error);
|
||||
|
||||
toast({
|
||||
title: t`An error occurred`,
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Async/Await Patterns
|
||||
|
||||
### Async Function Definitions
|
||||
|
||||
```typescript
|
||||
// ✅ Mark async functions clearly
|
||||
export const createDocument = async ({
|
||||
userId,
|
||||
title,
|
||||
}: Options): Promise<Document> => {
|
||||
// ...
|
||||
};
|
||||
|
||||
// ✅ Use await for promises
|
||||
const document = await prisma.document.create({ data });
|
||||
|
||||
// ✅ Use Promise.all for parallel operations
|
||||
const [document, recipients] = await Promise.all([
|
||||
getDocumentById({ documentId }),
|
||||
getRecipientsForDocument({ documentId }),
|
||||
]);
|
||||
```
|
||||
|
||||
### Void for Fire-and-Forget
|
||||
|
||||
```typescript
|
||||
// ✅ Use void for intentionally unwaited promises
|
||||
void handleAutoSave();
|
||||
|
||||
// ✅ Or in event handlers
|
||||
onClick={() => void onFormSubmit()}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Whitespace & Formatting
|
||||
|
||||
### Blank Lines Between Concepts
|
||||
|
||||
```typescript
|
||||
// ✅ Blank line after imports
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export const findDocuments = async () => {
|
||||
// ...
|
||||
};
|
||||
|
||||
// ✅ Blank line between logical sections
|
||||
const user = await prisma.user.findFirst({ where: { id: userId } });
|
||||
|
||||
let team = null;
|
||||
|
||||
if (teamId !== undefined) {
|
||||
team = await getTeamById({ userId, teamId });
|
||||
}
|
||||
|
||||
// ✅ Blank line before return statements
|
||||
const result = await someOperation();
|
||||
|
||||
return result;
|
||||
```
|
||||
|
||||
### Function/Method Spacing
|
||||
|
||||
```typescript
|
||||
// ✅ No blank lines between chained methods in same operation
|
||||
const documents = await prisma.document
|
||||
.findMany({ where: { userId } })
|
||||
.then((docs) => docs.map(maskTokens));
|
||||
|
||||
// ✅ Blank line between different operations
|
||||
const document = await createDocument({ userId });
|
||||
|
||||
await sendDocument({ documentId: document.id });
|
||||
|
||||
return document;
|
||||
```
|
||||
|
||||
### Object and Array Formatting
|
||||
|
||||
```typescript
|
||||
// ✅ Multi-line when complex
|
||||
const options = {
|
||||
userId,
|
||||
teamId,
|
||||
status: ExtendedDocumentStatus.ALL,
|
||||
page: 1,
|
||||
};
|
||||
|
||||
// ✅ Single line when simple
|
||||
const coords = { x: 0, y: 0 };
|
||||
|
||||
// ✅ Array items on separate lines when objects
|
||||
const recipients = [
|
||||
{
|
||||
name: 'John',
|
||||
email: 'john@example.com',
|
||||
},
|
||||
{
|
||||
name: 'Jane',
|
||||
email: 'jane@example.com',
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Variables
|
||||
|
||||
```typescript
|
||||
// ✅ camelCase for variables and functions
|
||||
const documentId = 123;
|
||||
const onSubmit = () => {};
|
||||
|
||||
// ✅ Descriptive names with auxiliary verbs for booleans
|
||||
const isLoading = false;
|
||||
const hasError = false;
|
||||
const canEdit = true;
|
||||
const shouldRender = true;
|
||||
|
||||
// ✅ Prefix with $ for DOM elements
|
||||
const $page = document.querySelector('.page');
|
||||
const $inputRef = useRef<HTMLInputElement>(null);
|
||||
```
|
||||
|
||||
### Types and Schemas
|
||||
|
||||
```typescript
|
||||
// ✅ PascalCase for types
|
||||
type CreateDocumentOptions = {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
// ✅ Prefix Zod schemas with Z
|
||||
const ZCreateDocumentSchema = z.object({
|
||||
title: z.string(),
|
||||
});
|
||||
|
||||
// ✅ Prefix type from Zod schema with T
|
||||
type TCreateDocumentSchema = z.infer<typeof ZCreateDocumentSchema>;
|
||||
```
|
||||
|
||||
### Constants
|
||||
|
||||
```typescript
|
||||
// ✅ UPPER_SNAKE_CASE for true constants
|
||||
const DEFAULT_DOCUMENT_DATE_FORMAT = 'dd/MM/yyyy';
|
||||
const MAX_FILE_SIZE = 1024 * 1024 * 5;
|
||||
|
||||
// ✅ camelCase for const variables that aren't "constants"
|
||||
const userId = await getUserId();
|
||||
```
|
||||
|
||||
### Functions
|
||||
|
||||
```typescript
|
||||
// ✅ Verb-based names for functions
|
||||
const createDocument = async () => {};
|
||||
const findDocuments = async () => {};
|
||||
const updateDocument = async () => {};
|
||||
const deleteDocument = async () => {};
|
||||
|
||||
// ✅ On prefix for event handlers
|
||||
const onSubmit = () => {};
|
||||
const onClick = () => {};
|
||||
const onFieldCopy = () => {}; // 'on' is also acceptable
|
||||
```
|
||||
|
||||
### Clarity Over Brevity
|
||||
|
||||
```typescript
|
||||
// ✅ Prefer descriptive names over abbreviations
|
||||
const superLongMethodThatIsCorrect = () => {};
|
||||
const recipientAuthenticationOptions = {};
|
||||
const documentMetadata = {};
|
||||
|
||||
// ❌ Avoid abbreviations that sacrifice clarity
|
||||
const supLongMethThatIsCorrect = () => {};
|
||||
const recipAuthOpts = {};
|
||||
const docMeta = {};
|
||||
|
||||
// ✅ Common abbreviations that are widely understood are acceptable
|
||||
const userId = 123;
|
||||
const htmlElement = document.querySelector('div');
|
||||
const apiResponse = await fetch('/api');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern Matching
|
||||
|
||||
### Using ts-pattern
|
||||
|
||||
```typescript
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
// ✅ Use match for complex conditionals
|
||||
const result = match(status)
|
||||
.with(ExtendedDocumentStatus.DRAFT, () => ({
|
||||
status: 'draft',
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.PENDING, () => ({
|
||||
status: 'pending',
|
||||
}))
|
||||
.with(ExtendedDocumentStatus.COMPLETED, () => ({
|
||||
status: 'completed',
|
||||
}))
|
||||
.exhaustive();
|
||||
|
||||
// ✅ Use .otherwise() for default case when not exhaustive
|
||||
const value = match(type)
|
||||
.with('text', () => 'Text field')
|
||||
.with('number', () => 'Number field')
|
||||
.otherwise(() => 'Unknown field');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Database & Prisma
|
||||
|
||||
### Query Structure
|
||||
|
||||
```typescript
|
||||
// ✅ Destructure commonly used fields
|
||||
const { id, email, name } = user;
|
||||
|
||||
// ✅ Use select to limit returned fields
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
// ✅ Use include for relations
|
||||
const document = await prisma.document.findFirst({
|
||||
where: { id: documentId },
|
||||
include: {
|
||||
recipients: true,
|
||||
fields: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Transactions
|
||||
|
||||
```typescript
|
||||
// ✅ Use transactions for related operations
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const document = await tx.document.create({ data });
|
||||
|
||||
await tx.field.createMany({ data: fieldsData });
|
||||
|
||||
await tx.documentAuditLog.create({ data: auditData });
|
||||
|
||||
return document;
|
||||
});
|
||||
```
|
||||
|
||||
### Where Clauses
|
||||
|
||||
```typescript
|
||||
// ✅ Build complex where clauses separately
|
||||
const whereClause: Prisma.DocumentWhereInput = {
|
||||
AND: [
|
||||
{ userId: user.id },
|
||||
{ deletedAt: null },
|
||||
{ status: { in: [DocumentStatus.DRAFT, DocumentStatus.PENDING] } },
|
||||
],
|
||||
};
|
||||
|
||||
const documents = await prisma.document.findMany({
|
||||
where: whereClause,
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TRPC Patterns
|
||||
|
||||
### Router Structure
|
||||
|
||||
```typescript
|
||||
// ✅ Destructure context and input at start
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { teamId } = ctx;
|
||||
const { templateId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: { templateId },
|
||||
});
|
||||
|
||||
return await getTemplateById({
|
||||
id: templateId,
|
||||
userId: ctx.user.id,
|
||||
teamId,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Request/Response Schemas
|
||||
|
||||
```typescript
|
||||
// ✅ Name schemas clearly
|
||||
const ZCreateDocumentRequestSchema = z.object({
|
||||
title: z.string(),
|
||||
recipients: z.array(ZRecipientSchema),
|
||||
});
|
||||
|
||||
const ZCreateDocumentResponseSchema = z.object({
|
||||
documentId: z.number(),
|
||||
status: z.string(),
|
||||
});
|
||||
```
|
||||
|
||||
### Error Handling in TRPC
|
||||
|
||||
```typescript
|
||||
// ✅ Catch and transform errors appropriately
|
||||
try {
|
||||
const result = await createDocument({ userId, data });
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
return AppError.toRestAPIError(err);
|
||||
}
|
||||
|
||||
// ✅ Or throw AppError directly
|
||||
if (!template) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Template not found',
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Additional Patterns
|
||||
|
||||
### Optional Chaining
|
||||
|
||||
```typescript
|
||||
// ✅ Use optional chaining for potentially undefined values
|
||||
const email = user?.email;
|
||||
const recipientToken = recipient?.token ?? '';
|
||||
|
||||
// ✅ Use nullish coalescing for defaults
|
||||
const pageSize = perPage ?? 10;
|
||||
const status = documentStatus ?? DocumentStatus.DRAFT;
|
||||
```
|
||||
|
||||
### Array Operations
|
||||
|
||||
```typescript
|
||||
// ✅ Use functional array methods
|
||||
const activeRecipients = recipients.filter((r) => r.signingStatus === 'SIGNED');
|
||||
const recipientEmails = recipients.map((r) => r.email);
|
||||
const hasSignedRecipients = recipients.some((r) => r.signingStatus === 'SIGNED');
|
||||
|
||||
// ✅ Use find instead of filter + [0]
|
||||
const recipient = recipients.find((r) => r.id === recipientId);
|
||||
```
|
||||
|
||||
### Conditional Rendering
|
||||
|
||||
```typescript
|
||||
// ✅ Use && for conditional rendering
|
||||
{isLoading && <Loader />}
|
||||
|
||||
// ✅ Use ternary for either/or
|
||||
{isLoading ? <Loader /> : <Content />}
|
||||
|
||||
// ✅ Extract complex conditions to variables
|
||||
const shouldShowAdvanced = isAdmin && hasPermission && !isDisabled;
|
||||
{shouldShowAdvanced && <AdvancedSettings />}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## When in Doubt
|
||||
|
||||
- **Consistency**: Follow the patterns you see in similar files
|
||||
- **Readability**: Favor code that's easy to read over clever one-liners
|
||||
- **Explicitness**: Be explicit rather than implicit
|
||||
- **Whitespace**: Use blank lines to separate logical sections
|
||||
- **Early Returns**: Use guard clauses to reduce nesting
|
||||
- **Functional**: Prefer functional patterns over imperative ones
|
||||
Reference in New Issue
Block a user