§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>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.45.0",
|
||||
"version": "0.46.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// §22.8 — the request-to-join API client builds scope-keyed URLs and methods.
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { joinTarget, requestJoin, acceptJoinRequest, declineJoinRequest } from './api.js'
|
||||
|
||||
function mockFetch() {
|
||||
const fn = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ok: true }),
|
||||
}))
|
||||
global.fetch = fn
|
||||
return fn
|
||||
}
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
describe('join-request api URLs', () => {
|
||||
it('joinTarget GETs the scope join-target', async () => {
|
||||
const f = mockFetch()
|
||||
await joinTarget('collection', 'features')
|
||||
expect(f).toHaveBeenCalledWith('/api/scopes/collection/features/join-target')
|
||||
})
|
||||
|
||||
it('requestJoin POSTs the desired role + message', async () => {
|
||||
const f = mockFetch()
|
||||
await requestJoin('project', 'ohm', { role: 'contributor', message: 'hi' })
|
||||
expect(f.mock.calls[0][0]).toBe('/api/scopes/project/ohm/join-requests')
|
||||
const opts = f.mock.calls[0][1]
|
||||
expect(opts.method).toBe('POST')
|
||||
expect(JSON.parse(opts.body)).toEqual({ role: 'contributor', message: 'hi' })
|
||||
})
|
||||
|
||||
it('acceptJoinRequest POSTs the accept route with an optional role override', async () => {
|
||||
const f = mockFetch()
|
||||
await acceptJoinRequest('collection', 'features', 7, 'contributor')
|
||||
expect(f.mock.calls[0][0]).toBe('/api/scopes/collection/features/join-requests/7/accept')
|
||||
expect(JSON.parse(f.mock.calls[0][1].body)).toEqual({ role: 'contributor' })
|
||||
})
|
||||
|
||||
it('declineJoinRequest POSTs the decline route', async () => {
|
||||
const f = mockFetch()
|
||||
await declineJoinRequest('collection', 'features', 7)
|
||||
expect(f.mock.calls[0][0]).toBe('/api/scopes/collection/features/join-requests/7/decline')
|
||||
expect(f.mock.calls[0][1].method).toBe('POST')
|
||||
})
|
||||
})
|
||||
@@ -367,6 +367,46 @@ export async function declineContributionRequest(slug, requestId) {
|
||||
))
|
||||
}
|
||||
|
||||
// §22.8: request-to-join a scope + the cross-collection inbox.
|
||||
// `joinTarget` feeds the request modal (scope name, the viewer's eligibility);
|
||||
// `requestJoin` submits the ask (desired role + optional message); accept/decline
|
||||
// are the scope Owner's inbox actions (accept writes the membership row).
|
||||
export async function joinTarget(scopeType, scopeId) {
|
||||
return jsonOrThrow(await fetch(
|
||||
`/api/scopes/${scopeType}/${encodeURIComponent(scopeId)}/join-target`,
|
||||
))
|
||||
}
|
||||
|
||||
export async function requestJoin(scopeType, scopeId, { role, message }) {
|
||||
const res = await fetch(
|
||||
`/api/scopes/${scopeType}/${encodeURIComponent(scopeId)}/join-requests`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role, message: message || null }),
|
||||
},
|
||||
)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function acceptJoinRequest(scopeType, scopeId, requestId, role) {
|
||||
return jsonOrThrow(await fetch(
|
||||
`/api/scopes/${scopeType}/${encodeURIComponent(scopeId)}/join-requests/${requestId}/accept`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role: role || null }),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
export async function declineJoinRequest(scopeType, scopeId, requestId) {
|
||||
return jsonOrThrow(await fetch(
|
||||
`/api/scopes/${scopeType}/${encodeURIComponent(scopeId)}/join-requests/${requestId}/decline`,
|
||||
{ method: 'POST' },
|
||||
))
|
||||
}
|
||||
|
||||
export async function mergeProposal(prNumber) {
|
||||
const res = await fetch(`/api/proposals/${prNumber}/merge`, { method: 'POST' })
|
||||
return jsonOrThrow(res)
|
||||
|
||||
@@ -10,6 +10,7 @@ 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' },
|
||||
@@ -31,6 +32,10 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
||||
// 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')
|
||||
@@ -48,9 +53,11 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
||||
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))
|
||||
@@ -182,8 +189,13 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
||||
// §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 && (
|
||||
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">
|
||||
@@ -191,6 +203,13 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{joinOpen && (
|
||||
<JoinRequestModal
|
||||
scopeType="collection"
|
||||
scopeId={cid}
|
||||
onClose={() => setJoinOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ 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)
|
||||
@@ -27,6 +28,7 @@ export default function CollectionDirectory({ projectId }) {
|
||||
const [version, setVersion] = useState(0)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [membersOpen, setMembersOpen] = useState(false)
|
||||
const [joinOpen, setJoinOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
@@ -46,6 +48,7 @@ export default function CollectionDirectory({ projectId }) {
|
||||
|
||||
const canCreate = !!viewer?.can_create_collection
|
||||
const canInvite = !!viewer?.can_invite
|
||||
const canRequestJoin = !!viewer?.can_request_join
|
||||
|
||||
return (
|
||||
<main className="chrome-pane">
|
||||
@@ -56,6 +59,9 @@ export default function CollectionDirectory({ projectId }) {
|
||||
{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>
|
||||
)}
|
||||
@@ -101,6 +107,13 @@ export default function CollectionDirectory({ projectId }) {
|
||||
onClose={() => setMembersOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{joinOpen && (
|
||||
<JoinRequestModal
|
||||
scopeType="project"
|
||||
scopeId={projectId}
|
||||
onClose={() => setJoinOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,15 +4,20 @@ import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||
|
||||
let mockItems = []
|
||||
let mockViewer = null
|
||||
vi.mock('../api', () => ({
|
||||
listCollections: vi.fn(async () => ({ items: mockItems })),
|
||||
listCollections: vi.fn(async () => ({ items: mockItems, viewer: mockViewer })),
|
||||
// JoinRequestModal (rendered behind the affordance) pulls these in.
|
||||
joinTarget: vi.fn(async () => ({ eligible: true, name: 'Ohm', already_requested: false })),
|
||||
requestJoin: vi.fn(async () => ({ status: 'pending' })),
|
||||
}))
|
||||
import CollectionDirectory from './CollectionDirectory.jsx'
|
||||
|
||||
beforeEach(() => { mockItems = [] })
|
||||
beforeEach(() => { mockItems = []; mockViewer = null })
|
||||
|
||||
function renderDir(items) {
|
||||
function renderDir(items, viewer = null) {
|
||||
mockItems = items
|
||||
mockViewer = viewer
|
||||
return render(
|
||||
<MemoryRouter initialEntries={["/p/ohm/"]}>
|
||||
<Routes>
|
||||
@@ -45,4 +50,27 @@ describe('CollectionDirectory', () => {
|
||||
renderDir([])
|
||||
await waitFor(() => expect(screen.getByText('No collections yet.')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('offers "Request to join" when the viewer holds no role (§22.8)', async () => {
|
||||
renderDir(
|
||||
[
|
||||
{ id: 'a', name: 'A', type: 'document' },
|
||||
{ id: 'b', name: 'B', type: 'document' },
|
||||
],
|
||||
{ can_request_join: true },
|
||||
)
|
||||
await waitFor(() => expect(screen.getByText('Request to join')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('hides "Request to join" from a member', async () => {
|
||||
renderDir(
|
||||
[
|
||||
{ id: 'a', name: 'A', type: 'document' },
|
||||
{ id: 'b', name: 'B', type: 'document' },
|
||||
],
|
||||
{ role: 'owner', can_request_join: false },
|
||||
)
|
||||
await waitFor(() => expect(screen.getByText('A')).toBeInTheDocument())
|
||||
expect(screen.queryByText('Request to join')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,7 +12,9 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
acceptContributionRequest,
|
||||
acceptJoinRequest,
|
||||
declineContributionRequest,
|
||||
declineJoinRequest,
|
||||
listNotifications,
|
||||
markNotificationRead,
|
||||
markNotificationsReadByFilter,
|
||||
@@ -228,11 +230,76 @@ function ContributionRequestRow({ item, onMarkRead }) {
|
||||
)
|
||||
}
|
||||
|
||||
// §22.8: the cross-collection inbox row — a request to join a scope, surfaced
|
||||
// to that scope's Owners across the subtree. Mirrors ContributionRequestRow:
|
||||
// the requester's role + message inline, an Accept/Decline pair that writes (or
|
||||
// refuses) the membership grant.
|
||||
function JoinRequestRow({ item, onMarkRead }) {
|
||||
const unread = !item.read_at
|
||||
const x = item.extras || {}
|
||||
const [outcome, setOutcome] = useState(null) // 'accepted' | 'declined'
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
async function act(accept) {
|
||||
if (busy || outcome) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
if (accept) await acceptJoinRequest(x.scope_type, x.scope_id, x.request_id)
|
||||
else await declineJoinRequest(x.scope_type, x.scope_id, x.request_id)
|
||||
setOutcome(accept ? 'accepted' : 'declined')
|
||||
await onMarkRead(item)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Action failed.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const roleLabel = x.requested_role === 'owner' ? 'Owner' : 'RFC Contributor'
|
||||
|
||||
return (
|
||||
<li className={`inbox-row inbox-row-action ${unread ? 'unread' : 'read'}`}>
|
||||
<div className="inbox-row-main">
|
||||
<span className="inbox-unread-dot" aria-hidden />
|
||||
<span className={`inbox-cat cat-${item.category || 'unknown'}`}>{item.category || '·'}</span>
|
||||
<span className="inbox-summary">{item.summary}</span>
|
||||
<span className="inbox-when">{formatWhen(item.created_at)}</span>
|
||||
</div>
|
||||
<div className="inbox-request-detail">
|
||||
<p><strong>Requested role:</strong> {roleLabel}</p>
|
||||
{x.message && <p><strong>Message:</strong> {x.message}</p>}
|
||||
</div>
|
||||
{error && <p className="field-error">{error}</p>}
|
||||
{outcome ? (
|
||||
<p className="inbox-request-outcome muted">
|
||||
{outcome === 'accepted'
|
||||
? `Accepted — they're now a member as ${roleLabel}.`
|
||||
: 'Declined.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="inbox-request-actions">
|
||||
<button type="button" className="btn-primary" disabled={busy || !x.request_id} onClick={() => act(true)}>
|
||||
Accept
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" disabled={busy || !x.request_id} onClick={() => act(false)}>
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function InboxRow({ item, onClick, onMarkRead, onClose }) {
|
||||
const pid = useProjectId()
|
||||
if (item.event_kind === 'contribution_request_on_pending_rfc') {
|
||||
return <ContributionRequestRow item={item} onMarkRead={onMarkRead} />
|
||||
}
|
||||
if (item.event_kind === 'join_request_on_scope') {
|
||||
return <JoinRequestRow item={item} onMarkRead={onMarkRead} />
|
||||
}
|
||||
const unread = !item.read_at
|
||||
const target = deepLink(item, pid)
|
||||
const handle = async () => {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// JoinRequestModal.jsx — §22.8: request-to-join a scope.
|
||||
//
|
||||
// A gated project or collection is invisible to non-members, so a user who
|
||||
// knows it exists asks to join — naming a desired role ({owner, contributor})
|
||||
// and an optional message. The request is recorded and fanned out to the
|
||||
// scope's Owners across the subtree (the cross-collection inbox), who accept
|
||||
// (writing the membership row) or decline.
|
||||
//
|
||||
// Sibling of ScopeMembersModal (the Owner's grant surface) and the per-RFC
|
||||
// ContributeRequestForm (the per-entry ask). Opens from the "Request to join"
|
||||
// affordance the directory / collection view shows when the viewer's
|
||||
// `can_request_join` flag is set.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { joinTarget, requestJoin } from '../api'
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'contributor', label: 'RFC Contributor — propose entries in the scope' },
|
||||
{ value: 'owner', label: 'Owner — administer the scope and its membership' },
|
||||
]
|
||||
|
||||
export default function JoinRequestModal({ scopeType, scopeId, scopeName, onClose }) {
|
||||
const [target, setTarget] = useState(null)
|
||||
const [role, setRole] = useState('contributor')
|
||||
const [message, setMessage] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [done, setDone] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
joinTarget(scopeType, scopeId)
|
||||
.then(t => { if (live) setTarget(t) })
|
||||
.catch(e => { if (live) setError(e.message || 'Could not load this scope.') })
|
||||
return () => { live = false }
|
||||
}, [scopeType, scopeId])
|
||||
|
||||
const label = (target && target.name) || scopeName || scopeId
|
||||
const where = scopeType === 'collection' ? 'collection' : 'project'
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (submitting) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await requestJoin(scopeType, scopeId, { role, message: message.trim() || null })
|
||||
setDone(true)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to send the request.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal" style={{ maxWidth: 520 }}>
|
||||
<div className="modal-header">
|
||||
<h2>Request to join</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{done ? (
|
||||
<p>
|
||||
Your request to join {where} <strong>{label}</strong> has been sent to
|
||||
its Owners. You'll get an inbox notification when it's decided.
|
||||
</p>
|
||||
) : target && !target.eligible ? (
|
||||
<p style={{ color: '#666' }}>
|
||||
{target.already_requested
|
||||
? `You already have a pending request to join ${where} ${label}.`
|
||||
: (target.reason || 'You cannot request to join this scope.')}
|
||||
</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="invitations-form">
|
||||
<p style={{ marginTop: 0, color: '#666' }}>
|
||||
Ask the Owners of {where} <strong>{label}</strong> for a role. An{' '}
|
||||
<strong>RFC Contributor</strong> may propose entries; an{' '}
|
||||
<strong>Owner</strong> administers the scope.
|
||||
</p>
|
||||
<label htmlFor="join-role">Role</label>
|
||||
<select id="join-role" value={role} onChange={e => setRole(e.target.value)}>
|
||||
{ROLE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<label htmlFor="join-message" style={{ marginTop: 10 }}>
|
||||
Message <span style={{ color: '#999' }}>(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="join-message"
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Who you are and why you'd like to join."
|
||||
maxLength={4000}
|
||||
/>
|
||||
<div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||
{submitting ? 'Sending…' : 'Send request'}
|
||||
</button>
|
||||
{error && <span style={{ color: '#c33' }}>{error}</span>}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn-link" onClick={onClose}>
|
||||
{done ? 'Close' : 'Cancel'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user