§22 S6: type-driven entry noun (§22.4a) — backend source of truth + chrome
The displayed noun for an entry is a framework concept keyed on the collection's immutable type — document→"RFC", specification→"Spec", bdd→"Feature" — not deployment content. The chrome reads it from the API instead of hardcoding "RFC". - collections.ENTRY_NOUN + entry_noun(type) (unknown type → generic "RFC"). - Surfaced as entry_noun on get_collection, list_collections items, the /api/deployment directory items, and GET /api/projects/:id. - Frontend: Catalog reads entry_noun for the "+ Propose New <noun>" control; ProposeModal fetches the active collection's noun for its title + field copy. - test_s6_entry_noun_vertical.py: map + API surfacing (collection + directory). Scope note: this is §22.4a item (2) — terminology. The deeper type-module work (item 1 per-type frontmatter schemas; item 3 specification release-planning + bdd scenario/coverage surfaces) is flagged OPEN in the design doc and is handed off to a follow-up spec+slice. Backend 528 passed; frontend 30 passed + build green. Part of v0.45.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -78,6 +78,11 @@ def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
|
|||||||
"type": collections_mod.collection_type(
|
"type": collections_mod.collection_type(
|
||||||
collections_mod.default_collection_id(r["id"])
|
collections_mod.default_collection_id(r["id"])
|
||||||
),
|
),
|
||||||
|
"entry_noun": collections_mod.entry_noun(
|
||||||
|
collections_mod.collection_type(
|
||||||
|
collections_mod.default_collection_id(r["id"])
|
||||||
|
)
|
||||||
|
),
|
||||||
"visibility": r["visibility"],
|
"visibility": r["visibility"],
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
@@ -221,6 +226,7 @@ def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
|
|||||||
"name": row["name"],
|
"name": row["name"],
|
||||||
"tagline": (dep["tagline"] if dep else "") or "",
|
"tagline": (dep["tagline"] if dep else "") or "",
|
||||||
"type": collections_mod.collection_type(cid),
|
"type": collections_mod.collection_type(cid),
|
||||||
|
"entry_noun": collections_mod.entry_noun(collections_mod.collection_type(cid)),
|
||||||
"visibility": row["visibility"],
|
"visibility": row["visibility"],
|
||||||
"initial_state": collections_mod.collection_initial_state(cid),
|
"initial_state": collections_mod.collection_initial_state(cid),
|
||||||
"theme": cfg.get("theme") or {},
|
"theme": cfg.get("theme") or {},
|
||||||
|
|||||||
@@ -14,6 +14,23 @@ from . import db
|
|||||||
|
|
||||||
DEFAULT_COLLECTION_ID = "default"
|
DEFAULT_COLLECTION_ID = "default"
|
||||||
|
|
||||||
|
# §22.4a item (2): the displayed noun for an entry is a type-driven label, a
|
||||||
|
# framework concept (like role names), not deployment content. The chrome reads
|
||||||
|
# this from the API rather than hardcoding "RFC", so a `specification` collection
|
||||||
|
# says "Spec" and a `bdd` collection says "Feature" with no per-deployment config.
|
||||||
|
ENTRY_NOUN = {
|
||||||
|
"document": "RFC",
|
||||||
|
"specification": "Spec",
|
||||||
|
"bdd": "Feature",
|
||||||
|
}
|
||||||
|
_DEFAULT_ENTRY_NOUN = "RFC"
|
||||||
|
|
||||||
|
|
||||||
|
def entry_noun(collection_type: str) -> str:
|
||||||
|
"""The §22.4a entry noun for a collection type. Unknown types fall back to
|
||||||
|
the generic 'RFC' so a future type is never label-less."""
|
||||||
|
return ENTRY_NOUN.get(collection_type, _DEFAULT_ENTRY_NOUN)
|
||||||
|
|
||||||
|
|
||||||
def _enabled_models_from_config(config_json: str | None) -> list[str] | None:
|
def _enabled_models_from_config(config_json: str | None) -> list[str] | None:
|
||||||
"""§22.12 per-collection enabled_models from a `config_json` blob, or None
|
"""§22.12 per-collection enabled_models from a `config_json` blob, or None
|
||||||
@@ -86,6 +103,7 @@ def get_collection(collection_id: str) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
out = dict(row)
|
out = dict(row)
|
||||||
out["enabled_models"] = _enabled_models_from_config(out.pop("config_json", None))
|
out["enabled_models"] = _enabled_models_from_config(out.pop("config_json", None))
|
||||||
|
out["entry_noun"] = entry_noun(out["type"])
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -102,5 +120,7 @@ def list_collections(project_id: str, include_unlisted: bool = False) -> list[di
|
|||||||
for r in rows:
|
for r in rows:
|
||||||
if not include_unlisted and r["visibility"] == "unlisted":
|
if not include_unlisted and r["visibility"] == "unlisted":
|
||||||
continue
|
continue
|
||||||
out.append(dict(r))
|
item = dict(r)
|
||||||
|
item["entry_noun"] = entry_noun(item["type"])
|
||||||
|
out.append(item)
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""§22.4a S6 — the type-driven entry noun.
|
||||||
|
|
||||||
|
The displayed noun for an entry is a framework concept keyed on the
|
||||||
|
collection's immutable type: document→"RFC", specification→"Spec", bdd→"Feature".
|
||||||
|
The chrome reads it from the API rather than hardcoding "RFC", so the propose
|
||||||
|
CTA + entry chrome name entries correctly per collection type with no
|
||||||
|
per-deployment config.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app import collections, db
|
||||||
|
from test_propose_vertical import ( # noqa: F401
|
||||||
|
app_with_fake_gitea, provision_user_row, sign_in_as, tmp_env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_entry_noun_map():
|
||||||
|
assert collections.entry_noun("document") == "RFC"
|
||||||
|
assert collections.entry_noun("specification") == "Spec"
|
||||||
|
assert collections.entry_noun("bdd") == "Feature"
|
||||||
|
# An unknown/future type falls back to the generic noun, never label-less.
|
||||||
|
assert collections.entry_noun("mystery") == "RFC"
|
||||||
|
|
||||||
|
|
||||||
|
def test_collection_api_surfaces_entry_noun(app_with_fake_gitea):
|
||||||
|
app, _ = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
provision_user_row(user_id=1, login="ben", role="owner")
|
||||||
|
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben",
|
||||||
|
role="owner", email="ben@test")
|
||||||
|
# Flip the default collection to a bdd type and confirm the noun follows.
|
||||||
|
db.conn().execute("UPDATE collections SET type='bdd' WHERE id='default'")
|
||||||
|
r = client.get("/api/projects/default/collections/default")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["entry_noun"] == "Feature"
|
||||||
|
|
||||||
|
|
||||||
|
def test_directory_items_carry_entry_noun(app_with_fake_gitea):
|
||||||
|
app, _ = app_with_fake_gitea
|
||||||
|
with TestClient(app) as client:
|
||||||
|
db.conn().execute("UPDATE projects SET visibility='public' WHERE id='default'")
|
||||||
|
db.conn().execute("UPDATE collections SET type='specification' WHERE id='default'")
|
||||||
|
r = client.get("/api/deployment")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
item = next(p for p in r.json()["projects"] if p["id"] == "default")
|
||||||
|
assert item["entry_noun"] == "Spec"
|
||||||
@@ -31,6 +31,9 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
|||||||
// collection's caps load; we fall back to "any authenticated viewer" so the
|
// collection's caps load; we fall back to "any authenticated viewer" so the
|
||||||
// common case doesn't flicker, then refine.
|
// common case doesn't flicker, then refine.
|
||||||
const [canContribute, setCanContribute] = useState(null)
|
const [canContribute, setCanContribute] = useState(null)
|
||||||
|
// §22.4a: the type-driven entry noun ("RFC" | "Spec" | "Feature") for this
|
||||||
|
// collection, read from the API. Defaults to the generic "RFC" until loaded.
|
||||||
|
const [entryNoun, setEntryNoun] = useState('RFC')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [sort, setSort] = useState('recent')
|
const [sort, setSort] = useState('recent')
|
||||||
const [activeChips, setActiveChips] = useState(new Set())
|
const [activeChips, setActiveChips] = useState(new Set())
|
||||||
@@ -46,7 +49,10 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
|||||||
listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([]))
|
listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([]))
|
||||||
setCanContribute(null)
|
setCanContribute(null)
|
||||||
getCollection(pid, cid)
|
getCollection(pid, cid)
|
||||||
.then(c => setCanContribute(!!c?.viewer?.can_contribute))
|
.then(c => {
|
||||||
|
setCanContribute(!!c?.viewer?.can_contribute)
|
||||||
|
setEntryNoun(c?.entry_noun || 'RFC')
|
||||||
|
})
|
||||||
.catch(() => setCanContribute(false))
|
.catch(() => setCanContribute(false))
|
||||||
}, [version, pid, cid])
|
}, [version, pid, cid])
|
||||||
|
|
||||||
@@ -177,7 +183,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
|||||||
// contribute to *this* collection (the scope-role gate); a granted
|
// contribute to *this* collection (the scope-role gate); a granted
|
||||||
// viewer with no contribute right in this collection sees nothing.
|
// viewer with no contribute right in this collection sees nothing.
|
||||||
mayPropose && (
|
mayPropose && (
|
||||||
<button className="btn-propose" onClick={onProposeRFC}>+ Propose New RFC</button>
|
<button className="btn-propose" onClick={onProposeRFC}>+ Propose New {entryNoun}</button>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<a className="btn-propose" href="/auth/login" title="Private beta — only invited emails can propose">
|
<a className="btn-propose" href="/auth/login" title="Private beta — only invited emails can propose">
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
// success navigates the proposer to the pending-idea view per §9.3.
|
// success navigates the proposer to the pending-idea view per §9.3.
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { proposeRFC, suggestTags } from '../api'
|
import { proposeRFC, suggestTags, getCollection } from '../api'
|
||||||
import { EVENTS, track } from '../lib/analytics'
|
import { EVENTS, track } from '../lib/analytics'
|
||||||
|
|
||||||
// How long the draft must sit unchanged before we ask for suggestions —
|
// How long the draft must sit unchanged before we ask for suggestions —
|
||||||
@@ -33,6 +33,9 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl
|
|||||||
// (App passes the `?propose=<term>` value here); the slug derives from it
|
// (App passes the `?propose=<term>` value here); the slug derives from it
|
||||||
// via the same effect that drives manual typing.
|
// via the same effect that drives manual typing.
|
||||||
const [title, setTitle] = useState(initialTitle)
|
const [title, setTitle] = useState(initialTitle)
|
||||||
|
// §22.4a: the type-driven noun for the active collection ("RFC" | "Spec" |
|
||||||
|
// "Feature"), read from the API. Defaults to "RFC" until the collection loads.
|
||||||
|
const [entryNoun, setEntryNoun] = useState('RFC')
|
||||||
const [slug, setSlug] = useState('')
|
const [slug, setSlug] = useState('')
|
||||||
const [slugEdited, setSlugEdited] = useState(false)
|
const [slugEdited, setSlugEdited] = useState(false)
|
||||||
const [pitch, setPitch] = useState('')
|
const [pitch, setPitch] = useState('')
|
||||||
@@ -53,6 +56,17 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl
|
|||||||
if (!slugEdited) setSlug(slugify(title))
|
if (!slugEdited) setSlug(slugify(title))
|
||||||
}, [title, slugEdited])
|
}, [title, slugEdited])
|
||||||
|
|
||||||
|
// §22.4a: resolve the active collection's entry noun so the modal chrome
|
||||||
|
// names entries per the collection type. Best-effort; falls back to "RFC".
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId || !collectionId) return
|
||||||
|
let live = true
|
||||||
|
getCollection(projectId, collectionId)
|
||||||
|
.then(c => { if (live) setEntryNoun(c?.entry_noun || 'RFC') })
|
||||||
|
.catch(() => {})
|
||||||
|
return () => { live = false }
|
||||||
|
}, [projectId, collectionId])
|
||||||
|
|
||||||
// #27: debounced tag-suggestion fetch. Fires after the draft sits
|
// #27: debounced tag-suggestion fetch. Fires after the draft sits
|
||||||
// unchanged for SUGGEST_DEBOUNCE_MS, only once there's something to go
|
// unchanged for SUGGEST_DEBOUNCE_MS, only once there's something to go
|
||||||
// on (a title). A stale-response guard keeps an earlier in-flight
|
// on (a title). A stale-response guard keeps an earlier in-flight
|
||||||
@@ -116,7 +130,7 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl
|
|||||||
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||||
<div className="modal">
|
<div className="modal">
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<h2>Propose a New RFC</h2>
|
<h2>Propose a New {entryNoun}</h2>
|
||||||
<button className="modal-close" onClick={onClose}>×</button>
|
<button className="modal-close" onClick={onClose}>×</button>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
@@ -130,7 +144,7 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl
|
|||||||
autoFocus
|
autoFocus
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="field-help">The word or topic this RFC will define.</p>
|
<p className="field-help">The word or topic this {entryNoun} will define.</p>
|
||||||
|
|
||||||
<label htmlFor="propose-slug">Slug</label>
|
<label htmlFor="propose-slug">Slug</label>
|
||||||
<input
|
<input
|
||||||
@@ -142,22 +156,22 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl
|
|||||||
/>
|
/>
|
||||||
<p className="field-help">Auto-derived from the title; edit if needed.</p>
|
<p className="field-help">Auto-derived from the title; edit if needed.</p>
|
||||||
|
|
||||||
<label htmlFor="propose-pitch">Why is this RFC needed?</label>
|
<label htmlFor="propose-pitch">Why is this {entryNoun} needed?</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="propose-pitch"
|
id="propose-pitch"
|
||||||
value={pitch}
|
value={pitch}
|
||||||
onChange={e => setPitch(e.target.value)}
|
onChange={e => setPitch(e.target.value)}
|
||||||
placeholder="One or two paragraphs answering 'why this RFC is needed.'"
|
placeholder={`One or two paragraphs answering 'why this ${entryNoun} is needed.'`}
|
||||||
rows={5}
|
rows={5}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label htmlFor="propose-use-case">What will you be using this RFC for? (optional)</label>
|
<label htmlFor="propose-use-case">What will you be using this {entryNoun} for? (optional)</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="propose-use-case"
|
id="propose-use-case"
|
||||||
value={useCase}
|
value={useCase}
|
||||||
onChange={e => setUseCase(e.target.value)}
|
onChange={e => setUseCase(e.target.value)}
|
||||||
placeholder="The concrete thing you intend to build or do with this RFC. Optional, but it helps ground the work."
|
placeholder={`The concrete thing you intend to build or do with this ${entryNoun}. Optional, but it helps ground the work.`}
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
<p className="field-help">
|
<p className="field-help">
|
||||||
|
|||||||
Reference in New Issue
Block a user