diff --git a/backend/src/database/DatabaseInterface.ts b/backend/src/database/DatabaseInterface.ts index 367a9f3..770eb1e 100644 --- a/backend/src/database/DatabaseInterface.ts +++ b/backend/src/database/DatabaseInterface.ts @@ -7,10 +7,19 @@ export interface DBProject { name: string; description: string | null; owner_id: string; + folder_id: string | null; created_at: string; updated_at: string; } +export interface DBProjectFolder { + id: string; + name: string; + parent_id: string | null; + owner_id: string; + created_at: string; +} + export interface DBDrawing { id: string; project_id: string; @@ -129,11 +138,19 @@ export interface DatabaseInterface { listUsers(): DBUser[]; // Projects - listProjects(ownerId?: string): DBProject[]; + listProjects(ownerId?: string, folderId?: string | null): DBProject[]; getProject(id: string): DBProject | null; createProject(data: Partial): DBProject; updateProject(id: string, data: Partial): DBProject | null; deleteProject(id: string): boolean; + moveProjectToFolder(projectId: string, folderId: string | null): DBProject | null; + + // Project Folders + listProjectFolders(ownerId: string): DBProjectFolder[]; + getProjectFolder(id: string): DBProjectFolder | null; + createProjectFolder(data: Partial): DBProjectFolder; + updateProjectFolder(id: string, data: Partial): DBProjectFolder | null; + deleteProjectFolder(id: string): boolean; // Drawings listDrawings(projectId: string): DBDrawing[]; diff --git a/backend/src/database/SqliteAdapter.ts b/backend/src/database/SqliteAdapter.ts index f6573f4..080a255 100644 --- a/backend/src/database/SqliteAdapter.ts +++ b/backend/src/database/SqliteAdapter.ts @@ -11,6 +11,7 @@ import type { DBElement, DBBlock, DBSetting, DBUser, DBSession, DBNotification, DBProjectShare, DBGlobalBlockFolder, DBGlobalBlock, + DBProjectFolder, } from './DatabaseInterface.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -28,6 +29,11 @@ export class SqliteAdapter implements DatabaseInterface { const schemaPath = join(__dirname, 'schema.sql'); const schema = readFileSync(schemaPath, 'utf-8'); this.db.exec(schema); + // Add folder_id column to projects table for existing databases + const columns = this.db.prepare("PRAGMA table_info('projects')").all() as { name: string }[]; + if (!columns.some(col => col.name === 'folder_id')) { + this.db.exec('ALTER TABLE projects ADD COLUMN folder_id TEXT REFERENCES project_folders(id) ON DELETE SET NULL'); + } } close(): void { @@ -74,10 +80,22 @@ export class SqliteAdapter implements DatabaseInterface { } // ─── Projects ────────────────────────────────────── - listProjects(ownerId?: string): DBProject[] { + listProjects(ownerId?: string, folderId?: string | null): DBProject[] { + if (ownerId && folderId !== undefined) { + if (folderId === null) { + return this.db.prepare('SELECT * FROM projects WHERE owner_id = ? AND folder_id IS NULL ORDER BY updated_at DESC').all(ownerId) as DBProject[]; + } + return this.db.prepare('SELECT * FROM projects WHERE owner_id = ? AND folder_id = ? ORDER BY updated_at DESC').all(ownerId, folderId) as DBProject[]; + } if (ownerId) { return this.db.prepare('SELECT * FROM projects WHERE owner_id = ? ORDER BY updated_at DESC').all(ownerId) as DBProject[]; } + if (folderId !== undefined) { + if (folderId === null) { + return this.db.prepare('SELECT * FROM projects WHERE folder_id IS NULL ORDER BY updated_at DESC').all() as DBProject[]; + } + return this.db.prepare('SELECT * FROM projects WHERE folder_id = ? ORDER BY updated_at DESC').all(folderId) as DBProject[]; + } return this.db.prepare('SELECT * FROM projects ORDER BY updated_at DESC').all() as DBProject[]; } @@ -88,16 +106,17 @@ export class SqliteAdapter implements DatabaseInterface { createProject(data: Partial): DBProject { const id = data.id ?? `proj-${Date.now()}`; this.db.prepare( - 'INSERT INTO projects (id, name, description, owner_id) VALUES (?, ?, ?, ?)', - ).run(id, data.name ?? 'Unbenannt', data.description ?? null, data.owner_id ?? 'user-default'); + 'INSERT INTO projects (id, name, description, owner_id, folder_id) VALUES (?, ?, ?, ?, ?)', + ).run(id, data.name ?? 'Unbenannt', data.description ?? null, data.owner_id ?? 'user-default', data.folder_id ?? null); return this.getProject(id)!; } updateProject(id: string, data: Partial): DBProject | null { const sets: string[] = []; - const vals: any[] = []; + const vals: unknown[] = []; if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); } if (data.description !== undefined) { sets.push('description = ?'); vals.push(data.description); } + if (data.folder_id !== undefined) { sets.push('folder_id = ?'); vals.push(data.folder_id); } sets.push("updated_at = datetime('now')"); if (sets.length > 1) { this.db.prepare(`UPDATE projects SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id); @@ -109,6 +128,45 @@ export class SqliteAdapter implements DatabaseInterface { return this.db.prepare('DELETE FROM projects WHERE id = ?').run(id).changes > 0; } + moveProjectToFolder(projectId: string, folderId: string | null): DBProject | null { + this.db.prepare('UPDATE projects SET folder_id = ?, updated_at = datetime(\'now\') WHERE id = ?').run(folderId, projectId); + return this.getProject(projectId); + } + + // ─── Project Folders ───────────────────────────────── + listProjectFolders(ownerId: string): DBProjectFolder[] { + return this.db.prepare('SELECT * FROM project_folders WHERE owner_id = ? ORDER BY name').all(ownerId) as DBProjectFolder[]; + } + + getProjectFolder(id: string): DBProjectFolder | null { + return (this.db.prepare('SELECT * FROM project_folders WHERE id = ?').get(id) as DBProjectFolder) ?? null; + } + + createProjectFolder(data: Partial): DBProjectFolder { + const id = data.id ?? `pfolder-${Date.now()}`; + this.db.prepare( + 'INSERT INTO project_folders (id, name, parent_id, owner_id) VALUES (?, ?, ?, ?)', + ).run(id, data.name ?? 'Neuer Ordner', data.parent_id ?? null, data.owner_id ?? 'user-default'); + return this.getProjectFolder(id)!; + } + + updateProjectFolder(id: string, data: Partial): DBProjectFolder | null { + const sets: string[] = []; + const vals: unknown[] = []; + if (data.name !== undefined) { sets.push('name = ?'); vals.push(data.name); } + if (data.parent_id !== undefined) { sets.push('parent_id = ?'); vals.push(data.parent_id); } + if (sets.length > 0) { + this.db.prepare(`UPDATE project_folders SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id); + } + return this.getProjectFolder(id); + } + + deleteProjectFolder(id: string): boolean { + // Move projects inside this folder to root (folder_id = NULL) before deleting + this.db.prepare('UPDATE projects SET folder_id = NULL WHERE folder_id = ?').run(id); + return this.db.prepare('DELETE FROM project_folders WHERE id = ?').run(id).changes > 0; + } + // ─── Drawings ────────────────────────────────────── listDrawings(projectId: string): DBDrawing[] { return this.db.prepare('SELECT * FROM drawings WHERE project_id = ? ORDER BY updated_at DESC').all(projectId) as DBDrawing[]; diff --git a/backend/src/database/schema.sql b/backend/src/database/schema.sql index 19df6c1..01d05e9 100644 --- a/backend/src/database/schema.sql +++ b/backend/src/database/schema.sql @@ -99,6 +99,25 @@ CREATE TABLE IF NOT EXISTS settings ( updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); +-- ─── Project Folders ─────────────────────────────────── +CREATE TABLE IF NOT EXISTS project_folders ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + parent_id TEXT, + owner_id TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (parent_id) REFERENCES project_folders(id) ON DELETE CASCADE, + FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_project_folders_parent ON project_folders(parent_id); +CREATE INDEX IF NOT EXISTS idx_project_folders_owner ON project_folders(owner_id); + +-- ─── Projects: add folder_id column ───────────────────── +-- NOTE: ALTER TABLE is idempotent-safe via try/catch in adapter; schema.sql +-- is only exec'd on fresh databases. For existing DBs, the adapter runs +-- the ALTER in init(). + -- ─── Indexes ──────────────────────────────────────── CREATE INDEX IF NOT EXISTS idx_drawings_project ON drawings(project_id); CREATE INDEX IF NOT EXISTS idx_layers_drawing ON layers(drawing_id); diff --git a/backend/src/routes/projectFolders.ts b/backend/src/routes/projectFolders.ts new file mode 100644 index 0000000..19d9b56 --- /dev/null +++ b/backend/src/routes/projectFolders.ts @@ -0,0 +1,111 @@ +/** + * Project Folders Routes – CRUD for project folders + move project to folder + */ +import type { FastifyInstance } from 'fastify'; +import type { DatabaseInterface } from '../database/DatabaseInterface.js'; +import { requireAuth } from '../auth/authMiddleware.js'; +import type { AuthService } from '../auth/AuthService.js'; +import { validateName, validateIdParam } from '../utils/validation.js'; + +export function registerProjectFolderRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) { + // ─── Project Folders ────────────────────────────────── + + // List all folders for the authenticated user + fastify.get('/api/project-folders', async (request, reply) => { + const user = requireAuth(request, reply, authService); + if (!user) return; + return db.listProjectFolders(user.id); + }); + + // Get a single folder + fastify.get('/api/project-folders/:id', async (request, reply) => { + const user = requireAuth(request, reply, authService); + if (!user) return; + const { id } = request.params as { id: string }; + const idErr = validateIdParam(id, 'id'); + if (idErr) return reply.code(400).send({ error: idErr }); + const folder = db.getProjectFolder(id); + if (!folder) return reply.code(404).send({ error: 'Folder not found' }); + if (folder.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' }); + return folder; + }); + + // Create a new folder + fastify.post('/api/project-folders', async (request, reply) => { + const user = requireAuth(request, reply, authService); + if (!user) return; + const body = request.body as { name?: string; parent_id?: string | null }; + const nameErr = validateName(body.name, 'name'); + if (nameErr) return reply.code(400).send({ error: nameErr }); + if (body.parent_id !== undefined && body.parent_id !== null) { + const pidErr = validateIdParam(body.parent_id, 'parent_id'); + if (pidErr) return reply.code(400).send({ error: pidErr }); + // Verify parent exists and belongs to user + const parent = db.getProjectFolder(body.parent_id); + if (!parent) return reply.code(404).send({ error: 'Parent folder not found' }); + if (parent.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' }); + } + return reply.code(201).send(db.createProjectFolder({ + name: body.name, + parent_id: body.parent_id ?? null, + owner_id: user.id, + })); + }); + + // Rename a folder + fastify.put('/api/project-folders/:id', async (request, reply) => { + const user = requireAuth(request, reply, authService); + if (!user) return; + const { id } = request.params as { id: string }; + const idErr = validateIdParam(id, 'id'); + if (idErr) return reply.code(400).send({ error: idErr }); + const folder = db.getProjectFolder(id); + if (!folder) return reply.code(404).send({ error: 'Folder not found' }); + if (folder.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' }); + const body = request.body as { name?: string }; + const nameErr = validateName(body.name, 'name'); + if (nameErr) return reply.code(400).send({ error: nameErr }); + const updated = db.updateProjectFolder(id, { name: body.name }); + if (!updated) return reply.code(404).send({ error: 'Folder not found' }); + return updated; + }); + + // Delete a folder (projects inside move to root) + fastify.delete('/api/project-folders/:id', async (request, reply) => { + const user = requireAuth(request, reply, authService); + if (!user) return; + const { id } = request.params as { id: string }; + const idErr = validateIdParam(id, 'id'); + if (idErr) return reply.code(400).send({ error: idErr }); + const folder = db.getProjectFolder(id); + if (!folder) return reply.code(404).send({ error: 'Folder not found' }); + if (folder.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' }); + const ok = db.deleteProjectFolder(id); + if (!ok) return reply.code(404).send({ error: 'Folder not found' }); + return reply.code(204).send(); + }); + + // Move project to folder + fastify.put('/api/projects/:id/folder', async (request, reply) => { + const user = requireAuth(request, reply, authService); + if (!user) return; + const { id } = request.params as { id: string }; + const idErr = validateIdParam(id, 'id'); + if (idErr) return reply.code(400).send({ error: idErr }); + const project = db.getProject(id); + if (!project) return reply.code(404).send({ error: 'Project not found' }); + if (project.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' }); + const body = request.body as { folder_id?: string | null }; + if (body.folder_id !== undefined && body.folder_id !== null) { + const fidErr = validateIdParam(body.folder_id, 'folder_id'); + if (fidErr) return reply.code(400).send({ error: fidErr }); + // Verify folder exists and belongs to user + const folder = db.getProjectFolder(body.folder_id); + if (!folder) return reply.code(404).send({ error: 'Folder not found' }); + if (folder.owner_id !== user.id) return reply.code(403).send({ error: 'Forbidden' }); + } + const updated = db.moveProjectToFolder(id, body.folder_id ?? null); + if (!updated) return reply.code(404).send({ error: 'Project not found' }); + return updated; + }); +} diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index 3cd389d..b2aedc5 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -8,11 +8,17 @@ import type { AuthService } from '../auth/AuthService.js'; import { validateName, validateIdParam } from '../utils/validation.js'; export function registerProjectRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) { - // List projects owned by the authenticated user + // List projects owned by the authenticated user, optionally filtered by folder fastify.get('/api/projects', async (request, reply) => { const user = requireAuth(request, reply, authService); if (!user) return; - return db.listProjects(user.id); + const folderId = (request.query as { folderId?: string | null }).folderId; + if (folderId === undefined || folderId === '' || folderId === 'null') { + return db.listProjects(user.id, folderId === 'null' ? null : undefined); + } + const fidErr = validateIdParam(folderId, 'folderId'); + if (fidErr) return reply.code(400).send({ error: fidErr }); + return db.listProjects(user.id, folderId); }); // Get single project diff --git a/backend/src/server.ts b/backend/src/server.ts index c6be285..c5f099a 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -67,6 +67,7 @@ export async function createServer(opts: ServerOptions) { const { registerNotificationRoutes } = await import('./routes/notifications.js'); const { registerShareRoutes } = await import('./routes/shares.js'); const { registerGlobalBlockRoutes } = await import('./routes/globalBlocks.js'); + const { registerProjectFolderRoutes } = await import('./routes/projectFolders.js'); const authService = new AuthService(opts.db); @@ -102,6 +103,7 @@ export async function createServer(opts: ServerOptions) { registerNotificationRoutes(fastify, opts.db, authService); registerShareRoutes(fastify, opts.db, authService); registerGlobalBlockRoutes(fastify, opts.db, authService); + registerProjectFolderRoutes(fastify, opts.db, authService); // Yjs collaboration WebSocket registerYjsWebSocket(fastify, authService); diff --git a/backend/tests/projectFolders.test.ts b/backend/tests/projectFolders.test.ts new file mode 100644 index 0000000..200b67f --- /dev/null +++ b/backend/tests/projectFolders.test.ts @@ -0,0 +1,285 @@ +/** + * Project Folders API Tests - CRUD for project folders + move project to folder + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { FastifyInstance } from 'fastify'; +import { SqliteAdapter } from '../src/database/SqliteAdapter.js'; +import { createServer } from '../src/server.js'; + +describe('Project Folders API', () => { + let app: FastifyInstance; + let db: SqliteAdapter; + let authToken: string; + let createdFolderId: string; + let childFolderId: string; + let createdProjectId: string; + + beforeAll(async () => { + db = new SqliteAdapter(':memory:'); + await db.init(); + app = await createServer({ db, port: 0 }); + await app.ready(); + + const regRes = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { email: 'folder-test@example.com', password: 'Password123!', name: 'Folder Test' }, + }); + const regBody = JSON.parse(regRes.body); + authToken = regBody.session.token; + }); + + afterAll(async () => { + await app.close(); + db.close(); + }); + + it('should list empty folders initially', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/project-folders', + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBe(0); + }); + + it('should create a folder', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/project-folders', + headers: { authorization: `Bearer ${authToken}` }, + payload: { name: 'Test Folder' }, + }); + expect(res.statusCode).toBe(201); + const body = JSON.parse(res.body); + expect(body.name).toBe('Test Folder'); + expect(body.parent_id).toBeNull(); + expect(body.id).toBeDefined(); + createdFolderId = body.id; + }); + + it('should create a child folder', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/project-folders', + headers: { authorization: `Bearer ${authToken}` }, + payload: { name: 'Child Folder', parent_id: createdFolderId }, + }); + expect(res.statusCode).toBe(201); + const body = JSON.parse(res.body); + expect(body.name).toBe('Child Folder'); + expect(body.parent_id).toBe(createdFolderId); + childFolderId = body.id; + }); + + it('should list folders including the created ones', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/project-folders', + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.length).toBe(2); + expect(body.some((f: { id: string }) => f.id === createdFolderId)).toBe(true); + expect(body.some((f: { id: string }) => f.id === childFolderId)).toBe(true); + }); + + it('should get a single folder', async () => { + const res = await app.inject({ + method: 'GET', + url: `/api/project-folders/${createdFolderId}`, + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.id).toBe(createdFolderId); + expect(body.name).toBe('Test Folder'); + }); + + it('should rename a folder', async () => { + const res = await app.inject({ + method: 'PUT', + url: `/api/project-folders/${createdFolderId}`, + headers: { authorization: `Bearer ${authToken}` }, + payload: { name: 'Renamed Folder' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.name).toBe('Renamed Folder'); + }); + + it('should reject folder creation without name', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/project-folders', + headers: { authorization: `Bearer ${authToken}` }, + payload: {}, + }); + expect(res.statusCode).toBe(400); + }); + + it('should reject rename without name', async () => { + const res = await app.inject({ + method: 'PUT', + url: `/api/project-folders/${createdFolderId}`, + headers: { authorization: `Bearer ${authToken}` }, + payload: {}, + }); + expect(res.statusCode).toBe(400); + }); + + it('should reject operations on non-existent folder', async () => { + const getRes = await app.inject({ + method: 'GET', + url: '/api/project-folders/nonexistent-folder', + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(getRes.statusCode).toBe(404); + + const delRes = await app.inject({ + method: 'DELETE', + url: '/api/project-folders/nonexistent-folder', + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(delRes.statusCode).toBe(404); + }); + + it('should reject unauthenticated requests', async () => { + const res = await app.inject({ + method: 'GET', + url: '/api/project-folders', + }); + expect(res.statusCode).toBe(401); + }); + + // ─── Project + Folder integration ──────────────────── + + it('should create a project with folder_id', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/projects', + headers: { authorization: `Bearer ${authToken}` }, + payload: { name: 'Folder Project', folder_id: createdFolderId }, + }); + expect(res.statusCode).toBe(201); + const body = JSON.parse(res.body); + expect(body.name).toBe('Folder Project'); + expect(body.folder_id).toBe(createdFolderId); + createdProjectId = body.id; + }); + + it('should list projects filtered by folder', async () => { + const res = await app.inject({ + method: 'GET', + url: `/api/projects?folderId=${createdFolderId}`, + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.length).toBe(1); + expect(body[0].id).toBe(createdProjectId); + }); + + it('should list unfoldered projects with folderId=null', async () => { + // First create a project without folder + await app.inject({ + method: 'POST', + url: '/api/projects', + headers: { authorization: `Bearer ${authToken}` }, + payload: { name: 'No Folder Project' }, + }); + + const res = await app.inject({ + method: 'GET', + url: '/api/projects?folderId=null', + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.every((p: { folder_id: string | null }) => p.folder_id === null)).toBe(true); + }); + + it('should move project to a different folder', async () => { + const res = await app.inject({ + method: 'PUT', + url: `/api/projects/${createdProjectId}/folder`, + headers: { authorization: `Bearer ${authToken}` }, + payload: { folder_id: childFolderId }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.folder_id).toBe(childFolderId); + }); + + it('should move project to root (folder_id = null)', async () => { + const res = await app.inject({ + method: 'PUT', + url: `/api/projects/${createdProjectId}/folder`, + headers: { authorization: `Bearer ${authToken}` }, + payload: { folder_id: null }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.folder_id).toBeNull(); + }); + + it('should reject moving project to non-existent folder', async () => { + const res = await app.inject({ + method: 'PUT', + url: `/api/projects/${createdProjectId}/folder`, + headers: { authorization: `Bearer ${authToken}` }, + payload: { folder_id: 'nonexistent-folder' }, + }); + expect(res.statusCode).toBe(404); + }); + + it('should delete folder and move projects to root', async () => { + // Move project back to folder first + await app.inject({ + method: 'PUT', + url: `/api/projects/${createdProjectId}/folder`, + headers: { authorization: `Bearer ${authToken}` }, + payload: { folder_id: childFolderId }, + }); + + // Delete the child folder + const delRes = await app.inject({ + method: 'DELETE', + url: `/api/project-folders/${childFolderId}`, + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(delRes.statusCode).toBe(204); + + // Verify project is now in root + const projRes = await app.inject({ + method: 'GET', + url: `/api/projects/${createdProjectId}`, + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(projRes.statusCode).toBe(200); + const projBody = JSON.parse(projRes.body); + expect(projBody.folder_id).toBeNull(); + }); + + it('should delete remaining folders', async () => { + const res = await app.inject({ + method: 'DELETE', + url: `/api/project-folders/${createdFolderId}`, + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(res.statusCode).toBe(204); + + // Verify no folders left + const listRes = await app.inject({ + method: 'GET', + url: '/api/project-folders', + headers: { authorization: `Bearer ${authToken}` }, + }); + const listBody = JSON.parse(listRes.body); + expect(listBody.length).toBe(0); + }); +}); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 1ffddbe..474bc63 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,20 +1,21 @@ /** - * Dashboard Page – Project overview after login + * Dashboard Page - 3-column layout with project folder tree, project cards, and info panel */ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { useAuth } from '../contexts/AuthContext'; import { NotificationPanel } from '../components/NotificationPanel'; import { ShareDialog } from '../components/ShareDialog'; +import TreeView from '../components/TreeView'; +import type { TreeNode } from '../types/ui.types'; +import { + getProjects, deleteProject, + getProjectFolders, createProjectFolder, renameProjectFolder, deleteProjectFolder, + moveProjectToFolder, getProjectShares, + type Project, type ProjectFolder, type ProjectShare, +} from '../services/api'; const API_BASE = import.meta.env.VITE_API_BASE || ''; - -interface Project { - id: string; - name: string; - description: string | null; - created_at: string; - updated_at: string; -} +const ROOT_FOLDER_ID = '__root__'; interface DashboardProps { onOpenProject: (projectId: string) => void; @@ -23,21 +24,35 @@ interface DashboardProps { export function Dashboard({ onOpenProject }: DashboardProps) { const { user, logout, token } = useAuth(); const [projects, setProjects] = useState([]); + const [allProjects, setAllProjects] = useState([]); + const [folders, setFolders] = useState([]); const [loading, setLoading] = useState(true); const [showCreate, setShowCreate] = useState(false); const [newName, setNewName] = useState(''); const [newDesc, setNewDesc] = useState(''); const [shareProjectId, setShareProjectId] = useState(null); + const [selectedFolderId, setSelectedFolderId] = useState(ROOT_FOLDER_ID); + const [selectedProjectId, setSelectedProjectId] = useState(null); + const [shares, setShares] = useState([]); + const [sharesLoading, setSharesLoading] = useState(false); + const [draggedProjectId, setDraggedProjectId] = useState(null); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number; folderId: string | null } | null>(null); + const [renamingFolderId, setRenamingFolderId] = useState(null); + const [renameValue, setRenameValue] = useState(''); + const [showCreateFolder, setShowCreateFolder] = useState(false); + const [newFolderName, setNewFolderName] = useState(''); + const [newFolderParent, setNewFolderParent] = useState(null); - const fetchProjects = useCallback(async () => { + const fetchAll = useCallback(async () => { + if (!token) return; setLoading(true); try { - const res = await fetch(`${API_BASE}/api/projects`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (res.ok) { - setProjects(await res.json()); - } + const [projs, fldrs] = await Promise.all([ + getProjects(token), + getProjectFolders(token), + ]); + setAllProjects(projs); + setFolders(fldrs); } catch { // ignore } finally { @@ -46,22 +61,86 @@ export function Dashboard({ onOpenProject }: DashboardProps) { }, [token]); useEffect(() => { - fetchProjects(); - }, [fetchProjects]); + fetchAll(); + }, [fetchAll]); + + useEffect(() => { + if (selectedFolderId === ROOT_FOLDER_ID) { + setProjects(allProjects); + } else if (selectedFolderId === null) { + setProjects(allProjects.filter(p => p.folder_id === null)); + } else { + setProjects(allProjects.filter(p => p.folder_id === selectedFolderId)); + } + }, [allProjects, selectedFolderId]); + + useEffect(() => { + if (!token || !selectedProjectId) { + setShares([]); + return; + } + setSharesLoading(true); + getProjectShares(token, selectedProjectId) + .then(s => setShares(s)) + .catch(() => setShares([])) + .finally(() => setSharesLoading(false)); + }, [token, selectedProjectId]); + + const selectedProject = useMemo( + () => allProjects.find(p => p.id === selectedProjectId) ?? null, + [allProjects, selectedProjectId], + ); + + const treeNodes = useMemo((): TreeNode[] => { + const rootProjectsCount = allProjects.filter(p => p.folder_id === null).length; + const root: TreeNode = { + id: ROOT_FOLDER_ID, + name: 'Alle Projekte', + count: allProjects.length, + expanded: true, + children: [ + { id: '__unfiled__', name: 'Ohne Ordner', count: rootProjectsCount }, + ], + }; + const buildFolderNode = (folder: ProjectFolder): TreeNode => { + const childFolders = folders.filter(f => f.parent_id === folder.id); + const projCount = allProjects.filter(p => p.folder_id === folder.id).length; + return { + id: folder.id, + name: folder.name, + count: projCount, + expanded: true, + children: childFolders.map(buildFolderNode), + }; + }; + const topFolders = folders.filter(f => f.parent_id === null); + root.children!.push(...topFolders.map(buildFolderNode)); + return [root]; + }, [folders, allProjects]); + + const folderNameById = useMemo(() => { + const map = new Map(); + map.set(ROOT_FOLDER_ID, 'Alle Projekte'); + map.set('__unfiled__', 'Ohne Ordner'); + for (const f of folders) map.set(f.id, f.name); + return map; + }, [folders]); const handleCreate = async () => { - if (!newName.trim()) return; + if (!newName.trim() || !token) return; try { + const folderId = selectedFolderId === ROOT_FOLDER_ID || selectedFolderId === '__unfiled__' + ? null : selectedFolderId; const res = await fetch(`${API_BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - body: JSON.stringify({ name: newName, description: newDesc || null }), + body: JSON.stringify({ name: newName, description: newDesc || null, folder_id: folderId }), }); if (res.ok) { setNewName(''); setNewDesc(''); setShowCreate(false); - fetchProjects(); + fetchAll(); } } catch { // ignore @@ -70,18 +149,158 @@ export function Dashboard({ onOpenProject }: DashboardProps) { const handleDelete = async (id: string, e: React.MouseEvent) => { e.stopPropagation(); - if (!confirm('Projekt wirklich löschen?')) return; + if (!confirm('Projekt wirklich loeschen?')) return; + if (!token) return; try { - await fetch(`${API_BASE}/api/projects/${id}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, - }); - fetchProjects(); + await deleteProject(token, id); + if (selectedProjectId === id) setSelectedProjectId(null); + fetchAll(); } catch { // ignore } }; + const handleFolderSelect = (id: string) => { + if (id === ROOT_FOLDER_ID) { + setSelectedFolderId(ROOT_FOLDER_ID); + } else if (id === '__unfiled__') { + setSelectedFolderId(null); + } else { + setSelectedFolderId(id); + } + }; + + const handleCreateFolder = async () => { + if (!newFolderName.trim() || !token) return; + const parentId = newFolderParent === ROOT_FOLDER_ID || newFolderParent === '__unfiled__' + ? null : newFolderParent; + try { + await createProjectFolder(token, newFolderName, parentId); + setNewFolderName(''); + setShowCreateFolder(false); + setNewFolderParent(null); + fetchAll(); + } catch { + // ignore + } + }; + + const handleRenameFolder = async (id: string) => { + if (!renameValue.trim() || !token) return; + try { + await renameProjectFolder(token, id, renameValue); + setRenamingFolderId(null); + setRenameValue(''); + fetchAll(); + } catch { + // ignore + } + }; + + const handleDeleteFolder = async (id: string) => { + if (!token) return; + if (!confirm('Ordner loeschen? Projekte darin werden nach "Ohne Ordner" verschoben.')) return; + try { + await deleteProjectFolder(token, id); + if (selectedFolderId === id) setSelectedFolderId(ROOT_FOLDER_ID); + fetchAll(); + } catch { + // ignore + } + }; + + const handleProjectCardClick = (projectId: string) => { + setSelectedProjectId(projectId); + }; + + const handleProjectDragStart = (e: React.DragEvent, projectId: string) => { + setDraggedProjectId(projectId); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', `project:${projectId}`); + }; + + const handleFolderDragOver = (e: React.DragEvent) => { + if (!draggedProjectId) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + (e.currentTarget as HTMLElement).classList.add('tree-drop-target'); + }; + + const handleFolderDragLeave = (e: React.DragEvent) => { + (e.currentTarget as HTMLElement).classList.remove('tree-drop-target'); + }; + + const handleFolderDrop = async (e: React.DragEvent, folderId: string) => { + e.preventDefault(); + e.stopPropagation(); + (e.currentTarget as HTMLElement).classList.remove('tree-drop-target'); + if (!draggedProjectId || !token) return; + const targetFolderId = folderId === ROOT_FOLDER_ID || folderId === '__unfiled__' ? null : folderId; + try { + await moveProjectToFolder(token, draggedProjectId, targetFolderId); + fetchAll(); + } catch { + // ignore + } finally { + setDraggedProjectId(null); + } + }; + + const handleContextMenu = (e: React.MouseEvent, folderId: string | null) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ x: e.clientX, y: e.clientY, folderId }); + }; + + const handleContextAction = (action: string, folderId: string | null) => { + setContextMenu(null); + if (action === 'new') { + setNewFolderParent(folderId); + setShowCreateFolder(true); + } else if (action === 'rename' && folderId) { + const folder = folders.find(f => f.id === folderId); + if (folder) { + setRenamingFolderId(folderId); + setRenameValue(folder.name); + } + } else if (action === 'delete' && folderId) { + handleDeleteFolder(folderId); + } + }; + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString('de-DE', { + year: 'numeric', month: 'long', day: 'numeric', + hour: '2-digit', minute: '2-digit', + }); + }; + + const renderFolderActions = (node: TreeNode) => { + if (node.id === ROOT_FOLDER_ID || node.id === '__unfiled__') return null; + return ( + + + + + ); + }; + return (
@@ -95,71 +314,222 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
-
-
-

Projekte

- -
- - {showCreate && ( -
- setNewName(e.target.value)} - autoFocus - /> - setNewDesc(e.target.value)} - /> - - +
+
handleContextMenu(e, null)}> +
+

Ordner

+
- )} - {loading ? ( -

Lade Projekte…

- ) : projects.length === 0 ? ( -

Noch keine Projekte. Erstellen Sie ein neues Projekt.

- ) : ( -
- {projects.map(project => ( -
onOpenProject(project.id)} - > -

{project.name}

- {project.description &&

{project.description}

} -
- Erstellt: {new Date(project.created_at).toLocaleDateString('de-DE')} -
- - -
-
+ {showCreateFolder && ( +
+ setNewFolderName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleCreateFolder(); if (e.key === 'Escape') setShowCreateFolder(false); }} + autoFocus + /> +
+ +
+
+ )} + + + + {renamingFolderId && ( +
+ setRenameValue(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleRenameFolder(renamingFolderId); if (e.key === 'Escape') { setRenamingFolderId(null); setRenameValue(''); } }} + onBlur={() => { if (renameValue.trim()) handleRenameFolder(renamingFolderId); else { setRenamingFolderId(null); setRenameValue(''); } }} + autoFocus + /> +
+ )} + +
+ {folders.map(folder => ( +
handleFolderDrop(e, folder.id)} + /> ))}
- )} +
+ +
+
+

{folderNameById.get(selectedFolderId ?? ROOT_FOLDER_ID) ?? 'Projekte'}

+ +
+ + {showCreate && ( +
+ setNewName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleCreate(); }} + autoFocus + /> + setNewDesc(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleCreate(); }} + /> + + +
+ )} + + {loading ? ( +

Lade Projekte...

+ ) : projects.length === 0 ? ( +

Keine Projekte in dieser Ansicht. Erstellen Sie ein neues Projekt.

+ ) : ( +
+ {projects.map(project => ( +
handleProjectCardClick(project.id)} + draggable + onDragStart={(e) => handleProjectDragStart(e, project.id)} + onDragEnd={() => setDraggedProjectId(null)} + > +

{project.name}

+ {project.description &&

{project.description}

} +
+ {new Date(project.created_at).toLocaleDateString('de-DE')} +
+ + + +
+
+
+ ))} +
+ )} +
+ +
+ {selectedProject ? ( + <> +

{selectedProject.name}

+ {selectedProject.description && ( +

{selectedProject.description}

+ )} +
+

Metadaten

+
+
Erstellt
+
{formatDate(selectedProject.created_at)}
+
Aktualisiert
+
{formatDate(selectedProject.updated_at)}
+
Owner
+
{selectedProject.owner_id}
+
Ordner
+
{selectedProject.folder_id ? (folderNameById.get(selectedProject.folder_id) ?? 'Unbekannt') : 'Ohne Ordner'}
+
+
+
+

Sichtbarkeit / Freigaben

+ {sharesLoading ? ( +

Lade Freigaben...

+ ) : shares.length === 0 ? ( +

Keine Freigaben

+ ) : ( +
    + {shares.map(share => ( +
  • + {share.shared_with_email} + {share.permission} +
  • + ))} +
+ )} +
+ + + ) : ( +
+

Waehlen Sie ein Projekt aus, um Details zu sehen.

+
+ )} +
+ {contextMenu && ( + <> +
setContextMenu(null)} /> +
+ + {contextMenu.folderId && contextMenu.folderId !== ROOT_FOLDER_ID && contextMenu.folderId !== '__unfiled__' && ( + <> + + + + )} +
+ + )} + {token && ( { }); } +// ─── Project Folders ───────────────────────────────────── +export interface ProjectFolder { + id: string; + name: string; + parent_id: string | null; + owner_id: string; + created_at: string; +} + +export async function getProjectFolders(token: string): Promise { + const res = await fetch(`${API_BASE}/api/project-folders`, { headers: authHeaders(token) }); + if (!res.ok) throw new Error('Failed to load project folders'); + return res.json(); +} + +export async function createProjectFolder(token: string, name: string, parentId?: string | null): Promise { + const res = await fetch(`${API_BASE}/api/project-folders`, { + method: 'POST', + headers: authHeaders(token), + body: JSON.stringify({ name, parent_id: parentId ?? null }), + }); + if (!res.ok) throw new Error('Failed to create project folder'); + return res.json(); +} + +export async function renameProjectFolder(token: string, id: string, name: string): Promise { + const res = await fetch(`${API_BASE}/api/project-folders/${id}`, { + method: 'PUT', + headers: authHeaders(token), + body: JSON.stringify({ name }), + }); + if (!res.ok) throw new Error('Failed to rename project folder'); + return res.json(); +} + +export async function deleteProjectFolder(token: string, id: string): Promise { + const res = await fetch(`${API_BASE}/api/project-folders/${id}`, { + method: 'DELETE', + headers: authHeaders(token), + }); + if (!res.ok) throw new Error('Failed to delete project folder'); +} + +export async function moveProjectToFolder(token: string, projectId: string, folderId: string | null): Promise { + const res = await fetch(`${API_BASE}/api/projects/${projectId}/folder`, { + method: 'PUT', + headers: authHeaders(token), + body: JSON.stringify({ folder_id: folderId }), + }); + if (!res.ok) throw new Error('Failed to move project to folder'); + return res.json(); +} + +export async function getProjectsByFolder(token: string, folderId: string | null): Promise { + const url = folderId === null + ? `${API_BASE}/api/projects?folderId=null` + : `${API_BASE}/api/projects?folderId=${folderId}`; + const res = await fetch(url, { headers: authHeaders(token) }); + if (!res.ok) throw new Error('Failed to load projects by folder'); + return res.json(); +} + // ─── Drawings ─────────────────────────────────────────── export async function getDrawings(token: string, projectId: string): Promise { const res = await fetch(`${API_BASE}/api/projects/${projectId}/drawings`, { headers: authHeaders(token) }); diff --git a/frontend/src/styles/auth.css b/frontend/src/styles/auth.css index 6755d5d..1031e4f 100644 --- a/frontend/src/styles/auth.css +++ b/frontend/src/styles/auth.css @@ -134,6 +134,343 @@ background: var(--bg-primary, #1a1a2e); color: var(--text-primary, #e0e0e0); font-family: 'Inter', system-ui, sans-serif; + display: flex; + flex-direction: column; +} + +.dashboard-3col { + display: grid; + grid-template-columns: 240px 1fr 300px; + flex: 1; + gap: 1px; + background: var(--border-color, #2a2a4a); + overflow: hidden; + min-height: 0; +} + +.dashboard-treeview { + background: var(--bg-secondary, #16213e); + overflow-y: auto; + padding: 12px; + position: relative; +} + +.dashboard-info-panel { + background: var(--bg-secondary, #16213e); + overflow-y: auto; + padding: 16px; +} + +.treeview-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.treeview-header h3 { + font-size: 0.875rem; + font-weight: 600; + margin: 0; + color: var(--text-primary, #e0e0e0); +} + +.treeview-add-folder-btn { + background: var(--accent-primary, #4a9eff); + color: white; + border: none; + border-radius: 4px; + width: 24px; + height: 24px; + cursor: pointer; + font-size: 16px; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +.treeview-add-folder-btn:hover { opacity: 0.85; } + +.folder-create-form { + margin-bottom: 8px; + padding: 8px; + background: var(--bg-input, #0f0f23); + border-radius: 6px; + border: 1px solid var(--border-color, #2a2a4a); +} + +.folder-create-form input { + width: 100%; + padding: 4px 8px; + border: 1px solid var(--border-color, #2a2a4a); + border-radius: 4px; + background: var(--bg-primary, #1a1a2e); + color: var(--text-primary, #e0e0e0); + font-size: 0.8125rem; + outline: none; + box-sizing: border-box; +} + +.folder-create-form input:focus { border-color: var(--accent-primary, #4a9eff); } + +.folder-create-actions { + display: flex; + gap: 4px; + margin-top: 4px; +} + +.folder-create-actions button { + padding: 4px 10px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.75rem; + font-weight: 600; +} + +.folder-create-actions button:first-child { + background: var(--accent-primary, #4a9eff); + color: white; +} + +.folder-create-actions button:last-child { + background: transparent; + color: var(--text-secondary, #888); + border: 1px solid var(--border-color, #2a2a4a); +} + +.folder-rename-form { + margin-bottom: 8px; + padding: 8px; + background: var(--bg-input, #0f0f23); + border-radius: 6px; + border: 1px solid var(--border-color, #2a2a4a); +} + +.folder-rename-form input { + width: 100%; + padding: 4px 8px; + border: 1px solid var(--border-color, #2a2a4a); + border-radius: 4px; + background: var(--bg-primary, #1a1a2e); + color: var(--text-primary, #e0e0e0); + font-size: 0.8125rem; + outline: none; + box-sizing: border-box; +} + +.folder-rename-form input:focus { border-color: var(--accent-primary, #4a9eff); } + +.folder-action-buttons { + display: inline-flex; + gap: 2px; + margin-left: 4px; +} + +.folder-action-btn { + background: none; + border: none; + cursor: pointer; + padding: 2px 4px; + font-size: 12px; + color: var(--text-secondary, #888); + border-radius: 3px; + transition: color 0.15s, background 0.15s; +} + +.folder-action-btn:hover { color: var(--accent-primary, #4a9eff); background: rgba(74, 158, 255, 0.1); } +.folder-action-btn.delete:hover { color: #e74c3c; background: rgba(231, 76, 60, 0.1); } + +.tree-drop-target { + background: rgba(74, 158, 255, 0.15) !important; + outline: 2px dashed var(--accent-primary, #4a9eff); +} + +.treeview-drop-overlay { + position: absolute; + top: 0; left: 0; right: 0; bottom: 0; + pointer-events: none; +} + +.treeview-drop-zone { + pointer-events: auto; +} + +.context-menu-overlay { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + z-index: 5000; +} + +.context-menu { + position: fixed; + z-index: 5001; + background: var(--bg-secondary, #16213e); + border: 1px solid var(--border-color, #2a2a4a); + border-radius: 6px; + box-shadow: 0 8px 24px rgba(0,0,0,0.4); + min-width: 160px; + padding: 4px 0; +} + +.context-menu-item { + display: block; + width: 100%; + text-align: left; + padding: 8px 16px; + background: none; + border: none; + cursor: pointer; + color: var(--text-primary, #e0e0e0); + font-size: 0.8125rem; + transition: background 0.15s; +} + +.context-menu-item:hover { background: rgba(74, 158, 255, 0.1); } +.context-menu-item.danger:hover { background: rgba(231, 76, 60, 0.1); color: #e74c3c; } + +.dashboard-project-card.selected { + border-color: var(--accent-primary, #4a9eff); + box-shadow: 0 0 0 1px var(--accent-primary, #4a9eff); +} + +.dashboard-open-btn { + padding: 4px 10px; + border: 1px solid var(--accent-primary, #4a9eff); + border-radius: 4px; + background: var(--accent-primary, #4a9eff); + color: white; + cursor: pointer; + font-size: 12px; + font-weight: 600; +} + +.dashboard-open-btn:hover { opacity: 0.85; } + +.info-panel-title { + font-size: 1.125rem; + font-weight: 700; + margin: 0 0 8px 0; + color: var(--text-primary, #e0e0e0); +} + +.info-panel-description { + font-size: 0.8125rem; + color: var(--text-secondary, #888); + margin: 0 0 16px 0; + line-height: 1.5; +} + +.info-panel-section { + margin-bottom: 20px; +} + +.info-panel-section h4 { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary, #888); + margin: 0 0 8px 0; + padding-bottom: 4px; + border-bottom: 1px solid var(--border-color, #2a2a4a); +} + +.info-panel-meta { + margin: 0; + display: grid; + grid-template-columns: auto 1fr; + gap: 4px 12px; +} + +.info-panel-meta dt { + font-size: 0.75rem; + color: var(--text-secondary, #888); + font-weight: 500; +} + +.info-panel-meta dd { + font-size: 0.75rem; + color: var(--text-primary, #e0e0e0); + margin: 0; +} + +.info-panel-loading, .info-panel-empty { + font-size: 0.75rem; + color: var(--text-secondary, #888); + padding: 8px 0; +} + +.info-panel-shares { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.info-panel-share-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 8px; + border: 1px solid var(--border-color, #2a2a4a); + border-radius: 4px; + font-size: 0.75rem; +} + +.info-panel-share-email { + color: var(--text-primary, #e0e0e0); +} + +.info-panel-share-perm { + font-size: 0.6875rem; + text-transform: uppercase; + font-weight: 600; + padding: 1px 6px; + border-radius: 3px; + background: var(--border-color, #2a2a4a); + color: var(--text-secondary, #888); +} + +.info-panel-open-btn { + width: 100%; + padding: 10px; + border: none; + border-radius: 6px; + background: var(--accent-primary, #4a9eff); + color: white; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + margin-top: 8px; +} + +.info-panel-open-btn:hover { opacity: 0.85; } + +.info-panel-placeholder { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + min-height: 200px; +} + +.info-panel-placeholder p { + color: var(--text-secondary, #888); + font-size: 0.875rem; + text-align: center; +} + +@media (max-width: 1024px) { + .dashboard-3col { + grid-template-columns: 1fr; + } + .dashboard-treeview, .dashboard-info-panel { + display: none; + } } .dashboard-header { @@ -179,9 +516,9 @@ } .dashboard-content { - max-width: 1000px; - margin: 0 auto; padding: 2rem; + overflow-y: auto; + background: var(--bg-secondary, #16213e); } .dashboard-section-header { diff --git a/test_report.md b/test_report.md index 1e7e06e..010efea 100644 --- a/test_report.md +++ b/test_report.md @@ -1,125 +1,118 @@ -# Test Report – 2 Features Implementation +# Test Report - Dashboard Redesign (3-Spalten Layout) -**Date:** 2026-07-01 -**Project:** web-cad-impl +**Datum:** 2026-07-03 +**Task:** Redesign Dashboard mit Projekt-Ordnern, 3-Spalten Layout, Info-Panel -## Features Implemented +## Summary -### Feature 1: Globale Block-Bibliothek als Baumstruktur -- Backend: `global_block_folders` and `global_blocks` DB tables -- Backend: CRUD API routes with auth middleware and input validation -- Frontend: API service functions in api.ts -- Frontend: BlockLibraryTree.tsx component with tree view, drag&drop, context menu, inline rename -- Frontend: CanvasArea drop handler for global blocks -- Frontend: RightSidebar integration with token prop +| Check | Result | +|-------|--------| +| Frontend `tsc --noEmit` | PASS (0 errors) | +| Frontend `vite build` | PASS (built in 3.73s) | +| Frontend `npm test` | PASS (374 tests, 15 files) | +| Backend `tsc --noEmit` | PASS (0 errors) | +| Backend `npm test` | PASS (239 tests, 16 files) | -### Feature 2: Gruppierungs-Tool -- RibbonBar: Gruppieren + Degruppieren buttons in Start tab -- App.tsx: handleGroup/handleUngroup with Yjs sync -- InteractionEngine: Group-aware selection (click group member → select all), group-aware movement -- RenderEngine: Dashed bounding box with group name label -- LeftSidebar: Group section with buttons -- Keyboard shortcuts: G (group), Ctrl+G (ungroup) -- GroupTool: getGroupElements() method for group expansion +## Backend Test Details -## Test Results +### New Tests: `projectFolders.test.ts` (18 tests) -### Backend Tests +``` +✓ tests/projectFolders.test.ts (18 tests) 283ms -**Command:** `cd backend && node_modules/.bin/tsc --noEmit && node_modules/.bin/vitest run` + Test Files 1 passed (1) + Tests 18 passed (18) +``` -**TypeScript Check:** Exit 0 – 0 errors +Tests cover: +- List empty folders initially +- Create folder (with/without parent) +- List folders with created items +- Get single folder +- Rename folder +- Reject folder creation without name +- Reject rename without name +- Reject operations on non-existent folder (404) +- Reject unauthenticated requests (401) +- Create project with folder_id +- List projects filtered by folder +- List unfoldered projects (folderId=null) +- Move project to different folder +- Move project to root (folder_id=null) +- Reject moving to non-existent folder (404) +- Delete folder moves projects to root +- Delete remaining folders + +### Full Backend Suite + +``` +Test Files 16 passed (16) + Tests 239 passed (239) + Duration 9.73s +``` + +(221 existing + 18 new = 239 total) + +## Frontend Test Details -**Test Results:** ``` Test Files 15 passed (15) -Tests 221 passed (221) -Duration 11.25s + Tests 374 passed (374) + Duration 12.23s ``` -**New test file:** `backend/tests/globalBlocks.test.ts` (28 tests) -- Authentication checks (401 without token) -- Folder CRUD: create, list (root/sub/all), rename, delete with cascade -- Block CRUD: create (in folder/root), list (all/folder/root), get, rename, move, update data, delete -- Validation: missing name, empty name, non-string block_data, self-parent prevention +## Build Verification -### Frontend Tests - -**Command:** `cd frontend && node_modules/.bin/tsc --noEmit && node_modules/.bin/vitest run && node_modules/.bin/vite build` - -**TypeScript Check:** Exit 0 – 0 errors - -**Test Results:** -``` -Test Files 15 passed (15) -Tests 367 passed (367) -Duration 12.83s -``` - -**New test file:** `frontend/tests/globalBlocks.test.ts` (18 tests) -- Global folders API: fetch all, fetch root (null), fetch sub-folders, error handling -- Create folder: with/without parent -- Rename/delete folder -- Global blocks API: fetch all, fetch by folder, fetch root-level -- Create block with full data -- Rename/delete block -- GroupManager.getGroupElements: single element, group members, any member, after ungroup - -**Build:** `vite build` Exit 0 – successful +### Frontend Build ``` +✓ 345 modules transformed. dist/index.html 0.37 kB │ gzip: 0.27 kB -dist/assets/index-DEeNVxCv.css 55.25 kB │ gzip: 9.48 kB -dist/assets/index-BKqmT5jX.js 972.79 kB │ gzip: 327.93 kB -built in 4.33s +dist/assets/index-BZ8Y_YgF.css 64.74 kB │ gzip: 10.84 kB +dist/assets/index-BaRGTmsw.js 994.68 kB │ gzip: 334.41 kB +✓ built in 3.73s ``` -## Smoke Test Description +### Backend TypeScript Check +``` +node_modules/.bin/tsc --noEmit +(exit 0, no errors) +``` -### Feature 1 – Global Block Library -- Backend API tested via integration tests: folders and blocks can be created, listed, renamed, moved, deleted with proper auth and validation -- Frontend API service functions tested with mocked fetch: all 8 functions (getGlobalFolders, createGlobalFolder, renameGlobalFolder, deleteGlobalFolder, getGlobalBlocks, createGlobalBlock, renameGlobalBlock, deleteGlobalBlock) verify correct URL, method, headers, and body -- BlockLibraryTree component renders tree structure with expand/collapse, context menu (right-click), inline rename (double-click), drag&drop blocks onto canvas, SVG file import +## Smoke-Test Description -### Feature 2 – Group Tool -- RibbonBar shows Gruppieren and Degruppieren buttons in Start tab -- LeftSidebar shows Gruppierung section with both buttons -- Keyboard shortcuts: G triggers group, Ctrl+G triggers ungroup -- GroupManager.getGroupElements returns all group members for any element in a group -- InteractionEngine group-aware selection: shift-click a group member selects all group members -- InteractionEngine group-aware movement: moving a selected element also moves all its group members -- RenderEngine draws dashed orange bounding box around group elements with group name label -- Groups sync to Yjs CRDT for real-time collaboration (collab.setGroup) +- **Frontend tsc:** 0 TypeScript errors — all types valid +- **Frontend build:** Vite build succeeds, output generated correctly +- **Frontend tests:** All 374 existing tests pass — no regressions +- **Backend tsc:** 0 TypeScript errors — new DBProjectFolder interface, listProjects signature, moveProjectToFolder, CRUD methods all type-safe +- **Backend tests:** All 239 tests pass (221 existing + 18 new) — new project folder CRUD and project-folder integration fully tested +- **API endpoints tested via inject:** GET/POST/PUT/DELETE /api/project-folders, PUT /api/projects/:id/folder, GET /api/projects?folderId=... ## Files Changed -### Modified (15 files) -- backend/src/database/DatabaseInterface.ts – Added DBGlobalBlockFolder, DBGlobalBlock types and interface methods -- backend/src/database/SqliteAdapter.ts – Implemented global folder/block CRUD methods -- backend/src/database/schema.sql – Added global_block_folders and global_blocks tables -- backend/src/server.ts – Registered globalBlockRoutes -- frontend/src/App.tsx – Added group/ungroup handlers, token prop to RightSidebar, groups prop to CanvasArea -- frontend/src/canvas/RenderEngine.ts – Added setGroups, drawGroupBoxes method -- frontend/src/components/CanvasArea.tsx – Added groups prop, sync to RenderEngine/InteractionEngine, global block drop handler -- frontend/src/components/LeftSidebar.tsx – Added Gruppierung section with group/ungroup buttons -- frontend/src/components/RibbonBar.tsx – Added Gruppieren/Degruppieren buttons in Start tab -- frontend/src/components/RightSidebar.tsx – Added BlockLibraryTree import and token prop -- frontend/src/interaction/index.ts – Added GroupManager import, group-aware selection and movement, G/Ctrl+G shortcuts -- frontend/src/services/api.ts – Added 8 global block API functions -- frontend/src/styles.css – Added CSS for global library tree, context menu, group section -- frontend/src/tools/modification/GroupTool.ts – Added getGroupElements method -- frontend/src/types/ui.types.ts – Added token to RightSidebarProps, groups to CanvasAreaProps, onGroup/onUngroup to LeftSidebarProps +### Modified (8 files, +966/-94 lines) +1. `backend/src/database/DatabaseInterface.ts` — Added DBProjectFolder interface, folder_id to DBProject, listProjects(ownerId?, folderId?), moveProjectToFolder, project folder CRUD methods +2. `backend/src/database/SqliteAdapter.ts` — Implemented all new methods, ALTER TABLE for folder_id on existing DBs, updated createProject with folder_id +3. `backend/src/database/schema.sql` — Added project_folders table + indexes +4. `backend/src/routes/projects.ts` — Added folderId query param filtering +5. `backend/src/server.ts` — Registered projectFolders route +6. `frontend/src/pages/Dashboard.tsx` — Complete rewrite with 3-column layout (TreeView, project cards, info panel) +7. `frontend/src/services/api.ts` — Added ProjectFolder type, getProjectFolders, createProjectFolder, renameProjectFolder, deleteProjectFolder, moveProjectToFolder, getProjectsByFolder +8. `frontend/src/styles/auth.css` — Added 3-column grid layout, TreeView styles, info-panel styles, context menu, folder forms, responsive breakpoint -### New (4 files) -- backend/src/routes/globalBlocks.ts – CRUD routes for global folders and blocks -- backend/tests/globalBlocks.test.ts – Backend integration tests (28 tests) -- frontend/src/components/BlockLibraryTree.tsx – Tree view component for global block library -- frontend/tests/globalBlocks.test.ts – Frontend API service tests (18 tests) +### New (2 files) +9. `backend/src/routes/projectFolders.ts` — New route: GET/POST/PUT/DELETE /api/project-folders, PUT /api/projects/:id/folder (111 lines) +10. `backend/tests/projectFolders.test.ts` — 18 tests covering all folder CRUD + project-folder integration (285 lines) -## Git Diff --stat -``` -15 files changed, 684 insertions(+), 7 deletions(-) -``` +## Known Issues -## No Errors Encountered +- CSS build warning: `Unexpected "}"` at line 1519 — pre-existing, not caused by this change +- Chunk size warning for JS bundle > 500kB — pre-existing -All tests pass, build succeeds, TypeScript checks pass with 0 errors. +## No Forbidden Patterns + +- No `as any` in production code +- No `@ts-ignore` in production code +- No `eslint-disable` in production code +- No `console.log` in production code +- No empty methods or stubs +- No TODO/FIXME markers in main path