§22 S5: in-app create-project + global-directory empty states (@S5 C3.1–C3.2)
Adds the global-Owner create-project action and the role-aware deployment directory, completing slice S5 of the three-tier refactor (release v0.44.0, minor/non-breaking — no migration). Per docs/design/2026-06-05-three-tier-projects-collections.md Part E (S5), Part C.3 (C3.1–C3.2). Backend: - auth.can_create_project — global-Owner gate (deployment owner/admin or an explicit scope_type='global' Owner grant). - bot.create_project — provision the Gitea content repo (seed README so main exists), read+append+commit projects.yaml in the registry repo, audit-log (create_project). The bot stays the only git writer (§1). - POST /api/projects (api_deployment) — global-Owner gated; validates the id (slug, not 'default'), name, type, visibility, content_repo; commits via the bot, then re-mirrors the registry so projects + default collection rows flow from git (§22.2). GET /api/deployment gains viewer.can_create_project and default_project_readable. make_router now takes gitea + bot. Frontend: - api.createProject; DeploymentProvider surfaces viewer + defaultProjectReadable + refresh; DeploymentLanding redirects into the default only when readable (gated/absent default falls through to the directory, no 404 bounce). - Directory.jsx role-aware empty states (C3.1 "Create your first project" CTA; C3.2 "Nothing has been shared with you yet") + Owner-only "New project" control + CreateProjectModal. Tests: backend test_create_project_vertical.py (vertical + gates + the deployment empty-state signals); frontend Directory.test.jsx empty-state cases. Also: ignore the session-local .superpowers/ tooling dir. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,24 +5,34 @@ import { MemoryRouter } from 'react-router-dom'
|
||||
|
||||
// Mocked first so Directory (and ProjectLayout, which it imports entryNoun
|
||||
// from) resolve the deployment hook and api without a live fetch.
|
||||
let mockProjects = []
|
||||
vi.mock('../api', () => ({ getProject: vi.fn() }))
|
||||
let mockDeployment = {}
|
||||
vi.mock('../api', () => ({ getProject: vi.fn(), createProject: vi.fn() }))
|
||||
vi.mock('../context/DeploymentProvider', () => ({
|
||||
useDeployment: () => ({ name: 'Wiggleverse', tagline: 'a directory of projects', projects: mockProjects, defaultProjectId: null }),
|
||||
useDeployment: () => mockDeployment,
|
||||
}))
|
||||
import Directory from './Directory.jsx'
|
||||
|
||||
function renderDir(projects) {
|
||||
mockProjects = projects
|
||||
function renderDir(overrides = {}) {
|
||||
mockDeployment = {
|
||||
name: 'Wiggleverse',
|
||||
tagline: 'a directory of projects',
|
||||
projects: [],
|
||||
viewer: null,
|
||||
defaultProjectId: null,
|
||||
refresh: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
return render(<MemoryRouter><Directory /></MemoryRouter>)
|
||||
}
|
||||
|
||||
describe('Directory', () => {
|
||||
it('renders a card per visible project with the type-driven noun + link', () => {
|
||||
renderDir([
|
||||
{ id: 'ohm', name: 'Open Human Model', type: 'document', visibility: 'public' },
|
||||
{ id: 'ecomm', name: 'Ecomm', type: 'bdd', visibility: 'public' },
|
||||
])
|
||||
renderDir({
|
||||
projects: [
|
||||
{ id: 'ohm', name: 'Open Human Model', type: 'document', visibility: 'public' },
|
||||
{ id: 'ecomm', name: 'Ecomm', type: 'bdd', visibility: 'public' },
|
||||
],
|
||||
})
|
||||
const ohm = screen.getByText('Open Human Model').closest('a')
|
||||
expect(ohm).toHaveAttribute('href', '/p/ohm/')
|
||||
const ecomm = screen.getByText('Ecomm').closest('a')
|
||||
@@ -33,13 +43,41 @@ describe('Directory', () => {
|
||||
})
|
||||
|
||||
it('renders the deployment name and tagline', () => {
|
||||
renderDir([{ id: 'ohm', name: 'OHM', type: 'document', visibility: 'public' }])
|
||||
renderDir({ projects: [{ id: 'ohm', name: 'OHM', type: 'document', visibility: 'public' }] })
|
||||
expect(screen.getByText('Wiggleverse')).toBeInTheDocument()
|
||||
expect(screen.getByText('a directory of projects')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders an empty list without crashing when no projects are visible', () => {
|
||||
renderDir([])
|
||||
renderDir({ projects: [] })
|
||||
expect(screen.getByText('Wiggleverse')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// §22 S5 — role-aware empty states (C3.1 / C3.2).
|
||||
it('shows a "Create your first project" CTA to a global Owner on an empty directory', () => {
|
||||
renderDir({ projects: [], viewer: { can_create_project: true } })
|
||||
expect(screen.getByText('Create your first project')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a "nothing shared with you yet" note to a non-owner on an empty directory', () => {
|
||||
renderDir({ projects: [], viewer: { can_create_project: false } })
|
||||
expect(screen.getByText(/Nothing has been shared with you yet/i)).toBeInTheDocument()
|
||||
expect(screen.queryByText('Create your first project')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers a "New project" control to an Owner when the directory is non-empty', () => {
|
||||
renderDir({
|
||||
projects: [{ id: 'ohm', name: 'OHM', type: 'document', visibility: 'public' }],
|
||||
viewer: { can_create_project: true },
|
||||
})
|
||||
expect(screen.getByText('New project')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not offer a create control to a non-owner when the directory is non-empty', () => {
|
||||
renderDir({
|
||||
projects: [{ id: 'ohm', name: 'OHM', type: 'document', visibility: 'public' }],
|
||||
viewer: { can_create_project: false },
|
||||
})
|
||||
expect(screen.queryByText('New project')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user