Files
rfc-app/frontend/src/components/ContributeRequestForm.jsx
T
Ben Stull 3c9109c392 v0.29.0: #28 Parts 2+3 — create-RFC offers + contribute-to-pending requests
Extends the v0.26.0 (#28 Part 1) read-time scanner into three buckets in
one pass — active link (Part 1), pending-RFC contribute offer (Part 3),
create-RFC offer (Part 2) — precedence active > pending > candidate. The
backend still emits only structured segments (never HTML), so the surface
stays XSS-safe by construction.

Part 2 — create-RFC offers: a multi-word tag from the #27 taxonomy with no
defining RFC renders, for a create-rights viewer, as an inline "+ create
RFC" affordance that opens the propose modal pre-filled (?propose=<term>;
ProposeModal gained initialTitle). Conservative multi-word gate; broader
heuristics + the Haiku path are deferred.

Part 3 — contribute-to-pending offers: a term matching a super-draft
renders, for a signed-in non-owner, an "ask to contribute" affordance with
the owner's display name. It opens a 3-field request form (who/why/optional
use-case); submitting lands a contribution_requests row (migration 024) and
one actionable §15 notification per owner (new kind
contribution_request_on_pending_rfc, personal-direct). The owner's inbox
shows who/why/use-case inline with Accept/Decline. Accept fires #12's
owner-invite flow with the requester as invitee and echoes a notification
back; decline notifies the requester. Pre-merge idea PRs are out of scope.

New endpoints: GET /api/rfcs/{slug}/contribution-target,
POST /api/rfcs/{slug}/contribution-requests,
.../{id}/accept, .../{id}/decline. The invite issue path was refactored
into one reusable api_invitations.issue_invitation(...) chokepoint shared
by the manual invite endpoint and Part 3's accept.

Tests: 9 new (3 scanner-bucket unit + 6 e2e). Full suite 374 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:10:13 -07:00

164 lines
5.9 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ContributeRequestForm.jsx — roadmap #28 Part 3.
//
// The "ask to contribute" popover, opened from an `rfc-pending` affordance
// in LinkedText (App reads `?contribute=<slug>&term=<term>`). It loads the
// contribution target (RFC title + owner display + the viewer's
// eligibility), shows the framing line "<owner> is working on an RFC for
// '<term>'", and collects the three #15/#26-vocabulary fields:
//
// * Who I am (required, free-text)
// * Why I'm asking (required, free-text)
// * What I'd use it for (optional, mirrors #26)
//
// Submitting POSTs the request, which lands in each owner's §15 inbox.
// When the viewer isn't eligible (anonymous, already a collaborator, or
// has a pending ask) the form shows the backend's reason instead.
import { useEffect, useState } from 'react'
import { contributionTarget, requestContribution } from '../api'
export default function ContributeRequestForm({ slug, term, onClose }) {
const [target, setTarget] = useState(null)
const [loadError, setLoadError] = useState(null)
const [whoIAm, setWhoIAm] = useState('')
const [why, setWhy] = useState('')
const [useCase, setUseCase] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
const [done, setDone] = useState(false)
useEffect(() => {
let live = true
contributionTarget(slug)
.then(t => { if (live) setTarget(t) })
.catch(err => { if (live) setLoadError(err.message || 'Could not load this RFC.') })
return () => { live = false }
}, [slug])
async function handleSubmit(e) {
e.preventDefault()
if (!whoIAm.trim() || !why.trim()) return
setSubmitting(true)
setError(null)
try {
await requestContribution(slug, {
matchedTerm: term || target?.title || slug,
whoIAm: whoIAm.trim(),
why: why.trim(),
useCase: useCase.trim() || null,
})
setDone(true)
} catch (err) {
setError(err.message || 'Could not send your request.')
} finally {
setSubmitting(false)
}
}
const owner = target?.owner || 'The owner'
const label = term || target?.title || slug
return (
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal">
<div className="modal-header">
<h2>Ask to contribute</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
{loadError && (
<div className="modal-body"><p className="field-error">{loadError}</p></div>
)}
{!loadError && done && (
<>
<div className="modal-body">
<p>
Your request has been sent to <strong>{owner}</strong>. You'll hear back
in your inbox; if it's accepted you'll get an invitation by email to
join the RFC.
</p>
</div>
<div className="modal-actions">
<button type="button" className="btn-primary" onClick={onClose}>Done</button>
</div>
</>
)}
{!loadError && !done && target && !target.eligible && (
<>
<div className="modal-body">
<p className="field-help" style={{ marginTop: 0 }}>
{owner} is working on an RFC for <strong>'{label}'</strong>.
</p>
<p>{target.already_requested
? "You've already asked to contribute to this RFC the owner has your request."
: (target.reason || 'You cannot ask to contribute to this RFC right now.')}</p>
</div>
<div className="modal-actions">
<button type="button" className="btn-secondary" onClick={onClose}>Close</button>
</div>
</>
)}
{!loadError && !done && target && target.eligible && (
<form onSubmit={handleSubmit}>
<div className="modal-body">
<p className="field-help" style={{ marginTop: 0 }}>
<strong>{owner}</strong> is working on an RFC for <strong>'{label}'</strong>.
Tell them a little about why you'd like to contribute.
</p>
<label htmlFor="contribute-who">Who I am</label>
<textarea
id="contribute-who"
value={whoIAm}
onChange={e => setWhoIAm(e.target.value)}
placeholder="Your name and a sentence of context."
rows={2}
autoFocus
required
/>
<label htmlFor="contribute-why">Why I'm asking to contribute</label>
<textarea
id="contribute-why"
value={why}
onChange={e => setWhy(e.target.value)}
placeholder="What you'd bring, or what draws you to this RFC."
rows={3}
required
/>
<label htmlFor="contribute-use-case">What I'd use the RFC for (optional)</label>
<textarea
id="contribute-use-case"
value={useCase}
onChange={e => setUseCase(e.target.value)}
placeholder="The concrete thing you intend to build or do with it. Optional."
rows={2}
/>
{error && <p className="field-error">{error}</p>}
</div>
<div className="modal-actions">
<button type="button" className="btn-secondary" onClick={onClose}>Cancel</button>
<button
type="submit"
className="btn-primary"
disabled={!whoIAm.trim() || !why.trim() || submitting}
>
{submitting ? 'Sending…' : 'Send request'}
</button>
</div>
</form>
)}
{!loadError && !done && !target && (
<div className="modal-body"><p className="field-help">Loading</p></div>
)}
</div>
</div>
)
}