diff --git a/backend/app/api_deployment.py b/backend/app/api_deployment.py index 93ca897..7c7513b 100644 --- a/backend/app/api_deployment.py +++ b/backend/app/api_deployment.py @@ -78,6 +78,11 @@ def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter: "type": collections_mod.collection_type( 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"], } for r in rows @@ -221,6 +226,7 @@ def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter: "name": row["name"], "tagline": (dep["tagline"] if dep else "") or "", "type": collections_mod.collection_type(cid), + "entry_noun": collections_mod.entry_noun(collections_mod.collection_type(cid)), "visibility": row["visibility"], "initial_state": collections_mod.collection_initial_state(cid), "theme": cfg.get("theme") or {}, diff --git a/backend/app/collections.py b/backend/app/collections.py index 76204fe..5fba493 100644 --- a/backend/app/collections.py +++ b/backend/app/collections.py @@ -14,6 +14,23 @@ from . import db 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: """§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 out = dict(row) out["enabled_models"] = _enabled_models_from_config(out.pop("config_json", None)) + out["entry_noun"] = entry_noun(out["type"]) return out @@ -102,5 +120,7 @@ def list_collections(project_id: str, include_unlisted: bool = False) -> list[di for r in rows: if not include_unlisted and r["visibility"] == "unlisted": continue - out.append(dict(r)) + item = dict(r) + item["entry_noun"] = entry_noun(item["type"]) + out.append(item) return out diff --git a/backend/tests/test_s6_entry_noun_vertical.py b/backend/tests/test_s6_entry_noun_vertical.py new file mode 100644 index 0000000..5f0691e --- /dev/null +++ b/backend/tests/test_s6_entry_noun_vertical.py @@ -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" diff --git a/frontend/src/components/Catalog.jsx b/frontend/src/components/Catalog.jsx index e147ac4..fe7dbfd 100644 --- a/frontend/src/components/Catalog.jsx +++ b/frontend/src/components/Catalog.jsx @@ -31,6 +31,9 @@ export default function Catalog({ viewer, onProposeRFC, version }) { // collection's caps load; we fall back to "any authenticated viewer" so the // common case doesn't flicker, then refine. 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 [sort, setSort] = useState('recent') 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([])) setCanContribute(null) 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)) }, [version, pid, cid]) @@ -177,7 +183,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) { // contribute to *this* collection (the scope-role gate); a granted // viewer with no contribute right in this collection sees nothing. mayPropose && ( - + ) ) : ( diff --git a/frontend/src/components/ProposeModal.jsx b/frontend/src/components/ProposeModal.jsx index ef7a110..d84618b 100644 --- a/frontend/src/components/ProposeModal.jsx +++ b/frontend/src/components/ProposeModal.jsx @@ -12,7 +12,7 @@ // success navigates the proposer to the pending-idea view per §9.3. import { useEffect, useRef, useState } from 'react' -import { proposeRFC, suggestTags } from '../api' +import { proposeRFC, suggestTags, getCollection } from '../api' import { EVENTS, track } from '../lib/analytics' // 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=` value here); the slug derives from it // via the same effect that drives manual typing. 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 [slugEdited, setSlugEdited] = useState(false) const [pitch, setPitch] = useState('') @@ -53,6 +56,17 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl if (!slugEdited) setSlug(slugify(title)) }, [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 // unchanged for SUGGEST_DEBOUNCE_MS, only once there's something to go // on (a title). A stale-response guard keeps an earlier in-flight @@ -116,7 +130,7 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl
{ if (e.target === e.currentTarget) onClose() }}>
-

Propose a New RFC

+

Propose a New {entryNoun}

@@ -130,7 +144,7 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl autoFocus required /> -

The word or topic this RFC will define.

+

The word or topic this {entryNoun} will define.

Auto-derived from the title; edit if needed.

- +