Files
rfc-app/frontend/src/components/CreateCollectionModal.jsx
T
Ben Stull 93cf506059 §22 S4: invitation modal + role-aware empty states (@S4 C.2, C.3)
The frontend surfaces for the S4 backend (Part E S4 / Part C.2, C.3):

- ScopeMembersModal — the Owner-only scope-role invitation surface (sibling of
  the per-RFC InvitationsModal): grant {owner, contributor} by email at the
  project or a single collection, with a scope picker bounded to the inviter's
  reach (no parent-grant-child-exclude option, C.2.5), plus a current-members
  list with revoke. Opened from the directory's owner-only "Members" control;
  contributors never see it (C.2.4).
- CreateCollectionModal — surfaces the S2 create-collection endpoint (was
  UI-less), gated on the viewer's can_create_collection capability.
- CollectionDirectory — reads the new `viewer` capability block to render the
  role-aware empty states: a project Owner sees "Create your first collection"
  (C3.3); a contributor without create rights sees the bare empty directory
  (C3.4); an Owner with management reach sees the "Members" control.
- Catalog — the empty collection shows "Propose the first entry" to a viewer
  who may contribute here (C3.5), and the propose control is gated on the
  collection's can_contribute flag (anon keeps the sign-in prompt, S2).
- api.js — getCollection, listScopeMembers, grantScopeMember, revokeScopeMember.

Frontend builds clean.

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

106 lines
4.1 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.
// CreateCollectionModal.jsx — §22 S2 endpoint, §22 S4 UI. The create-collection
// form the project directory's "Create your first collection" / "New
// collection" control opens. S2 shipped the POST
// /api/projects/:id/collections endpoint but left it UI-less; S4 surfaces it,
// gated on the viewer's `can_create_collection` capability.
//
// The form lets the Owner choose an id (a slug, not "default"), a type, an
// optional display name, and an optional visibility (defaulting to the
// project's). The backend commits a `.collection.yaml`, re-mirrors the
// registry, and returns the new collection; on success the caller refreshes
// the directory.
import { useState } from 'react'
import { createCollection } from '../api'
const TYPE_OPTIONS = [
{ value: 'document', label: 'Document — prose RFCs' },
{ value: 'specification', label: 'Specification' },
{ value: 'bdd', label: 'BDD — behaviour scenarios' },
]
const VISIBILITY_OPTIONS = [
{ value: '', label: 'Inherit from project' },
{ value: 'public', label: 'Public' },
{ value: 'unlisted', label: 'Unlisted (link-only)' },
{ value: 'gated', label: 'Gated (hidden from the public)' },
]
export default function CreateCollectionModal({ projectId, onClose, onCreated }) {
const [collectionId, setCollectionId] = useState('')
const [type, setType] = useState('document')
const [name, setName] = useState('')
const [visibility, setVisibility] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
async function handleCreate(e) {
e.preventDefault()
const cid = collectionId.trim().toLowerCase()
if (!cid) return
setSubmitting(true)
setError(null)
try {
const col = await createCollection(projectId, {
collectionId: cid,
type,
name: name.trim() || null,
visibility: visibility || null,
})
onCreated?.(col)
} catch (err) {
setError(err.message || 'Failed to create the collection.')
} finally {
setSubmitting(false)
}
}
return (
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal" style={{ maxWidth: 560 }}>
<div className="modal-header">
<h2>New collection</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
<div className="modal-body">
<form onSubmit={handleCreate} className="invitations-form">
<label htmlFor="col-id">Collection id</label>
<input
id="col-id"
value={collectionId}
onChange={e => setCollectionId(e.target.value)}
placeholder="e.g. model, features"
autoFocus
required
/>
<label htmlFor="col-type" style={{ marginTop: 10 }}>Type</label>
<select id="col-type" value={type} onChange={e => setType(e.target.value)}>
{TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<label htmlFor="col-name" style={{ marginTop: 10 }}>Display name (optional)</label>
<input
id="col-name"
value={name}
onChange={e => setName(e.target.value)}
placeholder="e.g. The Model"
/>
<label htmlFor="col-vis" style={{ marginTop: 10 }}>Visibility</label>
<select id="col-vis" value={visibility} onChange={e => setVisibility(e.target.value)}>
{VISIBILITY_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<div style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center' }}>
<button type="submit" className="btn-primary" disabled={submitting}>
{submitting ? 'Creating…' : 'Create collection'}
</button>
{error && <span style={{ color: '#c33' }}>{error}</span>}
</div>
</form>
</div>
<div className="modal-footer">
<button type="button" className="btn-link" onClick={onClose}>Close</button>
</div>
</div>
</div>
)
}