Files
rfc-app/frontend/src/components/ProposalView.jsx
T
Ben Stull 7c6c906db2 #26: optional proposed-use-case field on propose-RFC + propose-PR
Adds an optional "What will you be using this for?" capture as a sibling
to the required justification on both propose surfaces, per roadmap #26.

- propose-RFC modal (ProposeModal): optional textarea below the required
  "Why is this RFC needed?" pitch, labeled "What will you be using this
  RFC for? (optional)".
- propose-PR modal (PRModal): optional textarea below the required
  description, labeled "What will you be using this change for?".
- Backend: ProposeBody / OpenPRBody gain an optional `proposed_use_case`
  (NULL/omitted accepted, no min, 8000-char cap matching the existing
  free-text bound). Persisted to a new canonical side table
  `proposed_use_cases` keyed by PR number, mirrored onto the cache
  columns added by migration 021. Returned on the proposal list/detail,
  RFC detail (by slug), and PR detail endpoints.
- Display: ProposalView, RFCView (main only), and PRView render the
  captured use case with a muted "left blank" treatment when NULL.
- migration 021: nullable `proposed_use_case` on cached_rfcs/cached_prs
  plus the reconcile-proof `proposed_use_cases` truth table.
- New vertical test_proposed_use_case_vertical: persists+returns when
  supplied, accepted as NULL/omitted, for both surfaces.

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

177 lines
6.3 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.
// ProposalView.jsx — §9.3 pending-idea view.
//
// Renders the proposed entry's body and frontmatter (read-only) with a
// status banner ("Pending idea — awaiting review"). The header strip
// carries Merge / Decline / Withdraw per the viewer's affordances. The
// decline two-step composer-then-preview from §9.3 ships in Slice 1
// as a single-step required-comment input; the preview-confirm ceremony
// can land with the rest of §9.3's UX polish in the §19.2 "pending-idea
// view's interaction design (remainder)" topic.
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { marked } from 'marked'
import { getProposal, mergeProposal, declineProposal, withdrawProposal } from '../api'
export default function ProposalView({ viewer, onChange }) {
const { prNumber } = useParams()
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [acting, setActing] = useState(false)
const [declineOpen, setDeclineOpen] = useState(false)
const [declineComment, setDeclineComment] = useState('')
const navigate = useNavigate()
function refresh() {
setData(null); setError(null)
getProposal(prNumber).then(setData).catch(err => setError(err.message))
}
useEffect(refresh, [prNumber])
if (error) return <div className="entry-view"><p>Error: {error}</p></div>
if (!data) return <div className="entry-view">Loading</div>
const isOpen = data.state === 'open'
async function doMerge() {
setActing(true)
try {
const { slug } = await mergeProposal(prNumber)
onChange?.()
navigate(`/rfc/${slug}`)
} catch (e) {
setError(e.message)
setActing(false)
}
}
async function doDecline() {
if (!declineComment.trim()) return
setActing(true)
try {
await declineProposal(prNumber, declineComment.trim())
onChange?.()
refresh()
setDeclineOpen(false)
setDeclineComment('')
} catch (e) {
setError(e.message)
} finally {
setActing(false)
}
}
async function doWithdraw() {
if (!confirm('Withdraw this proposal? The PR will be closed; the conversation stays attached as historical record.')) return
setActing(true)
try {
await withdrawProposal(prNumber)
onChange?.()
refresh()
} catch (e) {
setError(e.message)
} finally {
setActing(false)
}
}
return (
<article className="entry-view">
<div className={`entry-state-banner ${data.state === 'merged' ? 'merged' : data.state === 'open' ? '' : 'declined'}`}>
{isOpen ? 'Pending idea — awaiting review'
: data.state === 'merged' ? 'Merged — now a super-draft'
: 'Closed'}
</div>
<h1 className="entry-title">
{data.entry?.title || data.title.replace(/^Propose:\s*/, '')}
</h1>
<div className="entry-meta">
<span>PR #{data.pr_number}</span>
{data.opened_by && <> · proposed by <strong>@{data.opened_by}</strong></>}
{data.opened_at && <> · {new Date(data.opened_at).toLocaleDateString()}</>}
{data.entry?.tags?.length > 0 && (
<div style={{ marginTop: 6 }}>
{data.entry.tags.map(t => <span key={t} className="entry-tag">{t}</span>)}
</div>
)}
</div>
{isOpen && (
<div className="entry-actions">
{data.affordances.merge && (
<button className="btn-primary" onClick={doMerge} disabled={acting}>
{acting ? 'Merging…' : 'Merge proposal'}
</button>
)}
{data.affordances.decline && (
<button className="btn-danger" onClick={() => setDeclineOpen(true)} disabled={acting}>
Decline
</button>
)}
{data.affordances.withdraw && (
<button className="btn-secondary" onClick={doWithdraw} disabled={acting}>
Withdraw proposal
</button>
)}
</div>
)}
{declineOpen && (
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) setDeclineOpen(false) }}>
<div className="modal">
<div className="modal-header">
<h2>Decline proposal</h2>
<button className="modal-close" onClick={() => setDeclineOpen(false)}>×</button>
</div>
<div className="modal-body">
<label>Comment to the proposer (required)</label>
<textarea
value={declineComment}
onChange={e => setDeclineComment(e.target.value)}
rows={5}
placeholder="The proposer will read this verbatim."
autoFocus
/>
<p className="field-help">
Per §9.3, the decline ceremony's two-step preview-and-confirm
surface lands with the rest of §9.3 UX in Slice 2. For now the
comment goes directly to the PR and to the proposer's inbox.
</p>
</div>
<div className="modal-actions">
<button type="button" className="btn-secondary" onClick={() => setDeclineOpen(false)}>Cancel</button>
<button
type="button"
className="btn-danger"
onClick={doDecline}
disabled={!declineComment.trim() || acting}
>
{acting ? 'Declining…' : 'Send decline'}
</button>
</div>
</div>
</div>
)}
<h3 style={{ fontSize: 13, fontWeight: 700, color: '#888', textTransform: 'uppercase', letterSpacing: '0.05em', marginTop: 24 }}>
Proposed entry
</h3>
<div
className="entry-body"
dangerouslySetInnerHTML={{ __html: marked.parse(data.entry?.body || '') }}
/>
{/* #26: the optional ground-truth use case the proposer supplied. */}
<h3 style={{ fontSize: 13, fontWeight: 700, color: '#888', textTransform: 'uppercase', letterSpacing: '0.05em', marginTop: 24 }}>
Intended use case
</h3>
{data.proposed_use_case
? <div className="entry-body" dangerouslySetInnerHTML={{ __html: marked.parse(data.proposed_use_case) }} />
: <p style={{ color: '#999', fontStyle: 'italic' }}>Left blank by the proposer.</p>}
</article>
)
}