3c9109c392
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>
164 lines
5.9 KiB
React
164 lines
5.9 KiB
React
// 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>
|
||
)
|
||
}
|