mirror of
https://github.com/johndoe6345789/postgres.git
synced 2026-04-24 13:55:00 +00:00
Add reusable components and code style guide following config-driven architecture
Co-authored-by: johndoe6345789 <224850594+johndoe6345789@users.noreply.github.com>
This commit is contained in:
542
CODE_STYLE.md
Normal file
542
CODE_STYLE.md
Normal file
@@ -0,0 +1,542 @@
|
||||
# Code Style Guide
|
||||
|
||||
This document outlines the coding standards and best practices for the PostgreSQL Admin Panel project.
|
||||
|
||||
## General Principles
|
||||
|
||||
### 1. Keep Components Small and Reusable
|
||||
- **Maximum component size**: ~200 lines of code
|
||||
- **Single Responsibility**: Each component should do one thing well
|
||||
- **Reusability**: Extract common patterns into shared components
|
||||
- **Example**: Instead of a 1000+ line dashboard, break it into:
|
||||
- `TableManagerTab.tsx` (table management UI)
|
||||
- `ColumnManagerTab.tsx` (column management UI)
|
||||
- `CreateTableDialog.tsx` (reusable dialog)
|
||||
- `ColumnDialog.tsx` (reusable for add/modify/drop)
|
||||
|
||||
### 2. Configuration-Driven Architecture
|
||||
- **Use JSON configuration**: Define features in `src/config/features.json`
|
||||
- **Don't hardcode**: Pull data types, actions, and UI settings from config
|
||||
- **Example**:
|
||||
```typescript
|
||||
// ❌ Bad - Hardcoded
|
||||
const dataTypes = ['INTEGER', 'VARCHAR', 'TEXT'];
|
||||
|
||||
// ✅ Good - Config-driven
|
||||
import { getDataTypes } from '@/utils/featureConfig';
|
||||
const dataTypes = getDataTypes().map(dt => dt.name);
|
||||
```
|
||||
|
||||
### 3. Leverage Existing Utilities
|
||||
- Use `src/utils/featureConfig.ts` functions:
|
||||
- `getFeatures()` - Get all enabled features
|
||||
- `getFeatureById(id)` - Get specific feature config
|
||||
- `getDataTypes()` - Get database data types
|
||||
- `getNavItems()` - Get navigation items
|
||||
|
||||
## TypeScript Standards
|
||||
|
||||
### Type Definitions
|
||||
```typescript
|
||||
// ✅ Good - Explicit types
|
||||
type TableManagerTabProps = {
|
||||
tables: Array<{ table_name: string }>;
|
||||
onCreateTable: (tableName: string, columns: any[]) => Promise<void>;
|
||||
onDropTable: (tableName: string) => Promise<void>;
|
||||
};
|
||||
|
||||
// ❌ Bad - Using 'any' without reason
|
||||
function handleData(data: any) { }
|
||||
|
||||
// ✅ Good - Proper typing
|
||||
function handleData(data: { id: number; name: string }) { }
|
||||
```
|
||||
|
||||
### Avoid Type Assertions
|
||||
```typescript
|
||||
// ❌ Bad
|
||||
const value = response as SomeType;
|
||||
|
||||
// ✅ Good - Validate first
|
||||
if (isValidType(response)) {
|
||||
const value = response;
|
||||
}
|
||||
```
|
||||
|
||||
## React/Next.js Standards
|
||||
|
||||
### Component Structure
|
||||
```typescript
|
||||
'use client'; // Only if component uses client-side features
|
||||
|
||||
import { useState } from 'react'; // React imports first
|
||||
import { Button } from '@mui/material'; // Third-party imports
|
||||
import { getFeatures } from '@/utils/featureConfig'; // Local imports
|
||||
|
||||
type ComponentProps = {
|
||||
// Props type definition
|
||||
};
|
||||
|
||||
export default function ComponentName({ prop1, prop2 }: ComponentProps) {
|
||||
// 1. Hooks
|
||||
const [state, setState] = useState();
|
||||
|
||||
// 2. Derived state
|
||||
const derivedValue = useMemo(() => compute(), [deps]);
|
||||
|
||||
// 3. Handlers
|
||||
const handleClick = () => { };
|
||||
|
||||
// 4. Effects
|
||||
useEffect(() => { }, []);
|
||||
|
||||
// 5. Render
|
||||
return <div>...</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Client vs Server Components
|
||||
```typescript
|
||||
// ✅ Server Component (default) - No 'use client'
|
||||
export default function ServerComponent() {
|
||||
// Can fetch data, use async/await
|
||||
// Cannot use hooks, events, or browser APIs
|
||||
return <div>Static content</div>;
|
||||
}
|
||||
|
||||
// ✅ Client Component - Add 'use client'
|
||||
'use client';
|
||||
export default function ClientComponent() {
|
||||
const [state, setState] = useState();
|
||||
return <button onClick={() => setState()}>Click</button>;
|
||||
}
|
||||
```
|
||||
|
||||
### Prop Naming
|
||||
```typescript
|
||||
// ✅ Good - Clear and consistent
|
||||
type DialogProps = {
|
||||
open: boolean; // State boolean
|
||||
onClose: () => void; // Event handler (on*)
|
||||
onCreate: (data) => Promise<void>; // Async handler
|
||||
tables: Table[]; // Plural for arrays
|
||||
selectedTable: string; // Singular for single value
|
||||
};
|
||||
|
||||
// ❌ Bad - Unclear naming
|
||||
type DialogProps = {
|
||||
isOpen: boolean; // Don't use 'is' prefix unnecessarily
|
||||
close: () => void; // Missing 'on' prefix
|
||||
data: any; // Too generic
|
||||
};
|
||||
```
|
||||
|
||||
## File Organization
|
||||
|
||||
### Directory Structure
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js pages and routes
|
||||
│ ├── admin/ # Admin pages
|
||||
│ └── api/ # API routes
|
||||
├── components/ # Reusable React components
|
||||
│ └── admin/ # Admin-specific components
|
||||
├── config/ # Configuration files
|
||||
│ └── features.json # Feature definitions (USE THIS!)
|
||||
├── utils/ # Utility functions
|
||||
│ └── featureConfig.ts # Config helpers (USE THIS!)
|
||||
├── models/ # Database models
|
||||
└── types/ # TypeScript type definitions
|
||||
```
|
||||
|
||||
### File Naming
|
||||
- **Components**: PascalCase - `TableManagerTab.tsx`
|
||||
- **Utilities**: camelCase - `featureConfig.ts`
|
||||
- **Tests**: Same as source + `.test.ts` - `featureConfig.test.ts`
|
||||
- **Types**: PascalCase - `UserTypes.ts`
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### Small, Focused Components
|
||||
```typescript
|
||||
// ✅ Good - Small, single purpose
|
||||
export default function CreateTableDialog({ open, onClose, onCreate }) {
|
||||
// Only handles table creation dialog
|
||||
return <Dialog>...</Dialog>;
|
||||
}
|
||||
|
||||
// ❌ Bad - Too many responsibilities
|
||||
export default function AdminDashboard() {
|
||||
// 1000+ lines handling:
|
||||
// - Navigation
|
||||
// - Table management
|
||||
// - Column management
|
||||
// - Query execution
|
||||
// - All dialogs inline
|
||||
}
|
||||
```
|
||||
|
||||
### Reusable Dialog Pattern
|
||||
```typescript
|
||||
// ✅ Good - Reusable for multiple operations
|
||||
export default function ColumnDialog({
|
||||
open,
|
||||
mode, // 'add' | 'modify' | 'drop'
|
||||
onSubmit,
|
||||
}) {
|
||||
// Single dialog component, multiple use cases
|
||||
}
|
||||
|
||||
// Usage:
|
||||
<ColumnDialog mode="add" onSubmit={handleAdd} />
|
||||
<ColumnDialog mode="modify" onSubmit={handleModify} />
|
||||
<ColumnDialog mode="drop" onSubmit={handleDrop} />
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### Local State
|
||||
```typescript
|
||||
// ✅ Good - Related state grouped
|
||||
const [dialog, setDialog] = useState({ open: false, mode: 'add' });
|
||||
|
||||
// ❌ Bad - Too many separate states
|
||||
const [openAddDialog, setOpenAddDialog] = useState(false);
|
||||
const [openModifyDialog, setOpenModifyDialog] = useState(false);
|
||||
const [openDropDialog, setOpenDropDialog] = useState(false);
|
||||
```
|
||||
|
||||
### Async Operations
|
||||
```typescript
|
||||
// ✅ Good - Proper error handling
|
||||
const handleSubmit = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await apiCall();
|
||||
setSuccess('Operation completed');
|
||||
} catch (error) {
|
||||
setError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ❌ Bad - No error handling
|
||||
const handleSubmit = async () => {
|
||||
await apiCall();
|
||||
setSuccess('Done');
|
||||
};
|
||||
```
|
||||
|
||||
## API Route Standards
|
||||
|
||||
### Validation Pattern
|
||||
```typescript
|
||||
// ✅ Good - Validate inputs
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { tableName, columns } = await request.json();
|
||||
|
||||
if (!tableName || !columns || columns.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Table name and columns are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!isValidIdentifier(tableName)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid table name format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Process request...
|
||||
}
|
||||
```
|
||||
|
||||
### SQL Injection Prevention
|
||||
```typescript
|
||||
// ✅ Good - Validate identifiers
|
||||
function isValidIdentifier(name: string): boolean {
|
||||
return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name);
|
||||
}
|
||||
|
||||
// ✅ Good - Use parameterized queries
|
||||
await db.execute(sql`SELECT * FROM ${sql.identifier(tableName)}`);
|
||||
|
||||
// ❌ Bad - String concatenation
|
||||
await db.execute(`SELECT * FROM ${tableName}`); // SQL injection risk!
|
||||
```
|
||||
|
||||
## Testing Standards
|
||||
|
||||
### Test File Naming
|
||||
- Unit tests: `ComponentName.test.tsx` or `utilityName.test.ts`
|
||||
- Integration tests: `tests/integration/FeatureName.spec.ts`
|
||||
- E2E tests: `tests/e2e/FeatureName.e2e.ts`
|
||||
|
||||
### Test Structure
|
||||
```typescript
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('FeatureName', () => {
|
||||
describe('Specific functionality', () => {
|
||||
it('should do something specific', () => {
|
||||
// Arrange
|
||||
const input = 'test';
|
||||
|
||||
// Act
|
||||
const result = functionUnderTest(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe('expected');
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Playwright Test Pattern
|
||||
```typescript
|
||||
test.describe('Feature Name', () => {
|
||||
test('should validate API endpoint', async ({ page }) => {
|
||||
const response = await page.request.post('/api/endpoint', {
|
||||
data: { field: 'value' },
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Material-UI Standards
|
||||
|
||||
### Component Usage
|
||||
```typescript
|
||||
// ✅ Good - Consistent spacing
|
||||
<Box sx={{ mt: 2, mb: 2, p: 2 }}>
|
||||
<Button variant="contained" startIcon={<AddIcon />}>
|
||||
Add Item
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
// ❌ Bad - Inconsistent styling
|
||||
<div style={{ marginTop: '16px', padding: '10px' }}>
|
||||
<Button>Add Item</Button>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Dialog Pattern
|
||||
```typescript
|
||||
// ✅ Good - Complete dialog structure
|
||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Title</DialogTitle>
|
||||
<DialogContent>
|
||||
{/* Content */}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="contained">Confirm</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### User-Facing Errors
|
||||
```typescript
|
||||
// ✅ Good - Clear, actionable messages
|
||||
setError('Table name must contain only letters, numbers, and underscores');
|
||||
|
||||
// ❌ Bad - Technical jargon
|
||||
setError('RegExp validation failed on identifier');
|
||||
```
|
||||
|
||||
### API Errors
|
||||
```typescript
|
||||
// ✅ Good - Structured error responses
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Invalid table name format',
|
||||
details: 'Table names must start with a letter or underscore'
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
### Component Documentation
|
||||
```typescript
|
||||
/**
|
||||
* Dialog for creating a new database table
|
||||
*
|
||||
* Features:
|
||||
* - Dynamic column builder
|
||||
* - Type selection from config
|
||||
* - Validation for table/column names
|
||||
*
|
||||
* @example
|
||||
* <CreateTableDialog
|
||||
* open={isOpen}
|
||||
* onClose={handleClose}
|
||||
* onCreate={handleCreate}
|
||||
* />
|
||||
*/
|
||||
export default function CreateTableDialog(props) { }
|
||||
```
|
||||
|
||||
### Function Documentation
|
||||
```typescript
|
||||
/**
|
||||
* Validates if a string is a safe SQL identifier
|
||||
* Prevents SQL injection by ensuring only alphanumeric and underscore
|
||||
*
|
||||
* @param name - The identifier to validate
|
||||
* @returns true if valid, false otherwise
|
||||
*
|
||||
* @example
|
||||
* isValidIdentifier('my_table') // true
|
||||
* isValidIdentifier('my-table!') // false
|
||||
*/
|
||||
function isValidIdentifier(name: string): boolean { }
|
||||
```
|
||||
|
||||
## Git Commit Standards
|
||||
|
||||
### Commit Message Format
|
||||
```
|
||||
type(scope): Short description
|
||||
|
||||
Longer description if needed
|
||||
|
||||
- List changes
|
||||
- One per line
|
||||
```
|
||||
|
||||
### Commit Types
|
||||
- `feat`: New feature
|
||||
- `fix`: Bug fix
|
||||
- `refactor`: Code refactoring
|
||||
- `test`: Adding tests
|
||||
- `docs`: Documentation changes
|
||||
- `style`: Code style changes (formatting)
|
||||
- `chore`: Maintenance tasks
|
||||
|
||||
### Examples
|
||||
```
|
||||
feat(admin): Add table manager UI component
|
||||
|
||||
- Create TableManagerTab component
|
||||
- Extract CreateTableDialog to separate file
|
||||
- Use features.json for configuration
|
||||
- Add validation for table names
|
||||
|
||||
fix(api): Prevent SQL injection in table creation
|
||||
|
||||
- Add identifier validation
|
||||
- Use parameterized queries
|
||||
- Add security tests
|
||||
```
|
||||
|
||||
## Performance Best Practices
|
||||
|
||||
### Avoid Unnecessary Re-renders
|
||||
```typescript
|
||||
// ✅ Good - Memoize callbacks
|
||||
const handleClick = useCallback(() => {
|
||||
doSomething();
|
||||
}, [dependency]);
|
||||
|
||||
// ✅ Good - Memoize expensive computations
|
||||
const derivedData = useMemo(() => {
|
||||
return expensiveComputation(data);
|
||||
}, [data]);
|
||||
```
|
||||
|
||||
### Optimize Bundle Size
|
||||
```typescript
|
||||
// ✅ Good - Named imports
|
||||
import { Button, TextField } from '@mui/material';
|
||||
|
||||
// ❌ Bad - Default imports (larger bundle)
|
||||
import Button from '@mui/material/Button';
|
||||
import TextField from '@mui/material/TextField';
|
||||
```
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### Authentication
|
||||
```typescript
|
||||
// ✅ Good - Always check session first
|
||||
const session = await getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
```
|
||||
|
||||
### Input Validation
|
||||
```typescript
|
||||
// ✅ Good - Validate all inputs
|
||||
if (!isValidIdentifier(tableName)) {
|
||||
return NextResponse.json({ error: 'Invalid format' }, { status: 400 });
|
||||
}
|
||||
|
||||
// ✅ Good - Sanitize user input
|
||||
const sanitized = tableName.trim().toLowerCase();
|
||||
```
|
||||
|
||||
## ESLint & Prettier
|
||||
|
||||
This project uses ESLint and Prettier for code quality:
|
||||
|
||||
```bash
|
||||
# Check code style
|
||||
npm run lint
|
||||
|
||||
# Fix auto-fixable issues
|
||||
npm run lint:fix
|
||||
|
||||
# Check TypeScript types
|
||||
npm run check:types
|
||||
```
|
||||
|
||||
### Key Rules
|
||||
- **No unused variables**: Remove or prefix with `_`
|
||||
- **Consistent quotes**: Single quotes for strings
|
||||
- **Semicolons**: Required at end of statements
|
||||
- **Indentation**: 2 spaces
|
||||
- **Line length**: Max 100 characters (soft limit)
|
||||
- **Trailing commas**: Required in multiline
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Component Checklist
|
||||
- [ ] Less than 200 lines
|
||||
- [ ] Uses feature config from JSON
|
||||
- [ ] Has proper TypeScript types
|
||||
- [ ] Includes error handling
|
||||
- [ ] Has tests (if logic-heavy)
|
||||
- [ ] Follows naming conventions
|
||||
- [ ] Documented if complex
|
||||
|
||||
### PR Checklist
|
||||
- [ ] Code follows style guide
|
||||
- [ ] Components are small and reusable
|
||||
- [ ] Uses configuration from features.json
|
||||
- [ ] Tests added/updated
|
||||
- [ ] Documentation updated
|
||||
- [ ] Linter passes
|
||||
- [ ] Type checking passes
|
||||
- [ ] No console.log statements
|
||||
- [ ] Error handling implemented
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: January 2026
|
||||
**Maintained by**: Development Team
|
||||
**Questions?**: Open an issue with label `documentation`
|
||||
226
TESTING.md
Normal file
226
TESTING.md
Normal file
@@ -0,0 +1,226 @@
|
||||
# Testing Guide for Table Manager and Column Manager Features
|
||||
|
||||
This document describes the test coverage for the newly implemented Table Manager and Column Manager features in the PostgreSQL Admin Panel.
|
||||
|
||||
## Test Files
|
||||
|
||||
### Integration Tests (Playwright API Tests)
|
||||
|
||||
#### 1. `tests/integration/TableManager.spec.ts`
|
||||
Tests for the Table Management API endpoints (`/api/admin/table-manage`):
|
||||
|
||||
**Create Table Tests:**
|
||||
- ✅ Creates new table with proper column definitions
|
||||
- ✅ Validates table name is required
|
||||
- ✅ Validates at least one column is required
|
||||
- ✅ Rejects invalid table names (SQL injection prevention)
|
||||
- ✅ Requires authentication for all operations
|
||||
|
||||
**Drop Table Tests:**
|
||||
- ✅ Validates table name is required
|
||||
- ✅ Rejects invalid table names
|
||||
- ✅ Requires authentication
|
||||
|
||||
**Test Coverage:**
|
||||
- Input validation
|
||||
- SQL injection prevention
|
||||
- Authentication/authorization
|
||||
- Error handling
|
||||
|
||||
#### 2. `tests/integration/ColumnManager.spec.ts`
|
||||
Tests for the Column Management API endpoints (`/api/admin/column-manage`):
|
||||
|
||||
**Add Column Tests:**
|
||||
- ✅ Requires authentication
|
||||
- ✅ Validates all required fields (tableName, columnName, dataType)
|
||||
- ✅ Rejects invalid table names
|
||||
- ✅ Rejects invalid column names
|
||||
|
||||
**Modify Column Tests:**
|
||||
- ✅ Requires authentication
|
||||
- ✅ Validates required fields
|
||||
- ✅ Rejects invalid identifiers
|
||||
|
||||
**Drop Column Tests:**
|
||||
- ✅ Requires authentication
|
||||
- ✅ Validates required fields
|
||||
- ✅ Rejects invalid identifiers
|
||||
|
||||
**Test Coverage:**
|
||||
- Input validation
|
||||
- SQL injection prevention
|
||||
- Authentication/authorization
|
||||
- Error handling for all CRUD operations
|
||||
|
||||
### End-to-End Tests (Playwright UI Tests)
|
||||
|
||||
#### 3. `tests/e2e/AdminDashboard.e2e.ts`
|
||||
Tests for the admin dashboard UI and user flows:
|
||||
|
||||
**Navigation Tests:**
|
||||
- ✅ Redirects to login when not authenticated
|
||||
- ✅ Displays login page with proper form elements
|
||||
|
||||
**Table Manager UI Tests:**
|
||||
- 🔄 Display Table Manager tab (requires auth - skipped)
|
||||
- 🔄 Open create table dialog (requires auth - skipped)
|
||||
|
||||
**Column Manager UI Tests:**
|
||||
- 🔄 Display Column Manager tab (requires auth - skipped)
|
||||
- 🔄 Show table selector (requires auth - skipped)
|
||||
|
||||
**Security Tests:**
|
||||
- ✅ Blocks admin API access without authentication
|
||||
- ✅ Blocks table management without authentication
|
||||
- ✅ Blocks column management without authentication
|
||||
|
||||
**Note:** Some UI tests are skipped because they require an authenticated session. These can be enabled when a test authentication mechanism is implemented.
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Run All Tests
|
||||
```bash
|
||||
npm test # Run Vitest unit tests
|
||||
npm run test:e2e # Run Playwright E2E tests
|
||||
```
|
||||
|
||||
### Run Specific Test Files
|
||||
```bash
|
||||
# Run integration tests only
|
||||
npx playwright test tests/integration/
|
||||
|
||||
# Run specific test file
|
||||
npx playwright test tests/integration/TableManager.spec.ts
|
||||
|
||||
# Run e2e tests only
|
||||
npx playwright test tests/e2e/
|
||||
```
|
||||
|
||||
### Run Tests in Watch Mode
|
||||
```bash
|
||||
npm run test -- --watch # Vitest watch mode
|
||||
```
|
||||
|
||||
### Run Tests with UI
|
||||
```bash
|
||||
npx playwright test --ui # Playwright UI mode
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
### Integration Tests Pattern
|
||||
```typescript
|
||||
test.describe('Feature Name', () => {
|
||||
test.describe('Specific Functionality', () => {
|
||||
test('should do something specific', async ({ page }) => {
|
||||
const response = await page.request.post('/api/endpoint', {
|
||||
data: { /* test data */ },
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(expectedStatus);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### E2E Tests Pattern
|
||||
```typescript
|
||||
test.describe('UI Feature', () => {
|
||||
test('should display correct elements', async ({ page }) => {
|
||||
await page.goto('/path');
|
||||
|
||||
await expect(page.getByRole('button', { name: /action/i })).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Security Testing
|
||||
|
||||
All tests verify that:
|
||||
1. **Authentication is required** for admin operations
|
||||
2. **Input validation** prevents SQL injection
|
||||
3. **Invalid identifiers** are rejected (table/column names)
|
||||
4. **Error messages** don't leak sensitive information
|
||||
|
||||
## Test Coverage Summary
|
||||
|
||||
| Feature | API Tests | UI Tests | Security Tests | Total Tests |
|
||||
|---------|-----------|----------|----------------|-------------|
|
||||
| Table Manager | 7 | 2 (2 skipped) | 3 | 12 |
|
||||
| Column Manager | 9 | 2 (2 skipped) | 3 | 14 |
|
||||
| Admin Dashboard | - | 3 | 3 | 6 |
|
||||
| **Total** | **16** | **7** | **9** | **32** |
|
||||
|
||||
## Future Test Improvements
|
||||
|
||||
### Short Term
|
||||
- [ ] Add authenticated session fixture for UI tests
|
||||
- [ ] Enable skipped UI tests with proper authentication
|
||||
- [ ] Add tests for success scenarios with valid credentials
|
||||
- [ ] Test visual column builder interactions
|
||||
- [ ] Test schema refresh after operations
|
||||
|
||||
### Medium Term
|
||||
- [ ] Add performance tests for large table operations
|
||||
- [ ] Add accessibility tests (a11y)
|
||||
- [ ] Add visual regression tests
|
||||
- [ ] Test error recovery and rollback scenarios
|
||||
- [ ] Add tests for concurrent operations
|
||||
|
||||
### Long Term
|
||||
- [ ] Integration tests with real PostgreSQL database
|
||||
- [ ] Load testing for multiple simultaneous users
|
||||
- [ ] Cross-browser compatibility tests
|
||||
- [ ] Mobile responsiveness tests
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
Tests are designed to run in CI/CD pipelines:
|
||||
|
||||
```yaml
|
||||
# Example CI configuration
|
||||
- name: Run Integration Tests
|
||||
run: npm run test:e2e -- tests/integration/
|
||||
|
||||
- name: Run E2E Tests
|
||||
run: npm run test:e2e -- tests/e2e/
|
||||
```
|
||||
|
||||
## Test Data Management
|
||||
|
||||
- Tests use **faker** library for generating random test data
|
||||
- Each test run creates unique table names to avoid conflicts
|
||||
- Tests validate authentication is required, so they expect 401 responses when not authenticated
|
||||
- No database cleanup is required for API validation tests
|
||||
|
||||
## Debugging Tests
|
||||
|
||||
### View Test Results
|
||||
```bash
|
||||
npx playwright show-report # View HTML report
|
||||
```
|
||||
|
||||
### Debug Specific Test
|
||||
```bash
|
||||
npx playwright test --debug tests/integration/TableManager.spec.ts
|
||||
```
|
||||
|
||||
### View Test Traces
|
||||
```bash
|
||||
npx playwright show-trace trace.zip
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new features:
|
||||
1. Add integration tests for new API endpoints
|
||||
2. Add E2E tests for new UI components
|
||||
3. Ensure security tests cover authentication
|
||||
4. Update this documentation with new test coverage
|
||||
5. Run all tests before submitting PR
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** January 2026
|
||||
**Test Framework:** Playwright + Vitest
|
||||
**Coverage Status:** ✅ API Validation | 🔄 UI Tests (partial - needs auth)
|
||||
202
src/components/admin/ColumnDialog.tsx
Normal file
202
src/components/admin/ColumnDialog.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControlLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type ColumnDialogProps = {
|
||||
open: boolean;
|
||||
mode: 'add' | 'modify' | 'drop';
|
||||
tableName: string;
|
||||
columns?: Array<{ column_name: string }>;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: any) => Promise<void>;
|
||||
dataTypes?: string[];
|
||||
};
|
||||
|
||||
export default function ColumnDialog({
|
||||
open,
|
||||
mode,
|
||||
tableName,
|
||||
columns = [],
|
||||
onClose,
|
||||
onSubmit,
|
||||
dataTypes = ['INTEGER', 'BIGINT', 'VARCHAR', 'TEXT', 'BOOLEAN', 'TIMESTAMP', 'DATE', 'JSON', 'JSONB'],
|
||||
}: ColumnDialogProps) {
|
||||
const [columnName, setColumnName] = useState('');
|
||||
const [columnType, setColumnType] = useState('VARCHAR');
|
||||
const [nullable, setNullable] = useState(true);
|
||||
const [defaultValue, setDefaultValue] = useState('');
|
||||
const [selectedColumn, setSelectedColumn] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
// Reset form when dialog closes
|
||||
setColumnName('');
|
||||
setColumnType('VARCHAR');
|
||||
setNullable(true);
|
||||
setDefaultValue('');
|
||||
setSelectedColumn('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data: any = {};
|
||||
|
||||
if (mode === 'add') {
|
||||
data.columnName = columnName;
|
||||
data.dataType = columnType;
|
||||
data.nullable = nullable;
|
||||
if (defaultValue) data.defaultValue = defaultValue;
|
||||
} else if (mode === 'modify') {
|
||||
data.columnName = selectedColumn;
|
||||
data.newType = columnType;
|
||||
data.nullable = nullable;
|
||||
} else if (mode === 'drop') {
|
||||
data.columnName = selectedColumn;
|
||||
}
|
||||
|
||||
await onSubmit(data);
|
||||
onClose();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getTitle = () => {
|
||||
switch (mode) {
|
||||
case 'add':
|
||||
return `Add Column to ${tableName}`;
|
||||
case 'modify':
|
||||
return `Modify Column in ${tableName}`;
|
||||
case 'drop':
|
||||
return `Drop Column from ${tableName}`;
|
||||
default:
|
||||
return 'Column Operation';
|
||||
}
|
||||
};
|
||||
|
||||
const isFormValid = () => {
|
||||
if (mode === 'add') {
|
||||
return columnName.trim() && columnType;
|
||||
}
|
||||
return selectedColumn.trim();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose}>
|
||||
<DialogTitle>{getTitle()}</DialogTitle>
|
||||
<DialogContent>
|
||||
{mode === 'drop' && (
|
||||
<Typography variant="body2" color="error" gutterBottom>
|
||||
Warning: This will permanently delete the column and all its data!
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{mode === 'add' ? (
|
||||
<>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Column Name"
|
||||
value={columnName}
|
||||
onChange={e => setColumnName(e.target.value)}
|
||||
sx={{ mt: 2, mb: 2 }}
|
||||
/>
|
||||
<Select
|
||||
fullWidth
|
||||
value={columnType}
|
||||
onChange={e => setColumnType(e.target.value)}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{dataTypes.map(type => (
|
||||
<MenuItem key={type} value={type}>
|
||||
{type}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox checked={nullable} onChange={e => setNullable(e.target.checked)} />
|
||||
}
|
||||
label="Nullable"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Default Value (optional)"
|
||||
value={defaultValue}
|
||||
onChange={e => setDefaultValue(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Select
|
||||
fullWidth
|
||||
value={selectedColumn}
|
||||
onChange={e => setSelectedColumn(e.target.value)}
|
||||
displayEmpty
|
||||
sx={{ mt: 2, mb: 2 }}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>Select a column</em>
|
||||
</MenuItem>
|
||||
{columns.map(col => (
|
||||
<MenuItem key={col.column_name} value={col.column_name}>
|
||||
{col.column_name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{mode === 'modify' && selectedColumn && (
|
||||
<>
|
||||
<Select
|
||||
fullWidth
|
||||
value={columnType}
|
||||
onChange={e => setColumnType(e.target.value)}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{dataTypes.map(type => (
|
||||
<MenuItem key={type} value={type}>
|
||||
{type}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox checked={nullable} onChange={e => setNullable(e.target.checked)} />
|
||||
}
|
||||
label="Nullable"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
variant="contained"
|
||||
color={mode === 'drop' ? 'error' : 'primary'}
|
||||
disabled={loading || !isFormValid()}
|
||||
>
|
||||
{mode === 'add' ? 'Add Column' : mode === 'modify' ? 'Modify Column' : 'Drop Column'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
215
src/components/admin/ColumnManagerTab.tsx
Normal file
215
src/components/admin/ColumnManagerTab.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
'use client';
|
||||
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getDataTypes, getFeatureById } from '@/utils/featureConfig';
|
||||
import ColumnDialog from './ColumnDialog';
|
||||
|
||||
type ColumnManagerTabProps = {
|
||||
tables: Array<{ table_name: string }>;
|
||||
onAddColumn: (tableName: string, data: any) => Promise<void>;
|
||||
onModifyColumn: (tableName: string, data: any) => Promise<void>;
|
||||
onDropColumn: (tableName: string, data: any) => Promise<void>;
|
||||
};
|
||||
|
||||
export default function ColumnManagerTab({
|
||||
tables,
|
||||
onAddColumn,
|
||||
onModifyColumn,
|
||||
onDropColumn,
|
||||
}: ColumnManagerTabProps) {
|
||||
const [selectedTable, setSelectedTable] = useState('');
|
||||
const [tableSchema, setTableSchema] = useState<any>(null);
|
||||
const [dialogState, setDialogState] = useState<{
|
||||
open: boolean;
|
||||
mode: 'add' | 'modify' | 'drop';
|
||||
}>({ open: false, mode: 'add' });
|
||||
|
||||
// Get feature configuration from JSON
|
||||
const feature = getFeatureById('column-management');
|
||||
const dataTypes = getDataTypes().map(dt => dt.name);
|
||||
|
||||
// Check if actions are enabled from config
|
||||
const canAdd = feature?.ui.actions.includes('add');
|
||||
const canModify = feature?.ui.actions.includes('modify');
|
||||
const canDelete = feature?.ui.actions.includes('delete');
|
||||
|
||||
// Fetch schema when table is selected
|
||||
useEffect(() => {
|
||||
if (selectedTable) {
|
||||
fetchTableSchema();
|
||||
} else {
|
||||
setTableSchema(null);
|
||||
}
|
||||
}, [selectedTable]);
|
||||
|
||||
const fetchTableSchema = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/table-schema', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tableName: selectedTable }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTableSchema(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch schema:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleColumnOperation = async (data: any) => {
|
||||
switch (dialogState.mode) {
|
||||
case 'add':
|
||||
await onAddColumn(selectedTable, data);
|
||||
break;
|
||||
case 'modify':
|
||||
await onModifyColumn(selectedTable, data);
|
||||
break;
|
||||
case 'drop':
|
||||
await onDropColumn(selectedTable, data);
|
||||
break;
|
||||
}
|
||||
await fetchTableSchema(); // Refresh schema
|
||||
};
|
||||
|
||||
const openDialog = (mode: 'add' | 'modify' | 'drop') => {
|
||||
setDialogState({ open: true, mode });
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogState({ ...dialogState, open: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
{feature?.name || 'Column Manager'}
|
||||
</Typography>
|
||||
|
||||
{feature?.description && (
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
{feature.description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Paper sx={{ p: 2, mt: 2, mb: 2 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Select a table to manage its columns:
|
||||
</Typography>
|
||||
<Select
|
||||
fullWidth
|
||||
value={selectedTable}
|
||||
onChange={e => setSelectedTable(e.target.value)}
|
||||
displayEmpty
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>Select a table</em>
|
||||
</MenuItem>
|
||||
{tables.map(table => (
|
||||
<MenuItem key={table.table_name} value={table.table_name}>
|
||||
{table.table_name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</Paper>
|
||||
|
||||
{selectedTable && (
|
||||
<>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
{canAdd && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => openDialog('add')}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
Add Column
|
||||
</Button>
|
||||
)}
|
||||
{canModify && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<EditIcon />}
|
||||
onClick={() => openDialog('modify')}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
Modify Column
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={() => openDialog('drop')}
|
||||
>
|
||||
Drop Column
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{tableSchema && (
|
||||
<Paper sx={{ mt: 2 }}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Current Columns for {selectedTable}
|
||||
</Typography>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell><strong>Column Name</strong></TableCell>
|
||||
<TableCell><strong>Data Type</strong></TableCell>
|
||||
<TableCell><strong>Nullable</strong></TableCell>
|
||||
<TableCell><strong>Default</strong></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{tableSchema.columns?.map((col: any) => (
|
||||
<TableRow key={col.column_name}>
|
||||
<TableCell>{col.column_name}</TableCell>
|
||||
<TableCell>{col.data_type}</TableCell>
|
||||
<TableCell>{col.is_nullable}</TableCell>
|
||||
<TableCell>{col.column_default || 'NULL'}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ColumnDialog
|
||||
open={dialogState.open}
|
||||
mode={dialogState.mode}
|
||||
tableName={selectedTable}
|
||||
columns={tableSchema?.columns || []}
|
||||
onClose={closeDialog}
|
||||
onSubmit={handleColumnOperation}
|
||||
dataTypes={dataTypes}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
162
src/components/admin/CreateTableDialog.tsx
Normal file
162
src/components/admin/CreateTableDialog.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
MenuItem,
|
||||
Select,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
|
||||
type Column = {
|
||||
name: string;
|
||||
type: string;
|
||||
length?: number;
|
||||
nullable: boolean;
|
||||
primaryKey: boolean;
|
||||
};
|
||||
|
||||
type CreateTableDialogProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: (tableName: string, columns: Column[]) => Promise<void>;
|
||||
dataTypes?: string[];
|
||||
};
|
||||
|
||||
export default function CreateTableDialog({
|
||||
open,
|
||||
onClose,
|
||||
onCreate,
|
||||
dataTypes = ['INTEGER', 'BIGINT', 'SERIAL', 'VARCHAR', 'TEXT', 'BOOLEAN', 'TIMESTAMP', 'DATE', 'JSON', 'JSONB'],
|
||||
}: CreateTableDialogProps) {
|
||||
const [tableName, setTableName] = useState('');
|
||||
const [columns, setColumns] = useState<Column[]>([
|
||||
{ name: '', type: 'VARCHAR', length: 255, nullable: true, primaryKey: false },
|
||||
]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleCreate = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await onCreate(tableName, columns.filter(col => col.name.trim()));
|
||||
handleClose();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setTableName('');
|
||||
setColumns([{ name: '', type: 'VARCHAR', length: 255, nullable: true, primaryKey: false }]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const addColumn = () => {
|
||||
setColumns([...columns, { name: '', type: 'VARCHAR', length: 255, nullable: true, primaryKey: false }]);
|
||||
};
|
||||
|
||||
const updateColumn = (index: number, field: string, value: any) => {
|
||||
const updated = [...columns];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
setColumns(updated);
|
||||
};
|
||||
|
||||
const removeColumn = (index: number) => {
|
||||
if (columns.length > 1) {
|
||||
setColumns(columns.filter((_, i) => i !== index));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Create New Table</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Table Name"
|
||||
value={tableName}
|
||||
onChange={e => setTableName(e.target.value)}
|
||||
sx={{ mt: 2, mb: 2 }}
|
||||
/>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
Columns:
|
||||
</Typography>
|
||||
{columns.map((col, index) => (
|
||||
<Box key={index} sx={{ mb: 2, p: 2, border: '1px solid #ddd', borderRadius: 1 }}>
|
||||
<TextField
|
||||
label="Column Name"
|
||||
value={col.name}
|
||||
onChange={e => updateColumn(index, 'name', e.target.value)}
|
||||
sx={{ mr: 1, mb: 1 }}
|
||||
/>
|
||||
<Select
|
||||
value={col.type}
|
||||
onChange={e => updateColumn(index, 'type', e.target.value)}
|
||||
sx={{ mr: 1, mb: 1, minWidth: 120 }}
|
||||
>
|
||||
{dataTypes.map(type => (
|
||||
<MenuItem key={type} value={type}>
|
||||
{type}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{col.type === 'VARCHAR' && (
|
||||
<TextField
|
||||
label="Length"
|
||||
type="number"
|
||||
value={col.length || 255}
|
||||
onChange={e => updateColumn(index, 'length', e.target.value)}
|
||||
sx={{ mr: 1, mb: 1, width: 100 }}
|
||||
/>
|
||||
)}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={col.nullable}
|
||||
onChange={e => updateColumn(index, 'nullable', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Nullable"
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={col.primaryKey}
|
||||
onChange={e => updateColumn(index, 'primaryKey', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Primary Key"
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
{columns.length > 1 && (
|
||||
<IconButton onClick={() => removeColumn(index)} color="error" size="small">
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
<Button startIcon={<AddIcon />} onClick={addColumn} variant="outlined">
|
||||
Add Column
|
||||
</Button>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
<Button onClick={handleCreate} variant="contained" disabled={loading || !tableName.trim()}>
|
||||
Create Table
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
85
src/components/admin/DropTableDialog.tsx
Normal file
85
src/components/admin/DropTableDialog.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
MenuItem,
|
||||
Select,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
|
||||
type DropTableDialogProps = {
|
||||
open: boolean;
|
||||
tables: Array<{ table_name: string }>;
|
||||
onClose: () => void;
|
||||
onDrop: (tableName: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export default function DropTableDialog({
|
||||
open,
|
||||
tables,
|
||||
onClose,
|
||||
onDrop,
|
||||
}: DropTableDialogProps) {
|
||||
const [selectedTable, setSelectedTable] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleDrop = async () => {
|
||||
if (!selectedTable) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await onDrop(selectedTable);
|
||||
handleClose();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedTable('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose}>
|
||||
<DialogTitle>Drop Table</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" color="error" gutterBottom>
|
||||
Warning: This will permanently delete the table and all its data!
|
||||
</Typography>
|
||||
<Select
|
||||
fullWidth
|
||||
value={selectedTable}
|
||||
onChange={e => setSelectedTable(e.target.value)}
|
||||
displayEmpty
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>Select a table to drop</em>
|
||||
</MenuItem>
|
||||
{tables.map(table => (
|
||||
<MenuItem key={table.table_name} value={table.table_name}>
|
||||
{table.table_name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleDrop}
|
||||
color="error"
|
||||
variant="contained"
|
||||
disabled={loading || !selectedTable}
|
||||
>
|
||||
Drop Table
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
116
src/components/admin/TableManagerTab.tsx
Normal file
116
src/components/admin/TableManagerTab.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import TableChartIcon from '@mui/icons-material/TableChart';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Paper,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
import { getDataTypes, getFeatureById } from '@/utils/featureConfig';
|
||||
import CreateTableDialog from './CreateTableDialog';
|
||||
import DropTableDialog from './DropTableDialog';
|
||||
|
||||
type TableManagerTabProps = {
|
||||
tables: Array<{ table_name: string }>;
|
||||
onCreateTable: (tableName: string, columns: any[]) => Promise<void>;
|
||||
onDropTable: (tableName: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export default function TableManagerTab({
|
||||
tables,
|
||||
onCreateTable,
|
||||
onDropTable,
|
||||
}: TableManagerTabProps) {
|
||||
const [openCreateDialog, setOpenCreateDialog] = useState(false);
|
||||
const [openDropDialog, setOpenDropDialog] = useState(false);
|
||||
|
||||
// Get feature configuration from JSON
|
||||
const feature = getFeatureById('table-management');
|
||||
const dataTypes = getDataTypes().map(dt => dt.name);
|
||||
|
||||
// Check if actions are enabled
|
||||
const canCreate = feature?.ui.actions.includes('create');
|
||||
const canDelete = feature?.ui.actions.includes('delete');
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h5" gutterBottom>
|
||||
{feature?.name || 'Table Manager'}
|
||||
</Typography>
|
||||
|
||||
{feature?.description && (
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
{feature.description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 2, mb: 2 }}>
|
||||
{canCreate && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => setOpenCreateDialog(true)}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
Create Table
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={() => setOpenDropDialog(true)}
|
||||
>
|
||||
Drop Table
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ mt: 2 }}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Existing Tables
|
||||
</Typography>
|
||||
<List>
|
||||
{tables.map(table => (
|
||||
<ListItem key={table.table_name}>
|
||||
<ListItemIcon>
|
||||
<TableChartIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={table.table_name} />
|
||||
</ListItem>
|
||||
))}
|
||||
{tables.length === 0 && (
|
||||
<ListItem>
|
||||
<ListItemText primary="No tables found" />
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<CreateTableDialog
|
||||
open={openCreateDialog}
|
||||
onClose={() => setOpenCreateDialog(false)}
|
||||
onCreate={onCreateTable}
|
||||
dataTypes={dataTypes}
|
||||
/>
|
||||
|
||||
<DropTableDialog
|
||||
open={openDropDialog}
|
||||
tables={tables}
|
||||
onClose={() => setOpenDropDialog(false)}
|
||||
onDrop={onDropTable}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
337
src/utils/featureConfig.test.ts
Normal file
337
src/utils/featureConfig.test.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getDataTypes,
|
||||
getEnabledFeaturesByPriority,
|
||||
getFeatureById,
|
||||
getFeatures,
|
||||
getNavItems,
|
||||
} from './featureConfig';
|
||||
|
||||
describe('FeatureConfig', () => {
|
||||
describe('getFeatures', () => {
|
||||
it('should return only enabled features', () => {
|
||||
const features = getFeatures();
|
||||
|
||||
expect(features).toBeDefined();
|
||||
expect(Array.isArray(features)).toBe(true);
|
||||
|
||||
// All returned features should be enabled
|
||||
features.forEach(feature => {
|
||||
expect(feature.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return features with required properties', () => {
|
||||
const features = getFeatures();
|
||||
|
||||
features.forEach(feature => {
|
||||
expect(feature).toHaveProperty('id');
|
||||
expect(feature).toHaveProperty('name');
|
||||
expect(feature).toHaveProperty('description');
|
||||
expect(feature).toHaveProperty('enabled');
|
||||
expect(feature).toHaveProperty('priority');
|
||||
expect(feature).toHaveProperty('endpoints');
|
||||
expect(feature).toHaveProperty('ui');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return features with valid UI configuration', () => {
|
||||
const features = getFeatures();
|
||||
|
||||
features.forEach(feature => {
|
||||
expect(feature.ui).toHaveProperty('showInNav');
|
||||
expect(feature.ui).toHaveProperty('icon');
|
||||
expect(feature.ui).toHaveProperty('actions');
|
||||
expect(Array.isArray(feature.ui.actions)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFeatureById', () => {
|
||||
it('should return feature when ID exists and is enabled', () => {
|
||||
const feature = getFeatureById('database-crud');
|
||||
|
||||
expect(feature).toBeDefined();
|
||||
expect(feature?.id).toBe('database-crud');
|
||||
expect(feature?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should return feature for table-management', () => {
|
||||
const feature = getFeatureById('table-management');
|
||||
|
||||
expect(feature).toBeDefined();
|
||||
expect(feature?.id).toBe('table-management');
|
||||
expect(feature?.name).toBe('Table Management');
|
||||
});
|
||||
|
||||
it('should return feature for column-management', () => {
|
||||
const feature = getFeatureById('column-management');
|
||||
|
||||
expect(feature).toBeDefined();
|
||||
expect(feature?.id).toBe('column-management');
|
||||
expect(feature?.name).toBe('Column Management');
|
||||
});
|
||||
|
||||
it('should return feature for sql-query', () => {
|
||||
const feature = getFeatureById('sql-query');
|
||||
|
||||
expect(feature).toBeDefined();
|
||||
expect(feature?.id).toBe('sql-query');
|
||||
expect(feature?.name).toBe('SQL Query Interface');
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent feature ID', () => {
|
||||
const feature = getFeatureById('non-existent-feature');
|
||||
|
||||
expect(feature).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for disabled feature', () => {
|
||||
// This test assumes there might be disabled features in the config
|
||||
const features = getFeatures();
|
||||
const enabledIds = features.map(f => f.id);
|
||||
|
||||
// Try to get a feature that doesn't exist in enabled list
|
||||
const disabledFeature = getFeatureById('disabled-test-feature');
|
||||
expect(disabledFeature).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDataTypes', () => {
|
||||
it('should return array of data types', () => {
|
||||
const dataTypes = getDataTypes();
|
||||
|
||||
expect(dataTypes).toBeDefined();
|
||||
expect(Array.isArray(dataTypes)).toBe(true);
|
||||
expect(dataTypes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return data types with required properties', () => {
|
||||
const dataTypes = getDataTypes();
|
||||
|
||||
dataTypes.forEach(dataType => {
|
||||
expect(dataType).toHaveProperty('name');
|
||||
expect(dataType).toHaveProperty('category');
|
||||
expect(dataType).toHaveProperty('requiresLength');
|
||||
expect(typeof dataType.name).toBe('string');
|
||||
expect(typeof dataType.category).toBe('string');
|
||||
expect(typeof dataType.requiresLength).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
it('should include common PostgreSQL data types', () => {
|
||||
const dataTypes = getDataTypes();
|
||||
const typeNames = dataTypes.map(dt => dt.name);
|
||||
|
||||
// Check for essential PostgreSQL types
|
||||
expect(typeNames).toContain('INTEGER');
|
||||
expect(typeNames).toContain('VARCHAR');
|
||||
expect(typeNames).toContain('TEXT');
|
||||
expect(typeNames).toContain('BOOLEAN');
|
||||
expect(typeNames).toContain('TIMESTAMP');
|
||||
});
|
||||
|
||||
it('should have VARCHAR with requiresLength = true', () => {
|
||||
const dataTypes = getDataTypes();
|
||||
const varchar = dataTypes.find(dt => dt.name === 'VARCHAR');
|
||||
|
||||
expect(varchar).toBeDefined();
|
||||
expect(varchar?.requiresLength).toBe(true);
|
||||
expect(varchar?.defaultLength).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have INTEGER with requiresLength = false', () => {
|
||||
const dataTypes = getDataTypes();
|
||||
const integer = dataTypes.find(dt => dt.name === 'INTEGER');
|
||||
|
||||
expect(integer).toBeDefined();
|
||||
expect(integer?.requiresLength).toBe(false);
|
||||
});
|
||||
|
||||
it('should categorize data types correctly', () => {
|
||||
const dataTypes = getDataTypes();
|
||||
|
||||
const integer = dataTypes.find(dt => dt.name === 'INTEGER');
|
||||
expect(integer?.category).toBe('numeric');
|
||||
|
||||
const varchar = dataTypes.find(dt => dt.name === 'VARCHAR');
|
||||
expect(varchar?.category).toBe('text');
|
||||
|
||||
const boolean = dataTypes.find(dt => dt.name === 'BOOLEAN');
|
||||
expect(boolean?.category).toBe('boolean');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNavItems', () => {
|
||||
it('should return array of navigation items', () => {
|
||||
const navItems = getNavItems();
|
||||
|
||||
expect(navItems).toBeDefined();
|
||||
expect(Array.isArray(navItems)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return nav items with required properties', () => {
|
||||
const navItems = getNavItems();
|
||||
|
||||
navItems.forEach(item => {
|
||||
expect(item).toHaveProperty('id');
|
||||
expect(item).toHaveProperty('label');
|
||||
expect(item).toHaveProperty('icon');
|
||||
expect(item).toHaveProperty('featureId');
|
||||
});
|
||||
});
|
||||
|
||||
it('should only return nav items for enabled features', () => {
|
||||
const navItems = getNavItems();
|
||||
|
||||
navItems.forEach(item => {
|
||||
const feature = getFeatureById(item.featureId);
|
||||
expect(feature).toBeDefined();
|
||||
expect(feature?.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should include Tables nav item', () => {
|
||||
const navItems = getNavItems();
|
||||
const tablesItem = navItems.find(item => item.id === 'tables');
|
||||
|
||||
expect(tablesItem).toBeDefined();
|
||||
expect(tablesItem?.featureId).toBe('database-crud');
|
||||
});
|
||||
|
||||
it('should include SQL Query nav item', () => {
|
||||
const navItems = getNavItems();
|
||||
const queryItem = navItems.find(item => item.id === 'query');
|
||||
|
||||
expect(queryItem).toBeDefined();
|
||||
expect(queryItem?.featureId).toBe('sql-query');
|
||||
});
|
||||
|
||||
it('should include Table Manager nav item', () => {
|
||||
const navItems = getNavItems();
|
||||
const tableManagerItem = navItems.find(item => item.id === 'table-manager');
|
||||
|
||||
expect(tableManagerItem).toBeDefined();
|
||||
expect(tableManagerItem?.featureId).toBe('table-management');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEnabledFeaturesByPriority', () => {
|
||||
it('should return features with high priority', () => {
|
||||
const highPriorityFeatures = getEnabledFeaturesByPriority('high');
|
||||
|
||||
expect(Array.isArray(highPriorityFeatures)).toBe(true);
|
||||
|
||||
highPriorityFeatures.forEach(feature => {
|
||||
expect(feature.priority).toBe('high');
|
||||
expect(feature.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return all enabled features have high priority', () => {
|
||||
const highPriorityFeatures = getEnabledFeaturesByPriority('high');
|
||||
|
||||
// All current features should have high priority
|
||||
expect(highPriorityFeatures.length).toBeGreaterThan(0);
|
||||
|
||||
const ids = highPriorityFeatures.map(f => f.id);
|
||||
expect(ids).toContain('database-crud');
|
||||
expect(ids).toContain('table-management');
|
||||
expect(ids).toContain('column-management');
|
||||
expect(ids).toContain('sql-query');
|
||||
});
|
||||
|
||||
it('should return empty array for non-existent priority', () => {
|
||||
const features = getEnabledFeaturesByPriority('non-existent-priority');
|
||||
|
||||
expect(Array.isArray(features)).toBe(true);
|
||||
expect(features.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should return empty array for low priority (if none exist)', () => {
|
||||
const lowPriorityFeatures = getEnabledFeaturesByPriority('low');
|
||||
|
||||
expect(Array.isArray(lowPriorityFeatures)).toBe(true);
|
||||
// Expecting 0 since current config has all high priority
|
||||
expect(lowPriorityFeatures.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Feature endpoints', () => {
|
||||
it('should have valid endpoint structure for database-crud', () => {
|
||||
const feature = getFeatureById('database-crud');
|
||||
|
||||
expect(feature?.endpoints).toBeDefined();
|
||||
expect(Array.isArray(feature?.endpoints)).toBe(true);
|
||||
expect(feature?.endpoints.length).toBeGreaterThan(0);
|
||||
|
||||
feature?.endpoints.forEach(endpoint => {
|
||||
expect(endpoint).toHaveProperty('path');
|
||||
expect(endpoint).toHaveProperty('methods');
|
||||
expect(endpoint).toHaveProperty('description');
|
||||
expect(endpoint.path).toMatch(/^\/api\//);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have valid endpoint structure for table-management', () => {
|
||||
const feature = getFeatureById('table-management');
|
||||
|
||||
expect(feature?.endpoints).toBeDefined();
|
||||
const tableManageEndpoint = feature?.endpoints.find(
|
||||
ep => ep.path === '/api/admin/table-manage'
|
||||
);
|
||||
|
||||
expect(tableManageEndpoint).toBeDefined();
|
||||
expect(tableManageEndpoint?.methods).toContain('POST');
|
||||
expect(tableManageEndpoint?.methods).toContain('DELETE');
|
||||
});
|
||||
|
||||
it('should have valid endpoint structure for column-management', () => {
|
||||
const feature = getFeatureById('column-management');
|
||||
|
||||
expect(feature?.endpoints).toBeDefined();
|
||||
const columnManageEndpoint = feature?.endpoints.find(
|
||||
ep => ep.path === '/api/admin/column-manage'
|
||||
);
|
||||
|
||||
expect(columnManageEndpoint).toBeDefined();
|
||||
expect(columnManageEndpoint?.methods).toContain('POST');
|
||||
expect(columnManageEndpoint?.methods).toContain('PUT');
|
||||
expect(columnManageEndpoint?.methods).toContain('DELETE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Feature UI configuration', () => {
|
||||
it('should have correct UI actions for database-crud', () => {
|
||||
const feature = getFeatureById('database-crud');
|
||||
|
||||
expect(feature?.ui.actions).toContain('create');
|
||||
expect(feature?.ui.actions).toContain('read');
|
||||
expect(feature?.ui.actions).toContain('update');
|
||||
expect(feature?.ui.actions).toContain('delete');
|
||||
});
|
||||
|
||||
it('should have correct UI actions for table-management', () => {
|
||||
const feature = getFeatureById('table-management');
|
||||
|
||||
expect(feature?.ui.actions).toContain('create');
|
||||
expect(feature?.ui.actions).toContain('delete');
|
||||
});
|
||||
|
||||
it('should have correct UI actions for column-management', () => {
|
||||
const feature = getFeatureById('column-management');
|
||||
|
||||
expect(feature?.ui.actions).toContain('add');
|
||||
expect(feature?.ui.actions).toContain('modify');
|
||||
expect(feature?.ui.actions).toContain('delete');
|
||||
});
|
||||
|
||||
it('should all features have showInNav in UI config', () => {
|
||||
const features = getFeatures();
|
||||
|
||||
features.forEach(feature => {
|
||||
expect(typeof feature.ui.showInNav).toBe('boolean');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user