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:
@@ -682,6 +682,20 @@ export async function editMetadata(slug, { title, tags, prDescription }) {
|
|||||||
return jsonOrThrow(res)
|
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 ────────────────────────────────
|
// ── Slice 5: §13 graduation + §13.1 claim ────────────────────────────────
|
||||||
|
|
||||||
export async function claimOwnership(slug) {
|
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.
|
||||||
|
})
|
||||||
@@ -46,7 +46,9 @@ import GraduateDialog from './GraduateDialog.jsx'
|
|||||||
import InvitationsModal from './InvitationsModal.jsx'
|
import InvitationsModal from './InvitationsModal.jsx'
|
||||||
import { claimOwnership, retireRFC, unretireRFC } from '../api'
|
import { claimOwnership, retireRFC, unretireRFC } from '../api'
|
||||||
import { EVENTS, track } from '../lib/analytics'
|
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_IDLE_MS = 5 * 60 * 1000 // §8.6 idle window; exact value is impl detail.
|
||||||
const MANUAL_DEBOUNCE_MS = 800
|
const MANUAL_DEBOUNCE_MS = 800
|
||||||
@@ -70,10 +72,14 @@ export default function RFCView({ viewer }) {
|
|||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const pid = useProjectId()
|
const pid = useProjectId()
|
||||||
|
const cid = useCollectionId()
|
||||||
|
|
||||||
const branchParam = searchParams.get('branch') || 'main'
|
const branchParam = searchParams.get('branch') || 'main'
|
||||||
|
|
||||||
const [entry, setEntry] = useState(null)
|
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 [mainView, setMainView] = useState(null)
|
||||||
const [branchView, setBranchView] = useState(null)
|
const [branchView, setBranchView] = useState(null)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
@@ -142,6 +148,15 @@ export default function RFCView({ viewer }) {
|
|||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [slug, pid])
|
}, [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
|
// Per §9.4 / §17's routing-collapse rule, super-drafts render through
|
||||||
// the same surface as active RFCs — the bot, the chat, the change
|
// 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.
|
// 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>}
|
: <span style={{ color: '#999', fontStyle: 'italic' }}>Left blank by the proposer.</span>}
|
||||||
</div>
|
</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' && (
|
{inDiscuss && branchParam !== 'main' && (
|
||||||
<div className="discuss-mode-banner">
|
<div className="discuss-mode-banner">
|
||||||
Discuss mode on <strong>{branchParam}</strong> — chat freely;
|
Discuss mode on <strong>{branchParam}</strong> — chat freely;
|
||||||
|
|||||||
Reference in New Issue
Block a user