5 Commits

Author SHA1 Message Date
Experiments DB Dev 5874ed8374 feat(tests): Jest backend (38 tests), Jest RTL frontend (28 tests), Playwright E2E spec 2026-04-15 13:23:48 -04:00
Experiments DB Dev c359cb4bb9 merge: frontend UI into main 2026-04-15 13:18:34 -04:00
Experiments DB Dev c32a5d200d feat(frontend): React UI — pages, forms, audit log section, Tailwind, Vite build 2026-04-15 13:18:31 -04:00
Experiments DB Dev f1d2449808 merge: database setup and backend API into main 2026-04-15 10:40:41 -04:00
Experiments DB Dev 04d1eee8f8 feat(backend): Prisma schema, Express routes, audit middleware, error handling 2026-04-15 10:40:39 -04:00
52 changed files with 25428 additions and 0 deletions
+8398
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "experiments-backend",
"version": "1.0.0",
"description": "Animal experiments database API",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"migrate": "prisma migrate deploy",
"migrate:dev": "prisma migrate dev",
"generate": "prisma generate",
"test": "jest --runInBand --forceExit",
"test:coverage": "jest --runInBand --forceExit --coverage"
},
"dependencies": {
"@prisma/client": "^5.14.0",
"cors": "^2.8.5",
"express": "^4.19.2",
"express-validator": "^7.1.0",
"helmet": "^7.1.0",
"morgan": "^1.10.0",
"uuid": "^9.0.1"
},
"devDependencies": {
"jest": "^29.7.0",
"nodemon": "^3.1.3",
"prisma": "^5.14.0",
"supertest": "^7.0.0"
},
"jest": {
"testEnvironment": "node",
"testMatch": ["**/tests/**/*.test.js"],
"globalSetup": "./tests/setup.js",
"globalTeardown": "./tests/teardown.js"
}
}
@@ -0,0 +1,49 @@
-- CreateTable
CREATE TABLE "experiments" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "experiments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "animals" (
"id" TEXT NOT NULL,
"experiment_id" TEXT NOT NULL,
"animal_id_string" TEXT NOT NULL,
"animal_name" TEXT NOT NULL,
CONSTRAINT "animals_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "daily_statuses" (
"id" TEXT NOT NULL,
"animal_id" TEXT NOT NULL,
"date" DATE NOT NULL,
"experiment_description" TEXT,
"vitals" TEXT,
"treatment" TEXT,
"notes" TEXT,
CONSTRAINT "daily_statuses_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "audit_logs" (
"id" TEXT NOT NULL,
"table_name" TEXT NOT NULL,
"record_id" TEXT NOT NULL,
"action" TEXT NOT NULL,
"changes" JSONB NOT NULL,
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "animals" ADD CONSTRAINT "animals_experiment_id_fkey" FOREIGN KEY ("experiment_id") REFERENCES "experiments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "daily_statuses" ADD CONSTRAINT "daily_statuses_animal_id_fkey" FOREIGN KEY ("animal_id") REFERENCES "animals"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+52
View File
@@ -0,0 +1,52 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Experiment {
id String @id @default(uuid())
title String
created_at DateTime @default(now())
animals Animal[]
@@map("experiments")
}
model Animal {
id String @id @default(uuid())
experiment_id String
animal_id_string String
animal_name String
experiment Experiment @relation(fields: [experiment_id], references: [id], onDelete: Cascade)
daily_statuses DailyStatus[]
@@map("animals")
}
model DailyStatus {
id String @id @default(uuid())
animal_id String
date DateTime @db.Date
experiment_description String?
vitals String?
treatment String?
notes String?
animal Animal @relation(fields: [animal_id], references: [id], onDelete: Cascade)
@@map("daily_statuses")
}
model AuditLog {
id String @id @default(uuid())
table_name String
record_id String
action String
changes Json
timestamp DateTime @default(now())
@@map("audit_logs")
}
+38
View File
@@ -0,0 +1,38 @@
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const { globalErrorHandler } = require('./middleware/errorHandler');
const experimentsRouter = require('./routes/experiments');
const animalsRouter = require('./routes/animals');
const dailyStatusesRouter = require('./routes/dailyStatuses');
const auditLogsRouter = require('./routes/auditLogs');
const app = express();
app.use(helmet());
app.use(cors({
origin: process.env.CORS_ORIGIN || '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
app.use(express.json({ limit: '1mb' }));
app.use(morgan(process.env.NODE_ENV === 'test' ? 'silent' : process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.use('/api/experiments', experimentsRouter);
app.use('/api/animals', animalsRouter);
app.use('/api/daily-statuses', dailyStatusesRouter);
app.use('/api/audit-logs', auditLogsRouter);
app.use((req, res) => {
res.status(404).json({ error: `Route ${req.method} ${req.path} not found` });
});
app.use(globalErrorHandler);
module.exports = app;
+25
View File
@@ -0,0 +1,25 @@
const prisma = require('./lib/prisma');
const app = require('./app');
const PORT = process.env.PORT || 3001;
async function startServer() {
try {
await prisma.$connect();
console.log('Database connected successfully');
if (process.env.NODE_ENV === 'production') {
const { execSync } = require('child_process');
execSync('npx prisma migrate deploy', { stdio: 'inherit' });
}
app.listen(PORT, '0.0.0.0', () => {
console.log(`Server running on port ${PORT}`);
});
} catch (err) {
console.error('Failed to start server:', err);
process.exit(1);
}
}
startServer();
+7
View File
@@ -0,0 +1,7 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'],
});
module.exports = prisma;
+65
View File
@@ -0,0 +1,65 @@
const prisma = require('../lib/prisma');
/**
* Factory that creates audit middleware for a given Prisma model.
* On UPDATE: captures before/after diff.
* On DELETE: captures the full record before deletion.
*
* @param {string} tableName - The human-readable table name for audit logs
* @param {object} model - The Prisma model delegate (e.g., prisma.experiment)
* @param {string} idParam - The route param name that holds the record id (default: 'id')
*/
function auditMiddleware(tableName, model, idParam = 'id') {
return async (req, res, next) => {
const recordId = req.params[idParam];
if (!recordId) return next();
try {
const before = await model.findUnique({ where: { id: recordId } });
if (!before) return next();
// Attach before-state so route handlers can reference it if needed
req._auditBefore = before;
// Shared audit writer — called by both res.json and res.send interceptors
async function writeAuditLog() {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
const action = req.method === 'DELETE' ? 'DELETE' : 'UPDATE';
const after = action === 'DELETE' ? null : await model.findUnique({ where: { id: recordId } });
await prisma.auditLog.create({
data: {
table_name: tableName,
record_id: recordId,
action,
changes: { before, after },
},
});
} catch (auditErr) {
console.error('Audit log write failed:', auditErr.message);
}
}
}
// Wrap res.json (used by JSON-returning routes)
const originalJson = res.json.bind(res);
res.json = async function (data) {
await writeAuditLog();
return originalJson(data);
};
// Wrap res.send (used by DELETE 204 routes)
const originalSend = res.send.bind(res);
res.send = async function (data) {
await writeAuditLog();
return originalSend(data);
};
next();
} catch (err) {
next(err);
}
};
}
module.exports = auditMiddleware;
+40
View File
@@ -0,0 +1,40 @@
const { validationResult } = require('express-validator');
/**
* Validation check middleware — call after express-validator chains.
* Returns 422 with field-level errors if validation fails.
*/
function validateRequest(req, res, next) {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({
error: 'Validation failed',
details: errors.array().map((e) => ({ field: e.path, message: e.msg })),
});
}
next();
}
/**
* Global Express error handler — must be registered last.
*/
function globalErrorHandler(err, req, res, next) {
console.error(err);
// Prisma known errors
if (err.code === 'P2025') {
return res.status(404).json({ error: 'Record not found' });
}
if (err.code === 'P2003') {
return res.status(400).json({ error: 'Foreign key constraint failed — referenced record does not exist' });
}
if (err.code === 'P2002') {
return res.status(409).json({ error: 'A record with these unique fields already exists' });
}
const status = err.status || 500;
const message = process.env.NODE_ENV === 'production' ? 'Internal server error' : err.message;
res.status(status).json({ error: message });
}
module.exports = { validateRequest, globalErrorHandler };
+113
View File
@@ -0,0 +1,113 @@
const router = require('express').Router();
const { body, query } = require('express-validator');
const prisma = require('../lib/prisma');
const auditMiddleware = require('../middleware/auditMiddleware');
const { validateRequest } = require('../middleware/errorHandler');
const animalValidation = [
body('experiment_id').notEmpty().withMessage('experiment_id is required').matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('experiment_id must be a valid UUID'),
body('animal_id_string').trim().notEmpty().withMessage('animal_id_string is required'),
body('animal_name').trim().notEmpty().withMessage('animal_name is required'),
];
const animalUpdateValidation = [
body('animal_id_string').optional().trim().notEmpty().withMessage('animal_id_string cannot be empty'),
body('animal_name').optional().trim().notEmpty().withMessage('animal_name cannot be empty'),
];
// GET /api/animals?experimentId=<uuid> — list for experiment
router.get(
'/',
[query('experimentId').optional().matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('experimentId must be a valid UUID')],
validateRequest,
async (req, res, next) => {
try {
const where = req.query.experimentId ? { experiment_id: req.query.experimentId } : {};
const animals = await prisma.animal.findMany({
where,
orderBy: { animal_name: 'asc' },
include: { _count: { select: { daily_statuses: true } } },
});
res.json(animals);
} catch (err) {
next(err);
}
}
);
// GET /api/animals/:id — get one with daily statuses
router.get('/:id', async (req, res, next) => {
try {
const animal = await prisma.animal.findUnique({
where: { id: req.params.id },
include: { daily_statuses: { orderBy: { date: 'desc' } } },
});
if (!animal) return res.status(404).json({ error: 'Animal not found' });
res.json(animal);
} catch (err) {
next(err);
}
});
// POST /api/animals — create
router.post('/', animalValidation, validateRequest, async (req, res, next) => {
try {
const { experiment_id, animal_id_string, animal_name } = req.body;
const animal = await prisma.animal.create({
data: { experiment_id, animal_id_string, animal_name },
});
await prisma.auditLog.create({
data: {
table_name: 'animals',
record_id: animal.id,
action: 'CREATE',
changes: { before: null, after: animal },
},
});
res.status(201).json(animal);
} catch (err) {
next(err);
}
});
// PUT /api/animals/:id — update
router.put(
'/:id',
auditMiddleware('animals', prisma.animal),
animalUpdateValidation,
validateRequest,
async (req, res, next) => {
try {
const { animal_id_string, animal_name } = req.body;
const data = {};
if (animal_id_string !== undefined) data.animal_id_string = animal_id_string;
if (animal_name !== undefined) data.animal_name = animal_name;
const animal = await prisma.animal.update({
where: { id: req.params.id },
data,
});
res.json(animal);
} catch (err) {
next(err);
}
}
);
// DELETE /api/animals/:id — delete
router.delete(
'/:id',
auditMiddleware('animals', prisma.animal),
async (req, res, next) => {
try {
await prisma.animal.delete({ where: { id: req.params.id } });
res.status(204).send();
} catch (err) {
next(err);
}
}
);
module.exports = router;
+54
View File
@@ -0,0 +1,54 @@
const router = require('express').Router();
const { query } = require('express-validator');
const prisma = require('../lib/prisma');
const { validateRequest } = require('../middleware/errorHandler');
// GET /api/audit-logs?tableName=&recordId=&limit=&offset= — query logs
router.get(
'/',
[
query('tableName').optional().isString(),
query('recordId').optional().matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('recordId must be a valid UUID'),
query('limit').optional().isInt({ min: 1, max: 200 }).withMessage('limit must be 1200').toInt(),
query('offset').optional().isInt({ min: 0 }).withMessage('offset must be ≥0').toInt(),
],
validateRequest,
async (req, res, next) => {
try {
const { tableName, recordId } = req.query;
const limit = req.query.limit || 50;
const offset = req.query.offset || 0;
const where = {};
if (tableName) where.table_name = tableName;
if (recordId) where.record_id = recordId;
const [logs, total] = await Promise.all([
prisma.auditLog.findMany({
where,
orderBy: { timestamp: 'desc' },
take: limit,
skip: offset,
}),
prisma.auditLog.count({ where }),
]);
res.json({ logs, total, limit, offset });
} catch (err) {
next(err);
}
}
);
// GET /api/audit-logs/:id — single log entry
router.get('/:id', async (req, res, next) => {
try {
const log = await prisma.auditLog.findUnique({ where: { id: req.params.id } });
if (!log) return res.status(404).json({ error: 'Audit log not found' });
res.json(log);
} catch (err) {
next(err);
}
});
module.exports = router;
+129
View File
@@ -0,0 +1,129 @@
const router = require('express').Router();
const { body, query } = require('express-validator');
const prisma = require('../lib/prisma');
const auditMiddleware = require('../middleware/auditMiddleware');
const { validateRequest } = require('../middleware/errorHandler');
const statusValidation = [
body('animal_id').notEmpty().withMessage('animal_id is required').matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('animal_id must be a valid UUID'),
body('date').notEmpty().withMessage('date is required').isISO8601().withMessage('date must be a valid ISO 8601 date (YYYY-MM-DD)'),
body('experiment_description').optional().isString(),
body('vitals').optional().isString(),
body('treatment').optional().isString(),
body('notes').optional().isString(),
];
const statusUpdateValidation = [
body('date').optional().isISO8601().withMessage('date must be a valid ISO 8601 date'),
body('experiment_description').optional().isString(),
body('vitals').optional().isString(),
body('treatment').optional().isString(),
body('notes').optional().isString(),
];
// GET /api/daily-statuses?animalId=<uuid> — list for animal
router.get(
'/',
[query('animalId').optional().matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('animalId must be a valid UUID')],
validateRequest,
async (req, res, next) => {
try {
const where = req.query.animalId ? { animal_id: req.query.animalId } : {};
const statuses = await prisma.dailyStatus.findMany({
where,
orderBy: { date: 'desc' },
include: { animal: { select: { animal_name: true, animal_id_string: true } } },
});
res.json(statuses);
} catch (err) {
next(err);
}
}
);
// GET /api/daily-statuses/:id — get one
router.get('/:id', async (req, res, next) => {
try {
const status = await prisma.dailyStatus.findUnique({
where: { id: req.params.id },
include: { animal: true },
});
if (!status) return res.status(404).json({ error: 'Daily status not found' });
res.json(status);
} catch (err) {
next(err);
}
});
// POST /api/daily-statuses — create
router.post('/', statusValidation, validateRequest, async (req, res, next) => {
try {
const { animal_id, date, experiment_description, vitals, treatment, notes } = req.body;
const status = await prisma.dailyStatus.create({
data: {
animal_id,
date: new Date(date),
experiment_description,
vitals,
treatment,
notes,
},
});
await prisma.auditLog.create({
data: {
table_name: 'daily_statuses',
record_id: status.id,
action: 'CREATE',
changes: { before: null, after: status },
},
});
res.status(201).json(status);
} catch (err) {
next(err);
}
});
// PUT /api/daily-statuses/:id — update
router.put(
'/:id',
auditMiddleware('daily_statuses', prisma.dailyStatus),
statusUpdateValidation,
validateRequest,
async (req, res, next) => {
try {
const { date, experiment_description, vitals, treatment, notes } = req.body;
const data = {};
if (date !== undefined) data.date = new Date(date);
if (experiment_description !== undefined) data.experiment_description = experiment_description;
if (vitals !== undefined) data.vitals = vitals;
if (treatment !== undefined) data.treatment = treatment;
if (notes !== undefined) data.notes = notes;
const status = await prisma.dailyStatus.update({
where: { id: req.params.id },
data,
});
res.json(status);
} catch (err) {
next(err);
}
}
);
// DELETE /api/daily-statuses/:id — delete
router.delete(
'/:id',
auditMiddleware('daily_statuses', prisma.dailyStatus),
async (req, res, next) => {
try {
await prisma.dailyStatus.delete({ where: { id: req.params.id } });
res.status(204).send();
} catch (err) {
next(err);
}
}
);
module.exports = router;
+94
View File
@@ -0,0 +1,94 @@
const router = require('express').Router();
const { body } = require('express-validator');
const prisma = require('../lib/prisma');
const auditMiddleware = require('../middleware/auditMiddleware');
const { validateRequest } = require('../middleware/errorHandler');
const experimentValidation = [
body('title').trim().notEmpty().withMessage('Title is required').isLength({ max: 255 }).withMessage('Title must be ≤255 characters'),
];
// GET /api/experiments — list all
router.get('/', async (req, res, next) => {
try {
const experiments = await prisma.experiment.findMany({
orderBy: { created_at: 'desc' },
include: { _count: { select: { animals: true } } },
});
res.json(experiments);
} catch (err) {
next(err);
}
});
// GET /api/experiments/:id — get one with animals
router.get('/:id', async (req, res, next) => {
try {
const experiment = await prisma.experiment.findUnique({
where: { id: req.params.id },
include: { animals: true },
});
if (!experiment) return res.status(404).json({ error: 'Experiment not found' });
res.json(experiment);
} catch (err) {
next(err);
}
});
// POST /api/experiments — create
router.post('/', experimentValidation, validateRequest, async (req, res, next) => {
try {
const { title } = req.body;
const experiment = await prisma.experiment.create({ data: { title } });
// Write CREATE audit log
await prisma.auditLog.create({
data: {
table_name: 'experiments',
record_id: experiment.id,
action: 'CREATE',
changes: { before: null, after: experiment },
},
});
res.status(201).json(experiment);
} catch (err) {
next(err);
}
});
// PUT /api/experiments/:id — update
router.put(
'/:id',
auditMiddleware('experiments', prisma.experiment),
experimentValidation,
validateRequest,
async (req, res, next) => {
try {
const { title } = req.body;
const experiment = await prisma.experiment.update({
where: { id: req.params.id },
data: { title },
});
res.json(experiment);
} catch (err) {
next(err);
}
}
);
// DELETE /api/experiments/:id — delete
router.delete(
'/:id',
auditMiddleware('experiments', prisma.experiment),
async (req, res, next) => {
try {
await prisma.experiment.delete({ where: { id: req.params.id } });
res.status(204).send();
} catch (err) {
next(err);
}
}
);
module.exports = router;
+37
View File
@@ -0,0 +1,37 @@
// Shared Prisma mock — individual test files override methods as needed.
const prismaMock = {
experiment: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
},
animal: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
},
dailyStatus: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
},
auditLog: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
count: jest.fn(),
},
$connect: jest.fn(),
$disconnect: jest.fn(),
};
module.exports = prismaMock;
+120
View File
@@ -0,0 +1,120 @@
const request = require('supertest');
jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma'));
const prisma = require('../src/lib/prisma');
const app = require('../src/app');
const EXP_ID = 'bbbbbbbb-0000-0000-0000-000000000001';
const ANIMAL = {
id: 'cccccccc-0000-0000-0000-000000000001',
experiment_id: EXP_ID,
animal_id_string: 'M-001',
animal_name: 'Whiskers',
_count: { daily_statuses: 0 },
};
beforeEach(() => jest.clearAllMocks());
describe('GET /api/animals', () => {
it('returns animals filtered by experimentId', async () => {
prisma.animal.findMany.mockResolvedValue([ANIMAL]);
const res = await request(app).get(`/api/animals?experimentId=${EXP_ID}`);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].animal_id_string).toBe('M-001');
});
it('returns 422 for invalid UUID experimentId', async () => {
const res = await request(app).get('/api/animals?experimentId=not-a-uuid');
expect(res.status).toBe(422);
});
});
describe('GET /api/animals/:id', () => {
it('returns animal with daily_statuses', async () => {
prisma.animal.findUnique.mockResolvedValue({ ...ANIMAL, daily_statuses: [] });
const res = await request(app).get(`/api/animals/${ANIMAL.id}`);
expect(res.status).toBe(200);
expect(res.body.animal_name).toBe('Whiskers');
});
it('returns 404 for unknown animal', async () => {
prisma.animal.findUnique.mockResolvedValue(null);
const res = await request(app).get('/api/animals/00000000-0000-0000-0000-000000000000');
expect(res.status).toBe(404);
});
});
describe('POST /api/animals', () => {
it('creates animal with UUID and writes CREATE audit log', async () => {
prisma.animal.create.mockResolvedValue(ANIMAL);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app)
.post('/api/animals')
.send({ experiment_id: EXP_ID, animal_id_string: 'M-001', animal_name: 'Whiskers' });
expect(res.status).toBe(201);
expect(res.body.id).toMatch(/^[0-9a-f-]{36}$/i);
expect(prisma.auditLog.create).toHaveBeenCalled();
});
it('returns 422 when experiment_id is missing', async () => {
const res = await request(app)
.post('/api/animals')
.send({ animal_id_string: 'M-001', animal_name: 'Whiskers' });
expect(res.status).toBe(422);
expect(res.body.details).toEqual(
expect.arrayContaining([expect.objectContaining({ field: 'experiment_id' })])
);
});
it('returns 422 when experiment_id is not a UUID', async () => {
const res = await request(app)
.post('/api/animals')
.send({ experiment_id: 'bad-id', animal_id_string: 'M-001', animal_name: 'Whiskers' });
expect(res.status).toBe(422);
});
it('returns 422 when animal_name is missing', async () => {
const res = await request(app)
.post('/api/animals')
.send({ experiment_id: EXP_ID, animal_id_string: 'M-001' });
expect(res.status).toBe(422);
});
});
describe('PUT /api/animals/:id', () => {
it('updates animal and writes UPDATE audit log', async () => {
const updated = { ...ANIMAL, animal_name: 'Mr Whiskers' };
prisma.animal.findUnique
.mockResolvedValueOnce(ANIMAL)
.mockResolvedValueOnce(updated);
prisma.animal.update.mockResolvedValue(updated);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app)
.put(`/api/animals/${ANIMAL.id}`)
.send({ animal_name: 'Mr Whiskers' });
expect(res.status).toBe(200);
expect(res.body.animal_name).toBe('Mr Whiskers');
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ action: 'UPDATE' }) })
);
});
});
describe('DELETE /api/animals/:id', () => {
it('deletes animal and writes DELETE audit log', async () => {
prisma.animal.findUnique.mockResolvedValue(ANIMAL);
prisma.animal.delete.mockResolvedValue(ANIMAL);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app).delete(`/api/animals/${ANIMAL.id}`);
expect(res.status).toBe(204);
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ action: 'DELETE' }) })
);
});
});
+70
View File
@@ -0,0 +1,70 @@
const request = require('supertest');
jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma'));
const prisma = require('../src/lib/prisma');
const app = require('../src/app');
const LOG = {
id: 'ffffffff-0000-0000-0000-000000000001',
table_name: 'experiments',
record_id: 'aaaaaaaa-0000-0000-0000-000000000001',
action: 'UPDATE',
changes: { before: { title: 'Old' }, after: { title: 'New' } },
timestamp: new Date().toISOString(),
};
beforeEach(() => jest.clearAllMocks());
describe('GET /api/audit-logs', () => {
it('returns paginated logs', async () => {
prisma.auditLog.findMany.mockResolvedValue([LOG]);
prisma.auditLog.count.mockResolvedValue(1);
const res = await request(app).get('/api/audit-logs');
expect(res.status).toBe(200);
expect(res.body.logs).toHaveLength(1);
expect(res.body.total).toBe(1);
expect(res.body.logs[0].action).toBe('UPDATE');
});
it('filters by tableName', async () => {
prisma.auditLog.findMany.mockResolvedValue([LOG]);
prisma.auditLog.count.mockResolvedValue(1);
const res = await request(app).get('/api/audit-logs?tableName=experiments');
expect(res.status).toBe(200);
expect(prisma.auditLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { table_name: 'experiments' } })
);
});
it('returns 422 for invalid recordId', async () => {
const res = await request(app).get('/api/audit-logs?recordId=not-a-uuid');
expect(res.status).toBe(422);
});
it('respects limit and offset params', async () => {
prisma.auditLog.findMany.mockResolvedValue([]);
prisma.auditLog.count.mockResolvedValue(0);
const res = await request(app).get('/api/audit-logs?limit=10&offset=20');
expect(res.status).toBe(200);
expect(res.body.limit).toBe(10);
expect(res.body.offset).toBe(20);
});
});
describe('GET /api/audit-logs/:id', () => {
it('returns a single log entry', async () => {
prisma.auditLog.findUnique.mockResolvedValue(LOG);
const res = await request(app).get(`/api/audit-logs/${LOG.id}`);
expect(res.status).toBe(200);
expect(res.body.action).toBe('UPDATE');
});
it('returns 404 for unknown log id', async () => {
prisma.auditLog.findUnique.mockResolvedValue(null);
const res = await request(app).get('/api/audit-logs/00000000-0000-0000-0000-000000000000');
expect(res.status).toBe(404);
});
});
+133
View File
@@ -0,0 +1,133 @@
const request = require('supertest');
jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma'));
const prisma = require('../src/lib/prisma');
const app = require('../src/app');
const ANIMAL_ID = 'dddddddd-0000-0000-0000-000000000001';
const STATUS = {
id: 'eeeeeeee-0000-0000-0000-000000000001',
animal_id: ANIMAL_ID,
date: new Date('2024-06-01').toISOString(),
experiment_description: 'Baseline measurement',
vitals: 'HR 72, Temp 37.2',
treatment: 'Control',
notes: 'Healthy',
};
beforeEach(() => jest.clearAllMocks());
describe('GET /api/daily-statuses', () => {
it('returns statuses filtered by animalId', async () => {
prisma.dailyStatus.findMany.mockResolvedValue([STATUS]);
const res = await request(app).get(`/api/daily-statuses?animalId=${ANIMAL_ID}`);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].vitals).toBe('HR 72, Temp 37.2');
});
it('returns 422 for invalid animalId UUID', async () => {
const res = await request(app).get('/api/daily-statuses?animalId=invalid');
expect(res.status).toBe(422);
});
});
describe('GET /api/daily-statuses/:id', () => {
it('returns a single status entry', async () => {
prisma.dailyStatus.findUnique.mockResolvedValue({ ...STATUS, animal: {} });
const res = await request(app).get(`/api/daily-statuses/${STATUS.id}`);
expect(res.status).toBe(200);
expect(res.body.id).toBe(STATUS.id);
});
it('returns 404 for unknown id', async () => {
prisma.dailyStatus.findUnique.mockResolvedValue(null);
const res = await request(app).get('/api/daily-statuses/00000000-0000-0000-0000-000000000000');
expect(res.status).toBe(404);
});
});
describe('POST /api/daily-statuses', () => {
it('creates status and returns 201 with UUID', async () => {
prisma.dailyStatus.create.mockResolvedValue(STATUS);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app)
.post('/api/daily-statuses')
.send({ animal_id: ANIMAL_ID, date: '2024-06-01', vitals: 'HR 72' });
expect(res.status).toBe(201);
expect(res.body.id).toMatch(/^[0-9a-f-]{36}$/i);
expect(prisma.auditLog.create).toHaveBeenCalled();
});
it('returns 422 when animal_id is missing', async () => {
const res = await request(app)
.post('/api/daily-statuses')
.send({ date: '2024-06-01' });
expect(res.status).toBe(422);
expect(res.body.details).toEqual(
expect.arrayContaining([expect.objectContaining({ field: 'animal_id' })])
);
});
it('returns 422 when date is missing', async () => {
const res = await request(app)
.post('/api/daily-statuses')
.send({ animal_id: ANIMAL_ID });
expect(res.status).toBe(422);
expect(res.body.details).toEqual(
expect.arrayContaining([expect.objectContaining({ field: 'date' })])
);
});
it('returns 422 when date is not a valid ISO date', async () => {
const res = await request(app)
.post('/api/daily-statuses')
.send({ animal_id: ANIMAL_ID, date: 'not-a-date' });
expect(res.status).toBe(422);
});
});
describe('PUT /api/daily-statuses/:id', () => {
it('updates status and writes UPDATE audit log', async () => {
const updated = { ...STATUS, vitals: 'HR 80' };
prisma.dailyStatus.findUnique
.mockResolvedValueOnce(STATUS)
.mockResolvedValueOnce(updated);
prisma.dailyStatus.update.mockResolvedValue(updated);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app)
.put(`/api/daily-statuses/${STATUS.id}`)
.send({ vitals: 'HR 80' });
expect(res.status).toBe(200);
expect(res.body.vitals).toBe('HR 80');
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ action: 'UPDATE' }) })
);
});
it('returns 422 when date format is invalid', async () => {
prisma.dailyStatus.findUnique.mockResolvedValue(STATUS);
const res = await request(app)
.put(`/api/daily-statuses/${STATUS.id}`)
.send({ date: 'garbage' });
expect(res.status).toBe(422);
});
});
describe('DELETE /api/daily-statuses/:id', () => {
it('deletes status and writes DELETE audit log', async () => {
prisma.dailyStatus.findUnique.mockResolvedValue(STATUS);
prisma.dailyStatus.delete.mockResolvedValue(STATUS);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app).delete(`/api/daily-statuses/${STATUS.id}`);
expect(res.status).toBe(204);
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ action: 'DELETE' }) })
);
});
});
+138
View File
@@ -0,0 +1,138 @@
const request = require('supertest');
// Mock Prisma before importing app so the app uses the mock
jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma'));
const prisma = require('../src/lib/prisma');
const app = require('../src/app');
const EXPERIMENT = {
id: 'aaaaaaaa-0000-0000-0000-000000000001',
title: 'Test Study',
created_at: new Date('2024-01-01').toISOString(),
_count: { animals: 0 },
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET /api/experiments', () => {
it('returns list of experiments', async () => {
prisma.experiment.findMany.mockResolvedValue([EXPERIMENT]);
const res = await request(app).get('/api/experiments');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].title).toBe('Test Study');
});
it('returns empty array when no experiments', async () => {
prisma.experiment.findMany.mockResolvedValue([]);
const res = await request(app).get('/api/experiments');
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
});
describe('GET /api/experiments/:id', () => {
it('returns single experiment', async () => {
prisma.experiment.findUnique.mockResolvedValue({ ...EXPERIMENT, animals: [] });
const res = await request(app).get(`/api/experiments/${EXPERIMENT.id}`);
expect(res.status).toBe(200);
expect(res.body.id).toBe(EXPERIMENT.id);
});
it('returns 404 for unknown id', async () => {
prisma.experiment.findUnique.mockResolvedValue(null);
const res = await request(app).get('/api/experiments/00000000-0000-0000-0000-000000000000');
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found/i);
});
});
describe('POST /api/experiments', () => {
it('creates experiment and returns 201 with UUID', async () => {
prisma.experiment.create.mockResolvedValue(EXPERIMENT);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app)
.post('/api/experiments')
.send({ title: 'Test Study' });
expect(res.status).toBe(201);
expect(res.body.id).toBe(EXPERIMENT.id);
expect(res.body.title).toBe('Test Study');
// UUID format assertion
expect(res.body.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
);
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ action: 'CREATE' }) })
);
});
it('returns 422 when title is missing', async () => {
const res = await request(app).post('/api/experiments').send({});
expect(res.status).toBe(422);
expect(res.body.details).toEqual(
expect.arrayContaining([expect.objectContaining({ field: 'title' })])
);
});
it('returns 422 when title exceeds 255 chars', async () => {
const res = await request(app)
.post('/api/experiments')
.send({ title: 'x'.repeat(256) });
expect(res.status).toBe(422);
});
});
describe('PUT /api/experiments/:id', () => {
it('updates experiment and writes audit log', async () => {
const updated = { ...EXPERIMENT, title: 'Renamed Study' };
prisma.experiment.findUnique
.mockResolvedValueOnce(EXPERIMENT) // audit middleware fetches before-state
.mockResolvedValueOnce(updated); // audit middleware fetches after-state
prisma.experiment.update.mockResolvedValue(updated);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app)
.put(`/api/experiments/${EXPERIMENT.id}`)
.send({ title: 'Renamed Study' });
expect(res.status).toBe(200);
expect(res.body.title).toBe('Renamed Study');
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ action: 'UPDATE' }) })
);
});
it('returns 422 with empty title', async () => {
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
const res = await request(app)
.put(`/api/experiments/${EXPERIMENT.id}`)
.send({ title: '' });
expect(res.status).toBe(422);
});
});
describe('DELETE /api/experiments/:id', () => {
it('deletes experiment and writes DELETE audit log', async () => {
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
prisma.experiment.delete.mockResolvedValue(EXPERIMENT);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app).delete(`/api/experiments/${EXPERIMENT.id}`);
expect(res.status).toBe(204);
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ action: 'DELETE' }) })
);
});
});
describe('GET /health', () => {
it('returns ok status', async () => {
const res = await request(app).get('/health');
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
});
});
+6
View File
@@ -0,0 +1,6 @@
// Global setup — runs once before all test suites.
// When DATABASE_URL points to a real test DB, Prisma migrations are applied here.
// In CI/local runs without a live DB, tests use mocked Prisma via jest.mock.
module.exports = async function () {
process.env.NODE_ENV = 'test';
};
+3
View File
@@ -0,0 +1,3 @@
module.exports = async function () {
// Graceful shutdown of any open handles
};
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
['@babel/preset-react', { runtime: 'automatic' }],
],
};
+105
View File
@@ -0,0 +1,105 @@
// @ts-check
const { test, expect } = require('@playwright/test');
/**
* E2E tests run against the full Docker stack (frontend on port 52867,
* backend on port 3001). To run locally:
* docker-compose up -d
* npx playwright test
*/
const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:52867';
test.describe('UI Flow — create experiment → add animal → log status', () => {
test('full happy path', async ({ page }) => {
await page.goto(BASE_URL);
// ── Create experiment ────────────────────────────────────────
await page.click('button:has-text("New Experiment")');
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByLabel(/experiment title/i).fill('E2E Test Study');
await page.click('button:has-text("Create Experiment")');
// Modal closes and new experiment appears in the list
await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(page.getByText('E2E Test Study')).toBeVisible();
// ── Navigate into experiment ──────────────────────────────────
await page.click('text=E2E Test Study');
await expect(page.url()).toContain('/experiments/');
// ── Add animal ────────────────────────────────────────────────
await page.click('button:has-text("Add Animal")');
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByLabel(/animal id/i).fill('M-E2E-001');
await page.getByLabel(/animal name/i).fill('TestMouse');
await page.click('button:has-text("Add Animal")');
await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(page.getByText('TestMouse')).toBeVisible();
// ── Navigate into animal ──────────────────────────────────────
await page.click('text=TestMouse');
await expect(page.url()).toContain('/animals/');
// ── Log daily status ──────────────────────────────────────────
await page.click('button:has-text("Log Daily Status")');
await expect(page.getByRole('dialog')).toBeVisible();
// Date is pre-filled with today; add vitals
await page.getByLabel(/vitals/i).fill('HR 70, Temp 37.0');
await page.getByLabel(/treatment/i).fill('Control');
await page.click('button:has-text("Log Status")');
await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(page.getByText('HR 70, Temp 37.0')).toBeVisible();
});
});
test.describe('UX Flow — validation error states', () => {
test('empty experiment title shows error', async ({ page }) => {
await page.goto(BASE_URL);
await page.click('button:has-text("New Experiment")');
await page.click('button:has-text("Create Experiment")');
await expect(page.getByRole('alert')).toContainText(/title is required/i);
});
test('empty animal fields show errors', async ({ page }) => {
await page.goto(BASE_URL);
// Need an experiment to navigate into — create one first
await page.click('button:has-text("New Experiment")');
await page.getByLabel(/experiment title/i).fill('UX Test Study');
await page.click('button:has-text("Create Experiment")');
await page.click('text=UX Test Study');
await page.click('button:has-text("Add Animal")');
await page.click('button:has-text("Add Animal")'); // submit without filling
const alerts = page.getByRole('alert');
await expect(alerts).toHaveCount(2); // ID + Name errors
});
test('missing date on daily status shows error', async ({ page }) => {
await page.goto(BASE_URL);
// Create experiment + animal to get to daily status form
await page.click('button:has-text("New Experiment")');
await page.getByLabel(/experiment title/i).fill('Date Test Study');
await page.click('button:has-text("Create Experiment")');
await page.click('text=Date Test Study');
await page.click('button:has-text("Add Animal")');
await page.getByLabel(/animal id/i).fill('M-DT-001');
await page.getByLabel(/animal name/i).fill('DateTestMouse');
await page.click('button:has-text("Add Animal")');
await page.click('text=DateTestMouse');
await page.click('button:has-text("Log Daily Status")');
// Clear the date field
await page.getByLabel(/date/i).fill('');
await page.click('button:has-text("Log Status")');
await expect(page.getByRole('alert')).toContainText(/date is required/i);
});
});
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Animal Experiments Database</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+13996
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
{
"name": "experiments-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "jest --runInBand --forceExit",
"test:coverage": "jest --runInBand --forceExit --coverage",
"test:e2e": "playwright test"
},
"dependencies": {
"axios": "^1.7.2",
"date-fns": "^3.6.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.24.0"
},
"devDependencies": {
"@babel/core": "^7.24.7",
"@babel/preset-env": "^7.24.7",
"@babel/preset-react": "^7.24.6",
"@playwright/test": "^1.44.1",
"@testing-library/jest-dom": "^6.4.6",
"@testing-library/react": "^14.3.1",
"@testing-library/user-event": "^14.5.2",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.19",
"babel-jest": "^29.7.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.4",
"vite": "^4.5.3"
},
"jest": {
"testEnvironment": "jsdom",
"setupFilesAfterEnv": ["<rootDir>/tests/setup.js"],
"testMatch": ["**/tests/**/*.test.{js,jsx}"],
"transform": {
"^.+\\.[jt]sx?$": "babel-jest"
},
"moduleNameMapper": {
"\\.(css|less|scss|sass)$": "identity-obj-proxy"
}
}
}
+14
View File
@@ -0,0 +1,14 @@
const { defineConfig, devices } = require('@playwright/test');
module.exports = defineConfig({
testDir: './e2e',
timeout: 30000,
retries: process.env.CI ? 2 : 0,
use: {
baseURL: process.env.E2E_BASE_URL || 'http://localhost:52867',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
});
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+50
View File
@@ -0,0 +1,50 @@
import React from 'react';
import { Routes, Route, Link, useLocation } from 'react-router-dom';
import Dashboard from './pages/Dashboard';
import ExperimentDetail from './pages/ExperimentDetail';
import AnimalDetail from './pages/AnimalDetail';
function Navbar() {
return (
<header className="bg-white border-b border-gray-200 sticky top-0 z-40">
<div className="max-w-5xl mx-auto px-4 h-14 flex items-center justify-between">
<Link to="/" className="flex items-center gap-2 font-bold text-gray-900 hover:text-blue-700 transition-colors">
<svg className="w-6 h-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
Animal Experiments DB
</Link>
<nav className="text-sm text-gray-500">
<Link to="/" className="hover:text-blue-600 transition-colors">Dashboard</Link>
</nav>
</div>
</header>
);
}
function NotFound() {
return (
<div className="max-w-5xl mx-auto px-4 py-20 text-center">
<h1 className="text-4xl font-bold text-gray-300 mb-4">404</h1>
<p className="text-gray-500 mb-6">Page not found.</p>
<Link to="/" className="text-blue-600 hover:underline"> Return to Dashboard</Link>
</div>
);
}
export default function App() {
return (
<div className="min-h-screen bg-gray-50">
<Navbar />
<main>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/experiments/:id" element={<ExperimentDetail />} />
<Route path="/animals/:id" element={<AnimalDetail />} />
<Route path="*" element={<NotFound />} />
</Routes>
</main>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
headers: { 'Content-Type': 'application/json' },
timeout: 10000,
});
// Response interceptor — normalize errors
api.interceptors.response.use(
(res) => res,
(err) => {
const message =
err.response?.data?.error ||
err.response?.data?.message ||
err.message ||
'An unexpected error occurred';
const details = err.response?.data?.details || null;
return Promise.reject({ message, details, status: err.response?.status });
}
);
export default api;
// ── Experiments ────────────────────────────────────────────────────────────────
export const experimentsApi = {
list: () => api.get('/experiments').then((r) => r.data),
get: (id) => api.get(`/experiments/${id}`).then((r) => r.data),
create: (data) => api.post('/experiments', data).then((r) => r.data),
update: (id, data) => api.put(`/experiments/${id}`, data).then((r) => r.data),
delete: (id) => api.delete(`/experiments/${id}`),
};
// ── Animals ───────────────────────────────────────────────────────────────────
export const animalsApi = {
list: (experimentId) =>
api.get('/animals', { params: { experimentId } }).then((r) => r.data),
get: (id) => api.get(`/animals/${id}`).then((r) => r.data),
create: (data) => api.post('/animals', data).then((r) => r.data),
update: (id, data) => api.put(`/animals/${id}`, data).then((r) => r.data),
delete: (id) => api.delete(`/animals/${id}`),
};
// ── Daily Statuses ─────────────────────────────────────────────────────────────
export const dailyStatusesApi = {
list: (animalId) =>
api.get('/daily-statuses', { params: { animalId } }).then((r) => r.data),
get: (id) => api.get(`/daily-statuses/${id}`).then((r) => r.data),
create: (data) => api.post('/daily-statuses', data).then((r) => r.data),
update: (id, data) => api.put(`/daily-statuses/${id}`, data).then((r) => r.data),
delete: (id) => api.delete(`/daily-statuses/${id}`),
};
// ── Audit Logs ────────────────────────────────────────────────────────────────
export const auditLogsApi = {
list: (params) => api.get('/audit-logs', { params }).then((r) => r.data),
};
+96
View File
@@ -0,0 +1,96 @@
import React, { useState, useEffect } from 'react';
import Button from './ui/Button';
import FormField, { Input } from './ui/FormField';
import Alert from './ui/Alert';
import { animalsApi } from '../api/client';
export default function AnimalForm({ existing, experimentId, onSuccess, onCancel }) {
const [fields, setFields] = useState({
animal_id_string: existing?.animal_id_string ?? '',
animal_name: existing?.animal_name ?? '',
});
const [errors, setErrors] = useState({});
const [apiError, setApiError] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
setFields({
animal_id_string: existing?.animal_id_string ?? '',
animal_name: existing?.animal_name ?? '',
});
setErrors({});
setApiError(null);
}, [existing]);
const set = (k) => (e) => setFields((f) => ({ ...f, [k]: e.target.value }));
function validate() {
const e = {};
if (!fields.animal_id_string.trim()) e.animal_id_string = 'Animal ID is required';
if (!fields.animal_name.trim()) e.animal_name = 'Animal name is required';
setErrors(e);
return Object.keys(e).length === 0;
}
async function handleSubmit(e) {
e.preventDefault();
if (!validate()) return;
setLoading(true);
setApiError(null);
try {
const payload = {
animal_id_string: fields.animal_id_string.trim(),
animal_name: fields.animal_name.trim(),
};
const result = existing
? await animalsApi.update(existing.id, payload)
: await animalsApi.create({ ...payload, experiment_id: experimentId });
onSuccess(result);
} catch (err) {
setApiError(err);
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit} noValidate className="space-y-4">
{apiError && (
<Alert
type="error"
message={apiError.message}
details={apiError.details}
onDismiss={() => setApiError(null)}
/>
)}
<FormField label="Animal ID" name="animal_id_string" error={errors.animal_id_string} required>
<Input
name="animal_id_string"
value={fields.animal_id_string}
onChange={set('animal_id_string')}
placeholder="e.g. M-001"
required
disabled={loading}
/>
</FormField>
<FormField label="Animal Name" name="animal_name" error={errors.animal_name} required>
<Input
name="animal_name"
value={fields.animal_name}
onChange={set('animal_name')}
placeholder="e.g. Whiskers"
required
disabled={loading}
/>
</FormField>
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>
Cancel
</Button>
<Button type="submit" loading={loading}>
{existing ? 'Save Changes' : 'Add Animal'}
</Button>
</div>
</form>
);
}
+113
View File
@@ -0,0 +1,113 @@
import React, { useEffect, useState } from 'react';
import { auditLogsApi } from '../api/client';
import { format } from 'date-fns';
function DiffView({ changes }) {
const { before, after } = changes || {};
if (!before && after) {
return <span className="text-green-700 text-xs">Created: {JSON.stringify(after, null, 2)}</span>;
}
if (before && !after) {
return <span className="text-red-700 text-xs">Deleted record</span>;
}
if (before && after) {
const keys = Object.keys({ ...before, ...after }).filter(
(k) => JSON.stringify(before[k]) !== JSON.stringify(after[k])
);
if (keys.length === 0) return <span className="text-gray-400 text-xs">No field changes detected</span>;
return (
<dl className="text-xs space-y-1">
{keys.map((k) => (
<div key={k} className="grid grid-cols-3 gap-2">
<dt className="font-medium text-gray-600">{k}</dt>
<dd className="text-red-600 line-through col-span-1 truncate">{String(before[k] ?? '')}</dd>
<dd className="text-green-700 col-span-1 truncate">{String(after[k] ?? '')}</dd>
</div>
))}
</dl>
);
}
return null;
}
const ACTION_BADGE = {
CREATE: 'bg-green-100 text-green-800',
UPDATE: 'bg-yellow-100 text-yellow-800',
DELETE: 'bg-red-100 text-red-800',
};
export default function AuditLogSection({ tableName, recordId }) {
const [logs, setLogs] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [expanded, setExpanded] = useState(null);
useEffect(() => {
setLoading(true);
auditLogsApi
.list({ tableName, recordId, limit: 50 })
.then(({ logs, total }) => {
setLogs(logs);
setTotal(total);
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [tableName, recordId]);
return (
<section aria-label="Modification History" className="mt-8">
<h3 className="text-base font-semibold text-gray-800 mb-3 flex items-center gap-2">
<svg className="w-4 h-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Modification History
{total > 0 && (
<span className="text-xs font-normal text-gray-500">({total} entries)</span>
)}
</h3>
{loading && <p className="text-sm text-gray-500">Loading history</p>}
{error && <p className="text-sm text-red-600">{error}</p>}
{!loading && !error && logs.length === 0 && (
<p className="text-sm text-gray-400 italic">No modification history yet.</p>
)}
{logs.length > 0 && (
<div className="border border-gray-200 rounded-lg overflow-hidden divide-y divide-gray-100">
{logs.map((log) => (
<div key={log.id} className="bg-white">
<button
className="w-full text-left px-4 py-3 flex items-center gap-3 hover:bg-gray-50 transition-colors"
onClick={() => setExpanded(expanded === log.id ? null : log.id)}
aria-expanded={expanded === log.id}
>
<span className={`shrink-0 px-2 py-0.5 rounded text-xs font-semibold ${ACTION_BADGE[log.action] || 'bg-gray-100 text-gray-700'}`}>
{log.action}
</span>
<span className="text-xs text-gray-500 shrink-0">
{format(new Date(log.timestamp), 'yyyy-MM-dd HH:mm:ss')}
</span>
<span className="text-xs text-gray-400 truncate flex-1">
{log.table_name} / {log.record_id}
</span>
<svg
className={`w-4 h-4 text-gray-400 shrink-0 transition-transform ${expanded === log.id ? 'rotate-180' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{expanded === log.id && (
<div className="px-4 pb-3 bg-gray-50 border-t border-gray-100">
<DiffView changes={log.changes} />
</div>
)}
</div>
))}
</div>
)}
</section>
);
}
+139
View File
@@ -0,0 +1,139 @@
import React, { useState, useEffect } from 'react';
import { format } from 'date-fns';
import Button from './ui/Button';
import FormField, { Input, Textarea } from './ui/FormField';
import Alert from './ui/Alert';
import { dailyStatusesApi } from '../api/client';
export default function DailyStatusForm({ existing, animalId, onSuccess, onCancel }) {
const today = format(new Date(), 'yyyy-MM-dd');
const [fields, setFields] = useState({
date: existing ? format(new Date(existing.date), 'yyyy-MM-dd') : today,
experiment_description: existing?.experiment_description ?? '',
vitals: existing?.vitals ?? '',
treatment: existing?.treatment ?? '',
notes: existing?.notes ?? '',
});
const [errors, setErrors] = useState({});
const [apiError, setApiError] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
setFields({
date: existing ? format(new Date(existing.date), 'yyyy-MM-dd') : today,
experiment_description: existing?.experiment_description ?? '',
vitals: existing?.vitals ?? '',
treatment: existing?.treatment ?? '',
notes: existing?.notes ?? '',
});
setErrors({});
setApiError(null);
}, [existing]);
const set = (k) => (e) => setFields((f) => ({ ...f, [k]: e.target.value }));
function validate() {
const e = {};
if (!fields.date) {
e.date = 'Date is required';
} else if (!/^\d{4}-\d{2}-\d{2}$/.test(fields.date)) {
e.date = 'Date must be in YYYY-MM-DD format';
} else {
const d = new Date(fields.date);
if (isNaN(d.getTime())) e.date = 'Invalid date';
}
setErrors(e);
return Object.keys(e).length === 0;
}
async function handleSubmit(e) {
e.preventDefault();
if (!validate()) return;
setLoading(true);
setApiError(null);
try {
const payload = {
date: fields.date,
experiment_description: fields.experiment_description || null,
vitals: fields.vitals || null,
treatment: fields.treatment || null,
notes: fields.notes || null,
};
const result = existing
? await dailyStatusesApi.update(existing.id, payload)
: await dailyStatusesApi.create({ ...payload, animal_id: animalId });
onSuccess(result);
} catch (err) {
setApiError(err);
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit} noValidate className="space-y-4">
{apiError && (
<Alert
type="error"
message={apiError.message}
details={apiError.details}
onDismiss={() => setApiError(null)}
/>
)}
<FormField label="Date" name="date" error={errors.date} required>
<Input
type="date"
name="date"
value={fields.date}
onChange={set('date')}
required
disabled={loading}
/>
</FormField>
<FormField label="Experiment Description" name="experiment_description">
<Textarea
name="experiment_description"
value={fields.experiment_description}
onChange={set('experiment_description')}
placeholder="Describe the experimental conditions…"
disabled={loading}
/>
</FormField>
<FormField label="Vitals" name="vitals">
<Input
name="vitals"
value={fields.vitals}
onChange={set('vitals')}
placeholder="e.g. HR 72bpm, Temp 37.2°C, Weight 280g"
disabled={loading}
/>
</FormField>
<FormField label="Treatment" name="treatment">
<Input
name="treatment"
value={fields.treatment}
onChange={set('treatment')}
placeholder="e.g. Drug X — 10mg/kg oral"
disabled={loading}
/>
</FormField>
<FormField label="Notes" name="notes">
<Textarea
name="notes"
value={fields.notes}
onChange={set('notes')}
placeholder="Any additional observations…"
disabled={loading}
/>
</FormField>
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>
Cancel
</Button>
<Button type="submit" loading={loading}>
{existing ? 'Save Changes' : 'Log Status'}
</Button>
</div>
</form>
);
}
@@ -0,0 +1,74 @@
import React, { useState, useEffect } from 'react';
import Button from './ui/Button';
import FormField, { Input } from './ui/FormField';
import Alert from './ui/Alert';
import { experimentsApi } from '../api/client';
export default function ExperimentForm({ existing, onSuccess, onCancel }) {
const [title, setTitle] = useState(existing?.title ?? '');
const [errors, setErrors] = useState({});
const [apiError, setApiError] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
setTitle(existing?.title ?? '');
setErrors({});
setApiError(null);
}, [existing]);
function validate() {
const e = {};
if (!title.trim()) e.title = 'Title is required';
else if (title.trim().length > 255) e.title = 'Title must be 255 characters or fewer';
setErrors(e);
return Object.keys(e).length === 0;
}
async function handleSubmit(e) {
e.preventDefault();
if (!validate()) return;
setLoading(true);
setApiError(null);
try {
const result = existing
? await experimentsApi.update(existing.id, { title: title.trim() })
: await experimentsApi.create({ title: title.trim() });
onSuccess(result);
} catch (err) {
setApiError(err);
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit} noValidate className="space-y-4">
{apiError && (
<Alert
type="error"
message={apiError.message}
details={apiError.details}
onDismiss={() => setApiError(null)}
/>
)}
<FormField label="Experiment Title" name="title" error={errors.title} required>
<Input
name="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Drug Efficacy Study Q3"
required
disabled={loading}
/>
</FormField>
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>
Cancel
</Button>
<Button type="submit" loading={loading}>
{existing ? 'Save Changes' : 'Create Experiment'}
</Button>
</div>
</form>
);
}
+33
View File
@@ -0,0 +1,33 @@
import React from 'react';
const styles = {
error: 'bg-red-50 border-red-200 text-red-800',
success: 'bg-green-50 border-green-200 text-green-800',
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
info: 'bg-blue-50 border-blue-200 text-blue-800',
};
export default function Alert({ type = 'error', message, details, onDismiss }) {
if (!message) return null;
return (
<div role="alert" className={`rounded-md border px-4 py-3 text-sm ${styles[type]}`}>
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<p className="font-medium">{message}</p>
{details && (
<ul className="mt-1 list-disc list-inside space-y-0.5 text-xs opacity-80">
{details.map((d, i) => (
<li key={i}>{d.field ? `${d.field}: ${d.message}` : d.message}</li>
))}
</ul>
)}
</div>
{onDismiss && (
<button onClick={onDismiss} className="shrink-0 opacity-60 hover:opacity-100" aria-label="Dismiss">
</button>
)}
</div>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import React from 'react';
const variants = {
primary: 'bg-blue-600 hover:bg-blue-700 text-white border-transparent',
secondary: 'bg-white hover:bg-gray-50 text-gray-700 border-gray-300',
danger: 'bg-red-600 hover:bg-red-700 text-white border-transparent',
ghost: 'bg-transparent hover:bg-gray-100 text-gray-600 border-transparent',
};
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
};
export default function Button({
children,
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
onClick,
type = 'button',
className = '',
...rest
}) {
return (
<button
type={type}
disabled={disabled || loading}
onClick={onClick}
className={`
inline-flex items-center gap-1.5 rounded-md border font-medium
transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1
disabled:opacity-50 disabled:cursor-not-allowed
${variants[variant]} ${sizes[size]} ${className}
`}
{...rest}
>
{loading && (
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" />
</svg>
)}
{children}
</button>
);
}
@@ -0,0 +1,19 @@
import React from 'react';
import Modal from './Modal';
import Button from './Button';
export default function ConfirmDialog({ isOpen, onClose, onConfirm, title, message, loading }) {
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
<p className="text-sm text-gray-600 mb-6">{message}</p>
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button variant="danger" onClick={onConfirm} loading={loading}>
Delete
</Button>
</div>
</Modal>
);
}
+58
View File
@@ -0,0 +1,58 @@
import React from 'react';
export default function FormField({ label, name, error, required, children }) {
return (
<div className="flex flex-col gap-1">
<label htmlFor={name} className="text-sm font-medium text-gray-700">
{label}
{required && <span className="text-red-500 ml-1" aria-hidden="true">*</span>}
</label>
{children}
{error && (
<p role="alert" className="text-sm text-red-600">{error}</p>
)}
</div>
);
}
export function Input({ id, name, type = 'text', value, onChange, placeholder, required, disabled, className = '', ...rest }) {
return (
<input
id={id || name}
name={name}
type={type}
value={value}
onChange={onChange}
placeholder={placeholder}
required={required}
disabled={disabled}
className={`
w-full rounded-md border border-gray-300 px-3 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
disabled:bg-gray-100 disabled:cursor-not-allowed
${className}
`}
{...rest}
/>
);
}
export function Textarea({ id, name, value, onChange, placeholder, rows = 3, disabled, className = '' }) {
return (
<textarea
id={id || name}
name={name}
value={value}
onChange={onChange}
placeholder={placeholder}
rows={rows}
disabled={disabled}
className={`
w-full rounded-md border border-gray-300 px-3 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
disabled:bg-gray-100 disabled:cursor-not-allowed resize-y
${className}
`}
/>
);
}
+40
View File
@@ -0,0 +1,40 @@
import React, { useEffect } from 'react';
export default function Modal({ isOpen, onClose, title, children, size = 'md' }) {
useEffect(() => {
if (!isOpen) return;
const handleKey = (e) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, [isOpen, onClose]);
if (!isOpen) return null;
const widths = { sm: 'max-w-md', md: 'max-w-lg', lg: 'max-w-2xl', xl: 'max-w-4xl' };
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div className={`bg-white rounded-xl shadow-2xl w-full mx-4 ${widths[size]} max-h-[90vh] flex flex-col`}>
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 id="modal-title" className="text-lg font-semibold text-gray-900">{title}</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 rounded p-1 transition-colors"
aria-label="Close modal"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-6 overflow-y-auto flex-1">{children}</div>
</div>
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-gray-50 text-gray-900 antialiased;
}
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
+175
View File
@@ -0,0 +1,175 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { animalsApi, dailyStatusesApi } from '../api/client';
import { format } from 'date-fns';
import Button from '../components/ui/Button';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import DailyStatusForm from '../components/DailyStatusForm';
import Alert from '../components/ui/Alert';
import AuditLogSection from '../components/AuditLogSection';
export default function AnimalDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [animal, setAnimal] = useState(null);
const [statuses, setStatuses] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [successMsg, setSuccessMsg] = useState(null);
const [showAddStatus, setShowAddStatus] = useState(false);
const [editStatus, setEditStatus] = useState(null);
const [deleteStatus, setDeleteStatus] = useState(null);
const [deleteLoading, setDeleteLoading] = useState(false);
async function load() {
setLoading(true);
setError(null);
try {
const [animalData, statusData] = await Promise.all([
animalsApi.get(id),
dailyStatusesApi.list(id),
]);
setAnimal(animalData);
setStatuses(statusData);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, [id]);
function flash(msg) {
setSuccessMsg(msg);
setTimeout(() => setSuccessMsg(null), 3000);
}
function handleStatusSaved() {
setShowAddStatus(false);
setEditStatus(null);
load();
flash(editStatus ? 'Daily status updated.' : 'Daily status logged.');
}
async function handleDeleteStatus() {
setDeleteLoading(true);
try {
await dailyStatusesApi.delete(deleteStatus.id);
setDeleteStatus(null);
load();
flash('Daily status removed.');
} catch (err) {
setError(err.message);
setDeleteStatus(null);
} finally {
setDeleteLoading(false);
}
}
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400">Loading</div>;
if (error) return (
<div className="max-w-5xl mx-auto px-4 py-8">
<Alert type="error" message={error} />
<Button variant="secondary" className="mt-4" onClick={() => navigate(-1)}> Back</Button>
</div>
);
return (
<div className="max-w-5xl mx-auto px-4 py-8">
{/* Breadcrumb */}
<nav className="text-sm text-gray-500 mb-4">
<Link to="/" className="hover:text-blue-600">Experiments</Link>
<span className="mx-2">/</span>
<Link to={`/experiments/${animal.experiment_id}`} className="hover:text-blue-600">
Experiment
</Link>
<span className="mx-2">/</span>
<span className="text-gray-900 font-medium">{animal.animal_name}</span>
</nav>
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">{animal.animal_name}</h1>
<p className="text-sm text-gray-500 mt-0.5">
ID: <span className="font-mono">{animal.animal_id_string}</span>
{' · '}
{statuses.length} daily status {statuses.length !== 1 ? 'entries' : 'entry'}
</p>
</div>
<Button onClick={() => setShowAddStatus(true)}>+ Log Daily Status</Button>
</div>
{successMsg && <Alert type="success" message={successMsg} />}
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
{statuses.length === 0 ? (
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl">
<p className="text-gray-400 mb-3">No daily statuses logged yet.</p>
<Button onClick={() => setShowAddStatus(true)}>+ Log First Status</Button>
</div>
) : (
<div className="space-y-3">
{statuses.map((s) => (
<div key={s.id} className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-center justify-between mb-3">
<span className="font-semibold text-gray-900">
{format(new Date(s.date), 'MMMM d, yyyy')}
</span>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => setEditStatus(s)}>Edit</Button>
<Button size="sm" variant="danger" onClick={() => setDeleteStatus(s)}>Delete</Button>
</div>
</div>
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
{[
['Experiment Description', s.experiment_description],
['Vitals', s.vitals],
['Treatment', s.treatment],
['Notes', s.notes],
]
.filter(([, v]) => v)
.map(([label, value]) => (
<div key={label} className="flex flex-col">
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">{label}</dt>
<dd className="text-gray-800 mt-0.5 whitespace-pre-wrap">{value}</dd>
</div>
))}
</dl>
{!s.experiment_description && !s.vitals && !s.treatment && !s.notes && (
<p className="text-sm text-gray-400 italic">No details recorded for this date.</p>
)}
</div>
))}
</div>
)}
<AuditLogSection tableName="daily_statuses" recordId={undefined} />
<Modal isOpen={showAddStatus} onClose={() => setShowAddStatus(false)} title="Log Daily Status" size="lg">
<DailyStatusForm animalId={id} onSuccess={handleStatusSaved} onCancel={() => setShowAddStatus(false)} />
</Modal>
<Modal isOpen={!!editStatus} onClose={() => setEditStatus(null)} title="Edit Daily Status" size="lg">
<DailyStatusForm
existing={editStatus}
animalId={id}
onSuccess={handleStatusSaved}
onCancel={() => setEditStatus(null)}
/>
</Modal>
<ConfirmDialog
isOpen={!!deleteStatus}
onClose={() => setDeleteStatus(null)}
onConfirm={handleDeleteStatus}
loading={deleteLoading}
title="Delete Daily Status"
message={`Delete the status entry for ${deleteStatus ? format(new Date(deleteStatus.date), 'MMMM d, yyyy') : ''}?`}
/>
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { experimentsApi } from '../api/client';
import Button from '../components/ui/Button';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import ExperimentForm from '../components/ExperimentForm';
import Alert from '../components/ui/Alert';
import { format } from 'date-fns';
import AuditLogSection from '../components/AuditLogSection';
export default function Dashboard() {
const navigate = useNavigate();
const [experiments, setExperiments] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [editTarget, setEditTarget] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [successMsg, setSuccessMsg] = useState(null);
async function loadExperiments() {
setLoading(true);
setError(null);
try {
const data = await experimentsApi.list();
setExperiments(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
useEffect(() => { loadExperiments(); }, []);
function flash(msg) {
setSuccessMsg(msg);
setTimeout(() => setSuccessMsg(null), 3000);
}
function handleCreated(exp) {
setShowCreateModal(false);
loadExperiments();
flash(`Experiment "${exp.title}" created.`);
}
function handleUpdated(exp) {
setEditTarget(null);
loadExperiments();
flash(`Experiment "${exp.title}" updated.`);
}
async function handleDelete() {
setDeleteLoading(true);
try {
await experimentsApi.delete(deleteTarget.id);
setDeleteTarget(null);
loadExperiments();
flash('Experiment deleted.');
} catch (err) {
setError(err.message);
setDeleteTarget(null);
} finally {
setDeleteLoading(false);
}
}
return (
<div className="max-w-5xl mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Experiments</h1>
<p className="text-sm text-gray-500 mt-0.5">Manage all animal experimental studies</p>
</div>
<Button onClick={() => setShowCreateModal(true)}>
+ New Experiment
</Button>
</div>
{successMsg && <Alert type="success" message={successMsg} className="mb-4" />}
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
{loading && (
<div className="text-center py-16 text-gray-400">Loading experiments</div>
)}
{!loading && experiments.length === 0 && (
<div className="text-center py-20 border-2 border-dashed border-gray-200 rounded-xl">
<p className="text-gray-400 text-lg mb-2">No experiments yet</p>
<p className="text-gray-400 text-sm mb-4">Create your first experiment to get started.</p>
<Button onClick={() => setShowCreateModal(true)}>+ New Experiment</Button>
</div>
)}
{!loading && experiments.length > 0 && (
<div className="grid gap-4">
{experiments.map((exp) => (
<div
key={exp.id}
className="bg-white border border-gray-200 rounded-xl p-5 flex items-center justify-between hover:border-blue-300 hover:shadow-sm transition-all cursor-pointer group"
onClick={() => navigate(`/experiments/${exp.id}`)}
>
<div>
<h2 className="font-semibold text-gray-900 group-hover:text-blue-700 transition-colors">
{exp.title}
</h2>
<div className="text-sm text-gray-500 mt-0.5 flex gap-4">
<span>{exp._count?.animals ?? 0} animal{exp._count?.animals !== 1 ? 's' : ''}</span>
<span>Created {format(new Date(exp.created_at), 'MMM d, yyyy')}</span>
</div>
</div>
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<Button
size="sm"
variant="secondary"
onClick={() => setEditTarget(exp)}
>
Edit
</Button>
<Button
size="sm"
variant="danger"
onClick={() => setDeleteTarget(exp)}
>
Delete
</Button>
</div>
</div>
))}
</div>
)}
<AuditLogSection tableName="experiments" />
{/* Create modal */}
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title="New Experiment">
<ExperimentForm onSuccess={handleCreated} onCancel={() => setShowCreateModal(false)} />
</Modal>
{/* Edit modal */}
<Modal isOpen={!!editTarget} onClose={() => setEditTarget(null)} title="Edit Experiment">
<ExperimentForm
existing={editTarget}
onSuccess={handleUpdated}
onCancel={() => setEditTarget(null)}
/>
</Modal>
{/* Delete confirmation */}
<ConfirmDialog
isOpen={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete}
loading={deleteLoading}
title="Delete Experiment"
message={`Are you sure you want to delete "${deleteTarget?.title}"? All associated animals and daily statuses will be permanently removed.`}
/>
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { experimentsApi, animalsApi } from '../api/client';
import Button from '../components/ui/Button';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import AnimalForm from '../components/AnimalForm';
import Alert from '../components/ui/Alert';
import AuditLogSection from '../components/AuditLogSection';
export default function ExperimentDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [experiment, setExperiment] = useState(null);
const [animals, setAnimals] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [successMsg, setSuccessMsg] = useState(null);
const [showAddAnimal, setShowAddAnimal] = useState(false);
const [editAnimal, setEditAnimal] = useState(null);
const [deleteAnimal, setDeleteAnimal] = useState(null);
const [deleteLoading, setDeleteLoading] = useState(false);
async function load() {
setLoading(true);
setError(null);
try {
const [exp, anims] = await Promise.all([
experimentsApi.get(id),
animalsApi.list(id),
]);
setExperiment(exp);
setAnimals(anims);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, [id]);
function flash(msg) {
setSuccessMsg(msg);
setTimeout(() => setSuccessMsg(null), 3000);
}
function handleAnimalSaved(animal) {
setShowAddAnimal(false);
setEditAnimal(null);
load();
flash(editAnimal ? `Animal "${animal.animal_name}" updated.` : `Animal "${animal.animal_name}" added.`);
}
async function handleDeleteAnimal() {
setDeleteLoading(true);
try {
await animalsApi.delete(deleteAnimal.id);
setDeleteAnimal(null);
load();
flash('Animal removed.');
} catch (err) {
setError(err.message);
setDeleteAnimal(null);
} finally {
setDeleteLoading(false);
}
}
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400">Loading</div>;
if (error) return (
<div className="max-w-5xl mx-auto px-4 py-8">
<Alert type="error" message={error} />
<Button variant="secondary" className="mt-4" onClick={() => navigate('/')}> Back</Button>
</div>
);
return (
<div className="max-w-5xl mx-auto px-4 py-8">
{/* Breadcrumb */}
<nav className="text-sm text-gray-500 mb-4">
<Link to="/" className="hover:text-blue-600">Experiments</Link>
<span className="mx-2">/</span>
<span className="text-gray-900 font-medium">{experiment.title}</span>
</nav>
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">{experiment.title}</h1>
<p className="text-sm text-gray-500 mt-0.5">
{animals.length} animal{animals.length !== 1 ? 's' : ''} enrolled
</p>
</div>
<Button onClick={() => setShowAddAnimal(true)}>+ Add Animal</Button>
</div>
{successMsg && <Alert type="success" message={successMsg} />}
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
{animals.length === 0 ? (
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl">
<p className="text-gray-400 mb-3">No animals enrolled in this experiment yet.</p>
<Button onClick={() => setShowAddAnimal(true)}>+ Add First Animal</Button>
</div>
) : (
<div className="grid gap-3">
{animals.map((animal) => (
<div
key={animal.id}
className="bg-white border border-gray-200 rounded-xl p-4 flex items-center justify-between hover:border-blue-300 hover:shadow-sm transition-all cursor-pointer group"
onClick={() => navigate(`/animals/${animal.id}`)}
>
<div>
<div className="font-semibold text-gray-900 group-hover:text-blue-700 transition-colors">
{animal.animal_name}
</div>
<div className="text-sm text-gray-500">
ID: {animal.animal_id_string} · {animal._count?.daily_statuses ?? 0} daily status entries
</div>
</div>
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<Button size="sm" variant="secondary" onClick={() => setEditAnimal(animal)}>Edit</Button>
<Button size="sm" variant="danger" onClick={() => setDeleteAnimal(animal)}>Remove</Button>
</div>
</div>
))}
</div>
)}
<AuditLogSection tableName="animals" recordId={undefined} />
<Modal isOpen={showAddAnimal} onClose={() => setShowAddAnimal(false)} title="Add Animal">
<AnimalForm experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setShowAddAnimal(false)} />
</Modal>
<Modal isOpen={!!editAnimal} onClose={() => setEditAnimal(null)} title="Edit Animal">
<AnimalForm
existing={editAnimal}
experimentId={id}
onSuccess={handleAnimalSaved}
onCancel={() => setEditAnimal(null)}
/>
</Modal>
<ConfirmDialog
isOpen={!!deleteAnimal}
onClose={() => setDeleteAnimal(null)}
onConfirm={handleDeleteAnimal}
loading={deleteLoading}
title="Remove Animal"
message={`Remove "${deleteAnimal?.animal_name}" and all its daily statuses from this experiment?`}
/>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./index.html', './src/**/*.{js,jsx}'],
theme: {
extend: {
colors: {
brand: {
50: '#eff6ff',
100: '#dbeafe',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
900: '#1e3a8a',
},
},
},
},
plugins: [],
};
+73
View File
@@ -0,0 +1,73 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AnimalForm from '../src/components/AnimalForm';
import * as client from '../src/api/client';
jest.mock('../src/api/client', () => ({
animalsApi: {
create: jest.fn(),
update: jest.fn(),
},
}));
const EXP_ID = 'exp-001';
const onSuccess = jest.fn();
const onCancel = jest.fn();
beforeEach(() => jest.clearAllMocks());
describe('AnimalForm', () => {
it('renders Animal ID and Animal Name fields', () => {
render(<AnimalForm experimentId={EXP_ID} onSuccess={onSuccess} onCancel={onCancel} />);
expect(screen.getByLabelText(/animal id/i)).toBeInTheDocument();
expect(screen.getByLabelText(/animal name/i)).toBeInTheDocument();
});
it('shows required field errors on empty submit', async () => {
render(<AnimalForm experimentId={EXP_ID} onSuccess={onSuccess} onCancel={onCancel} />);
fireEvent.click(screen.getByRole('button', { name: /add animal/i }));
expect(await screen.findAllByRole('alert')).toHaveLength(2);
expect(client.animalsApi.create).not.toHaveBeenCalled();
});
it('calls create with correct payload', async () => {
const animal = { id: 'a-1', animal_id_string: 'M-001', animal_name: 'Whiskers', experiment_id: EXP_ID };
client.animalsApi.create.mockResolvedValue(animal);
render(<AnimalForm experimentId={EXP_ID} onSuccess={onSuccess} onCancel={onCancel} />);
await userEvent.type(screen.getByLabelText(/animal id/i), 'M-001');
await userEvent.type(screen.getByLabelText(/animal name/i), 'Whiskers');
fireEvent.click(screen.getByRole('button', { name: /add animal/i }));
await waitFor(() =>
expect(client.animalsApi.create).toHaveBeenCalledWith({
animal_id_string: 'M-001',
animal_name: 'Whiskers',
experiment_id: EXP_ID,
})
);
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(animal));
});
it('pre-fills fields for existing animal and uses update API', async () => {
const existing = { id: 'a-1', animal_id_string: 'M-001', animal_name: 'Whiskers' };
const updated = { ...existing, animal_name: 'Mr Whiskers' };
client.animalsApi.update.mockResolvedValue(updated);
render(<AnimalForm existing={existing} experimentId={EXP_ID} onSuccess={onSuccess} onCancel={onCancel} />);
expect(screen.getByDisplayValue('Whiskers')).toBeInTheDocument();
const nameInput = screen.getByLabelText(/animal name/i);
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'Mr Whiskers');
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
await waitFor(() =>
expect(client.animalsApi.update).toHaveBeenCalledWith('a-1', {
animal_id_string: 'M-001',
animal_name: 'Mr Whiskers',
})
);
});
});
+42
View File
@@ -0,0 +1,42 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Button from '../src/components/ui/Button';
describe('Button component', () => {
it('renders with children text', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
it('calls onClick when clicked', () => {
const onClick = jest.fn();
render(<Button onClick={onClick}>Click</Button>);
fireEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Submit</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('is disabled and shows spinner when loading', () => {
render(<Button loading>Submit</Button>);
const btn = screen.getByRole('button');
expect(btn).toBeDisabled();
expect(btn.querySelector('svg')).toBeTruthy();
});
it('renders danger variant', () => {
render(<Button variant="danger">Delete</Button>);
const btn = screen.getByRole('button');
expect(btn.className).toContain('bg-red-600');
});
it('does not call onClick when disabled', () => {
const onClick = jest.fn();
render(<Button disabled onClick={onClick}>Click</Button>);
fireEvent.click(screen.getByRole('button'));
expect(onClick).not.toHaveBeenCalled();
});
});
+93
View File
@@ -0,0 +1,93 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import DailyStatusForm from '../src/components/DailyStatusForm';
import * as client from '../src/api/client';
jest.mock('../src/api/client', () => ({
dailyStatusesApi: {
create: jest.fn(),
update: jest.fn(),
},
}));
const ANIMAL_ID = 'animal-001';
const onSuccess = jest.fn();
const onCancel = jest.fn();
beforeEach(() => jest.clearAllMocks());
describe('DailyStatusForm', () => {
it('renders date field (required) and optional fields', () => {
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />);
expect(screen.getByLabelText(/date/i)).toBeInTheDocument();
expect(screen.getByLabelText(/vitals/i)).toBeInTheDocument();
expect(screen.getByLabelText(/treatment/i)).toBeInTheDocument();
expect(screen.getByLabelText(/notes/i)).toBeInTheDocument();
expect(screen.getByLabelText(/experiment description/i)).toBeInTheDocument();
});
it('pre-fills date with today by default', () => {
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />);
const today = new Date().toISOString().slice(0, 10);
expect(screen.getByLabelText(/date/i)).toHaveValue(today);
});
it('shows error when date is cleared', async () => {
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />);
const dateInput = screen.getByLabelText(/date/i);
await userEvent.clear(dateInput);
fireEvent.click(screen.getByRole('button', { name: /log status/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/date is required/i);
expect(client.dailyStatusesApi.create).not.toHaveBeenCalled();
});
it('calls create with correct payload including animal_id', async () => {
const status = { id: 's-1', animal_id: ANIMAL_ID, date: '2024-06-01', vitals: 'HR 72' };
client.dailyStatusesApi.create.mockResolvedValue(status);
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />);
const dateInput = screen.getByLabelText(/date/i);
await userEvent.clear(dateInput);
await userEvent.type(dateInput, '2024-06-01');
await userEvent.type(screen.getByLabelText(/vitals/i), 'HR 72');
fireEvent.click(screen.getByRole('button', { name: /log status/i }));
await waitFor(() =>
expect(client.dailyStatusesApi.create).toHaveBeenCalledWith(
expect.objectContaining({ animal_id: ANIMAL_ID, vitals: 'HR 72' })
)
);
await waitFor(() => expect(onSuccess).toHaveBeenCalled());
});
it('shows "Save Changes" and pre-fills fields for existing status', () => {
const existing = {
id: 's-1',
date: '2024-06-01T00:00:00.000Z',
vitals: 'HR 72',
treatment: 'Control',
notes: 'Normal',
experiment_description: 'Baseline',
};
render(
<DailyStatusForm
existing={existing}
animalId={ANIMAL_ID}
onSuccess={onSuccess}
onCancel={onCancel}
/>
);
expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument();
expect(screen.getByDisplayValue('HR 72')).toBeInTheDocument();
});
it('calls onCancel when Cancel is clicked', () => {
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />);
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(onCancel).toHaveBeenCalled();
});
});
+105
View File
@@ -0,0 +1,105 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ExperimentForm from '../src/components/ExperimentForm';
import * as client from '../src/api/client';
jest.mock('../src/api/client', () => ({
experimentsApi: {
create: jest.fn(),
update: jest.fn(),
},
}));
const onSuccess = jest.fn();
const onCancel = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
describe('ExperimentForm', () => {
it('renders title input and submit button', () => {
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
expect(screen.getByLabelText(/experiment title/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /create experiment/i })).toBeInTheDocument();
});
it('shows validation error when submitting empty title', async () => {
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
fireEvent.click(screen.getByRole('button', { name: /create experiment/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/title is required/i);
expect(client.experimentsApi.create).not.toHaveBeenCalled();
});
it('shows error when title exceeds 255 chars', async () => {
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
await userEvent.type(screen.getByLabelText(/experiment title/i), 'x'.repeat(256));
fireEvent.click(screen.getByRole('button', { name: /create experiment/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/255/i);
expect(client.experimentsApi.create).not.toHaveBeenCalled();
});
it('calls create API with trimmed title and calls onSuccess', async () => {
const experiment = { id: 'abc-123', title: 'New Study' };
client.experimentsApi.create.mockResolvedValue(experiment);
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
await userEvent.type(screen.getByLabelText(/experiment title/i), 'New Study');
fireEvent.click(screen.getByRole('button', { name: /create experiment/i }));
await waitFor(() => expect(client.experimentsApi.create).toHaveBeenCalledWith({ title: 'New Study' }));
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(experiment));
});
it('shows "Save Changes" button for existing experiment', () => {
render(
<ExperimentForm
existing={{ id: 'abc', title: 'Old Title' }}
onSuccess={onSuccess}
onCancel={onCancel}
/>
);
expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument();
expect(screen.getByDisplayValue('Old Title')).toBeInTheDocument();
});
it('calls update API for existing experiment', async () => {
const updated = { id: 'abc', title: 'Updated Title' };
client.experimentsApi.update.mockResolvedValue(updated);
render(
<ExperimentForm
existing={{ id: 'abc', title: 'Old Title' }}
onSuccess={onSuccess}
onCancel={onCancel}
/>
);
const input = screen.getByDisplayValue('Old Title');
await userEvent.clear(input);
await userEvent.type(input, 'Updated Title');
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
await waitFor(() =>
expect(client.experimentsApi.update).toHaveBeenCalledWith('abc', { title: 'Updated Title' })
);
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(updated));
});
it('calls onCancel when Cancel is clicked', () => {
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(onCancel).toHaveBeenCalled();
});
it('shows API error when create fails', async () => {
client.experimentsApi.create.mockRejectedValue({ message: 'Server error', details: null });
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
await userEvent.type(screen.getByLabelText(/experiment title/i), 'Test');
fireEvent.click(screen.getByRole('button', { name: /create experiment/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/server error/i);
});
});
+31
View File
@@ -0,0 +1,31 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Modal from '../src/components/ui/Modal';
describe('Modal component', () => {
it('does not render when isOpen is false', () => {
render(<Modal isOpen={false} onClose={() => {}} title="Test"><p>content</p></Modal>);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('renders title and children when open', () => {
render(<Modal isOpen={true} onClose={() => {}} title="My Modal"><p>Hello</p></Modal>);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('My Modal')).toBeInTheDocument();
expect(screen.getByText('Hello')).toBeInTheDocument();
});
it('calls onClose when close button is clicked', () => {
const onClose = jest.fn();
render(<Modal isOpen={true} onClose={onClose} title="T"><p>x</p></Modal>);
fireEvent.click(screen.getByLabelText(/close modal/i));
expect(onClose).toHaveBeenCalled();
});
it('calls onClose when Escape key is pressed', () => {
const onClose = jest.fn();
render(<Modal isOpen={true} onClose={onClose} title="T"><p>x</p></Modal>);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalled();
});
});
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': {
target: process.env.VITE_API_URL || 'http://localhost:3001',
changeOrigin: true,
},
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./tests/setup.js'],
css: false,
},
});