§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:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.43.0",
|
||||
"version": "0.44.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -448,16 +448,19 @@ function DeploymentLanding() {
|
||||
// OHM's "land in the corpus" UX is preserved; the directory appears only
|
||||
// when 2+ projects are visible to the caller. Visibility is per-caller
|
||||
// (§22.5), so the unlisted projects never count toward the directory.
|
||||
const { projects, defaultProjectId, loading } = useDeployment()
|
||||
const { projects, defaultProjectId, defaultProjectReadable, loading } = useDeployment()
|
||||
if (loading) {
|
||||
return <main className="chrome-pane"><div className="boot">Loading…</div></main>
|
||||
}
|
||||
if (projects.length === 1) {
|
||||
return <Navigate to={`/p/${projects[0].id}/`} replace />
|
||||
}
|
||||
if (projects.length === 0 && defaultProjectId) {
|
||||
// No enumerable projects but a default exists (e.g. an anon hitting a
|
||||
// deployment whose only project is unlisted-but-default) — land there.
|
||||
if (projects.length === 0 && defaultProjectReadable && defaultProjectId) {
|
||||
// No enumerable projects but the default is readable by this viewer (e.g. an
|
||||
// anon hitting a deployment whose only project is unlisted-but-default) —
|
||||
// land there. §22 S5: when the default is gated to this viewer (C3.2) or
|
||||
// absent (C3.1, no projects), we do NOT bounce into a 404 — we fall through
|
||||
// to the directory's role-aware empty state below.
|
||||
return <Navigate to={`/p/${defaultProjectId}/`} replace />
|
||||
}
|
||||
return <Directory />
|
||||
|
||||
@@ -182,6 +182,24 @@ export async function getProject(projectId) {
|
||||
return jsonOrThrow(await fetch(`/api/projects/${projectId}`))
|
||||
}
|
||||
|
||||
// §22 S5: create-project (global Owner). The backend provisions a Gitea content
|
||||
// repo, commits the project to projects.yaml, re-mirrors the registry, and
|
||||
// returns the new project { id, name, type, visibility }.
|
||||
export async function createProject({ projectId, name, type, visibility, contentRepo }) {
|
||||
const res = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project_id: projectId,
|
||||
name,
|
||||
type,
|
||||
visibility: visibility || null,
|
||||
content_repo: contentRepo || null,
|
||||
}),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// §22.4 (Plan B) / §22 S2: per-collection serving. With a projectId + a
|
||||
// collectionId, read the collection-scoped routes; with only a projectId, the
|
||||
// project default-collection compat path; with neither, the unscoped path.
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// CreateProjectModal.jsx — §22 S5. The create-project form the deployment
|
||||
// directory's "Create your first project" / "New project" control opens, gated
|
||||
// on the viewer's `can_create_project` capability (a global Owner action).
|
||||
//
|
||||
// The form lets the Owner choose a project id (a slug, not "default"), a display
|
||||
// name, the type of its initial (default) collection, a visibility, and an
|
||||
// optional content-repo name (defaulting to `<id>-content`). The backend
|
||||
// provisions the Gitea content repo, commits the project to projects.yaml,
|
||||
// re-mirrors the registry, and returns the new project; on success the caller
|
||||
// refreshes the directory (and the N=1 redirect then lands in the new project).
|
||||
|
||||
import { useState } from 'react'
|
||||
import { createProject } from '../api'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'document', label: 'Document — prose RFCs' },
|
||||
{ value: 'specification', label: 'Specification' },
|
||||
{ value: 'bdd', label: 'BDD — behaviour scenarios' },
|
||||
]
|
||||
|
||||
const VISIBILITY_OPTIONS = [
|
||||
{ value: 'public', label: 'Public' },
|
||||
{ value: 'unlisted', label: 'Unlisted (link-only)' },
|
||||
{ value: 'gated', label: 'Gated (hidden from the public)' },
|
||||
]
|
||||
|
||||
export default function CreateProjectModal({ onClose, onCreated }) {
|
||||
const [projectId, setProjectId] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [type, setType] = useState('document')
|
||||
const [visibility, setVisibility] = useState('public')
|
||||
const [contentRepo, setContentRepo] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
async function handleCreate(e) {
|
||||
e.preventDefault()
|
||||
const pid = projectId.trim().toLowerCase()
|
||||
if (!pid || !name.trim()) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const project = await createProject({
|
||||
projectId: pid,
|
||||
name: name.trim(),
|
||||
type,
|
||||
visibility,
|
||||
contentRepo: contentRepo.trim() || null,
|
||||
})
|
||||
onCreated?.(project)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to create the project.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal" style={{ maxWidth: 560 }}>
|
||||
<div className="modal-header">
|
||||
<h2>New project</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<form onSubmit={handleCreate} className="invitations-form">
|
||||
<label htmlFor="proj-id">Project id</label>
|
||||
<input
|
||||
id="proj-id"
|
||||
value={projectId}
|
||||
onChange={e => setProjectId(e.target.value)}
|
||||
placeholder="e.g. ohm, acme"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<label htmlFor="proj-name" style={{ marginTop: 10 }}>Display name</label>
|
||||
<input
|
||||
id="proj-name"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="e.g. Open Human Model"
|
||||
required
|
||||
/>
|
||||
<label htmlFor="proj-type" style={{ marginTop: 10 }}>Initial collection type</label>
|
||||
<select id="proj-type" value={type} onChange={e => setType(e.target.value)}>
|
||||
{TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<label htmlFor="proj-vis" style={{ marginTop: 10 }}>Visibility</label>
|
||||
<select id="proj-vis" value={visibility} onChange={e => setVisibility(e.target.value)}>
|
||||
{VISIBILITY_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<label htmlFor="proj-repo" style={{ marginTop: 10 }}>Content repo (optional)</label>
|
||||
<input
|
||||
id="proj-repo"
|
||||
value={contentRepo}
|
||||
onChange={e => setContentRepo(e.target.value)}
|
||||
placeholder="defaults to <id>-content"
|
||||
/>
|
||||
<div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Creating…' : 'Create project'}
|
||||
</button>
|
||||
{error && <span style={{ color: '#c33' }}>{error}</span>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn-link" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +1,71 @@
|
||||
// §22.10 (M3) — the deployment directory at `/`. Renders the caller-visible
|
||||
// projects (from /api/deployment) as cards linking into each project's
|
||||
// `/p/<id>/` home. App's DeploymentLanding only mounts this when 2+ projects
|
||||
// are visible; the N=1 case redirects straight into the single project so
|
||||
// OHM's "land in the corpus" UX is preserved.
|
||||
// `/p/<id>/` home. App's DeploymentLanding only mounts this when the N=1
|
||||
// land-in-corpus redirect does not apply (2+ visible projects, or no readable
|
||||
// default) so OHM's "land in the corpus" UX is preserved.
|
||||
//
|
||||
// §22 S5 — role-aware empty states + the global-Owner create-project action.
|
||||
// The deployment payload carries a `viewer` block; the directory reads it to
|
||||
// render:
|
||||
// * C3.1: a global Owner sees a "Create your first project" CTA (and a "New
|
||||
// project" control when the directory is non-empty), opening the
|
||||
// create-project modal (choose id, name, type, visibility).
|
||||
// * C3.2: a non-owner (a granted account with no roles) sees the empty
|
||||
// directory with no create action and a note that nothing is shared yet.
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useDeployment } from '../context/DeploymentProvider'
|
||||
import { entryNoun } from './ProjectLayout.jsx'
|
||||
import CreateProjectModal from './CreateProjectModal.jsx'
|
||||
|
||||
export default function Directory() {
|
||||
const { name, tagline, projects } = useDeployment()
|
||||
const { name, tagline, projects, viewer, refresh } = useDeployment()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const canCreate = !!viewer?.can_create_project
|
||||
|
||||
return (
|
||||
<main className="chrome-pane">
|
||||
<div className="directory">
|
||||
<h1>{name}</h1>
|
||||
<div className="directory-head">
|
||||
<h1>{name}</h1>
|
||||
<div className="directory-actions">
|
||||
{canCreate && projects.length > 0 && (
|
||||
<button className="btn-primary" onClick={() => setCreateOpen(true)}>New project</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{tagline && <p className="directory-tagline">{tagline}</p>}
|
||||
<ul className="directory-list">
|
||||
{projects.map(p => (
|
||||
<li key={p.id} className="directory-card">
|
||||
<Link to={`/p/${p.id}/`}>
|
||||
<span className="directory-card-name">{p.name}</span>
|
||||
<span className="directory-card-type">{entryNoun(p.type)}s</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{projects.length === 0 ? (
|
||||
// C3.1 / C3.2: role-keyed empty state.
|
||||
canCreate ? (
|
||||
<div className="directory-empty">
|
||||
<p className="directory-tagline">No projects yet.</p>
|
||||
<button className="btn-primary" onClick={() => setCreateOpen(true)}>
|
||||
Create your first project
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="directory-tagline">Nothing has been shared with you yet.</p>
|
||||
)
|
||||
) : (
|
||||
<ul className="directory-list">
|
||||
{projects.map(p => (
|
||||
<li key={p.id} className="directory-card">
|
||||
<Link to={`/p/${p.id}/`}>
|
||||
<span className="directory-card-name">{p.name}</span>
|
||||
<span className="directory-card-type">{entryNoun(p.type)}s</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{createOpen && (
|
||||
<CreateProjectModal
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={() => { setCreateOpen(false); refresh?.() }}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,22 +2,30 @@
|
||||
// VITE_APP_NAME. Fetches GET /api/deployment once on boot and provides it to
|
||||
// the whole tree:
|
||||
//
|
||||
// { name, tagline, defaultProjectId, projects[], loading }
|
||||
// { name, tagline, defaultProjectId, defaultProjectReadable, projects[],
|
||||
// viewer, loading, refresh }
|
||||
//
|
||||
// `projects` is the per-caller-visible set (§22.5: public + member-gated;
|
||||
// unlisted omitted). `defaultProjectId` is the corpus-served project the
|
||||
// M3-frontend guard keys on. During the pre-fetch paint, consumers fall back
|
||||
// to the neutral brandTitle() default ('RFC') — never a hardcoded deployment
|
||||
// name.
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
// M3-frontend guard keys on. `defaultProjectReadable` (§22 S5) is whether that
|
||||
// redirect target is reachable by this viewer — the deployment landing only
|
||||
// bounces into it when true, else it renders the role-aware directory empty
|
||||
// state. `viewer` carries the §22 S5 capability flags (`can_create_project`).
|
||||
// `refresh` re-fetches the payload (e.g. after creating a project). During the
|
||||
// pre-fetch paint, consumers fall back to the neutral brandTitle() default
|
||||
// ('RFC') — never a hardcoded deployment name.
|
||||
import { createContext, useContext, useCallback, useEffect, useState } from 'react'
|
||||
import { getDeployment } from '../api'
|
||||
|
||||
const DeploymentContext = createContext({
|
||||
name: '',
|
||||
tagline: '',
|
||||
defaultProjectId: null,
|
||||
defaultProjectReadable: false,
|
||||
projects: [],
|
||||
viewer: null,
|
||||
loading: true,
|
||||
refresh: () => {},
|
||||
})
|
||||
|
||||
export function useDeployment() {
|
||||
@@ -29,33 +37,43 @@ export function DeploymentProvider({ children }) {
|
||||
name: '',
|
||||
tagline: '',
|
||||
defaultProjectId: null,
|
||||
defaultProjectReadable: false,
|
||||
projects: [],
|
||||
viewer: null,
|
||||
loading: true,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
getDeployment()
|
||||
const load = useCallback((cancelledRef) => {
|
||||
return getDeployment()
|
||||
.then(d => {
|
||||
if (cancelled) return
|
||||
if (cancelledRef?.cancelled) return
|
||||
setState({
|
||||
name: d.name || '',
|
||||
tagline: d.tagline || '',
|
||||
defaultProjectId: d.default_project_id || null,
|
||||
defaultProjectReadable: !!d.default_project_readable,
|
||||
projects: Array.isArray(d.projects) ? d.projects : [],
|
||||
viewer: d.viewer || null,
|
||||
loading: false,
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
// A failed deployment fetch must not wedge the app: fall back to the
|
||||
// neutral brand and an empty directory so the shell still paints.
|
||||
if (!cancelled) setState(s => ({ ...s, loading: false }))
|
||||
if (!cancelledRef?.cancelled) setState(s => ({ ...s, loading: false }))
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const ref = { cancelled: false }
|
||||
load(ref)
|
||||
return () => { ref.cancelled = true }
|
||||
}, [load])
|
||||
|
||||
const refresh = useCallback(() => load(), [load])
|
||||
|
||||
return (
|
||||
<DeploymentContext.Provider value={state}>
|
||||
<DeploymentContext.Provider value={{ ...state, refresh }}>
|
||||
{children}
|
||||
</DeploymentContext.Provider>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user