diff --git a/frontend/src/api.js b/frontend/src/api.js index 2ea0f05..2195c97 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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 `.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) { diff --git a/frontend/src/components/MetadataFieldsPanel.jsx b/frontend/src/components/MetadataFieldsPanel.jsx new file mode 100644 index 0000000..4fc2d28 --- /dev/null +++ b/frontend/src/components/MetadataFieldsPanel.jsx @@ -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 ( +
+ {Object.entries(fields).map(([name, def]) => ( +
+ + {!canEdit ? ( + + ) : def.type === 'enum' ? ( + + ) : def.type === 'tags' ? ( +
+ {(draft[name] || []).map(t => ( + + {t} + + + ))} + 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…" /> +
+ ) : ( + setField(name, e.target.value || null)} /> + )} +
+ ))} + {canEdit && ( +
+ + {saved && !error && Saved} + {error && {error}} +
+ )} +
+ ) +} + +function ReadOnlyValue({ type, value }) { + if (type === 'tags') { + return ( + + {(value || []).map(t => {t})} + + ) + } + return {value ?? '—'} +} diff --git a/frontend/src/components/MetadataFieldsPanel.test.jsx b/frontend/src/components/MetadataFieldsPanel.test.jsx new file mode 100644 index 0000000..57fa260 --- /dev/null +++ b/frontend/src/components/MetadataFieldsPanel.test.jsx @@ -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( + ) + expect(container.firstChild).toBeNull() + }) + + it('renders one control per schema field with current values (read-only)', () => { + render() + 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() + 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() + expect(screen.getByText('Save')).toBeDisabled() + }) + + it('adds and removes a tag', async () => { + saveEntryMeta.mockResolvedValue({ ok: true, meta: {} }) + render() + 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 && …}` branch. +}) diff --git a/frontend/src/components/RFCView.jsx b/frontend/src/components/RFCView.jsx index 8c6eabc..5600fa6 100644 --- a/frontend/src/components/RFCView.jsx +++ b/frontend/src/components/RFCView.jsx @@ -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 }) { : Left blank by the proposer.} )} + {/* §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 && ( + + )} {inDiscuss && branchParam !== 'main' && (
Discuss mode on {branchParam} — chat freely;