Files
rfc-app/frontend/src/components/CollectionDirectory.jsx
T
Ben Stull fcc3c84d76 §22 S6: request-to-join + cross-collection inbox (§22.8) — v0.46.0
Ships the request side of joining a gated scope, completing the §22.8 pair
(S4 shipped the invite half). A user who knows a project/collection exists
asks to join it naming a desired role; the request fans out to that scope's
Owners across the subtree (the cross-collection inbox, §22.11), who accept
(writing the memberships row via memberships.grant) or decline. Built by
analogy to §28 contribution_requests + the S4 memberships surface.

Backend
- migration 032: join_requests (scope_type ∈ {project,collection}, scope_id,
  requester, requested_role, message, status, granted_role); one-open-per
  (scope, requester) partial unique index. Additive — no rebuild.
- api_join_requests.py: GET join-target / POST join-requests / POST
  {id}/accept / {id}/decline under /api/scopes/{scope_type}/{scope_id}/.
  Accept grants via memberships.grant; the request POST does not require the
  scope be readable (that is how one joins a gated scope).
- notify: fan_out_join_request (subtree-Owner enumeration via
  _scope_owner_user_ids), notify_join_decided, 3 render_summary cases.
- auth.effective_role_at_scope — scope-grain twin of effective_scope_role,
  folding global → project for a project target.
- api_collections: viewer.can_request_join on the project + collection blocks.

Frontend
- api.js join verbs; JoinRequestModal; "Request to join" affordance in the
  collection directory + catalog footer; JoinRequestRow in the inbox.

Tests: backend test_join_requests_vertical (11) + test_migration_032 (5);
frontend api.joinrequests + CollectionDirectory cases. 546 backend / 36
frontend green.

Per docs/design/2026-06-05-three-tier-projects-collections.md Part E (S6) and
SPEC.md §22.8 / §22.11. Closes the request-to-join item flagged open at
0.45.0; per-type surfaces (§22.4a items 1 & 3) remain the last S6 item.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 02:11:16 -07:00

120 lines
4.6 KiB
React

// §22 S2 — the project collection directory at `/p/<project>/`. Lists the
// project's caller-visible collections as cards linking into each collection's
// `/p/<project>/c/<collection>/` home. When exactly one collection is visible
// the directory is skipped and we redirect straight into it (the S1 C3.7/C3.8
// single-collection UX, preserved).
//
// §22 S4 — role-aware empty states + owner controls. The list response carries
// a `viewer` capability block; the directory reads it to render:
// * C3.3: a project Owner sees a "Create your first collection" CTA (and a
// "New collection" control when the directory is non-empty), opening the
// create-collection modal (choose a type + id + visibility).
// * C3.4: a contributor without create rights sees the empty directory with
// no create action.
// * C.2: an Owner with membership-management reach sees a "Members" control
// opening the scope-role invitation modal.
import { useEffect, useState } from 'react'
import { Link, Navigate } from 'react-router-dom'
import { listCollections } from '../api'
import { collectionHome } from '../lib/entryPaths'
import { entryNoun } from './ProjectLayout.jsx'
import CreateCollectionModal from './CreateCollectionModal.jsx'
import ScopeMembersModal from './ScopeMembersModal.jsx'
import JoinRequestModal from './JoinRequestModal.jsx'
export default function CollectionDirectory({ projectId }) {
const [cols, setCols] = useState(null)
const [viewer, setViewer] = useState(null)
const [version, setVersion] = useState(0)
const [createOpen, setCreateOpen] = useState(false)
const [membersOpen, setMembersOpen] = useState(false)
const [joinOpen, setJoinOpen] = useState(false)
useEffect(() => {
let live = true
listCollections(projectId)
.then(d => { if (live) { setCols(d.items); setViewer(d.viewer || null) } })
.catch(() => { if (live) { setCols([]); setViewer(null) } })
return () => { live = false }
}, [projectId, version])
if (cols === null) {
return <main className="chrome-pane"><div className="boot">Loading</div></main>
}
// C3.7/C3.8: a single visible collection skips the directory.
if (cols.length === 1) {
return <Navigate to={collectionHome(projectId, cols[0].id)} replace />
}
const canCreate = !!viewer?.can_create_collection
const canInvite = !!viewer?.can_invite
const canRequestJoin = !!viewer?.can_request_join
return (
<main className="chrome-pane">
<div className="directory">
<div className="directory-head">
<h1>Collections</h1>
<div className="directory-actions">
{canInvite && (
<button className="btn-link" onClick={() => setMembersOpen(true)}>Members</button>
)}
{canRequestJoin && (
<button className="btn-link" onClick={() => setJoinOpen(true)}>Request to join</button>
)}
{canCreate && cols.length > 0 && (
<button className="btn-primary" onClick={() => setCreateOpen(true)}>New collection</button>
)}
</div>
</div>
{cols.length === 0 ? (
// C3.3 / C3.4: role-keyed empty state.
canCreate ? (
<div className="directory-empty">
<p className="directory-tagline">No collections yet.</p>
<button className="btn-primary" onClick={() => setCreateOpen(true)}>
Create your first collection
</button>
</div>
) : (
<p className="directory-tagline">No collections yet.</p>
)
) : (
<ul className="directory-list">
{cols.map(c => (
<li key={c.id} className="directory-card">
<Link to={collectionHome(projectId, c.id)}>
<span className="directory-card-name">{c.name || c.id}</span>
<span className="directory-card-type">{entryNoun(c.type)}s</span>
</Link>
</li>
))}
</ul>
)}
</div>
{createOpen && (
<CreateCollectionModal
projectId={projectId}
onClose={() => setCreateOpen(false)}
onCreated={() => { setCreateOpen(false); setVersion(v => v + 1) }}
/>
)}
{membersOpen && (
<ScopeMembersModal
projectId={projectId}
collections={cols}
viewer={viewer}
onClose={() => setMembersOpen(false)}
/>
)}
{joinOpen && (
<JoinRequestModal
scopeType="project"
scopeId={projectId}
onClose={() => setJoinOpen(false)}
/>
)}
</main>
)
}