diff --git a/README.md b/README.md index f6948ad..5a83916 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ This project is a full-stack web application featuring: - 🛠️ **Admin Panel** - Manage tables, columns, and data through a beautiful UI - 📊 **Table Manager** - Create and drop tables with visual column definition - 🔧 **Column Manager** - Add, modify, and drop columns from existing tables -- 🔒 **Constraint Manager** - Add and manage UNIQUE and CHECK constraints (API ready, UI in progress) +- 🔒 **Constraint Manager** - Add and manage UNIQUE and CHECK constraints (fully implemented) - 📊 **SQL Query Interface** - Execute custom queries with safety validation - 🔒 **JWT Authentication** with secure session management - 📦 **DrizzleORM** - Support for PostgreSQL, MySQL, and SQLite @@ -767,10 +767,9 @@ See [ROADMAP.md](ROADMAP.md) for planned features and improvements. - ✅ Table Manager - Create and drop tables with visual column builder - ✅ Column Manager - Add, modify, and drop columns from existing tables - ✅ Schema management interface for table and column operations -- 🔄 Constraint Manager - Add and manage UNIQUE and CHECK constraints (API complete, UI in progress) +- ✅ Constraint Manager - Add and manage UNIQUE and CHECK constraints (fully implemented) **Upcoming features:** -- Complete constraint management UI - Visual database designer - Multi-database server connections - Advanced query builder diff --git a/ROADMAP.md b/ROADMAP.md index 0446461..860fa63 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -60,11 +60,11 @@ See `src/config/features.json` for the complete feature configuration. - [x] ✅ Create schema management interface - [x] ✅ Implement table creation/editing UI (API ready, UI implemented) - [x] ✅ Add column type management UI (API ready, UI implemented) - - [ ] Add data validation and constraints management 🏗️ **IN PROGRESS** + - [x] Add data validation and constraints management ✅ **COMPLETED** - [x] ✅ Implement constraints API (UNIQUE, CHECK constraints) - [x] ✅ Add constraint listing endpoint - [x] ✅ Add constraint creation/deletion endpoints - - [ ] Build constraints management UI + - [x] ✅ Build constraints management UI - [ ] Add PRIMARY KEY constraint support - [ ] Add DEFAULT value management - [ ] Add NOT NULL constraint management diff --git a/TESTING.md b/TESTING.md index 33b873f..03b4537 100644 --- a/TESTING.md +++ b/TESTING.md @@ -149,9 +149,9 @@ All tests verify that: | Feature Config | - | - | - | 40 | 40 | | Table Manager | 7 | 2 (2 skipped) | 3 | - | 12 | | Column Manager | 9 | 2 (2 skipped) | 3 | - | 14 | -| Constraint Manager | 14 | 0 (UI pending) | 3 | 4 | 21 | +| Constraint Manager | 14 | 3 (3 skipped) | 4 | 4 | 25 | | Admin Dashboard | - | 3 | 3 | - | 6 | -| **Total** | **30** | **7** | **12** | **44** | **93** | +| **Total** | **30** | **10** | **16** | **44** | **100** | ## Feature: Constraint Management Tests @@ -186,6 +186,25 @@ Tests for the Constraint Management API endpoints (`/api/admin/constraints`): - Error handling for all CRUD operations - Support for UNIQUE and CHECK constraints +### End-to-End Tests (Playwright UI Tests) + +#### 2. `tests/e2e/AdminDashboard.e2e.ts` - Constraints Manager UI + +**UI Tests:** +- 🔄 Display Constraints tab (requires auth - skipped) +- 🔄 Show table selector in Constraints Manager (requires auth - skipped) +- 🔄 Open add constraint dialog (requires auth - skipped) + +**Security Tests:** +- ✅ Blocks constraint API access without authentication + +**Note:** UI tests are skipped because they require an authenticated session. These can be enabled when a test authentication mechanism is implemented. + +**Components Implemented:** +- ✅ `ConstraintManagerTab.tsx` - Main UI component for managing constraints +- ✅ `ConstraintDialog.tsx` - Reusable dialog for add/delete constraint operations +- ✅ Integration with admin dashboard navigation and handlers + ### Unit Tests #### 2. `src/utils/featureConfig.test.ts` @@ -269,4 +288,4 @@ When adding new features: **Last Updated:** January 2026 **Test Framework:** Playwright + Vitest -**Coverage Status:** ✅ API Validation | 🔄 UI Tests (partial - needs auth) +**Coverage Status:** ✅ API Validation | 🔄 UI Tests (partial - needs auth) | ✅ Constraint Manager UI Complete diff --git a/src/app/admin/dashboard/page.tsx b/src/app/admin/dashboard/page.tsx index 3661bb4..721e715 100644 --- a/src/app/admin/dashboard/page.tsx +++ b/src/app/admin/dashboard/page.tsx @@ -5,6 +5,7 @@ import CodeIcon from '@mui/icons-material/Code'; import DeleteIcon from '@mui/icons-material/Delete'; import EditIcon from '@mui/icons-material/Edit'; import LogoutIcon from '@mui/icons-material/Logout'; +import RuleIcon from '@mui/icons-material/Rule'; import StorageIcon from '@mui/icons-material/Storage'; import TableChartIcon from '@mui/icons-material/TableChart'; import ViewColumnIcon from '@mui/icons-material/ViewColumn'; @@ -43,6 +44,7 @@ import { import { ThemeProvider } from '@mui/material/styles'; import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useState } from 'react'; +import ConstraintManagerTab from '@/components/admin/ConstraintManagerTab'; import { theme } from '@/utils/theme'; const DRAWER_WIDTH = 240; @@ -491,6 +493,71 @@ export default function AdminDashboard() { } }; + // Constraint Management Handlers + const handleAddConstraint = async (tableName: string, data: any) => { + setLoading(true); + setError(''); + setSuccessMessage(''); + + try { + const response = await fetch('/api/admin/constraints', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tableName, + ...data, + }), + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || 'Failed to add constraint'); + } + + setSuccessMessage(result.message || 'Constraint added successfully'); + } catch (err: any) { + setError(err.message); + throw err; + } finally { + setLoading(false); + } + }; + + const handleDropConstraint = async (tableName: string, constraintName: string) => { + setLoading(true); + setError(''); + setSuccessMessage(''); + + try { + const response = await fetch('/api/admin/constraints', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tableName, + constraintName, + }), + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || 'Failed to drop constraint'); + } + + setSuccessMessage(result.message || 'Constraint dropped successfully'); + } catch (err: any) { + setError(err.message); + throw err; + } finally { + setLoading(false); + } + }; + return ( @@ -555,6 +622,14 @@ export default function AdminDashboard() { + + setTabValue(4)}> + + + + + + @@ -763,6 +838,14 @@ export default function AdminDashboard() { )} + + + + {successMessage && ( setSuccessMessage('')}> {successMessage} diff --git a/src/components/admin/ConstraintDialog.tsx b/src/components/admin/ConstraintDialog.tsx new file mode 100644 index 0000000..b7c9c27 --- /dev/null +++ b/src/components/admin/ConstraintDialog.tsx @@ -0,0 +1,188 @@ +'use client'; + +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + MenuItem, + Select, + TextField, + Typography, +} from '@mui/material'; +import { useEffect, useState } from 'react'; + +type ConstraintDialogProps = { + open: boolean; + mode: 'add' | 'delete'; + constraintTypes: Array<{ + name: string; + description: string; + requiresColumn: boolean; + requiresExpression: boolean; + }>; + selectedConstraint?: any; + onClose: () => void; + onSubmit: (data: any) => Promise; +}; + +export default function ConstraintDialog({ + open, + mode, + constraintTypes, + selectedConstraint, + onClose, + onSubmit, +}: ConstraintDialogProps) { + const [constraintName, setConstraintName] = useState(''); + const [constraintType, setConstraintType] = useState('UNIQUE'); + const [columnName, setColumnName] = useState(''); + const [checkExpression, setCheckExpression] = useState(''); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!open) { + // Reset form when dialog closes + setConstraintName(''); + setConstraintType('UNIQUE'); + setColumnName(''); + setCheckExpression(''); + } + }, [open]); + + const handleSubmit = async () => { + setLoading(true); + try { + if (mode === 'add') { + const data: any = { + constraintName, + constraintType, + }; + + // Get the current constraint type config + const currentType = constraintTypes.find(ct => ct.name === constraintType); + + if (currentType?.requiresColumn) { + data.columnName = columnName; + } + + if (currentType?.requiresExpression) { + data.checkExpression = checkExpression; + } + + await onSubmit(data); + } else if (mode === 'delete') { + // For delete, we just need to confirm + await onSubmit({}); + } + onClose(); + } finally { + setLoading(false); + } + }; + + const getTitle = () => { + if (mode === 'add') { + return 'Add Constraint'; + } + return `Delete Constraint: ${selectedConstraint?.constraint_name}`; + }; + + const isFormValid = () => { + if (mode === 'delete') { + return true; // Always valid for delete + } + + if (!constraintName.trim() || !constraintType) { + return false; + } + + const currentType = constraintTypes.find(ct => ct.name === constraintType); + + if (currentType?.requiresColumn && !columnName.trim()) { + return false; + } + + if (currentType?.requiresExpression && !checkExpression.trim()) { + return false; + } + + return true; + }; + + const currentType = constraintTypes.find(ct => ct.name === constraintType); + + return ( + + {getTitle()} + + {mode === 'delete' ? ( + + Are you sure you want to delete the constraint " + {selectedConstraint?.constraint_name}"? This action cannot be undone. + + ) : ( + <> + setConstraintName(e.target.value)} + sx={{ mt: 2, mb: 2 }} + helperText="A unique name for this constraint" + /> + + + + {currentType?.requiresColumn && ( + setColumnName(e.target.value)} + sx={{ mb: 2 }} + helperText="The column to apply this constraint to" + /> + )} + + {currentType?.requiresExpression && ( + setCheckExpression(e.target.value)} + sx={{ mb: 2 }} + multiline + rows={3} + helperText="Boolean expression for the check constraint (e.g., price > 0)" + /> + )} + + )} + + + + + + + ); +} diff --git a/src/components/admin/ConstraintManagerTab.tsx b/src/components/admin/ConstraintManagerTab.tsx new file mode 100644 index 0000000..2006cfe --- /dev/null +++ b/src/components/admin/ConstraintManagerTab.tsx @@ -0,0 +1,203 @@ +'use client'; + +import AddIcon from '@mui/icons-material/Add'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { + Box, + Button, + MenuItem, + Paper, + Select, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material'; +import { useCallback, useEffect, useState } from 'react'; +import { getConstraintTypes, getFeatureById } from '@/utils/featureConfig'; +import ConstraintDialog from './ConstraintDialog'; + +type ConstraintManagerTabProps = { + tables: Array<{ table_name: string }>; + onAddConstraint: (tableName: string, data: any) => Promise; + onDropConstraint: (tableName: string, constraintName: string) => Promise; +}; + +export default function ConstraintManagerTab({ + tables, + onAddConstraint, + onDropConstraint, +}: ConstraintManagerTabProps) { + const [selectedTable, setSelectedTable] = useState(''); + const [constraints, setConstraints] = useState([]); + const [dialogState, setDialogState] = useState<{ + open: boolean; + mode: 'add' | 'delete'; + }>({ open: false, mode: 'add' }); + const [selectedConstraint, setSelectedConstraint] = useState(null); + + // Get feature configuration from JSON + const feature = getFeatureById('constraint-management'); + const constraintTypes = getConstraintTypes(); + + // Check if actions are enabled from config + const canAdd = feature?.ui.actions.includes('add'); + const canDelete = feature?.ui.actions.includes('delete'); + + // Fetch constraints when table is selected + const fetchConstraints = useCallback(async () => { + try { + const response = await fetch( + `/api/admin/constraints?tableName=${selectedTable}`, + { + method: 'GET', + }, + ); + + if (response.ok) { + const data = await response.json(); + setConstraints(data.constraints || []); + } + } catch (error) { + console.error('Failed to fetch constraints:', error); + } + }, [selectedTable]); + + useEffect(() => { + if (selectedTable) { + fetchConstraints(); + } else { + setConstraints([]); + } + }, [selectedTable, fetchConstraints]); + + const handleConstraintOperation = async (data: any) => { + if (dialogState.mode === 'add') { + await onAddConstraint(selectedTable, data); + } else if (dialogState.mode === 'delete' && selectedConstraint) { + await onDropConstraint(selectedTable, selectedConstraint.constraint_name); + } + await fetchConstraints(); // Refresh constraints list + }; + + const openAddDialog = () => { + setSelectedConstraint(null); + setDialogState({ open: true, mode: 'add' }); + }; + + const openDeleteDialog = (constraint: any) => { + setSelectedConstraint(constraint); + setDialogState({ open: true, mode: 'delete' }); + }; + + const closeDialog = () => { + setDialogState({ ...dialogState, open: false }); + setSelectedConstraint(null); + }; + + return ( + <> + + {feature?.name || 'Constraint Manager'} + + + {feature?.description && ( + + {feature.description} + + )} + + + + + + {selectedTable && ( + <> + + {canAdd && ( + + )} + + + + + + + + Constraint Name + Type + Column + Expression + Actions + + + + {constraints.map((constraint) => ( + + {constraint.constraint_name} + {constraint.constraint_type} + {constraint.column_name || '-'} + {constraint.check_clause || '-'} + + {canDelete && ( + + )} + + + ))} + {constraints.length === 0 && ( + + + No constraints found for this table + + + )} + +
+
+
+ + )} + + + + ); +} diff --git a/tests/e2e/AdminDashboard.e2e.ts b/tests/e2e/AdminDashboard.e2e.ts index 0be4570..65a1b33 100644 --- a/tests/e2e/AdminDashboard.e2e.ts +++ b/tests/e2e/AdminDashboard.e2e.ts @@ -95,5 +95,44 @@ test.describe('Admin Dashboard', () => { expect(response.status()).toBe(401); }); + + test('should not allow constraint management without auth', async ({ page }) => { + const response = await page.request.get('/api/admin/constraints?tableName=test'); + + expect(response.status()).toBe(401); + }); + }); + + test.describe('Constraints Manager UI', () => { + test.skip('should display Constraints tab after login', async ({ page }) => { + // This test would require actual authentication + // Skipping for now as it needs a real admin user + + // await page.goto('/admin/dashboard'); + // await expect(page.getByText('Constraints')).toBeVisible(); + }); + + test.skip('should show table selector in Constraints Manager', async ({ page }) => { + // This test would require authentication + // Skipping for now + + // await page.goto('/admin/dashboard'); + // await page.getByText('Constraints').click(); + + // await expect(page.getByText(/select a table/i)).toBeVisible(); + }); + + test.skip('should open add constraint dialog', async ({ page }) => { + // This test would require authentication + // Skipping for now + + // await page.goto('/admin/dashboard'); + // await page.getByText('Constraints').click(); + // Select a table first + // await page.getByRole('button', { name: /add constraint/i }).click(); + + // await expect(page.getByText('Add Constraint')).toBeVisible(); + // await expect(page.getByLabel(/constraint name/i)).toBeVisible(); + }); }); });