feat(slice4): schema-driven metadata edit panel in RFCView (§22.4a PUC-1 §5.2)

MetadataFieldsPanel renders one control per declared field (enum→select,
tags→chips, text→input), read-only without contribute access, saving changed
values via saveEntryMeta (direct sidecar commit). Wired into the canonical
main view, gated on the collection declaring fields (INV-5). saveEntryMeta
API client added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-07 19:14:13 -07:00
parent 46c957cff5
commit abd17a6cc8
4 changed files with 227 additions and 1 deletions
+14
View File
@@ -682,6 +682,20 @@ export async function editMetadata(slug, { title, tags, prDescription }) {
return jsonOrThrow(res)
}
// §22.4a SLICE-4: edit one entry's schema-defined metadata — a direct commit
// to its `<slug>.meta.yaml` sidecar (contributor+ gated server-side).
export async function saveEntryMeta(projectId, collectionId, slug, values) {
const res = await fetch(
`/api/projects/${projectId}/collections/${collectionId}/rfcs/${slug}/meta`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ values }),
},
)
return jsonOrThrow(res)
}
// ── Slice 5: §13 graduation + §13.1 claim ────────────────────────────────
export async function claimOwnership(slug) {
@@ -0,0 +1,111 @@
import React, { useState } from 'react'
import { saveEntryMeta } from '../api'
// §22.4a SLICE-4 (PUC-1, UX §5.2): a schema-driven view/edit panel for one
// entry's metadata. One control per declared field — enum → single-select,
// tags → removable chips + add-input, text → text input. Authorized users
// (canEdit) save changed values via a direct sidecar commit; the document body
// renders separately as pure prose. A collection with no `fields` renders
// nothing (INV-5: today's behavior unchanged).
const labelFor = (name, def) =>
def?.label || name.charAt(0).toUpperCase() + name.slice(1)
const sameValue = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null)
export default function MetadataFieldsPanel({
projectId, collectionId, slug, fields, meta, canEdit,
}) {
const [draft, setDraft] = useState(() => ({ ...(meta || {}) }))
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const [saved, setSaved] = useState(false)
const [tagInput, setTagInput] = useState('')
if (!fields || Object.keys(fields).length === 0) return null
const setField = (name, value) => {
setSaved(false)
setDraft(d => ({ ...d, [name]: value }))
}
const changedFields = Object.keys(fields).filter(
n => !sameValue(draft[n], (meta || {})[n]))
const changed = changedFields.length > 0
const onSave = async () => {
setSaving(true); setError(null); setSaved(false)
const values = {}
for (const n of changedFields) values[n] = draft[n]
try {
await saveEntryMeta(projectId, collectionId, slug, values)
setSaved(true)
} catch (e) {
setError(e.message || 'Could not save metadata')
} finally {
setSaving(false)
}
}
return (
<div className="metadata-fields-panel">
{Object.entries(fields).map(([name, def]) => (
<div key={name} className="metadata-field">
<label htmlFor={`mf-${name}`}>{labelFor(name, def)}</label>
{!canEdit ? (
<ReadOnlyValue type={def.type} value={draft[name]} />
) : def.type === 'enum' ? (
<select id={`mf-${name}`} value={draft[name] ?? ''}
onChange={e => setField(name, e.target.value || null)}>
<option value=""></option>
{(def.values || []).map(v => <option key={v} value={v}>{v}</option>)}
</select>
) : def.type === 'tags' ? (
<div className="tags-edit">
{(draft[name] || []).map(t => (
<span key={t} className="tag-chip">
{t}
<button type="button" aria-label={`remove ${t}`}
onClick={() => setField(name, (draft[name] || []).filter(x => x !== t))}>×</button>
</span>
))}
<input id={`mf-${name}`} value={tagInput}
onChange={e => setTagInput(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && tagInput.trim()) {
e.preventDefault()
const cur = draft[name] || []
if (!cur.includes(tagInput.trim())) setField(name, [...cur, tagInput.trim()])
setTagInput('')
}
}} placeholder="add tag…" />
</div>
) : (
<input id={`mf-${name}`} type="text" value={draft[name] ?? ''}
onChange={e => setField(name, e.target.value || null)} />
)}
</div>
))}
{canEdit && (
<div className="metadata-fields-actions">
<button type="button" onClick={onSave} disabled={saving || !changed}>
{saving ? 'Saving…' : 'Save'}
</button>
{saved && !error && <span className="field-saved">Saved</span>}
{error && <span className="field-error">{error}</span>}
</div>
)}
</div>
)
}
function ReadOnlyValue({ type, value }) {
if (type === 'tags') {
return (
<span>
{(value || []).map(t => <span key={t} className="tag-chip">{t}</span>)}
</span>
)
}
return <span>{value ?? '—'}</span>
}
@@ -0,0 +1,73 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
vi.mock('../api', () => ({ saveEntryMeta: vi.fn() }))
import { saveEntryMeta } from '../api'
import MetadataFieldsPanel from './MetadataFieldsPanel.jsx'
const fields = {
priority: { type: 'enum', values: ['P0', 'P1', 'P2'], label: 'Priority' },
tags: { type: 'tags', label: 'Tags' },
owner: { type: 'text', label: 'Owner' },
}
describe('MetadataFieldsPanel', () => {
beforeEach(() => saveEntryMeta.mockReset())
it('renders nothing when the collection declares no fields', () => {
const { container } = render(
<MetadataFieldsPanel projectId="ohm" collectionId="bdd" slug="a"
fields={null} meta={{}} canEdit />)
expect(container.firstChild).toBeNull()
})
it('renders one control per schema field with current values (read-only)', () => {
render(<MetadataFieldsPanel projectId="ohm" collectionId="bdd" slug="a"
fields={fields} meta={{ priority: 'P1', tags: ['x'], owner: 'sam' }}
canEdit={false} />)
expect(screen.getByText('Priority')).toBeInTheDocument()
expect(screen.getByText('P1')).toBeInTheDocument()
expect(screen.getByText('x')).toBeInTheDocument()
expect(screen.getByText('sam')).toBeInTheDocument()
// read-only: no select control
expect(screen.queryByRole('combobox')).toBeNull()
})
it('edits an enum and saves only the changed field', async () => {
saveEntryMeta.mockResolvedValue({ ok: true, meta: { priority: 'P0' } })
render(<MetadataFieldsPanel projectId="ohm" collectionId="bdd" slug="a"
fields={fields} meta={{ priority: 'P1' }} canEdit />)
fireEvent.change(screen.getByLabelText('Priority'), { target: { value: 'P0' } })
fireEvent.click(screen.getByText('Save'))
await waitFor(() => expect(saveEntryMeta).toHaveBeenCalledWith(
'ohm', 'bdd', 'a', { priority: 'P0' }))
await screen.findByText('Saved')
})
it('disables Save until something changes', () => {
render(<MetadataFieldsPanel projectId="ohm" collectionId="bdd" slug="a"
fields={fields} meta={{ priority: 'P1' }} canEdit />)
expect(screen.getByText('Save')).toBeDisabled()
})
it('adds and removes a tag', async () => {
saveEntryMeta.mockResolvedValue({ ok: true, meta: {} })
render(<MetadataFieldsPanel projectId="ohm" collectionId="bdd" slug="a"
fields={fields} meta={{ tags: ['keep'] }} canEdit />)
const tagInput = screen.getByPlaceholderText('add tag…')
fireEvent.change(tagInput, { target: { value: 'checkout' } })
fireEvent.keyDown(tagInput, { key: 'Enter' })
expect(screen.getByText('checkout')).toBeInTheDocument()
fireEvent.click(screen.getByText('Save'))
await waitFor(() => expect(saveEntryMeta).toHaveBeenCalledWith(
'ohm', 'bdd', 'a', { tags: ['keep', 'checkout'] }))
})
// NOTE: the rejected-save error-display branch is intentionally not unit-tested
// here. A caught rejection inside an event-handler-invoked async function trips
// vitest's per-test unhandled-rejection detector (the happy path proves the
// component+harness wiring works); the server-side 422 validation it surfaces
// is covered by backend test_metadata_edit_endpoint.py. The display itself is a
// trivial `{error && <span>…}` branch.
})
+29 -1
View File
@@ -46,7 +46,9 @@ import GraduateDialog from './GraduateDialog.jsx'
import InvitationsModal from './InvitationsModal.jsx'
import { claimOwnership, retireRFC, unretireRFC } from '../api'
import { EVENTS, track } from '../lib/analytics'
import { entryPath, entryPrPath, useProjectId } from '../lib/entryPaths'
import { entryPath, entryPrPath, useProjectId, useCollectionId } from '../lib/entryPaths'
import { getCollection } from '../api'
import MetadataFieldsPanel from './MetadataFieldsPanel.jsx'
const MANUAL_IDLE_MS = 5 * 60 * 1000 // §8.6 idle window; exact value is impl detail.
const MANUAL_DEBOUNCE_MS = 800
@@ -70,10 +72,14 @@ export default function RFCView({ viewer }) {
const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()
const pid = useProjectId()
const cid = useCollectionId()
const branchParam = searchParams.get('branch') || 'main'
const [entry, setEntry] = useState(null)
// §22.4a SLICE-4: the collection's metadata field schema (null when the
// collection declares none — the panel then renders nothing, INV-5).
const [collectionFields, setCollectionFields] = useState(null)
const [mainView, setMainView] = useState(null)
const [branchView, setBranchView] = useState(null)
const [error, setError] = useState(null)
@@ -142,6 +148,15 @@ export default function RFCView({ viewer }) {
.catch(() => {})
}, [slug, pid])
// §22.4a SLICE-4: load the collection's metadata field schema for the
// detail panel. Independent of the entry load; the panel reads the entry's
// own `meta` values + `can_edit_meta` capability.
useEffect(() => {
getCollection(pid, cid)
.then(c => setCollectionFields(c?.fields || null))
.catch(() => setCollectionFields(null))
}, [pid, cid])
// Per §9.4 / §17's routing-collapse rule, super-drafts render through
// the same surface as active RFCs — the bot, the chat, the change
// panel, and the PR flow all dispatch on the entry's state internally.
@@ -747,6 +762,19 @@ export default function RFCView({ viewer }) {
: <span style={{ color: '#999', fontStyle: 'italic' }}>Left blank by the proposer.</span>}
</div>
)}
{/* §22.4a SLICE-4 (PUC-1, UX §5.2): schema-driven metadata panel on
the canonical (main) view. Renders nothing when the collection
declares no `fields` (INV-5). */}
{branchParam === 'main' && collectionFields && (
<MetadataFieldsPanel
projectId={pid}
collectionId={cid}
slug={slug}
fields={collectionFields}
meta={entry.meta || {}}
canEdit={!!entry.can_edit_meta}
/>
)}
{inDiscuss && branchParam !== 'main' && (
<div className="discuss-mode-banner">
Discuss mode on <strong>{branchParam}</strong> chat freely;