Add project creation from team dashboard and update seed data
- Add project creation modal to TeamDashboardPage with team_id assignment - Update createProject API function to accept optional team_id - Update seed data to create a "Demo Team" and assign all projects to it - Admin user is added as team owner when present
This commit is contained in:
@@ -38,10 +38,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- TeamContext provider for managing current team selection
|
||||
- TeamSelector dropdown component (persists selection in localStorage)
|
||||
- Teams list page at `/teams`
|
||||
- Team dashboard page at `/teams/{slug}`
|
||||
- Team dashboard page at `/teams/{slug}` with inline project creation
|
||||
- Team settings page at `/teams/{slug}/settings`
|
||||
- Team members page at `/teams/{slug}/members`
|
||||
- Teams navigation link in header (authenticated users only)
|
||||
- Updated seed data to create a "Demo Team" and assign all seed projects to it
|
||||
- Added TypeScript types and API client functions for teams
|
||||
- Added integration tests for team CRUD, membership, and project operations
|
||||
- Added unit tests for TeamAuthorizationService
|
||||
|
||||
@@ -5,7 +5,7 @@ import hashlib
|
||||
import logging
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import Project, Package, Artifact, Tag, Upload, PackageVersion, ArtifactDependency
|
||||
from .models import Project, Package, Artifact, Tag, Upload, PackageVersion, ArtifactDependency, Team, TeamMembership, User
|
||||
from .storage import get_storage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -149,6 +149,33 @@ def seed_database(db: Session) -> None:
|
||||
logger.info("Seeding database with test data...")
|
||||
storage = get_storage()
|
||||
|
||||
# Find or use admin user for team ownership
|
||||
admin_user = db.query(User).filter(User.username == "admin").first()
|
||||
team_owner_username = admin_user.username if admin_user else "seed-user"
|
||||
|
||||
# Create a demo team
|
||||
demo_team = Team(
|
||||
name="Demo Team",
|
||||
slug="demo-team",
|
||||
description="A demonstration team with sample projects",
|
||||
created_by=team_owner_username,
|
||||
)
|
||||
db.add(demo_team)
|
||||
db.flush()
|
||||
|
||||
# Add admin user as team owner if they exist
|
||||
if admin_user:
|
||||
membership = TeamMembership(
|
||||
team_id=demo_team.id,
|
||||
user_id=admin_user.id,
|
||||
role="owner",
|
||||
invited_by=team_owner_username,
|
||||
)
|
||||
db.add(membership)
|
||||
db.flush()
|
||||
|
||||
logger.info(f"Created team: {demo_team.name} ({demo_team.slug})")
|
||||
|
||||
# Create projects and packages
|
||||
project_map = {}
|
||||
package_map = {}
|
||||
@@ -158,7 +185,8 @@ def seed_database(db: Session) -> None:
|
||||
name=project_data["name"],
|
||||
description=project_data["description"],
|
||||
is_public=project_data["is_public"],
|
||||
created_by="seed-user",
|
||||
created_by=team_owner_username,
|
||||
team_id=demo_team.id, # Assign to demo team
|
||||
)
|
||||
db.add(project)
|
||||
db.flush() # Get the ID
|
||||
@@ -174,7 +202,7 @@ def seed_database(db: Session) -> None:
|
||||
db.flush()
|
||||
package_map[(project_data["name"], package_data["name"])] = package
|
||||
|
||||
logger.info(f"Created {len(project_map)} projects and {len(package_map)} packages")
|
||||
logger.info(f"Created {len(project_map)} projects and {len(package_map)} packages (assigned to {demo_team.slug})")
|
||||
|
||||
# Create artifacts, tags, and versions
|
||||
artifact_count = 0
|
||||
@@ -212,7 +240,7 @@ def seed_database(db: Session) -> None:
|
||||
size=size,
|
||||
content_type=artifact_data["content_type"],
|
||||
original_name=artifact_data["filename"],
|
||||
created_by="seed-user",
|
||||
created_by=team_owner_username,
|
||||
s3_key=s3_key,
|
||||
ref_count=ref_count,
|
||||
)
|
||||
@@ -235,7 +263,7 @@ def seed_database(db: Session) -> None:
|
||||
artifact_id=sha256_hash,
|
||||
version=artifact_data["version"],
|
||||
version_source="explicit",
|
||||
created_by="seed-user",
|
||||
created_by=team_owner_username,
|
||||
)
|
||||
db.add(version)
|
||||
version_count += 1
|
||||
@@ -246,7 +274,7 @@ def seed_database(db: Session) -> None:
|
||||
package_id=package.id,
|
||||
name=tag_name,
|
||||
artifact_id=sha256_hash,
|
||||
created_by="seed-user",
|
||||
created_by=team_owner_username,
|
||||
)
|
||||
db.add(tag)
|
||||
tag_count += 1
|
||||
|
||||
@@ -166,7 +166,7 @@ export async function listProjectsSimple(params: ListParams = {}): Promise<Proje
|
||||
return data.items;
|
||||
}
|
||||
|
||||
export async function createProject(data: { name: string; description?: string; is_public?: boolean }): Promise<Project> {
|
||||
export async function createProject(data: { name: string; description?: string; is_public?: boolean; team_id?: string }): Promise<Project> {
|
||||
const response = await fetch(`${API_BASE}/projects`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -221,3 +221,100 @@
|
||||
.btn-secondary:hover {
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--color-bg);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-alpha);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.checkbox-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.empty-state .btn {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link, useParams, useNavigate } from 'react-router-dom';
|
||||
import { TeamDetail, Project, PaginatedResponse } from '../types';
|
||||
import { getTeam, listTeamProjects } from '../api';
|
||||
import { getTeam, listTeamProjects, createProject } from '../api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Badge } from '../components/Badge';
|
||||
import { Breadcrumb } from '../components/Breadcrumb';
|
||||
@@ -15,6 +15,9 @@ function TeamDashboardPage() {
|
||||
const [projects, setProjects] = useState<PaginatedResponse<Project> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showProjectForm, setShowProjectForm] = useState(false);
|
||||
const [newProject, setNewProject] = useState({ name: '', description: '', is_public: true });
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const loadTeamData = useCallback(async () => {
|
||||
if (!slug) return;
|
||||
@@ -38,6 +41,22 @@ function TeamDashboardPage() {
|
||||
loadTeamData();
|
||||
}, [loadTeamData]);
|
||||
|
||||
async function handleCreateProject(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!team) return;
|
||||
try {
|
||||
setCreating(true);
|
||||
const project = await createProject({ ...newProject, team_id: team.id });
|
||||
setNewProject({ name: '', description: '', is_public: true });
|
||||
setShowProjectForm(false);
|
||||
navigate(`/project/${project.name}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create project');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="team-dashboard">
|
||||
@@ -124,13 +143,64 @@ function TeamDashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showProjectForm && (
|
||||
<div className="modal-overlay" onClick={() => setShowProjectForm(false)}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<h2>Create New Project</h2>
|
||||
<form onSubmit={handleCreateProject}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="project-name">Project Name</label>
|
||||
<input
|
||||
id="project-name"
|
||||
type="text"
|
||||
value={newProject.name}
|
||||
onChange={e => setNewProject({ ...newProject, name: e.target.value })}
|
||||
placeholder="my-project"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="project-description">Description (optional)</label>
|
||||
<textarea
|
||||
id="project-description"
|
||||
value={newProject.description}
|
||||
onChange={e => setNewProject({ ...newProject, description: e.target.value })}
|
||||
placeholder="What is this project for?"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group checkbox-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newProject.is_public}
|
||||
onChange={e => setNewProject({ ...newProject, is_public: e.target.checked })}
|
||||
/>
|
||||
Public project
|
||||
</label>
|
||||
<span className="form-hint">Public projects are visible to everyone</span>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setShowProjectForm(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={creating}>
|
||||
{creating ? 'Creating...' : 'Create Project'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="team-section">
|
||||
<div className="section-header">
|
||||
<h2>Projects</h2>
|
||||
{isAdminOrOwner && (
|
||||
<Link to="/" className="btn btn-primary btn-sm">
|
||||
<button className="btn btn-primary btn-sm" onClick={() => setShowProjectForm(true)}>
|
||||
+ New Project
|
||||
</Link>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -138,7 +208,9 @@ function TeamDashboardPage() {
|
||||
<div className="empty-state">
|
||||
<p>No projects in this team yet.</p>
|
||||
{isAdminOrOwner && (
|
||||
<p className="empty-hint">Create a project and assign it to this team to get started.</p>
|
||||
<button className="btn btn-primary" onClick={() => setShowProjectForm(true)}>
|
||||
Create Project
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user