§22 M3-backend Plan B (write path, propose): project-scoped propose (v0.38.0)
A new entry can be proposed into a specific project; it lands in that project's
content repo and shows under that project's proposals. A non-default project is
no longer read-only.
- api.py: POST /api/projects/{pid}/rfcs/propose (propose body extracted into a
project-parameterized helper; unscoped /api/rfcs/propose kept as default
compat). Slug uniqueness, idea-PR reservation, landing state, and the
proposed_use_cases row scoped to the target project. GET
/api/projects/{pid}/proposals.
- cache.py: refresh_meta_pulls loops every project's content_repo, stamping
cached_prs.project_id; projects.content_repo(pid) helper.
- frontend: proposeRFC(projectId,…)/listProposals(projectId); ProposeModal
takes projectId; App resolves current project from the /p/<id>/ URL; Catalog
lists that project's proposals.
- tests: test_project_scoped_propose.py (lands scoped + gated 404). 447 backend
+ 11 Vitest green; clean build.
Known limitation: branch/PR/graduation edit flows + default-id re-stamp not yet
scoped (next slice). Per docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md.
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.37.0",
|
||||
"version": "0.38.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -61,6 +61,11 @@ export default function App() {
|
||||
// §22.9 — runtime deployment config (name for the brand, default project id
|
||||
// for building corpus links this slice; see DeploymentProvider).
|
||||
const deployment = useDeployment()
|
||||
// §22.4 — the project the viewer is currently in (from the /p/<id>/ URL),
|
||||
// so the propose modal (App-level chrome, above the route tree) targets the
|
||||
// right project. Falls back to the deployment default off a project route.
|
||||
const _projMatch = location.pathname.match(/^\/p\/([^/]+)/)
|
||||
const currentProjectId = (_projMatch && _projMatch[1]) || deployment.defaultProjectId
|
||||
// #28 Parts 2–3: the LinkedText create/contribute affordances route via
|
||||
// query params so they need no prop-threading from deep in a comment
|
||||
// list. `?propose=<term>` opens the propose modal pre-filled;
|
||||
@@ -372,12 +377,13 @@ export default function App() {
|
||||
<ProposeModal
|
||||
viewer={viewer}
|
||||
initialTitle={proposeParam || ''}
|
||||
projectId={currentProjectId}
|
||||
onClose={() => { setProposeOpen(false); clearParams('propose') }}
|
||||
onSubmitted={({ pr_number }) => {
|
||||
setProposeOpen(false)
|
||||
clearParams('propose')
|
||||
setCatalogVersion(v => v + 1)
|
||||
navigate(proposalPath(deployment.defaultProjectId, pr_number))
|
||||
navigate(proposalPath(currentProjectId, pr_number))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
+8
-4
@@ -198,16 +198,20 @@ export async function getRFC(projectId, slug) {
|
||||
return jsonOrThrow(await fetch(`/api/projects/${projectId}/rfcs/${slug}`))
|
||||
}
|
||||
|
||||
export async function listProposals() {
|
||||
return jsonOrThrow(await fetch('/api/proposals'))
|
||||
export async function listProposals(projectId) {
|
||||
const url = projectId ? `/api/projects/${projectId}/proposals` : '/api/proposals'
|
||||
return jsonOrThrow(await fetch(url))
|
||||
}
|
||||
|
||||
export async function getProposal(prNumber) {
|
||||
return jsonOrThrow(await fetch(`/api/proposals/${prNumber}`))
|
||||
}
|
||||
|
||||
export async function proposeRFC({ title, slug, pitch, tags, proposedUseCase }) {
|
||||
const res = await fetch('/api/rfcs/propose', {
|
||||
// §22.4 (Plan B write): propose into a specific project when projectId is
|
||||
// given; else the default-project compat path.
|
||||
export async function proposeRFC(projectId, { title, slug, pitch, tags, proposedUseCase }) {
|
||||
const url = projectId ? `/api/projects/${projectId}/rfcs/propose` : '/api/rfcs/propose'
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
// #26: proposed_use_case is optional; send null when blank so the
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
||||
|
||||
useEffect(() => {
|
||||
listRFCs(pid).then(d => setRfcs(d.items)).catch(() => setRfcs([]))
|
||||
listProposals().then(d => setProposals(d.items)).catch(() => setProposals([]))
|
||||
listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([]))
|
||||
}, [version, pid])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
|
||||
@@ -28,7 +28,7 @@ function slugify(title) {
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitle = '' }) {
|
||||
export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitle = '', projectId }) {
|
||||
// #28 Part 2: a "create RFC for '<term>'" affordance pre-fills the title
|
||||
// (App passes the `?propose=<term>` value here); the slug derives from it
|
||||
// via the same effect that drives manual typing.
|
||||
@@ -92,7 +92,7 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await proposeRFC({
|
||||
const result = await proposeRFC(projectId, {
|
||||
title: title.trim(),
|
||||
slug,
|
||||
pitch: pitch.trim(),
|
||||
|
||||
Reference in New Issue
Block a user