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
|
- TeamContext provider for managing current team selection
|
||||||
- TeamSelector dropdown component (persists selection in localStorage)
|
- TeamSelector dropdown component (persists selection in localStorage)
|
||||||
- Teams list page at `/teams`
|
- 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 settings page at `/teams/{slug}/settings`
|
||||||
- Team members page at `/teams/{slug}/members`
|
- Team members page at `/teams/{slug}/members`
|
||||||
- Teams navigation link in header (authenticated users only)
|
- 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 TypeScript types and API client functions for teams
|
||||||
- Added integration tests for team CRUD, membership, and project operations
|
- Added integration tests for team CRUD, membership, and project operations
|
||||||
- Added unit tests for TeamAuthorizationService
|
- Added unit tests for TeamAuthorizationService
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import hashlib
|
|||||||
import logging
|
import logging
|
||||||
from sqlalchemy.orm import Session
|
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
|
from .storage import get_storage
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -149,6 +149,33 @@ def seed_database(db: Session) -> None:
|
|||||||
logger.info("Seeding database with test data...")
|
logger.info("Seeding database with test data...")
|
||||||
storage = get_storage()
|
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
|
# Create projects and packages
|
||||||
project_map = {}
|
project_map = {}
|
||||||
package_map = {}
|
package_map = {}
|
||||||
@@ -158,7 +185,8 @@ def seed_database(db: Session) -> None:
|
|||||||
name=project_data["name"],
|
name=project_data["name"],
|
||||||
description=project_data["description"],
|
description=project_data["description"],
|
||||||
is_public=project_data["is_public"],
|
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.add(project)
|
||||||
db.flush() # Get the ID
|
db.flush() # Get the ID
|
||||||
@@ -174,7 +202,7 @@ def seed_database(db: Session) -> None:
|
|||||||
db.flush()
|
db.flush()
|
||||||
package_map[(project_data["name"], package_data["name"])] = package
|
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
|
# Create artifacts, tags, and versions
|
||||||
artifact_count = 0
|
artifact_count = 0
|
||||||
@@ -212,7 +240,7 @@ def seed_database(db: Session) -> None:
|
|||||||
size=size,
|
size=size,
|
||||||
content_type=artifact_data["content_type"],
|
content_type=artifact_data["content_type"],
|
||||||
original_name=artifact_data["filename"],
|
original_name=artifact_data["filename"],
|
||||||
created_by="seed-user",
|
created_by=team_owner_username,
|
||||||
s3_key=s3_key,
|
s3_key=s3_key,
|
||||||
ref_count=ref_count,
|
ref_count=ref_count,
|
||||||
)
|
)
|
||||||
@@ -235,7 +263,7 @@ def seed_database(db: Session) -> None:
|
|||||||
artifact_id=sha256_hash,
|
artifact_id=sha256_hash,
|
||||||
version=artifact_data["version"],
|
version=artifact_data["version"],
|
||||||
version_source="explicit",
|
version_source="explicit",
|
||||||
created_by="seed-user",
|
created_by=team_owner_username,
|
||||||
)
|
)
|
||||||
db.add(version)
|
db.add(version)
|
||||||
version_count += 1
|
version_count += 1
|
||||||
@@ -246,7 +274,7 @@ def seed_database(db: Session) -> None:
|
|||||||
package_id=package.id,
|
package_id=package.id,
|
||||||
name=tag_name,
|
name=tag_name,
|
||||||
artifact_id=sha256_hash,
|
artifact_id=sha256_hash,
|
||||||
created_by="seed-user",
|
created_by=team_owner_username,
|
||||||
)
|
)
|
||||||
db.add(tag)
|
db.add(tag)
|
||||||
tag_count += 1
|
tag_count += 1
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export async function listProjectsSimple(params: ListParams = {}): Promise<Proje
|
|||||||
return data.items;
|
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`, {
|
const response = await fetch(`${API_BASE}/projects`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -221,3 +221,100 @@
|
|||||||
.btn-secondary:hover {
|
.btn-secondary:hover {
|
||||||
background: var(--color-bg-tertiary);
|
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 { useState, useEffect, useCallback } from 'react';
|
||||||
import { Link, useParams, useNavigate } from 'react-router-dom';
|
import { Link, useParams, useNavigate } from 'react-router-dom';
|
||||||
import { TeamDetail, Project, PaginatedResponse } from '../types';
|
import { TeamDetail, Project, PaginatedResponse } from '../types';
|
||||||
import { getTeam, listTeamProjects } from '../api';
|
import { getTeam, listTeamProjects, createProject } from '../api';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { Badge } from '../components/Badge';
|
import { Badge } from '../components/Badge';
|
||||||
import { Breadcrumb } from '../components/Breadcrumb';
|
import { Breadcrumb } from '../components/Breadcrumb';
|
||||||
@@ -15,6 +15,9 @@ function TeamDashboardPage() {
|
|||||||
const [projects, setProjects] = useState<PaginatedResponse<Project> | null>(null);
|
const [projects, setProjects] = useState<PaginatedResponse<Project> | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 () => {
|
const loadTeamData = useCallback(async () => {
|
||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
@@ -38,6 +41,22 @@ function TeamDashboardPage() {
|
|||||||
loadTeamData();
|
loadTeamData();
|
||||||
}, [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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="team-dashboard">
|
<div className="team-dashboard">
|
||||||
@@ -124,13 +143,64 @@ function TeamDashboardPage() {
|
|||||||
</div>
|
</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="team-section">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<h2>Projects</h2>
|
<h2>Projects</h2>
|
||||||
{isAdminOrOwner && (
|
{isAdminOrOwner && (
|
||||||
<Link to="/" className="btn btn-primary btn-sm">
|
<button className="btn btn-primary btn-sm" onClick={() => setShowProjectForm(true)}>
|
||||||
+ New Project
|
+ New Project
|
||||||
</Link>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -138,7 +208,9 @@ function TeamDashboardPage() {
|
|||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<p>No projects in this team yet.</p>
|
<p>No projects in this team yet.</p>
|
||||||
{isAdminOrOwner && (
|
{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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user