fcc3c84d76
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>
216 lines
8.5 KiB
React
216 lines
8.5 KiB
React
// Catalog.jsx — the §7 left pane.
|
|
//
|
|
// One scrollable flat list of every super-draft and active entry,
|
|
// state-styled rather than grouped. Filter chips are AND-combined per
|
|
// §7.1; search is fuzzy over title + slug + id. The "Pending ideas"
|
|
// disclosure per §7.3 lives at the bottom, above the "+ Propose New RFC"
|
|
// button.
|
|
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
import { useParams, Link } from 'react-router-dom'
|
|
import { listRFCs, listProposals, getCollection } from '../api'
|
|
import { entryPath, proposalPath, useProjectId, useCollectionId } from '../lib/entryPaths'
|
|
import JoinRequestModal from './JoinRequestModal.jsx'
|
|
|
|
const STATE_CHIPS = [
|
|
{ id: 'super-draft', label: 'Super-draft' },
|
|
{ id: 'active', label: 'Active' },
|
|
]
|
|
|
|
const SORT_OPTIONS = [
|
|
{ id: 'recent', label: 'Recently active' },
|
|
{ id: 'title', label: 'Title' },
|
|
{ id: 'id', label: 'ID' },
|
|
{ id: 'state', label: 'State' },
|
|
]
|
|
|
|
export default function Catalog({ viewer, onProposeRFC, version }) {
|
|
const [rfcs, setRfcs] = useState([])
|
|
const [proposals, setProposals] = useState([])
|
|
// §22 S4: the viewer's contribute capability in this collection, driving the
|
|
// propose-first empty state (C3.5) and the propose control. `null` until the
|
|
// collection's caps load; we fall back to "any authenticated viewer" so the
|
|
// common case doesn't flicker, then refine.
|
|
const [canContribute, setCanContribute] = useState(null)
|
|
// §22.8: when the viewer can't contribute here but holds no role reaching the
|
|
// collection, offer "Request to join" in the footer.
|
|
const [canRequestJoin, setCanRequestJoin] = useState(false)
|
|
const [joinOpen, setJoinOpen] = useState(false)
|
|
// §22.4a: the type-driven entry noun ("RFC" | "Spec" | "Feature") for this
|
|
// collection, read from the API. Defaults to the generic "RFC" until loaded.
|
|
const [entryNoun, setEntryNoun] = useState('RFC')
|
|
const [search, setSearch] = useState('')
|
|
const [sort, setSort] = useState('recent')
|
|
const [activeChips, setActiveChips] = useState(new Set())
|
|
const [pendingOpen, setPendingOpen] = useState(true)
|
|
const { slug, prNumber } = useParams()
|
|
const pid = useProjectId()
|
|
// §22 S2: the catalog is scoped to the active collection (the `/c/:cid/`
|
|
// route segment, else the project's default collection).
|
|
const cid = useCollectionId()
|
|
|
|
useEffect(() => {
|
|
listRFCs(pid, cid).then(d => setRfcs(d.items)).catch(() => setRfcs([]))
|
|
listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([]))
|
|
setCanContribute(null)
|
|
setCanRequestJoin(false)
|
|
getCollection(pid, cid)
|
|
.then(c => {
|
|
setCanContribute(!!c?.viewer?.can_contribute)
|
|
setCanRequestJoin(!!c?.viewer?.can_request_join)
|
|
setEntryNoun(c?.entry_noun || 'RFC')
|
|
})
|
|
.catch(() => setCanContribute(false))
|
|
}, [version, pid, cid])
|
|
|
|
// While caps load, fall back to "any authenticated viewer" so the propose
|
|
// affordance on the common (default-collection) case doesn't flash off.
|
|
const mayPropose = canContribute === null ? !!viewer : canContribute
|
|
|
|
const filtered = useMemo(() => {
|
|
const needle = search.trim().toLowerCase()
|
|
let items = rfcs.filter(r => {
|
|
if (activeChips.size > 0 && !activeChips.has(r.state)) return false
|
|
if (!needle) return true
|
|
const hay = [r.title, r.slug, r.id || ''].join(' ').toLowerCase()
|
|
return hay.includes(needle)
|
|
})
|
|
items = [...items].sort((a, b) => {
|
|
// Starred items pin to the top of the current sort per §7.2.
|
|
if (a.starred_by_me !== b.starred_by_me) return a.starred_by_me ? -1 : 1
|
|
if (sort === 'title') return a.title.localeCompare(b.title)
|
|
if (sort === 'id') return (a.id || 'zzz').localeCompare(b.id || 'zzz')
|
|
if (sort === 'state') return a.state.localeCompare(b.state)
|
|
// recent
|
|
return (b.last_active_at || '').localeCompare(a.last_active_at || '')
|
|
})
|
|
return items
|
|
}, [rfcs, search, sort, activeChips])
|
|
|
|
function toggleChip(id) {
|
|
const next = new Set(activeChips)
|
|
next.has(id) ? next.delete(id) : next.add(id)
|
|
setActiveChips(next)
|
|
}
|
|
|
|
return (
|
|
<aside className="catalog">
|
|
<div className="catalog-search">
|
|
<input
|
|
placeholder="Search title, slug, ID…"
|
|
value={search}
|
|
onChange={e => setSearch(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="catalog-controls">
|
|
<label htmlFor="catalog-sort">Sort:</label>
|
|
<select id="catalog-sort" value={sort} onChange={e => setSort(e.target.value)}>
|
|
{SORT_OPTIONS.map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="catalog-chips">
|
|
{STATE_CHIPS.map(chip => (
|
|
<button
|
|
key={chip.id}
|
|
className={`chip ${activeChips.has(chip.id) ? 'active' : ''}`}
|
|
onClick={() => toggleChip(chip.id)}
|
|
>
|
|
{chip.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="catalog-list">
|
|
{filtered.length === 0 ? (
|
|
<div style={{ padding: '24px 14px', color: '#999', fontSize: 13 }}>
|
|
{rfcs.length === 0
|
|
// C3.5: a contributor sees a propose-first call to action; a
|
|
// granted viewer without contribute rights sees a bare empty
|
|
// state; an anonymous reader sees the read-only note (the footer
|
|
// carries the sign-in prompt).
|
|
? (mayPropose
|
|
? 'No entries yet. Propose the first entry below.'
|
|
: 'No entries in the catalog yet.')
|
|
: 'No matches.'}
|
|
</div>
|
|
) : (
|
|
filtered.map(r => {
|
|
const isActive = slug === r.slug
|
|
const isSuper = r.state === 'super-draft'
|
|
return (
|
|
<Link
|
|
key={r.slug}
|
|
to={entryPath(pid, r.slug, cid)}
|
|
className={`catalog-row ${isActive ? 'active' : ''} ${isSuper ? 'is-super' : ''}`}
|
|
>
|
|
<div className="row-top">
|
|
<span className={`row-id ${isSuper ? 'super' : ''}`}>
|
|
{isSuper ? 'super-draft' : (r.id || '—')}
|
|
</span>
|
|
</div>
|
|
<span className="row-title">{r.title}</span>
|
|
{r.tags.length > 0 && (
|
|
<span className="row-tags">{r.tags.join(' · ')}</span>
|
|
)}
|
|
</Link>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
<div className="catalog-pending">
|
|
<button className="pending-header" onClick={() => setPendingOpen(o => !o)}>
|
|
<span>{pendingOpen ? '▾' : '▸'} Pending ideas</span>
|
|
<span className="pending-count">{proposals.length}</span>
|
|
</button>
|
|
{pendingOpen && proposals.length > 0 && (
|
|
<div className="pending-list">
|
|
{proposals.map(p => (
|
|
<Link
|
|
key={p.pr_number}
|
|
to={proposalPath(pid, p.pr_number, cid)}
|
|
className={`pending-row ${String(prNumber) === String(p.pr_number) ? 'active' : ''}`}
|
|
>
|
|
<div>{p.title.replace(/^Propose:\s*/, '')}</div>
|
|
<div className="pending-by">by @{p.opened_by || '—'} · PR #{p.pr_number}</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
{pendingOpen && proposals.length === 0 && (
|
|
<div style={{ padding: '0 14px 12px', color: '#aaa', fontSize: 12 }}>
|
|
No pending proposals.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="catalog-footer">
|
|
{viewer ? (
|
|
// §22 S4: the propose control is offered only when the viewer may
|
|
// contribute to *this* collection (the scope-role gate); a granted
|
|
// viewer with no contribute right in this collection sees nothing.
|
|
mayPropose ? (
|
|
<button className="btn-propose" onClick={onProposeRFC}>+ Propose New {entryNoun}</button>
|
|
) : (
|
|
// §22.8: signed in but no contribute right here — offer to join.
|
|
canRequestJoin && (
|
|
<button className="btn-propose" onClick={() => setJoinOpen(true)}>Request to join</button>
|
|
)
|
|
)
|
|
) : (
|
|
<a className="btn-propose" href="/auth/login" title="Private beta — only invited emails can propose">
|
|
Sign in to propose <span className="beta-chip">Beta</span>
|
|
</a>
|
|
)}
|
|
</div>
|
|
{joinOpen && (
|
|
<JoinRequestModal
|
|
scopeType="collection"
|
|
scopeId={cid}
|
|
onClose={() => setJoinOpen(false)}
|
|
/>
|
|
)}
|
|
</aside>
|
|
)
|
|
}
|