Files
rfc-app/frontend/src/components/ProposalView.jsx
T
Ben Stull 999c4b65ef §22 M3 frontend: /p/<project>/ routing, runtime branding, directory, 308s (v0.35.0)
Implements the M3-frontend slice of the §22 multi-project track, per
docs/superpowers/specs/2026-06-03-m3-frontend-design.md (design merged in
#10). Completes the runtime-config cut 0.33.0 (M3-backend Plan A) began.

Frontend:
- DeploymentProvider boots GET /api/deployment → {name, tagline,
  defaultProjectId, projects}; brandTitle() neutral 'RFC' pre-fetch fallback.
- /p/:projectId/* routing with generic /e/<slug> segment. ProjectLayout
  fetches /api/projects/:id, applies per-project theme (reset on switch),
  provides ProjectContext, guards the corpus (served only for the default;
  others get NotServedPlaceholder — decouples this slice from Plan B).
- Directory at / (2+ projects) with N=1 redirect into the single project;
  ProjectSwitcher in deployment chrome; entry-noun by project type.
- VITE_APP_NAME hard cut: removed from vite.config + index.html; the 6 brand
  reads now use deployment.name via context; static <title>RFC</title> + JS
  document.title. Internal /rfc·/proposals links → /p/<project>/e|proposals
  via lib/entryPaths.

Backend:
- GET /api/deployment returns default_project_id (the guard contract).
- Server-side 308s: /rfc/<slug>, /rfc/<slug>/pr/<n>, /proposals/<n> →
  /p/<default>/… . nginx (testing + prod) routes /rfc/ and /proposals/ to
  the backend.

Tests: 3 new backend redirect/deployment tests (438 pass); Vitest unit for
DeploymentProvider, ProjectLayout (theme/guard/404), Directory (11 pass);
clean build with no VITE_APP_NAME. Playwright e2e deferred until Tier-1 seeds
a registry (see CHANGELOG 0.35.0 step 5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 05:58:59 -07:00

179 lines
6.4 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 { renderMarkdown } from '../lib/sanitizeHtml'
import { getProposal, mergeProposal, declineProposal, withdrawProposal } from '../api'
import { entryPath, useProjectId } from '../lib/entryPaths'
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()
const pid = useProjectId()
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(entryPath(pid, 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: renderMarkdown(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: renderMarkdown(data.proposed_use_case) }} />
: <p style={{ color: '#999', fontStyle: 'italic' }}>Left blank by the proposer.</p>}
</article>
)
}