Initial commit: ArchiTools modular dashboard platform
Complete Next.js 16 application with 13 fully implemented modules: Email Signature, Word XML Generator, Registratura, Dashboard, Tag Manager, IT Inventory, Address Book, Password Vault, Mini Utilities, Prompt Generator, Digital Signatures, Word Templates, and AI Chat. Includes core platform systems (module registry, feature flags, storage abstraction, i18n, theming, auth stub, tagging), 16 technical documentation files, Docker deployment config, and legacy HTML tool reference. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
748
docs/guides/MODULE-DEVELOPMENT.md
Normal file
748
docs/guides/MODULE-DEVELOPMENT.md
Normal file
@@ -0,0 +1,748 @@
|
||||
# Module Development Guide
|
||||
|
||||
> Step-by-step guide for creating a new ArchiTools module. Read [MODULE-SYSTEM.md](/docs/architecture/MODULE-SYSTEM.md) and [FEATURE-FLAGS.md](/docs/architecture/FEATURE-FLAGS.md) first.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 20+, pnpm
|
||||
- Familiarity with the module system architecture and feature flag system
|
||||
- A clear idea of your module's purpose, category, and naming
|
||||
|
||||
---
|
||||
|
||||
## Walkthrough: Creating a New Module
|
||||
|
||||
This guide walks through creating a **"Registru Corespondenta"** (correspondence register) module as a concrete example. Adapt names and types to your use case.
|
||||
|
||||
---
|
||||
|
||||
### Step 1: Create the Module Directory
|
||||
|
||||
```bash
|
||||
mkdir -p src/modules/registru-corespondenta/{components,hooks,services}
|
||||
touch src/modules/registru-corespondenta/{types.ts,config.ts,index.ts}
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
```
|
||||
src/modules/registru-corespondenta/
|
||||
components/
|
||||
hooks/
|
||||
services/
|
||||
types.ts
|
||||
config.ts
|
||||
index.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Define Types in `types.ts`
|
||||
|
||||
Define all TypeScript interfaces and types that are specific to this module. These types are internal to the module and must not be imported by other modules.
|
||||
|
||||
```typescript
|
||||
// src/modules/registru-corespondenta/types.ts
|
||||
|
||||
export interface Corespondenta {
|
||||
id: string;
|
||||
numar: string; // registration number
|
||||
data: string; // ISO date string
|
||||
expeditor: string; // sender
|
||||
destinatar: string; // recipient
|
||||
subiect: string; // subject
|
||||
tip: TipCorespondenta;
|
||||
status: StatusCorespondenta;
|
||||
fisiere: string[]; // attached file references
|
||||
note: string;
|
||||
creatLa: string; // ISO timestamp
|
||||
modificatLa: string; // ISO timestamp
|
||||
}
|
||||
|
||||
export type TipCorespondenta = 'intrare' | 'iesire' | 'intern';
|
||||
|
||||
export type StatusCorespondenta = 'inregistrat' | 'in-lucru' | 'finalizat' | 'arhivat';
|
||||
|
||||
export interface CorrespondentaFilters {
|
||||
tip?: TipCorespondenta;
|
||||
status?: StatusCorespondenta;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
searchTerm?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Guidelines:**
|
||||
- Use Romanian names for domain types to match business terminology.
|
||||
- Keep types self-contained. If you need a shared type (e.g., `DateRange`), check `src/types/` first.
|
||||
- Export everything -- the barrel export in `index.ts` will control what is actually public.
|
||||
|
||||
---
|
||||
|
||||
### Step 3: Create `config.ts` with ModuleConfig
|
||||
|
||||
This is the module's identity. Every field matters.
|
||||
|
||||
```typescript
|
||||
// src/modules/registru-corespondenta/config.ts
|
||||
|
||||
import type { ModuleConfig } from '@/types/modules';
|
||||
|
||||
export const registruCorrespondentaConfig: ModuleConfig = {
|
||||
id: 'registru-corespondenta',
|
||||
name: 'Registru Corespondenta',
|
||||
description: 'Evidenta si gestionarea corespondentei primite si expediate.',
|
||||
icon: 'mail',
|
||||
route: '/registru-corespondenta',
|
||||
category: 'operations',
|
||||
featureFlag: 'module.registru-corespondenta',
|
||||
visibility: 'all',
|
||||
version: '1.0.0',
|
||||
dependencies: [],
|
||||
storageNamespace: 'architools.registru-corespondenta',
|
||||
navOrder: 30,
|
||||
tags: ['corespondenta', 'registru', 'documente'],
|
||||
};
|
||||
```
|
||||
|
||||
**Checklist for config values:**
|
||||
- [ ] `id` is kebab-case and unique across all modules
|
||||
- [ ] `route` starts with `/` and does not collide with existing routes
|
||||
- [ ] `featureFlag` follows the `module.<id>` convention
|
||||
- [ ] `storageNamespace` follows the `architools.<id>` convention
|
||||
- [ ] `navOrder` does not conflict with other modules in the same category (check `src/config/modules.ts`)
|
||||
|
||||
---
|
||||
|
||||
### Step 4: Implement the Storage Service
|
||||
|
||||
Services handle all data persistence. They use the platform's storage abstraction layer and scope all keys under the module's `storageNamespace`.
|
||||
|
||||
```typescript
|
||||
// src/modules/registru-corespondenta/services/corespondenta-storage.ts
|
||||
|
||||
import { StorageService } from '@/lib/storage';
|
||||
import type { Corespondenta, CorrespondentaFilters } from '../types';
|
||||
import { registruCorrespondentaConfig } from '../config';
|
||||
|
||||
const COLLECTION_KEY = 'entries';
|
||||
|
||||
const storage = new StorageService(registruCorrespondentaConfig.storageNamespace);
|
||||
|
||||
export const corespondentaStorage = {
|
||||
async getAll(): Promise<Corespondenta[]> {
|
||||
return storage.get<Corespondenta[]>(COLLECTION_KEY) ?? [];
|
||||
},
|
||||
|
||||
async getById(id: string): Promise<Corespondenta | undefined> {
|
||||
const all = await this.getAll();
|
||||
return all.find((item) => item.id === id);
|
||||
},
|
||||
|
||||
async save(item: Corespondenta): Promise<void> {
|
||||
const all = await this.getAll();
|
||||
const index = all.findIndex((existing) => existing.id === item.id);
|
||||
|
||||
if (index >= 0) {
|
||||
all[index] = { ...item, modificatLa: new Date().toISOString() };
|
||||
} else {
|
||||
all.push({
|
||||
...item,
|
||||
creatLa: new Date().toISOString(),
|
||||
modificatLa: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
await storage.set(COLLECTION_KEY, all);
|
||||
},
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const all = await this.getAll();
|
||||
const filtered = all.filter((item) => item.id !== id);
|
||||
await storage.set(COLLECTION_KEY, filtered);
|
||||
},
|
||||
|
||||
async filter(filters: CorrespondentaFilters): Promise<Corespondenta[]> {
|
||||
let results = await this.getAll();
|
||||
|
||||
if (filters.tip) {
|
||||
results = results.filter((item) => item.tip === filters.tip);
|
||||
}
|
||||
if (filters.status) {
|
||||
results = results.filter((item) => item.status === filters.status);
|
||||
}
|
||||
if (filters.searchTerm) {
|
||||
const term = filters.searchTerm.toLowerCase();
|
||||
results = results.filter(
|
||||
(item) =>
|
||||
item.subiect.toLowerCase().includes(term) ||
|
||||
item.expeditor.toLowerCase().includes(term) ||
|
||||
item.destinatar.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
if (filters.startDate) {
|
||||
results = results.filter((item) => item.data >= filters.startDate!);
|
||||
}
|
||||
if (filters.endDate) {
|
||||
results = results.filter((item) => item.data <= filters.endDate!);
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
|
||||
async getNextNumber(tip: Corespondenta['tip']): Promise<string> {
|
||||
const all = await this.getAll();
|
||||
const ofType = all.filter((item) => item.tip === tip);
|
||||
const year = new Date().getFullYear();
|
||||
const prefix = tip === 'intrare' ? 'I' : tip === 'iesire' ? 'E' : 'N';
|
||||
const nextSeq = ofType.length + 1;
|
||||
return `${prefix}-${year}-${String(nextSeq).padStart(4, '0')}`;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Never call `localStorage` or `IndexedDB` directly. Use `StorageService` from `@/lib/storage`.
|
||||
- Always scope storage through the `storageNamespace` from config.
|
||||
- Keep services pure data logic -- no React, no UI concerns.
|
||||
|
||||
---
|
||||
|
||||
### Step 5: Write Hooks for Business Logic
|
||||
|
||||
Hooks bridge services and components. They encapsulate state management, loading states, error handling, and business rules.
|
||||
|
||||
```typescript
|
||||
// src/modules/registru-corespondenta/hooks/useCorespondenta.ts
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { corespondentaStorage } from '../services/corespondenta-storage';
|
||||
import type { Corespondenta, CorrespondentaFilters } from '../types';
|
||||
|
||||
export function useCorespondenta(filters?: CorrespondentaFilters) {
|
||||
const [items, setItems] = useState<Corespondenta[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const loadItems = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = filters
|
||||
? await corespondentaStorage.filter(filters)
|
||||
: await corespondentaStorage.getAll();
|
||||
setItems(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Eroare la incarcarea datelor'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
loadItems();
|
||||
}, [loadItems]);
|
||||
|
||||
const saveItem = useCallback(async (item: Corespondenta) => {
|
||||
await corespondentaStorage.save(item);
|
||||
await loadItems();
|
||||
}, [loadItems]);
|
||||
|
||||
const removeItem = useCallback(async (id: string) => {
|
||||
await corespondentaStorage.remove(id);
|
||||
await loadItems();
|
||||
}, [loadItems]);
|
||||
|
||||
return {
|
||||
items,
|
||||
isLoading,
|
||||
error,
|
||||
saveItem,
|
||||
removeItem,
|
||||
refresh: loadItems,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/modules/registru-corespondenta/hooks/useCorrespondentaForm.ts
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { corespondentaStorage } from '../services/corespondenta-storage';
|
||||
import type { Corespondenta, TipCorespondenta } from '../types';
|
||||
import { generateId } from '@/lib/utils';
|
||||
|
||||
export function useCorrespondentaForm(tip: TipCorespondenta) {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const createNew = useCallback(async (
|
||||
data: Omit<Corespondenta, 'id' | 'numar' | 'creatLa' | 'modificatLa'>
|
||||
): Promise<Corespondenta> => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const numar = await corespondentaStorage.getNextNumber(tip);
|
||||
const item: Corespondenta = {
|
||||
...data,
|
||||
id: generateId(),
|
||||
numar,
|
||||
creatLa: new Date().toISOString(),
|
||||
modificatLa: new Date().toISOString(),
|
||||
};
|
||||
await corespondentaStorage.save(item);
|
||||
return item;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [tip]);
|
||||
|
||||
return { createNew, isSaving };
|
||||
}
|
||||
```
|
||||
|
||||
**Guidelines:**
|
||||
- Hooks must be in the `hooks/` directory.
|
||||
- Hooks must not import from other modules.
|
||||
- Hooks must not contain JSX or rendering logic.
|
||||
- Keep hooks focused -- one hook per concern (list, form, export, etc.).
|
||||
|
||||
---
|
||||
|
||||
### Step 6: Build Components
|
||||
|
||||
Components live in `components/` and compose hooks + shared UI components.
|
||||
|
||||
```typescript
|
||||
// src/modules/registru-corespondenta/components/CorrespondentaList.tsx
|
||||
|
||||
'use client';
|
||||
|
||||
import { useCorespondenta } from '../hooks/useCorespondenta';
|
||||
import type { CorrespondentaFilters } from '../types';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
interface Props {
|
||||
filters?: CorrespondentaFilters;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
inregistrat: 'bg-blue-100 text-blue-800',
|
||||
'in-lucru': 'bg-yellow-100 text-yellow-800',
|
||||
finalizat: 'bg-green-100 text-green-800',
|
||||
arhivat: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
export function CorrespondentaList({ filters, onSelect }: Props) {
|
||||
const { items, isLoading, error } = useCorespondenta(filters);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<p className="text-sm text-destructive">
|
||||
Eroare: {error.message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nu exista inregistrari.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nr.</TableHead>
|
||||
<TableHead>Data</TableHead>
|
||||
<TableHead>Expeditor</TableHead>
|
||||
<TableHead>Subiect</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onSelect(item.id)}
|
||||
>
|
||||
<TableCell className="font-mono">{item.numar}</TableCell>
|
||||
<TableCell>{new Date(item.data).toLocaleDateString('ro-RO')}</TableCell>
|
||||
<TableCell>{item.expeditor}</TableCell>
|
||||
<TableCell>{item.subiect}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={STATUS_COLORS[item.status]}>
|
||||
{item.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The root component ties everything together and serves as the module's entry point:
|
||||
|
||||
```typescript
|
||||
// src/modules/registru-corespondenta/components/RegistruCorrespondentaModule.tsx
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { CorrespondentaList } from './CorrespondentaList';
|
||||
import type { CorrespondentaFilters } from '../types';
|
||||
|
||||
export default function RegistruCorrespondentaModule() {
|
||||
const [filters, setFilters] = useState<CorrespondentaFilters>({});
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Registru Corespondenta</h1>
|
||||
{/* Add "Adauga" button here */}
|
||||
</div>
|
||||
|
||||
{/* Filter bar component here */}
|
||||
|
||||
<CorrespondentaList filters={filters} onSelect={setSelectedId} />
|
||||
|
||||
{/* Detail/edit panel here */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- The root component must be a `default` export (required by `React.lazy`).
|
||||
- Use shared UI components from `@/components/ui/` (shadcn/ui).
|
||||
- Do not import components from other modules.
|
||||
- Mark all client-side components with `'use client'`.
|
||||
|
||||
---
|
||||
|
||||
### Step 7: Create the Barrel Export in `index.ts`
|
||||
|
||||
The barrel export defines the module's public API. The rest of the system may only import from this file.
|
||||
|
||||
```typescript
|
||||
// src/modules/registru-corespondenta/index.ts
|
||||
|
||||
export { registruCorrespondentaConfig as config } from './config';
|
||||
export { default } from './components/RegistruCorrespondentaModule';
|
||||
```
|
||||
|
||||
**Important:** The default export must be the root component. This is what `React.lazy(() => import('@/modules/registru-corespondenta'))` will resolve to.
|
||||
|
||||
---
|
||||
|
||||
### Step 8: Register the Module in `src/config/modules.ts`
|
||||
|
||||
```typescript
|
||||
// src/config/modules.ts
|
||||
|
||||
import { registruCorrespondentaConfig } from '@/modules/registru-corespondenta/config';
|
||||
// ... other imports
|
||||
|
||||
const moduleConfigs: ModuleConfig[] = [
|
||||
// ... existing modules
|
||||
registruCorrespondentaConfig,
|
||||
];
|
||||
```
|
||||
|
||||
Then add the lazy component entry in the module loader:
|
||||
|
||||
```typescript
|
||||
// src/lib/module-loader.ts
|
||||
|
||||
const lazyComponents: Record<string, React.LazyExoticComponent<React.ComponentType>> = {
|
||||
// ... existing entries
|
||||
'registru-corespondenta': React.lazy(() => import('@/modules/registru-corespondenta')),
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 9: Add the Feature Flag in `src/config/flags.ts`
|
||||
|
||||
```typescript
|
||||
// src/config/flags.ts -- add to the defaultFlags array
|
||||
|
||||
{
|
||||
key: 'module.registru-corespondenta',
|
||||
enabled: true,
|
||||
label: 'Registru Corespondenta',
|
||||
description: 'Activeaza modulul de evidenta a corespondentei.',
|
||||
category: 'module',
|
||||
overridable: true,
|
||||
},
|
||||
```
|
||||
|
||||
The `key` must exactly match the `featureFlag` value in your module's `config.ts`.
|
||||
|
||||
---
|
||||
|
||||
### Step 10: Add the Route
|
||||
|
||||
Create the Next.js App Router page for your module.
|
||||
|
||||
```typescript
|
||||
// src/app/(modules)/registru-corespondenta/page.tsx
|
||||
|
||||
'use client';
|
||||
|
||||
import { ModuleLoader } from '@/lib/module-loader';
|
||||
import { moduleRegistry } from '@/config/modules';
|
||||
import { useFeatureFlag } from '@/hooks/useFeatureFlag';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export default function RegistruCorrespondentaPage() {
|
||||
const config = moduleRegistry.get('registru-corespondenta');
|
||||
|
||||
if (!config) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const enabled = useFeatureFlag(config.featureFlag);
|
||||
|
||||
if (!enabled) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <ModuleLoader config={config} />;
|
||||
}
|
||||
```
|
||||
|
||||
The route path must match the `route` field in your module config (minus the leading `/`).
|
||||
|
||||
---
|
||||
|
||||
### Step 11: Test with Flag On/Off
|
||||
|
||||
**Flag enabled (default):**
|
||||
|
||||
1. Start the dev server: `pnpm dev`
|
||||
2. Verify the module appears in the sidebar under its category.
|
||||
3. Click the sidebar link. Verify the module loads and displays correctly.
|
||||
4. Open the browser Network tab. Confirm a separate chunk is fetched for the module.
|
||||
5. Test all CRUD operations. Verify localStorage keys are prefixed with `architools.registru-corespondenta`.
|
||||
|
||||
**Flag disabled:**
|
||||
|
||||
1. In `src/config/flags.ts`, set `enabled: false` for `module.registru-corespondenta` (or use the env var `NEXT_PUBLIC_FLAG_MODULE_REGISTRU_CORESPONDENTA=false`).
|
||||
2. Verify the module disappears from the sidebar.
|
||||
3. Navigate directly to `/registru-corespondenta`. Verify you get a 404.
|
||||
4. Open the Network tab. Confirm no chunk for the module is fetched anywhere in the application.
|
||||
|
||||
**Error boundary:**
|
||||
|
||||
1. Temporarily add `throw new Error('test')` to the root component's render.
|
||||
2. Verify the error boundary catches it, displays the error UI, and the rest of the app remains functional.
|
||||
3. Click "Reincearca" and verify the module attempts to re-render.
|
||||
4. Remove the test error.
|
||||
|
||||
---
|
||||
|
||||
## Module Checklist
|
||||
|
||||
Use this checklist before submitting a PR for a new module.
|
||||
|
||||
### Structure
|
||||
- [ ] Module directory is at `src/modules/<module-id>/`
|
||||
- [ ] Directory contains: `components/`, `hooks/`, `services/`, `types.ts`, `config.ts`, `index.ts`
|
||||
- [ ] No files outside the module directory reference internal module files (only `index.ts` is imported externally)
|
||||
|
||||
### Configuration
|
||||
- [ ] `config.ts` exports a valid `ModuleConfig`
|
||||
- [ ] `id` is kebab-case and unique
|
||||
- [ ] `route` starts with `/`, is unique, and matches the App Router path
|
||||
- [ ] `featureFlag` matches the key in `flags.ts`
|
||||
- [ ] `storageNamespace` follows `architools.<id>` convention and is unique
|
||||
- [ ] `navOrder` does not conflict within its category
|
||||
|
||||
### Registration
|
||||
- [ ] Config imported and added to `moduleConfigs` in `src/config/modules.ts`
|
||||
- [ ] Lazy component added to `lazyComponents` in `src/lib/module-loader.ts`
|
||||
- [ ] Feature flag added to `defaultFlags` in `src/config/flags.ts`
|
||||
- [ ] Route page created at `src/app/(modules)/<route>/page.tsx`
|
||||
|
||||
### Code Quality
|
||||
- [ ] All types defined in `types.ts`
|
||||
- [ ] Storage access goes through `StorageService`, scoped to `storageNamespace`
|
||||
- [ ] No direct `localStorage`/`IndexedDB` calls
|
||||
- [ ] No imports from other modules (`@/modules/*` other than own)
|
||||
- [ ] Root component is a `default` export
|
||||
- [ ] All client components have `'use client'` directive
|
||||
- [ ] Hooks contain no JSX
|
||||
|
||||
### Testing
|
||||
- [ ] Module loads correctly with flag enabled
|
||||
- [ ] Module is completely absent (no chunk, no nav item) with flag disabled
|
||||
- [ ] Direct URL navigation to disabled module returns 404
|
||||
- [ ] Error boundary catches thrown errors gracefully
|
||||
- [ ] Storage keys are correctly namespaced (inspect in DevTools > Application > Local Storage)
|
||||
- [ ] Module works after page refresh (data persists)
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### 1. Importing from another module
|
||||
|
||||
```typescript
|
||||
// WRONG
|
||||
import { formatDeviz } from '@/modules/devize-generator/services/formatter';
|
||||
|
||||
// RIGHT: If you need shared logic, move it to src/lib/ or src/utils/
|
||||
import { formatCurrency } from '@/lib/formatters';
|
||||
```
|
||||
|
||||
Modules are isolated units. Cross-module imports create hidden coupling and break the ability to independently enable/disable modules.
|
||||
|
||||
### 2. Bypassing the storage abstraction
|
||||
|
||||
```typescript
|
||||
// WRONG
|
||||
localStorage.setItem('registru-corespondenta.entries', JSON.stringify(data));
|
||||
|
||||
// RIGHT
|
||||
const storage = new StorageService(config.storageNamespace);
|
||||
await storage.set('entries', data);
|
||||
```
|
||||
|
||||
Direct storage calls bypass namespace isolation, offline support, and future migration to IndexedDB or a remote backend.
|
||||
|
||||
### 3. Skipping the feature flag gate on the route
|
||||
|
||||
```typescript
|
||||
// WRONG: Module is always accessible via URL even when flag is disabled
|
||||
export default function MyModulePage() {
|
||||
return <ModuleLoader config={config} />;
|
||||
}
|
||||
|
||||
// RIGHT
|
||||
export default function MyModulePage() {
|
||||
const enabled = useFeatureFlag(config.featureFlag);
|
||||
if (!enabled) notFound();
|
||||
return <ModuleLoader config={config} />;
|
||||
}
|
||||
```
|
||||
|
||||
Without the flag check on the route, a user can access a disabled module by typing the URL directly.
|
||||
|
||||
### 4. Putting business logic in components
|
||||
|
||||
```typescript
|
||||
// WRONG: Component directly manages storage and transforms data
|
||||
function MyList() {
|
||||
const [items, setItems] = useState([]);
|
||||
useEffect(() => {
|
||||
const raw = localStorage.getItem('...');
|
||||
const parsed = JSON.parse(raw);
|
||||
const filtered = parsed.filter(/* complex logic */);
|
||||
setItems(filtered);
|
||||
}, []);
|
||||
}
|
||||
|
||||
// RIGHT: Component delegates to a hook, which delegates to a service
|
||||
function MyList() {
|
||||
const { items, isLoading } = useCorespondenta(filters);
|
||||
}
|
||||
```
|
||||
|
||||
Business logic in components makes code untestable, unreusable, and hard to maintain. Follow the layers: Component -> Hook -> Service -> Storage.
|
||||
|
||||
### 5. Non-unique identifiers
|
||||
|
||||
```typescript
|
||||
// WRONG: Reusing an ID or namespace that another module already uses
|
||||
export const myConfig: ModuleConfig = {
|
||||
id: 'oferte', // already taken by the Oferte module
|
||||
storageNamespace: 'architools.oferte', // already taken
|
||||
};
|
||||
```
|
||||
|
||||
This will cause data corruption and routing conflicts. The registry validation catches duplicate IDs, but namespace collisions are only caught by code review.
|
||||
|
||||
### 6. Side effects in config.ts
|
||||
|
||||
```typescript
|
||||
// WRONG: config.ts should be pure data, no side effects
|
||||
console.log('Module loaded!');
|
||||
await initializeDatabase();
|
||||
|
||||
export const config: ModuleConfig = { ... };
|
||||
|
||||
// RIGHT: config.ts exports only a plain object
|
||||
export const config: ModuleConfig = { ... };
|
||||
```
|
||||
|
||||
Config files are imported at registration time for all modules, regardless of whether they are enabled. Side effects in config files run unconditionally and defeat the purpose of lazy loading.
|
||||
|
||||
### 7. Exporting internal types from index.ts
|
||||
|
||||
```typescript
|
||||
// WRONG: Exposing internal implementation types to the outside
|
||||
export { config } from './config';
|
||||
export { default } from './components/MyModule';
|
||||
export type { InternalState, PrivateHelper } from './types'; // leaking internals
|
||||
|
||||
// RIGHT: Only export what the platform needs
|
||||
export { config } from './config';
|
||||
export { default } from './components/MyModule';
|
||||
```
|
||||
|
||||
The barrel export should be minimal. The only consumers are the module registry (needs `config`) and the module loader (needs the default component). Internal types stay internal.
|
||||
|
||||
### 8. Monolithic root component
|
||||
|
||||
```typescript
|
||||
// WRONG: One 500-line component that does everything
|
||||
export default function MyModule() {
|
||||
// form logic, list logic, filter logic, modal logic, all inline
|
||||
}
|
||||
|
||||
// RIGHT: Compose from focused sub-components
|
||||
export default function MyModule() {
|
||||
return (
|
||||
<>
|
||||
<MyModuleHeader />
|
||||
<MyModuleFilters />
|
||||
<MyModuleList />
|
||||
<MyModuleDetailPanel />
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Break the module into focused components. The root component should primarily compose child components and manage top-level layout.
|
||||
Reference in New Issue
Block a user