3 Commits

Author SHA1 Message Date
tusuii
22f048989a feat: add CEO role to all dropdowns and access lists 2026-02-16 12:33:42 +05:30
tusuii
c604df281d feat: add more roles (tech_lead, scrum_master, product_owner, designer, qa)
- Registration form: added 5 new role options to dropdown
- Sidebar: new roles get proper nav access via ALL_ROLES/LEADER_ROLES
- Dashboard: isLeader check expanded to include new leadership roles
- Shared/Pages: role badge colors added for all new roles
- Invite modal: expanded role dropdown
2026-02-16 12:31:54 +05:30
tusuii
2db45de4c4 feat: add Kubernetes Kustomize deployment manifests
Add k8s/base/ directory with Kustomize manifests for deploying
the scrum-manager application to Kubernetes:

- Namespace (scrum-manager)
- MySQL: Deployment, Service, PVC, Secret
- Backend: Deployment (2 replicas) with init container, Service
- Frontend: Deployment (2 replicas), Service (NodePort), ConfigMap (nginx.conf)

All deployments include resource requests/limits, liveness/readiness
probes, and proper label selectors.
2026-02-16 12:25:56 +05:30
44 changed files with 5367 additions and 72 deletions

View File

@@ -1,12 +1,20 @@
FROM node:22-alpine # Build Stage
FROM node:22-alpine as build
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package*.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .
RUN npm run build
EXPOSE 5173 # Production Stage
FROM nginx:alpine
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -13,7 +13,7 @@ services:
volumes: volumes:
- mysql_data:/var/lib/mysql - mysql_data:/var/lib/mysql
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pscrumpass"] test: [ "CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pscrumpass" ]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 10 retries: 10
@@ -44,7 +44,7 @@ services:
container_name: scrum-frontend container_name: scrum-frontend
restart: unless-stopped restart: unless-stopped
ports: ports:
- "5173:5173" - "80:80"
depends_on: depends_on:
- backend - backend

View File

@@ -0,0 +1,84 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
labels:
app.kubernetes.io/name: backend
app.kubernetes.io/component: api
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: backend
app.kubernetes.io/component: api
template:
metadata:
labels:
app.kubernetes.io/name: backend
app.kubernetes.io/component: api
spec:
initContainers:
- name: wait-for-mysql
image: busybox:1.36
command:
- sh
- -c
- |
echo "Waiting for MySQL to be ready..."
until nc -z mysql 3306; do
echo "MySQL is not ready yet, retrying in 3s..."
sleep 3
done
echo "MySQL is ready!"
containers:
- name: backend
image: scrum-backend:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 3001
name: http
env:
- name: DB_HOST
value: mysql
- name: DB_PORT
value: "3306"
- name: DB_USER
valueFrom:
secretKeyRef:
name: mysql-secret
key: DB_USER
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: mysql-secret
key: DB_PASSWORD
- name: DB_NAME
valueFrom:
secretKeyRef:
name: mysql-secret
key: DB_NAME
- name: PORT
value: "3001"
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
livenessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 15
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 5

View File

@@ -0,0 +1,17 @@
apiVersion: v1
kind: Service
metadata:
name: backend
labels:
app.kubernetes.io/name: backend
app.kubernetes.io/component: api
spec:
type: ClusterIP
ports:
- port: 3001
targetPort: 3001
protocol: TCP
name: http
selector:
app.kubernetes.io/name: backend
app.kubernetes.io/component: api

View File

@@ -0,0 +1,31 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: frontend-nginx-config
labels:
app.kubernetes.io/name: frontend
app.kubernetes.io/component: web
data:
default.conf: |
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Serve static files
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API requests to backend service
location /api/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}

View File

@@ -0,0 +1,58 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
labels:
app.kubernetes.io/name: frontend
app.kubernetes.io/component: web
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: frontend
app.kubernetes.io/component: web
template:
metadata:
labels:
app.kubernetes.io/name: frontend
app.kubernetes.io/component: web
spec:
containers:
- name: frontend
image: scrum-frontend:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 80
name: http
volumeMounts:
- name: nginx-config
mountPath: /etc/nginx/conf.d/default.conf
subPath: default.conf
readOnly: true
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
volumes:
- name: nginx-config
configMap:
name: frontend-nginx-config

View File

@@ -0,0 +1,17 @@
apiVersion: v1
kind: Service
metadata:
name: frontend
labels:
app.kubernetes.io/name: frontend
app.kubernetes.io/component: web
spec:
type: NodePort
ports:
- port: 80
targetPort: 80
protocol: TCP
name: http
selector:
app.kubernetes.io/name: frontend
app.kubernetes.io/component: web

View File

@@ -0,0 +1,25 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: scrum-manager
labels:
- pairs:
app.kubernetes.io/part-of: scrum-manager
app.kubernetes.io/managed-by: kustomize
includeSelectors: true
resources:
- namespace.yaml
# MySQL
- mysql/secret.yaml
- mysql/pvc.yaml
- mysql/deployment.yaml
- mysql/service.yaml
# Backend
- backend/deployment.yaml
- backend/service.yaml
# Frontend
- frontend/configmap.yaml
- frontend/deployment.yaml
- frontend/service.yaml

View File

@@ -0,0 +1,74 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: mysql
labels:
app.kubernetes.io/name: mysql
app.kubernetes.io/component: database
spec:
replicas: 1
strategy:
type: Recreate # MySQL requires Recreate since PVC is ReadWriteOnce
selector:
matchLabels:
app.kubernetes.io/name: mysql
app.kubernetes.io/component: database
template:
metadata:
labels:
app.kubernetes.io/name: mysql
app.kubernetes.io/component: database
spec:
containers:
- name: mysql
image: mysql:8.0
ports:
- containerPort: 3306
name: mysql
env:
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: mysql-secret
key: MYSQL_ROOT_PASSWORD
- name: MYSQL_DATABASE
valueFrom:
secretKeyRef:
name: mysql-secret
key: DB_NAME
volumeMounts:
- name: mysql-data
mountPath: /var/lib/mysql
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: "1"
memory: 1Gi
livenessProbe:
exec:
command:
- mysqladmin
- ping
- -h
- localhost
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
exec:
command:
- mysqladmin
- ping
- -h
- localhost
initialDelaySeconds: 10
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 5
volumes:
- name: mysql-data
persistentVolumeClaim:
claimName: mysql-data-pvc

13
k8s/base/mysql/pvc.yaml Normal file
View File

@@ -0,0 +1,13 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mysql-data-pvc
labels:
app.kubernetes.io/name: mysql
app.kubernetes.io/component: database
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi

View File

@@ -0,0 +1,17 @@
apiVersion: v1
kind: Secret
metadata:
name: mysql-secret
labels:
app.kubernetes.io/name: mysql
app.kubernetes.io/component: database
type: Opaque
data:
# Base64 encoded values — change these for production!
# echo -n 'scrumpass' | base64 => c2NydW1wYXNz
# echo -n 'root' | base64 => cm9vdA==
# echo -n 'scrum_manager' | base64 => c2NydW1fbWFuYWdlcg==
MYSQL_ROOT_PASSWORD: c2NydW1wYXNz
DB_USER: cm9vdA==
DB_PASSWORD: c2NydW1wYXNz
DB_NAME: c2NydW1fbWFuYWdlcg==

View File

@@ -0,0 +1,17 @@
apiVersion: v1
kind: Service
metadata:
name: mysql
labels:
app.kubernetes.io/name: mysql
app.kubernetes.io/component: database
spec:
type: ClusterIP
ports:
- port: 3306
targetPort: 3306
protocol: TCP
name: mysql
selector:
app.kubernetes.io/name: mysql
app.kubernetes.io/component: database

6
k8s/base/namespace.yaml Normal file
View File

@@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: scrum-manager
labels:
app.kubernetes.io/part-of: scrum-manager

23
nginx.conf Normal file
View File

@@ -0,0 +1,23 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Serve static files
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API requests to backend
location /api/ {
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}

1193
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,9 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest",
"test:integration": "vitest -c vitest.integration.config.js"
}, },
"dependencies": { "dependencies": {
"react": "^19.2.0", "react": "^19.2.0",
@@ -16,6 +18,9 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@@ -24,8 +29,11 @@
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24", "eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0", "globals": "^16.5.0",
"jsdom": "^28.1.0",
"node-fetch": "^3.3.2",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.48.0", "typescript-eslint": "^8.48.0",
"vite": "^7.3.1" "vite": "^7.3.1",
"vitest": "^4.0.18"
} }
} }

View File

@@ -2,8 +2,8 @@ FROM node:22-alpine
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ COPY package*.json ./
RUN npm install RUN npm ci --only=production
COPY . . COPY . .

View File

@@ -85,6 +85,19 @@ export async function initDB() {
) )
`); `);
await conn.query(`
CREATE TABLE IF NOT EXISTS dependencies (
id VARCHAR(36) PRIMARY KEY,
task_id VARCHAR(36) NOT NULL,
depends_on_user_id VARCHAR(36),
description TEXT NOT NULL,
resolved BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (depends_on_user_id) REFERENCES users(id) ON DELETE SET NULL
)
`);
console.log('✅ Database tables initialized'); console.log('✅ Database tables initialized');
} finally { } finally {
conn.release(); conn.release();

View File

@@ -19,17 +19,24 @@ app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() }); res.json({ status: 'ok', timestamp: new Date().toISOString() });
}); });
// Initialize DB and start server
// Initialize DB and start server // Initialize DB and start server
async function start() { async function start() {
try { try {
await initDB(); await initDB();
if (process.env.NODE_ENV !== 'test') {
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`🚀 Backend server running on port ${PORT}`); console.log(`🚀 Backend server running on port ${PORT}`);
}); });
}
} catch (err) { } catch (err) {
console.error('❌ Failed to start server:', err); console.error('❌ Failed to start server:', err);
process.exit(1); process.exit(1);
} }
} }
start(); if (process.env.NODE_ENV !== 'test') {
start();
}
export { app, start };

2481
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -4,12 +4,17 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node index.js" "start": "node index.js",
"test": "vitest"
}, },
"dependencies": { "dependencies": {
"express": "^5.1.0", "bcryptjs": "^3.0.2",
"mysql2": "^3.14.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"bcryptjs": "^3.0.2" "express": "^5.1.0",
"mysql2": "^3.14.1"
},
"devDependencies": {
"supertest": "^7.2.2",
"vitest": "^4.0.18"
} }
} }

View File

@@ -4,7 +4,7 @@ import { randomUUID } from 'crypto';
const router = Router(); const router = Router();
// Helper: fetch full task with subtasks, comments, activities, tags // Helper: fetch full task with subtasks, comments, activities, tags, dependencies
async function getFullTask(taskId) { async function getFullTask(taskId) {
const [taskRows] = await pool.query('SELECT * FROM tasks WHERE id = ?', [taskId]); const [taskRows] = await pool.query('SELECT * FROM tasks WHERE id = ?', [taskId]);
if (taskRows.length === 0) return null; if (taskRows.length === 0) return null;
@@ -14,6 +14,7 @@ async function getFullTask(taskId) {
const [comments] = await pool.query('SELECT id, user_id AS userId, text, timestamp FROM comments WHERE task_id = ? ORDER BY timestamp', [taskId]); const [comments] = await pool.query('SELECT id, user_id AS userId, text, timestamp FROM comments WHERE task_id = ? ORDER BY timestamp', [taskId]);
const [activities] = await pool.query('SELECT id, text, timestamp FROM activities WHERE task_id = ? ORDER BY timestamp', [taskId]); const [activities] = await pool.query('SELECT id, text, timestamp FROM activities WHERE task_id = ? ORDER BY timestamp', [taskId]);
const [tagRows] = await pool.query('SELECT tag FROM task_tags WHERE task_id = ?', [taskId]); const [tagRows] = await pool.query('SELECT tag FROM task_tags WHERE task_id = ?', [taskId]);
const [depRows] = await pool.query('SELECT id, depends_on_user_id AS dependsOnUserId, description, resolved FROM dependencies WHERE task_id = ? ORDER BY created_at', [taskId]);
return { return {
id: task.id, id: task.id,
@@ -28,6 +29,7 @@ async function getFullTask(taskId) {
subtasks: subtasks.map(s => ({ id: s.id, title: s.title, done: !!s.done })), subtasks: subtasks.map(s => ({ id: s.id, title: s.title, done: !!s.done })),
comments: comments.map(c => ({ id: c.id, userId: c.userId, text: c.text, timestamp: c.timestamp?.toISOString() || '' })), comments: comments.map(c => ({ id: c.id, userId: c.userId, text: c.text, timestamp: c.timestamp?.toISOString() || '' })),
activity: activities.map(a => ({ id: a.id, text: a.text, timestamp: a.timestamp?.toISOString() || '' })), activity: activities.map(a => ({ id: a.id, text: a.text, timestamp: a.timestamp?.toISOString() || '' })),
dependencies: depRows.map(d => ({ id: d.id, dependsOnUserId: d.dependsOnUserId || '', description: d.description, resolved: !!d.resolved })),
}; };
} }
@@ -48,7 +50,7 @@ router.post('/', async (req, res) => {
const conn = await pool.getConnection(); const conn = await pool.getConnection();
try { try {
await conn.beginTransaction(); await conn.beginTransaction();
const { title, description, status, priority, assignee, reporter, dueDate, tags, subtasks } = req.body; const { title, description, status, priority, assignee, reporter, dueDate, tags, subtasks, dependencies } = req.body;
const id = randomUUID(); const id = randomUUID();
await conn.query( await conn.query(
@@ -70,6 +72,16 @@ router.post('/', async (req, res) => {
} }
} }
// Insert dependencies
if (dependencies && dependencies.length > 0) {
for (const dep of dependencies) {
await conn.query(
'INSERT INTO dependencies (id, task_id, depends_on_user_id, description, resolved) VALUES (?, ?, ?, ?, ?)',
[randomUUID(), id, dep.dependsOnUserId || null, dep.description, false]
);
}
}
// Add creation activity // Add creation activity
const actId = randomUUID(); const actId = randomUUID();
await conn.query('INSERT INTO activities (id, task_id, text) VALUES (?, ?, ?)', await conn.query('INSERT INTO activities (id, task_id, text) VALUES (?, ?, ?)',
@@ -188,6 +200,49 @@ router.post('/:id/activity', async (req, res) => {
} }
}); });
// --- DEPENDENCY ROUTES ---
// POST /api/tasks/:id/dependencies
router.post('/:id/dependencies', async (req, res) => {
try {
const { dependsOnUserId, description } = req.body;
const id = randomUUID();
await pool.query(
'INSERT INTO dependencies (id, task_id, depends_on_user_id, description, resolved) VALUES (?, ?, ?, ?, ?)',
[id, req.params.id, dependsOnUserId || null, description, false]
);
res.status(201).json({ id, dependsOnUserId: dependsOnUserId || '', description, resolved: false });
} catch (err) {
console.error('Add dependency error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// PUT /api/tasks/:id/dependencies/:depId
router.put('/:id/dependencies/:depId', async (req, res) => {
try {
const { resolved } = req.body;
await pool.query('UPDATE dependencies SET resolved = ? WHERE id = ? AND task_id = ?',
[resolved, req.params.depId, req.params.id]);
res.json({ success: true });
} catch (err) {
console.error('Update dependency error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// DELETE /api/tasks/:id/dependencies/:depId
router.delete('/:id/dependencies/:depId', async (req, res) => {
try {
await pool.query('DELETE FROM dependencies WHERE id = ? AND task_id = ?',
[req.params.depId, req.params.id]);
res.json({ success: true });
} catch (err) {
console.error('Delete dependency error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// DELETE /api/tasks/:id // DELETE /api/tasks/:id
router.delete('/:id', async (req, res) => { router.delete('/:id', async (req, res) => {
try { try {

127
server/tests/auth.test.js Normal file
View File

@@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import request from 'supertest';
import { app } from '../index.js';
import pool from '../db.js';
import bcrypt from 'bcryptjs';
// Mock dependencies
vi.mock('../db.js', () => ({
default: {
query: vi.fn(),
},
initDB: vi.fn()
}));
vi.mock('bcryptjs', () => ({
default: {
compare: vi.fn(),
hash: vi.fn()
}
}));
describe('Auth Routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('POST /api/auth/login', () => {
it('logs in successfully with correct credentials', async () => {
const mockUser = {
id: 'u1',
name: 'Test User',
email: 'test@test.com',
password_hash: 'hashed_password',
role: 'employee',
dept: 'dev'
};
// Mock DB response
pool.query.mockResolvedValue([[mockUser]]);
// Mock bcrypt comparison
bcrypt.compare.mockResolvedValue(true);
const res = await request(app)
.post('/api/auth/login')
.send({ email: 'test@test.com', password: 'password' });
expect(res.status).toBe(200);
expect(res.body).toEqual(expect.objectContaining({
id: 'u1',
email: 'test@test.com'
}));
expect(res.body).not.toHaveProperty('password_hash');
});
it('returns 401 for invalid password', async () => {
const mockUser = {
id: 'u1',
email: 'test@test.com',
password_hash: 'hashed_password'
};
pool.query.mockResolvedValue([[mockUser]]);
bcrypt.compare.mockResolvedValue(false);
const res = await request(app)
.post('/api/auth/login')
.send({ email: 'test@test.com', password: 'wrong' });
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: 'Invalid email or password' });
});
it('returns 401 for user not found', async () => {
pool.query.mockResolvedValue([[]]); // Empty array
const res = await request(app)
.post('/api/auth/login')
.send({ email: 'notfound@test.com', password: 'password' });
expect(res.status).toBe(401);
});
});
describe('POST /api/auth/register', () => {
it('registers a new user successfully', async () => {
pool.query.mockResolvedValueOnce([[]]); // Check existing email (empty)
pool.query.mockResolvedValueOnce({}); // Insert success
bcrypt.hash.mockResolvedValue('hashed_new_password');
const newUser = {
name: 'New User',
email: 'new@test.com',
password: 'password',
role: 'employee'
};
const res = await request(app)
.post('/api/auth/register')
.send(newUser);
expect(res.status).toBe(201);
expect(res.body).toEqual(expect.objectContaining({
name: 'New User',
email: 'new@test.com'
}));
// Verify DB insert called
expect(pool.query).toHaveBeenCalledTimes(2);
expect(pool.query).toHaveBeenLastCalledWith(
expect.stringContaining('INSERT INTO users'),
expect.arrayContaining(['New User', 'employee', 'new@test.com', 'hashed_new_password'])
);
});
it('returns 409 if email already exists', async () => {
pool.query.mockResolvedValueOnce([[{ id: 'existing' }]]);
const res = await request(app)
.post('/api/auth/register')
.send({ name: 'User', email: 'existing@test.com', password: 'pw' });
expect(res.status).toBe(409);
});
});
});

120
server/tests/tasks.test.js Normal file
View File

@@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import request from 'supertest';
import { app } from '../index.js';
import pool from '../db.js';
// Mock DB
const mockQuery = vi.fn();
const mockRelease = vi.fn();
const mockCommit = vi.fn();
const mockRollback = vi.fn();
const mockBeginTransaction = vi.fn();
vi.mock('../db.js', () => ({
default: {
query: vi.fn(),
getConnection: vi.fn()
},
initDB: vi.fn()
}));
describe('Task Routes', () => {
beforeEach(() => {
vi.clearAllMocks();
mockQuery.mockReset(); // Important to clear implementations
pool.query = mockQuery;
pool.getConnection.mockResolvedValue({
query: mockQuery,
release: mockRelease,
beginTransaction: mockBeginTransaction,
commit: mockCommit,
rollback: mockRollback
});
});
describe('GET /api/tasks', () => {
it('returns list of tasks', async () => {
// Mock fetching task IDs
mockQuery.mockResolvedValueOnce([[{ id: 't1' }]]);
// Mock getFullTask queries for 't1'
// 1. Task details
mockQuery.mockResolvedValueOnce([[{ id: 't1', title: 'Task 1', status: 'todo' }]]); // Task
// 2. Subtasks
mockQuery.mockResolvedValueOnce([[]]); // Subtasks
// 3. Comments
mockQuery.mockResolvedValueOnce([[]]); // Comments
// 4. Activities
mockQuery.mockResolvedValueOnce([[]]); // Activities
// 5. Tags
mockQuery.mockResolvedValueOnce([[]]); // Tags
// 6. Dependencies
mockQuery.mockResolvedValueOnce([[]]); // Dependencies
const res = await request(app).get('/api/tasks');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].title).toBe('Task 1');
});
});
describe('POST /api/tasks', () => {
it('creates a new task', async () => {
// Mock INSERTs (1: Task, 2: Activities) -> Return {}
mockQuery.mockResolvedValueOnce({}); // Insert task
mockQuery.mockResolvedValueOnce({}); // Insert activity
// getFullTask queries (3-8)
mockQuery.mockResolvedValueOnce([[{ id: 'new-id', title: 'New Task', status: 'todo' }]]); // Task
mockQuery.mockResolvedValueOnce([[]]); // Subtasks
mockQuery.mockResolvedValueOnce([[]]); // Comments
mockQuery.mockResolvedValueOnce([[]]); // Activities
mockQuery.mockResolvedValueOnce([[]]); // Tags
mockQuery.mockResolvedValueOnce([[]]); // Deps
const newTask = {
// For getFullTask called at end
title: 'New Task',
description: 'Desc',
status: 'todo'
};
const res = await request(app)
.post('/api/tasks')
.send(newTask);
expect(res.status).toBe(201);
expect(mockBeginTransaction).toHaveBeenCalled();
expect(mockCommit).toHaveBeenCalled();
expect(mockRelease).toHaveBeenCalled();
});
it('rolls back on error', async () => {
mockQuery.mockRejectedValue(new Error('DB Error'));
const res = await request(app)
.post('/api/tasks')
.send({ title: 'Task' });
expect(res.status).toBe(500);
expect(mockRollback).toHaveBeenCalled();
expect(mockRelease).toHaveBeenCalled();
});
});
describe('DELETE /api/tasks/:id', () => {
it('deletes a task', async () => {
mockQuery.mockResolvedValue({});
const res = await request(app).delete('/api/tasks/t1');
expect(res.status).toBe(200);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('DELETE FROM tasks'),
['t1']
);
});
});
});

9
server/vitest.config.js Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { apiFetchTasks, apiFetchUsers, apiCreateTask, apiUpdateTask, apiAddActivity } from './api'; import { apiFetchTasks, apiFetchUsers, apiCreateTask, apiUpdateTask, apiAddActivity, apiAddDependency, apiToggleDependency, apiRemoveDependency } from './api';
import type { Task, User, Status } from './data'; import type { Task, User, Status } from './data';
import { STATUS_LABELS } from './data'; import { STATUS_LABELS } from './data';
import { LoginPage } from './Login'; import { LoginPage } from './Login';
@@ -103,6 +103,7 @@ export default function App() {
reporter: currentUser.id, reporter: currentUser.id,
dueDate: task.dueDate, dueDate: task.dueDate,
tags: task.tags, tags: task.tags,
dependencies: (task.dependencies || []).map(d => ({ dependsOnUserId: d.dependsOnUserId, description: d.description })),
}); });
setTasks(prev => [...prev, created]); setTasks(prev => [...prev, created]);
} catch (err) { } catch (err) {
@@ -160,6 +161,31 @@ export default function App() {
} }
}; };
const handleAddDep = async (taskId: string, dep: { dependsOnUserId: string; description: string }) => {
try {
const newDep = await apiAddDependency(taskId, dep);
setTasks(prev => prev.map(t => t.id === taskId ? { ...t, dependencies: [...(t.dependencies || []), newDep] } : t));
if (activeTask?.id === taskId) setActiveTask(prev => prev ? { ...prev, dependencies: [...(prev.dependencies || []), newDep] } : prev);
} catch (err) { console.error('Failed to add dependency:', err); }
};
const handleToggleDep = async (taskId: string, depId: string, resolved: boolean) => {
try {
await apiToggleDependency(taskId, depId, resolved);
const updateDeps = (deps: any[]) => deps.map((d: any) => d.id === depId ? { ...d, resolved } : d);
setTasks(prev => prev.map(t => t.id === taskId ? { ...t, dependencies: updateDeps(t.dependencies || []) } : t));
if (activeTask?.id === taskId) setActiveTask(prev => prev ? { ...prev, dependencies: updateDeps(prev.dependencies || []) } : prev);
} catch (err) { console.error('Failed to toggle dependency:', err); }
};
const handleRemoveDep = async (taskId: string, depId: string) => {
try {
await apiRemoveDependency(taskId, depId);
setTasks(prev => prev.map(t => t.id === taskId ? { ...t, dependencies: (t.dependencies || []).filter((d: any) => d.id !== depId) } : t));
if (activeTask?.id === taskId) setActiveTask(prev => prev ? { ...prev, dependencies: (prev.dependencies || []).filter((d: any) => d.id !== depId) } : prev);
} catch (err) { console.error('Failed to remove dependency:', err); }
};
const displayPage = VIEW_PAGES.includes(activePage) ? activeView : activePage; const displayPage = VIEW_PAGES.includes(activePage) ? activeView : activePage;
const filteredMyTasks = tasks.filter(t => t.assignee === currentUser.id); const filteredMyTasks = tasks.filter(t => t.assignee === currentUser.id);
@@ -211,7 +237,7 @@ export default function App() {
<BottomToggleBar activeView={activeView} onViewChange={handleViewChange} /> <BottomToggleBar activeView={activeView} onViewChange={handleViewChange} />
)} )}
{activeTask && <TaskDrawer task={activeTask} currentUser={currentUser} onClose={() => setActiveTask(null)} onUpdate={handleUpdateTask} users={users} />} {activeTask && <TaskDrawer task={activeTask} currentUser={currentUser} onClose={() => setActiveTask(null)} onUpdate={handleUpdateTask} onAddDependency={handleAddDep} onToggleDependency={handleToggleDep} onRemoveDependency={handleRemoveDep} users={users} />}
{showAddModal && <AddTaskModal onClose={() => setShowAddModal(false)} onAdd={handleAddTask} defaultDate={addModalDefaults.date} defaultStatus={addModalDefaults.status} users={users} currentUser={currentUser} />} {showAddModal && <AddTaskModal onClose={() => setShowAddModal(false)} onAdd={handleAddTask} defaultDate={addModalDefaults.date} defaultStatus={addModalDefaults.status} users={users} currentUser={currentUser} />}
{quickAddDay && ( {quickAddDay && (

View File

@@ -8,7 +8,7 @@ export function DashboardPage({ tasks, currentUser, users }: { tasks: Task[]; cu
const overdue = tasks.filter(t => new Date(t.dueDate + 'T00:00:00') < new Date() && t.status !== 'done').length; const overdue = tasks.filter(t => new Date(t.dueDate + 'T00:00:00') < new Date() && t.status !== 'done').length;
const critical = tasks.filter(t => t.priority === 'critical' && t.status !== 'done').length; const critical = tasks.filter(t => t.priority === 'critical' && t.status !== 'done').length;
const isLeader = currentUser.role === 'cto' || currentUser.role === 'manager'; const isLeader = ['ceo', 'cto', 'manager', 'tech_lead', 'scrum_master', 'product_owner'].includes(currentUser.role);
const myTasks = tasks.filter(t => t.assignee === currentUser.id); const myTasks = tasks.filter(t => t.assignee === currentUser.id);
const myDone = myTasks.filter(t => t.status === 'done').length; const myDone = myTasks.filter(t => t.status === 'done').length;

View File

@@ -57,39 +57,45 @@ export function LoginPage({ onLogin }: { onLogin: (u: User) => void }) {
{mode === 'register' && ( {mode === 'register' && (
<> <>
<label className="login-label">Name</label> <label className="login-label" htmlFor="register-name">Name</label>
<div className="login-input-wrap"> <div className="login-input-wrap">
<input className={`login-input ${error ? 'error' : ''}`} type="text" placeholder="Your full name" <input id="register-name" className={`login-input ${error ? 'error' : ''}`} type="text" placeholder="Your full name"
value={name} onChange={e => { setName(e.target.value); setError(''); }} /> value={name} onChange={e => { setName(e.target.value); setError(''); }} />
</div> </div>
</> </>
)} )}
<label className="login-label">Email</label> <label className="login-label" htmlFor="login-email">Email</label>
<div className="login-input-wrap"> <div className="login-input-wrap">
<input className={`login-input ${error ? 'error' : ''}`} type="email" placeholder="you@company.io" <input id="login-email" className={`login-input ${error ? 'error' : ''}`} type="email" placeholder="you@company.io"
value={email} onChange={e => { setEmail(e.target.value); setError(''); }} /> value={email} onChange={e => { setEmail(e.target.value); setError(''); }} />
</div> </div>
<label className="login-label">Password</label> <label className="login-label" htmlFor="login-password">Password</label>
<div className="login-input-wrap"> <div className="login-input-wrap">
<input className={`login-input ${error ? 'error' : ''}`} type={showPass ? 'text' : 'password'} placeholder="••••••••" <input id="login-password" className={`login-input ${error ? 'error' : ''}`} type={showPass ? 'text' : 'password'} placeholder="••••••••"
value={pass} onChange={e => { setPass(e.target.value); setError(''); }} /> value={pass} onChange={e => { setPass(e.target.value); setError(''); }} />
<button type="button" className="login-eye" onClick={() => setShowPass(!showPass)}>{showPass ? '🙈' : '👁'}</button> <button type="button" className="login-eye" onClick={() => setShowPass(!showPass)}>{showPass ? '🙈' : '👁'}</button>
</div> </div>
{mode === 'register' && ( {mode === 'register' && (
<> <>
<label className="login-label">Role</label> <label className="login-label" htmlFor="register-role">Role</label>
<div className="login-input-wrap"> <div className="login-input-wrap">
<select className="login-input" value={role} onChange={e => setRole(e.target.value)}> <select id="register-role" className="login-input" value={role} onChange={e => setRole(e.target.value)}>
<option value="employee">Employee</option> <option value="employee">Employee</option>
<option value="tech_lead">Tech Lead</option>
<option value="scrum_master">Scrum Master</option>
<option value="product_owner">Product Owner</option>
<option value="designer">Designer</option>
<option value="qa">QA Engineer</option>
<option value="manager">Manager</option> <option value="manager">Manager</option>
<option value="cto">CTO</option> <option value="cto">CTO</option>
<option value="ceo">CEO</option>
</select> </select>
</div> </div>
<label className="login-label">Department</label> <label className="login-label" htmlFor="register-dept">Department</label>
<div className="login-input-wrap"> <div className="login-input-wrap">
<input className="login-input" type="text" placeholder="e.g. Backend, Frontend, DevOps" <input id="register-dept" className="login-input" type="text" placeholder="e.g. Backend, Frontend, DevOps"
value={dept} onChange={e => setDept(e.target.value)} /> value={dept} onChange={e => setDept(e.target.value)} />
</div> </div>
</> </>

View File

@@ -59,7 +59,7 @@ export function MembersPage({ tasks, users }: { tasks: Task[]; users: User[] })
const ut = tasks.filter(t => t.assignee === u.id); const ut = tasks.filter(t => t.assignee === u.id);
const done = ut.filter(t => t.status === 'done').length; const done = ut.filter(t => t.status === 'done').length;
const active = ut.filter(t => t.status !== 'done').length; const active = ut.filter(t => t.status !== 'done').length;
const roleColors: Record<string, string> = { cto: '#818cf8', manager: '#fb923c', employee: '#22c55e' }; const roleColors: Record<string, string> = { ceo: '#eab308', cto: '#818cf8', manager: '#fb923c', tech_lead: '#06b6d4', scrum_master: '#a855f7', product_owner: '#ec4899', designer: '#f43f5e', qa: '#14b8a6', employee: '#22c55e' };
return ( return (
<React.Fragment key={u.id}> <React.Fragment key={u.id}>
<tr onClick={() => setExpanded(expanded === u.id ? null : u.id)}> <tr onClick={() => setExpanded(expanded === u.id ? null : u.id)}>
@@ -99,7 +99,7 @@ export function MembersPage({ tasks, users }: { tasks: Task[]; users: User[] })
<div className="modal-field"><label>Email</label><input className="modal-input" placeholder="member@company.io" /></div> <div className="modal-field"><label>Email</label><input className="modal-input" placeholder="member@company.io" /></div>
<div className="modal-field"> <div className="modal-field">
<label>Role</label> <label>Role</label>
<select className="modal-input"><option value="employee">Employee</option><option value="manager">Manager</option></select> <select className="modal-input"><option value="employee">Employee</option><option value="tech_lead">Tech Lead</option><option value="scrum_master">Scrum Master</option><option value="product_owner">Product Owner</option><option value="designer">Designer</option><option value="qa">QA Engineer</option><option value="manager">Manager</option><option value="cto">CTO</option><option value="ceo">CEO</option></select>
</div> </div>
</div> </div>
<div className="modal-footer"><button className="btn-ghost" onClick={() => setShowInvite(false)}>Cancel</button><button className="btn-primary" onClick={() => setShowInvite(false)}>Send Invite</button></div> <div className="modal-footer"><button className="btn-ghost" onClick={() => setShowInvite(false)}>Cancel</button><button className="btn-primary" onClick={() => setShowInvite(false)}>Send Invite</button></div>

View File

@@ -51,7 +51,7 @@ export function ProgressBar({ subtasks }: { subtasks: Subtask[] }) {
} }
export function RoleBadge({ role }: { role: string }) { export function RoleBadge({ role }: { role: string }) {
const colors: Record<string, string> = { cto: '#818cf8', manager: '#fb923c', employee: '#22c55e' }; const colors: Record<string, string> = { ceo: '#eab308', cto: '#818cf8', manager: '#fb923c', tech_lead: '#06b6d4', scrum_master: '#a855f7', product_owner: '#ec4899', designer: '#f43f5e', qa: '#14b8a6', employee: '#22c55e' };
const c = colors[role] || '#64748b'; const c = colors[role] || '#64748b';
return <span className="role-badge" style={{ background: `${c}22`, color: c }}>{role.toUpperCase()}</span>; return <span className="role-badge" style={{ background: `${c}22`, color: c }}>{role.toUpperCase()}</span>;
} }

View File

@@ -2,14 +2,17 @@ import type { User } from './data';
import { Avatar } from './Shared'; import { Avatar } from './Shared';
import { RoleBadge } from './Shared'; import { RoleBadge } from './Shared';
const ALL_ROLES = ['ceo', 'cto', 'manager', 'tech_lead', 'scrum_master', 'product_owner', 'employee', 'designer', 'qa'];
const LEADER_ROLES = ['ceo', 'cto', 'manager', 'tech_lead', 'scrum_master', 'product_owner'];
const NAV_ITEMS = [ const NAV_ITEMS = [
{ id: 'dashboard', icon: '⊞', label: 'Dashboard', roles: ['cto', 'manager', 'employee'] }, { id: 'dashboard', icon: '⊞', label: 'Dashboard', roles: ALL_ROLES },
{ id: 'calendar', icon: '📅', label: 'Calendar', roles: ['cto', 'manager', 'employee'] }, { id: 'calendar', icon: '📅', label: 'Calendar', roles: ALL_ROLES },
{ id: 'kanban', icon: '▦', label: 'Kanban Board', roles: ['cto', 'manager', 'employee'] }, { id: 'kanban', icon: '▦', label: 'Kanban Board', roles: ALL_ROLES },
{ id: 'mytasks', icon: '✓', label: 'My Tasks', roles: ['employee'] }, { id: 'mytasks', icon: '✓', label: 'My Tasks', roles: ['employee', 'designer', 'qa'] },
{ id: 'teamtasks', icon: '👥', label: 'Team Tasks', roles: ['cto', 'manager'] }, { id: 'teamtasks', icon: '👥', label: 'Team Tasks', roles: LEADER_ROLES },
{ id: 'reports', icon: '📊', label: 'Reports', roles: ['cto', 'manager'] }, { id: 'reports', icon: '📊', label: 'Reports', roles: LEADER_ROLES },
{ id: 'members', icon: '👤', label: 'Members', roles: ['cto'] }, { id: 'members', icon: '👤', label: 'Members', roles: ['ceo', 'cto'] },
]; ];
interface SidebarProps { interface SidebarProps {

View File

@@ -6,12 +6,17 @@ import { Avatar, Tag, ProgressBar } from './Shared';
interface DrawerProps { interface DrawerProps {
task: Task; currentUser: User; onClose: () => void; task: Task; currentUser: User; onClose: () => void;
onUpdate: (updated: Task) => void; onUpdate: (updated: Task) => void;
onAddDependency: (taskId: string, dep: { dependsOnUserId: string; description: string }) => void;
onToggleDependency: (taskId: string, depId: string, resolved: boolean) => void;
onRemoveDependency: (taskId: string, depId: string) => void;
users: User[]; users: User[];
} }
export function TaskDrawer({ task, currentUser, onClose, onUpdate, users }: DrawerProps) { export function TaskDrawer({ task, currentUser, onClose, onUpdate, onAddDependency, onToggleDependency, onRemoveDependency, users }: DrawerProps) {
const [commentText, setCommentText] = useState(''); const [commentText, setCommentText] = useState('');
const [subtaskText, setSubtaskText] = useState(''); const [subtaskText, setSubtaskText] = useState('');
const [depUser, setDepUser] = useState('');
const [depDesc, setDepDesc] = useState('');
const updateField = (field: string, value: any) => { const updateField = (field: string, value: any) => {
const now = new Date().toISOString(); const now = new Date().toISOString();
@@ -49,8 +54,16 @@ export function TaskDrawer({ task, currentUser, onClose, onUpdate, users }: Draw
setCommentText(''); setCommentText('');
}; };
const handleAddDep = () => {
if (!depDesc.trim()) return;
onAddDependency(task.id, { dependsOnUserId: depUser, description: depDesc });
setDepDesc('');
setDepUser('');
};
const reporter = getUserById(users, task.reporter); const reporter = getUserById(users, task.reporter);
const doneCount = task.subtasks.filter(s => s.done).length; const doneCount = task.subtasks.filter(s => s.done).length;
const unresolvedDeps = (task.dependencies || []).filter(d => !d.resolved).length;
return ( return (
<> <>
@@ -69,7 +82,7 @@ export function TaskDrawer({ task, currentUser, onClose, onUpdate, users }: Draw
<div className="drawer-meta-label">Assignee</div> <div className="drawer-meta-label">Assignee</div>
<div className="drawer-meta-val"> <div className="drawer-meta-val">
<Avatar userId={task.assignee} size={20} users={users} /> <Avatar userId={task.assignee} size={20} users={users} />
<select className="drawer-select" value={task.assignee} onChange={e => updateField('assignee', e.target.value)}> <select className="drawer-select" aria-label="Assignee" value={task.assignee} onChange={e => updateField('assignee', e.target.value)}>
{users.map(u => <option key={u.id} value={u.id}>{u.name}</option>)} {users.map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
</select> </select>
</div> </div>
@@ -80,13 +93,13 @@ export function TaskDrawer({ task, currentUser, onClose, onUpdate, users }: Draw
</div> </div>
<div> <div>
<div className="drawer-meta-label">Status</div> <div className="drawer-meta-label">Status</div>
<select className="drawer-select" value={task.status} onChange={e => updateField('status', e.target.value)}> <select className="drawer-select" aria-label="Status" value={task.status} onChange={e => updateField('status', e.target.value)}>
{Object.entries(STATUS_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)} {Object.entries(STATUS_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select> </select>
</div> </div>
<div> <div>
<div className="drawer-meta-label">Priority</div> <div className="drawer-meta-label">Priority</div>
<select className="drawer-select" value={task.priority} onChange={e => updateField('priority', e.target.value)}> <select className="drawer-select" aria-label="Priority" value={task.priority} onChange={e => updateField('priority', e.target.value)}>
{['critical', 'high', 'medium', 'low'].map(p => <option key={p} value={p}>{p}</option>)} {['critical', 'high', 'medium', 'low'].map(p => <option key={p} value={p}>{p}</option>)}
</select> </select>
</div> </div>
@@ -100,6 +113,50 @@ export function TaskDrawer({ task, currentUser, onClose, onUpdate, users }: Draw
</div> </div>
</div> </div>
{/* Dependencies Section */}
<div className="drawer-section">
<div className="drawer-section-title">
🔗 Dependencies
{unresolvedDeps > 0 && <span className="dep-unresolved-badge">{unresolvedDeps} blocking</span>}
</div>
{(task.dependencies || []).length === 0 && (
<div className="dep-empty">No dependencies yet</div>
)}
{(task.dependencies || []).map(dep => {
const depUser = getUserById(users, dep.dependsOnUserId);
return (
<div key={dep.id} className={`dep-item ${dep.resolved ? 'dep-resolved' : 'dep-unresolved'}`}>
<button
className={`dep-check ${dep.resolved ? 'checked' : ''}`}
onClick={() => onToggleDependency(task.id, dep.id, !dep.resolved)}
title={dep.resolved ? 'Mark unresolved' : 'Mark resolved'}
>
{dep.resolved ? '✓' : ''}
</button>
<div className="dep-info">
{depUser && (
<span className="dep-user">
<Avatar userId={dep.dependsOnUserId} size={18} users={users} />
<span>{depUser.name}</span>
</span>
)}
<span className={`dep-desc ${dep.resolved ? 'done' : ''}`}>{dep.description}</span>
</div>
<button className="dep-remove" onClick={() => onRemoveDependency(task.id, dep.id)} title="Remove"></button>
</div>
);
})}
<div className="dep-add-row">
<select className="dep-add-select" value={depUser} onChange={e => setDepUser(e.target.value)}>
<option value="">Anyone</option>
{users.map(u => <option key={u.id} value={u.id}>{u.avatar} {u.name}</option>)}
</select>
<input className="dep-add-input" placeholder="Describe the dependency..." value={depDesc}
onChange={e => setDepDesc(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAddDep()} />
<button className="dep-add-btn" onClick={handleAddDep}>Add</button>
</div>
</div>
<div className="drawer-section"> <div className="drawer-section">
<div className="drawer-section-title">Subtasks <span style={{ color: '#64748b', fontWeight: 400, fontSize: 12 }}>{doneCount} of {task.subtasks.length} complete</span></div> <div className="drawer-section-title">Subtasks <span style={{ color: '#64748b', fontWeight: 400, fontSize: 12 }}>{doneCount} of {task.subtasks.length} complete</span></div>
{task.subtasks.length > 0 && <ProgressBar subtasks={task.subtasks} />} {task.subtasks.length > 0 && <ProgressBar subtasks={task.subtasks} />}
@@ -162,16 +219,38 @@ interface ModalProps {
currentUser: User; currentUser: User;
} }
interface PendingDep {
id: string;
dependsOnUserId: string;
description: string;
}
export function AddTaskModal({ onClose, onAdd, defaultDate, defaultStatus, users, currentUser }: ModalProps) { export function AddTaskModal({ onClose, onAdd, defaultDate, defaultStatus, users, currentUser }: ModalProps) {
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [desc, setDesc] = useState(''); const [desc, setDesc] = useState('');
const [assignee, setAssignee] = useState(users[0]?.id || ''); const [assignee, setAssignee] = useState(currentUser.id);
const [priority, setPriority] = useState<Priority>('medium'); const [priority, setPriority] = useState<Priority>('medium');
const [status, setStatus] = useState<Status>(defaultStatus || 'todo'); const [status, setStatus] = useState<Status>(defaultStatus || 'todo');
const [dueDate, setDueDate] = useState(defaultDate || new Date().toISOString().split('T')[0]); const [dueDate, setDueDate] = useState(defaultDate || new Date().toISOString().split('T')[0]);
const [tags, setTags] = useState(''); const [tags, setTags] = useState('');
const [error, setError] = useState(false); const [error, setError] = useState(false);
// Dependencies state
const [deps, setDeps] = useState<PendingDep[]>([]);
const [depUser, setDepUser] = useState('');
const [depDesc, setDepDesc] = useState('');
const addDep = () => {
if (!depDesc.trim()) return;
setDeps(prev => [...prev, { id: `pd${Date.now()}`, dependsOnUserId: depUser, description: depDesc }]);
setDepDesc('');
setDepUser('');
};
const removeDep = (id: string) => {
setDeps(prev => prev.filter(d => d.id !== id));
};
const submit = () => { const submit = () => {
if (!title.trim()) { setError(true); return; } if (!title.trim()) { setError(true); return; }
const task: Task = { const task: Task = {
@@ -180,6 +259,7 @@ export function AddTaskModal({ onClose, onAdd, defaultDate, defaultStatus, users
tags: tags ? tags.split(',').map(t => t.trim()).filter(Boolean) : [], tags: tags ? tags.split(',').map(t => t.trim()).filter(Boolean) : [],
subtasks: [], comments: [], subtasks: [], comments: [],
activity: [{ id: `a${Date.now()}`, text: `📝 Task created`, timestamp: new Date().toISOString() }], activity: [{ id: `a${Date.now()}`, text: `📝 Task created`, timestamp: new Date().toISOString() }],
dependencies: deps.map(d => ({ id: d.id, dependsOnUserId: d.dependsOnUserId, description: d.description, resolved: false })),
}; };
onAdd(task); onAdd(task);
onClose(); onClose();
@@ -200,7 +280,7 @@ export function AddTaskModal({ onClose, onAdd, defaultDate, defaultStatus, users
</div> </div>
<div className="modal-grid"> <div className="modal-grid">
<div className="modal-field"> <div className="modal-field">
<label>Assignee</label> <label>Assign To</label>
<select className="modal-input" value={assignee} onChange={e => setAssignee(e.target.value)}> <select className="modal-input" value={assignee} onChange={e => setAssignee(e.target.value)}>
{users.map(u => <option key={u.id} value={u.id}>{u.avatar} {u.name}</option>)} {users.map(u => <option key={u.id} value={u.id}>{u.avatar} {u.name}</option>)}
</select> </select>
@@ -226,6 +306,33 @@ export function AddTaskModal({ onClose, onAdd, defaultDate, defaultStatus, users
<label>Tags (comma separated)</label> <label>Tags (comma separated)</label>
<input className="modal-input" placeholder="devops, backend, ..." value={tags} onChange={e => setTags(e.target.value)} /> <input className="modal-input" placeholder="devops, backend, ..." value={tags} onChange={e => setTags(e.target.value)} />
</div> </div>
{/* Dependencies Section */}
<div className="modal-field">
<label>🔗 Dependencies / Blockers</label>
<div className="modal-deps-list">
{deps.map(d => {
const u = getUserById(users, d.dependsOnUserId);
return (
<div key={d.id} className="modal-dep-item">
<span className="modal-dep-icon"></span>
{u && <span className="modal-dep-user">{u.avatar} {u.name}:</span>}
<span className="modal-dep-desc">{d.description}</span>
<button className="modal-dep-remove" onClick={() => removeDep(d.id)}></button>
</div>
);
})}
</div>
<div className="modal-dep-add">
<select className="modal-dep-select" value={depUser} onChange={e => setDepUser(e.target.value)}>
<option value="">Blocked by (anyone)</option>
{users.map(u => <option key={u.id} value={u.id}>{u.avatar} {u.name}</option>)}
</select>
<input className="modal-dep-input" placeholder="e.g. Need API endpoints from backend team"
value={depDesc} onChange={e => setDepDesc(e.target.value)} onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addDep())} />
<button className="modal-dep-btn" onClick={addDep} type="button">+ Add</button>
</div>
</div>
</div> </div>
<div className="modal-footer"> <div className="modal-footer">
<button className="btn-ghost" onClick={onClose}>Cancel</button> <button className="btn-ghost" onClick={onClose}>Cancel</button>

View File

@@ -0,0 +1,54 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from '../App';
import * as api from '../api';
// Mock the API module
vi.mock('../api', () => ({
apiFetchTasks: vi.fn(),
apiFetchUsers: vi.fn(),
apiCreateTask: vi.fn(),
apiUpdateTask: vi.fn(),
apiAddActivity: vi.fn(),
apiLogin: vi.fn(),
}));
describe('App Component', () => {
it('renders login page when no user is logged in', () => {
render(<App />);
expect(screen.getByRole('button', { name: /sig\s*n\s*in/i })).toBeInTheDocument();
});
it('renders main content after login', async () => {
const mockUser = { id: 'u1', name: 'Test User', email: 'test@example.com', role: 'admin', dept: 'Engineering' };
const mockTasks = [{ id: 't1', title: 'Task 1', status: 'todo' }];
const mockUsers = [mockUser];
// Mock API responses
(api.apiLogin as any).mockResolvedValue(mockUser);
(api.apiFetchTasks as any).mockResolvedValue(mockTasks);
(api.apiFetchUsers as any).mockResolvedValue(mockUsers);
render(<App />);
// Simulate login
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
const loginButton = screen.getByRole('button', { name: /sig\s*n\s*in/i });
await userEvent.type(emailInput, 'test@example.com');
await userEvent.type(passwordInput, 'password');
await userEvent.click(loginButton);
// Wait for data loading
await waitFor(() => {
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
// Check if main content is rendered (e.g., Sidebar, Calendar)
expect(screen.getByText('Calendar')).toBeInTheDocument();
expect(screen.getByText('Test User')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { KanbanBoard } from '../Kanban';
import type { Task, User } from '../data';
// Mock Shared components
vi.mock('../Shared', () => ({
Avatar: ({ userId }: any) => <div data-testid="avatar">{userId}</div>,
PriorityBadge: ({ level }: any) => <div>{level}</div>,
StatusBadge: ({ status }: any) => <div>{status}</div>,
ProgressBar: () => <div>Progress</div>
}));
describe('KanbanBoard Component', () => {
const mockUser: User = { id: 'u1', name: 'Test User', email: 'test@example.com', role: 'emp', dept: 'dev', avatar: 'TU', color: '#000' };
const mockUsers = [mockUser];
const mockTasks: Task[] = [
{ id: 't1', title: 'Task 1', description: 'Desc', status: 'todo', priority: 'medium', assignee: 'u1', reporter: 'u1', dueDate: '2023-12-31', tags: [], subtasks: [], comments: [], activity: [], dependencies: [] },
{ id: 't2', title: 'Task 2', description: 'Desc', status: 'inprogress', priority: 'high', assignee: 'u1', reporter: 'u1', dueDate: '2023-12-31', tags: [], subtasks: [], comments: [], activity: [], dependencies: [] },
];
const mockOnTaskClick = vi.fn();
const mockOnAddTask = vi.fn();
const mockOnMoveTask = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it('renders columns and tasks', () => {
render(<KanbanBoard tasks={mockTasks} currentUser={mockUser} onTaskClick={mockOnTaskClick} onAddTask={mockOnAddTask} onMoveTask={mockOnMoveTask} filterUser={null} searchQuery="" users={mockUsers} />);
expect(screen.getByText('To Do')).toBeInTheDocument();
expect(screen.getByText('In Progress')).toBeInTheDocument();
expect(screen.getByText('Review')).toBeInTheDocument();
expect(screen.getByText('Done')).toBeInTheDocument();
expect(screen.getByText('Task 1')).toBeInTheDocument();
expect(screen.getByText('Task 2')).toBeInTheDocument();
});
it('filters tasks by search query', () => {
render(<KanbanBoard tasks={mockTasks} currentUser={mockUser} onTaskClick={mockOnTaskClick} onAddTask={mockOnAddTask} onMoveTask={mockOnMoveTask} filterUser={null} searchQuery="Task 1" users={mockUsers} />);
expect(screen.getByText('Task 1')).toBeInTheDocument();
expect(screen.queryByText('Task 2')).not.toBeInTheDocument();
});
it('calls onAddTask when + button clicked', () => {
render(<KanbanBoard tasks={mockTasks} currentUser={mockUser} onTaskClick={mockOnTaskClick} onAddTask={mockOnAddTask} onMoveTask={mockOnMoveTask} filterUser={null} searchQuery="" users={mockUsers} />);
// There are multiple + buttons (one per column)
const addButtons = screen.getAllByText('+');
fireEvent.click(addButtons[0]); // Click first column (Todo) add button
expect(mockOnAddTask).toHaveBeenCalledWith('todo');
});
it('calls onTaskClick when task clicked', () => {
render(<KanbanBoard tasks={mockTasks} currentUser={mockUser} onTaskClick={mockOnTaskClick} onAddTask={mockOnAddTask} onMoveTask={mockOnMoveTask} filterUser={null} searchQuery="" users={mockUsers} />);
fireEvent.click(screen.getByText('Task 1'));
expect(mockOnTaskClick).toHaveBeenCalledWith(mockTasks[0]);
});
});

View File

@@ -0,0 +1,57 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ListView } from '../ListView';
import type { Task, User } from '../data';
describe('ListView Component', () => {
const mockUser: User = { id: 'u1', name: 'Test User', email: 'test@example.com', role: 'emp', dept: 'dev', avatar: 'TU', color: '#000' };
const mockUsers = [mockUser];
// Test data
const mockTasks: Task[] = [
{ id: 't1', title: 'Task 1', description: 'Desc', status: 'todo', priority: 'medium', assignee: 'u1', reporter: 'u1', dueDate: '2023-12-31', tags: [], subtasks: [], comments: [], activity: [], dependencies: [] },
{ id: 't2', title: 'Task 2', description: 'Desc', status: 'done', priority: 'high', assignee: 'u1', reporter: 'u1', dueDate: '2023-12-31', tags: [], subtasks: [], comments: [], activity: [], dependencies: [] },
];
const mockOnTaskClick = vi.fn();
const mockOnToggleDone = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it('renders tasks in list', () => {
render(<ListView tasks={mockTasks} currentUser={mockUser} onTaskClick={mockOnTaskClick} onToggleDone={mockOnToggleDone} filterUser={null} searchQuery="" users={mockUsers} />);
expect(screen.getByText('Task 1')).toBeInTheDocument();
expect(screen.getByText('Task 2')).toBeInTheDocument();
// Check for headers (using getAllByText because buttons also have these labels)
expect(screen.getAllByText('Title').length).toBeGreaterThan(0);
expect(screen.getAllByText('Assignee').length).toBeGreaterThan(0);
expect(screen.getAllByText('Status').length).toBeGreaterThan(0);
});
it('filters tasks by search', () => {
render(<ListView tasks={mockTasks} currentUser={mockUser} onTaskClick={mockOnTaskClick} onToggleDone={mockOnToggleDone} filterUser={null} searchQuery="Task 1" users={mockUsers} />);
expect(screen.getByText('Task 1')).toBeInTheDocument();
expect(screen.queryByText('Task 2')).not.toBeInTheDocument();
});
it('handles task click', () => {
render(<ListView tasks={mockTasks} currentUser={mockUser} onTaskClick={mockOnTaskClick} onToggleDone={mockOnToggleDone} filterUser={null} searchQuery="" users={mockUsers} />);
fireEvent.click(screen.getByText('Task 1'));
expect(mockOnTaskClick).toHaveBeenCalledWith(mockTasks[0]);
});
it('handles toggle done', () => {
render(<ListView tasks={mockTasks} currentUser={mockUser} onTaskClick={mockOnTaskClick} onToggleDone={mockOnToggleDone} filterUser={null} searchQuery="" users={mockUsers} />);
const checkboxes = screen.getAllByRole('checkbox');
fireEvent.click(checkboxes[0]);
expect(mockOnToggleDone).toHaveBeenCalledWith('t1');
});
});

View File

@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginPage } from '../Login';
import * as api from '../api';
// Mock API
vi.mock('../api', () => ({
apiLogin: vi.fn(),
apiRegister: vi.fn(),
}));
describe('LoginPage Component', () => {
const mockOnLogin = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockOnLogin.mockClear();
});
it('renders login form by default', () => {
render(<LoginPage onLogin={mockOnLogin} />);
expect(screen.getByText(/scrum-manager/i)).toBeInTheDocument(); // Logo check
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
expect(screen.getByText(/no account\? register here/i)).toBeInTheDocument();
});
it('switches to register mode', async () => {
render(<LoginPage onLogin={mockOnLogin} />);
await userEvent.click(screen.getByText(/no account\? register here/i));
expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /create account/i })).toBeInTheDocument();
expect(screen.getByText(/already have an account\? sign in/i)).toBeInTheDocument();
});
it('handles successful login', async () => {
const mockUser = { id: 'u1', name: 'Test', email: 'test@example.com', role: 'emp', dept: 'dev' };
(api.apiLogin as any).mockResolvedValue(mockUser);
render(<LoginPage onLogin={mockOnLogin} />);
await userEvent.type(screen.getByLabelText(/email/i), 'test@example.com');
await userEvent.type(screen.getByLabelText(/password/i), 'password');
await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
expect(api.apiLogin).toHaveBeenCalledWith('test@example.com', 'password');
expect(mockOnLogin).toHaveBeenCalledWith(mockUser);
});
});
it('handles login failure', async () => {
(api.apiLogin as any).mockRejectedValue(new Error('Invalid credentials'));
render(<LoginPage onLogin={mockOnLogin} />);
await userEvent.type(screen.getByLabelText(/email/i), 'wrong@example.com');
await userEvent.type(screen.getByLabelText(/password/i), 'wrongpass');
await userEvent.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument();
});
expect(mockOnLogin).not.toHaveBeenCalled();
});
it('handles successful registration', async () => {
const mockUser = { id: 'u2', name: 'New User', email: 'new@example.com', role: 'employee', dept: 'dev' };
(api.apiRegister as any).mockResolvedValue(mockUser);
render(<LoginPage onLogin={mockOnLogin} />);
await userEvent.click(screen.getByText(/no account\? register here/i));
await userEvent.type(screen.getByLabelText(/name/i), 'New User');
await userEvent.type(screen.getByLabelText(/email/i), 'new@example.com');
await userEvent.type(screen.getByLabelText(/password/i), 'password123');
await userEvent.type(screen.getByLabelText(/department/i), 'DevOps'); // "e.g. Backend..." placeholder, checking label
await userEvent.click(screen.getByRole('button', { name: /create account/i }));
await waitFor(() => {
expect(api.apiRegister).toHaveBeenCalledWith({
name: 'New User',
email: 'new@example.com',
password: 'password123',
role: 'employee', // Default
dept: 'DevOps'
});
expect(mockOnLogin).toHaveBeenCalledWith(mockUser);
});
});
it('validates registration inputs', async () => {
render(<LoginPage onLogin={mockOnLogin} />);
await userEvent.click(screen.getByText(/no account\? register here/i));
await userEvent.click(screen.getByRole('button', { name: /create account/i }));
expect(screen.getByText(/all fields are required/i)).toBeInTheDocument();
expect(api.apiRegister).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TaskDrawer } from '../TaskDrawer';
import type { Task, User } from '../data';
// Mock Shared components to avoid testing their specific implementation here
vi.mock('../Shared', () => ({
Avatar: ({ userId }: any) => <div data-testid="avatar">{userId}</div>,
Tag: ({ label }: any) => <div>{label}</div>,
ProgressBar: () => <div>Progress</div>
}));
describe('TaskDrawer Component', () => {
const mockUser: User = { id: 'u1', name: 'Test User', email: 'test@example.com', role: 'emp', dept: 'dev', avatar: 'TU', color: '#000' };
const mockUsers = [mockUser, { id: 'u2', name: 'Other User', email: 'other@example.com', role: 'emp', dept: 'dev', avatar: 'OU', color: '#fff' }];
const mockTask: Task = {
id: 't1',
title: 'Test Task',
description: 'Test Description',
status: 'todo',
priority: 'medium',
assignee: 'u1',
reporter: 'u1',
dueDate: '2023-12-31',
tags: ['bug'],
subtasks: [],
comments: [],
activity: [],
dependencies: []
};
const mockOnUpdate = vi.fn();
const mockOnClose = vi.fn();
const mockOnAddDep = vi.fn();
const mockOnToggleDep = vi.fn();
const mockOnRemoveDep = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it('renders task details correctly', () => {
render(<TaskDrawer task={mockTask} currentUser={mockUser} onClose={mockOnClose} onUpdate={mockOnUpdate} onAddDependency={mockOnAddDep} onToggleDependency={mockOnToggleDep} onRemoveDependency={mockOnRemoveDep} users={mockUsers} />);
// screen.debug();
expect(screen.getByText('Test Task')).toBeInTheDocument();
expect(screen.getByText('Test Description')).toBeInTheDocument();
// Check for mocked avatar with userId
const avatars = screen.getAllByTestId('avatar');
expect(avatars.length).toBeGreaterThan(0);
expect(avatars[0]).toHaveTextContent('u1');
// expect(screen.getByText(/Test User/i)).toBeInTheDocument(); // Assignee name might be tricky in Select/Div
expect(screen.getByText('bug')).toBeInTheDocument();
});
it('updates title/description calling onUpdate', async () => {
// Title isn't editable in the drawer based on code, only via modal or just display?
// Checking code: <h2 className="drawer-title">{task.title}</h2>. It's not an input.
// But status, priority, assignee are selects.
render(<TaskDrawer task={mockTask} currentUser={mockUser} onClose={mockOnClose} onUpdate={mockOnUpdate} onAddDependency={mockOnAddDep} onToggleDependency={mockOnToggleDep} onRemoveDependency={mockOnRemoveDep} users={mockUsers} />);
// Update status
const statusSelect = screen.getByRole('combobox', { name: /status/i });
await userEvent.selectOptions(statusSelect, 'done');
expect(mockOnUpdate).toHaveBeenCalledWith(expect.objectContaining({
id: 't1',
status: 'done'
}));
});
it('adds a subtask', async () => {
render(<TaskDrawer task={mockTask} currentUser={mockUser} onClose={mockOnClose} onUpdate={mockOnUpdate} onAddDependency={mockOnAddDep} onToggleDependency={mockOnToggleDep} onRemoveDependency={mockOnRemoveDep} users={mockUsers} />);
const input = screen.getByPlaceholderText(/add a subtask/i);
await userEvent.type(input, 'New Subtask{enter}');
expect(mockOnUpdate).toHaveBeenCalledWith(expect.objectContaining({
subtasks: expect.arrayContaining([expect.objectContaining({ title: 'New Subtask', done: false })])
}));
});
it('adds a comment', async () => {
render(<TaskDrawer task={mockTask} currentUser={mockUser} onClose={mockOnClose} onUpdate={mockOnUpdate} onAddDependency={mockOnAddDep} onToggleDependency={mockOnToggleDep} onRemoveDependency={mockOnRemoveDep} users={mockUsers} />);
const input = screen.getByPlaceholderText(/add a comment/i);
await userEvent.type(input, 'This is a comment{enter}');
expect(mockOnUpdate).toHaveBeenCalledWith(expect.objectContaining({
comments: expect.arrayContaining([expect.objectContaining({ text: 'This is a comment', userId: 'u1' })])
}));
});
it('adds a dependency', async () => {
render(<TaskDrawer task={mockTask} currentUser={mockUser} onClose={mockOnClose} onUpdate={mockOnUpdate} onAddDependency={mockOnAddDep} onToggleDependency={mockOnToggleDep} onRemoveDependency={mockOnRemoveDep} users={mockUsers} />);
const descInput = screen.getByPlaceholderText(/describe the dependency/i);
await userEvent.type(descInput, 'Blocked by API{enter}');
// Default select is "Anyone" (empty string).
expect(mockOnAddDep).toHaveBeenCalledWith('t1', { dependsOnUserId: '', description: 'Blocked by API' });
});
});

View File

@@ -42,6 +42,7 @@ export async function apiCreateTask(task: {
title: string; description?: string; status?: string; priority?: string; title: string; description?: string; status?: string; priority?: string;
assignee?: string; reporter?: string; dueDate?: string; tags?: string[]; assignee?: string; reporter?: string; dueDate?: string; tags?: string[];
subtasks?: { title: string; done: boolean }[]; subtasks?: { title: string; done: boolean }[];
dependencies?: { dependsOnUserId: string; description: string }[];
}) { }) {
return request('/tasks', { return request('/tasks', {
method: 'POST', method: 'POST',
@@ -83,3 +84,24 @@ export async function apiAddActivity(taskId: string, text: string) {
body: JSON.stringify({ text }), body: JSON.stringify({ text }),
}); });
} }
// Dependencies
export async function apiAddDependency(taskId: string, dep: { dependsOnUserId: string; description: string }) {
return request(`/tasks/${taskId}/dependencies`, {
method: 'POST',
body: JSON.stringify(dep),
});
}
export async function apiToggleDependency(taskId: string, depId: string, resolved: boolean) {
return request(`/tasks/${taskId}/dependencies/${depId}`, {
method: 'PUT',
body: JSON.stringify({ resolved }),
});
}
export async function apiRemoveDependency(taskId: string, depId: string) {
return request(`/tasks/${taskId}/dependencies/${depId}`, {
method: 'DELETE',
});
}

View File

@@ -9,10 +9,12 @@ export interface User {
export interface Subtask { id: string; title: string; done: boolean } export interface Subtask { id: string; title: string; done: boolean }
export interface Comment { id: string; userId: string; text: string; timestamp: string } export interface Comment { id: string; userId: string; text: string; timestamp: string }
export interface Activity { id: string; text: string; timestamp: string } export interface Activity { id: string; text: string; timestamp: string }
export interface Dependency { id: string; dependsOnUserId: string; description: string; resolved: boolean }
export interface Task { export interface Task {
id: string; title: string; description: string; status: Status; priority: Priority; id: string; title: string; description: string; status: Status; priority: Priority;
assignee: string; reporter: string; dueDate: string; tags: string[]; assignee: string; reporter: string; dueDate: string; tags: string[];
subtasks: Subtask[]; comments: Comment[]; activity: Activity[]; subtasks: Subtask[]; comments: Comment[]; activity: Activity[];
dependencies: Dependency[];
} }
export const PRIORITY_COLORS: Record<Priority, { color: string; bg: string }> = { export const PRIORITY_COLORS: Record<Priority, { color: string; bg: string }> = {

View File

@@ -1655,6 +1655,281 @@ body {
background: var(--accent-hover); background: var(--accent-hover);
} }
/* DEPENDENCIES */
.dep-unresolved-badge {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
margin-left: 8px;
}
.dep-empty {
color: var(--text-muted);
font-size: 12px;
padding: 8px 0;
font-style: italic;
}
.dep-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
margin-bottom: 4px;
transition: background 0.15s;
}
.dep-item:hover {
background: rgba(255, 255, 255, 0.03);
}
.dep-unresolved {
border-left: 3px solid #ef4444;
}
.dep-resolved {
border-left: 3px solid #22c55e;
opacity: 0.7;
}
.dep-check {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--text-muted);
background: none;
color: #22c55e;
font-size: 13px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
flex-shrink: 0;
}
.dep-check.checked {
border-color: #22c55e;
background: rgba(34, 197, 94, 0.15);
}
.dep-check:hover {
border-color: var(--accent);
}
.dep-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.dep-user {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--accent);
}
.dep-desc {
font-size: 13px;
color: var(--text-primary);
}
.dep-desc.done {
text-decoration: line-through;
color: var(--text-muted);
}
.dep-remove {
background: none;
border: none;
color: var(--text-muted);
font-size: 14px;
cursor: pointer;
padding: 4px;
border-radius: 4px;
opacity: 0;
transition: all 0.15s;
}
.dep-item:hover .dep-remove {
opacity: 1;
}
.dep-remove:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
.dep-add-row {
display: flex;
gap: 6px;
margin-top: 8px;
}
.dep-add-select {
padding: 6px 8px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 12px;
font-family: inherit;
width: 140px;
outline: none;
}
.dep-add-select:focus {
border-color: var(--accent);
}
.dep-add-input {
flex: 1;
padding: 6px 10px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 12px;
font-family: inherit;
outline: none;
}
.dep-add-input:focus {
border-color: var(--accent);
}
.dep-add-btn {
padding: 6px 14px;
background: var(--accent);
border: none;
border-radius: 6px;
color: #fff;
font-size: 12px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
white-space: nowrap;
transition: background 0.15s;
}
.dep-add-btn:hover {
background: var(--accent-hover);
}
/* Modal dependency styles */
.modal-deps-list {
margin-bottom: 8px;
}
.modal-dep-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 4px;
font-size: 13px;
border-left: 3px solid #f59e0b;
}
.modal-dep-icon {
font-size: 14px;
}
.modal-dep-user {
color: var(--accent);
font-weight: 600;
font-size: 12px;
white-space: nowrap;
}
.modal-dep-desc {
flex: 1;
color: var(--text-primary);
}
.modal-dep-remove {
background: none;
border: none;
color: var(--text-muted);
font-size: 14px;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
}
.modal-dep-remove:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
.modal-dep-add {
display: flex;
gap: 6px;
}
.modal-dep-select {
padding: 8px 10px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-primary);
font-size: 12px;
font-family: inherit;
width: 160px;
outline: none;
}
.modal-dep-select:focus {
border-color: var(--accent);
}
.modal-dep-input {
flex: 1;
padding: 8px 12px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-primary);
font-size: 13px;
font-family: inherit;
outline: none;
}
.modal-dep-input:focus {
border-color: var(--accent);
}
.modal-dep-btn {
padding: 8px 14px;
background: var(--accent);
border: none;
border-radius: 8px;
color: #fff;
font-size: 12px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
white-space: nowrap;
transition: background 0.15s;
}
.modal-dep-btn:hover {
background: var(--accent-hover);
}
/* DASHBOARD */ /* DASHBOARD */
.dashboard { .dashboard {
padding: 20px; padding: 20px;

2
src/test/setup.ts Normal file
View File

@@ -0,0 +1,2 @@
import '@testing-library/jest-dom';

View File

@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
// fetch is global in Node 18+
describe('Integration Tests', () => {
const FRONTEND_URL = 'http://localhost:80';
const BACKEND_URL = 'http://localhost:3001';
it('Frontend is reachable', async () => {
try {
const res = await fetch(FRONTEND_URL);
expect(res.status).toBe(200);
const text = await res.text();
expect(text).toContain('<!doctype html>');
} catch (e) {
// If fetch fails (connection refused), test fails
throw new Error(`Frontend not reachable at ${FRONTEND_URL}: ${e.message}`);
}
});
it('Backend health check / API is reachable', async () => {
// We don't have a specific health endpoint, but we can try to hit an auth endpoint
// that requires valid input, expecting a 400 or 401 instead of connection refused.
try {
const res = await fetch(`${BACKEND_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
// Expecting 400 because we sent empty body, meaning server is up and parsing JSON
expect(res.status).toBe(400);
} catch (e) {
throw new Error(`Backend not reachable at ${BACKEND_URL}: ${e.message}`);
}
});
});

View File

@@ -1,4 +1,5 @@
import { defineConfig } from 'vite' /// <reference types="vitest" />
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
// https://vite.dev/config/ // https://vite.dev/config/
@@ -13,4 +14,9 @@ export default defineConfig({
}, },
}, },
}, },
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
},
}) })

View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['tests/integration/**/*.test.ts', 'tests/integration/**/*.test.js'],
testTimeout: 20000,
},
});