feat(slice3): faceted catalog pane gated on collection fields (§22.4a §5.1)
This commit is contained in:
@@ -150,6 +150,39 @@
|
|||||||
.row-title { font-size: var(--text-base); margin-top: 2px; }
|
.row-title { font-size: var(--text-base); margin-top: 2px; }
|
||||||
.catalog-row.is-super .row-title { color: var(--c-gray-500); }
|
.catalog-row.is-super .row-title { color: var(--c-gray-500); }
|
||||||
.row-tags { font-size: var(--text-xs); color: var(--c-gray-500); margin-top: 2px; }
|
.row-tags { font-size: var(--text-xs); color: var(--c-gray-500); margin-top: 2px; }
|
||||||
|
.row-malformed { font-size: var(--text-2xs); color: var(--c-warning-accent); font-weight: 600; }
|
||||||
|
|
||||||
|
/* §22.4a SLICE-3 — faceted left-pane filters (§5.1). */
|
||||||
|
.facets {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-bottom: 1px solid var(--c-gray-150);
|
||||||
|
}
|
||||||
|
.facet-group { margin-bottom: 8px; }
|
||||||
|
.facet-header {
|
||||||
|
background: none; border: 0; cursor: pointer; width: 100%; text-align: left;
|
||||||
|
font-size: var(--text-xs); font-weight: 700; color: var(--c-gray-600);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.04em; padding: 4px 0;
|
||||||
|
}
|
||||||
|
.facet-values { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.facet-value {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
font-size: var(--text-xs); color: var(--c-gray-600); cursor: pointer;
|
||||||
|
}
|
||||||
|
.facet-value-label { flex: 1; }
|
||||||
|
.facet-count { color: var(--c-gray-500); font-variant-numeric: tabular-nums; }
|
||||||
|
.facet-value-search {
|
||||||
|
width: 100%; margin: 4px 0; font-size: var(--text-xs);
|
||||||
|
padding: 2px 6px; border: 1px solid var(--c-gray-150); border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.facet-clear {
|
||||||
|
background: none; border: 0; cursor: pointer; padding: 0 0 6px;
|
||||||
|
font-size: var(--text-xs); color: var(--c-ink); text-decoration: underline;
|
||||||
|
}
|
||||||
|
.facet-empty { font-size: var(--text-xs); color: var(--c-gray-500); }
|
||||||
|
.facet-malformed {
|
||||||
|
display: flex; align-items: center; gap: 6px; margin-top: 6px;
|
||||||
|
font-size: var(--text-xs); color: var(--c-warning-accent); cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.catalog-pending {
|
.catalog-pending {
|
||||||
border-top: 1px solid var(--c-gray-150);
|
border-top: 1px solid var(--c-gray-150);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useParams, Link } from 'react-router-dom'
|
|||||||
import { listRFCs, listProposals, getCollection } from '../api'
|
import { listRFCs, listProposals, getCollection } from '../api'
|
||||||
import { entryPath, proposalPath, useProjectId, useCollectionId } from '../lib/entryPaths'
|
import { entryPath, proposalPath, useProjectId, useCollectionId } from '../lib/entryPaths'
|
||||||
import JoinRequestModal from './JoinRequestModal.jsx'
|
import JoinRequestModal from './JoinRequestModal.jsx'
|
||||||
|
import FacetGroups from './FacetGroups.jsx'
|
||||||
|
|
||||||
const STATE_CHIPS = [
|
const STATE_CHIPS = [
|
||||||
{ id: 'super-draft', label: 'Super-draft' },
|
{ id: 'super-draft', label: 'Super-draft' },
|
||||||
@@ -43,34 +44,67 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
|||||||
const [sort, setSort] = useState('recent')
|
const [sort, setSort] = useState('recent')
|
||||||
const [activeChips, setActiveChips] = useState(new Set())
|
const [activeChips, setActiveChips] = useState(new Set())
|
||||||
const [pendingOpen, setPendingOpen] = useState(true)
|
const [pendingOpen, setPendingOpen] = useState(true)
|
||||||
|
// §22.4a SLICE-3: faceted left pane. When the collection declares a `fields:`
|
||||||
|
// schema, the catalog renders faceted groups (counts from the server) instead
|
||||||
|
// of the legacy state chips, and re-fetches server-side as selections change.
|
||||||
|
const [fields, setFields] = useState(null) // collection field schema
|
||||||
|
const [facets, setFacets] = useState({}) // { field: { value: count } }
|
||||||
|
const [selections, setSelections] = useState({}) // { field: Set<string> }
|
||||||
|
const [malformedOnly, setMalformedOnly] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
const { slug, prNumber } = useParams()
|
const { slug, prNumber } = useParams()
|
||||||
const pid = useProjectId()
|
const pid = useProjectId()
|
||||||
// §22 S2: the catalog is scoped to the active collection (the `/c/:cid/`
|
// §22 S2: the catalog is scoped to the active collection (the `/c/:cid/`
|
||||||
// route segment, else the project's default collection).
|
// route segment, else the project's default collection).
|
||||||
const cid = useCollectionId()
|
const cid = useCollectionId()
|
||||||
|
|
||||||
|
// Collection-level data (proposals, caps, field schema) loads once per
|
||||||
|
// collection — independent of the facet selections, which only re-fetch the
|
||||||
|
// entry list. Switching collections clears any active facet selections.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listRFCs(pid, cid).then(d => setRfcs(d.items)).catch(() => setRfcs([]))
|
|
||||||
listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([]))
|
listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([]))
|
||||||
setCanContribute(null)
|
setCanContribute(null)
|
||||||
setCanRequestJoin(false)
|
setCanRequestJoin(false)
|
||||||
|
setSelections({})
|
||||||
|
setMalformedOnly(false)
|
||||||
getCollection(pid, cid)
|
getCollection(pid, cid)
|
||||||
.then(c => {
|
.then(c => {
|
||||||
setCanContribute(!!c?.viewer?.can_contribute)
|
setCanContribute(!!c?.viewer?.can_contribute)
|
||||||
setCanRequestJoin(!!c?.viewer?.can_request_join)
|
setCanRequestJoin(!!c?.viewer?.can_request_join)
|
||||||
setEntryNoun(c?.entry_noun || 'RFC')
|
setEntryNoun(c?.entry_noun || 'RFC')
|
||||||
|
setFields(c?.fields || null)
|
||||||
})
|
})
|
||||||
.catch(() => setCanContribute(false))
|
.catch(() => { setCanContribute(false); setFields(null) })
|
||||||
}, [version, pid, cid])
|
}, [version, pid, cid])
|
||||||
|
|
||||||
|
// §22.4a SLICE-3: the entry list re-fetches server-side whenever the facet
|
||||||
|
// selections or the malformed toggle change (in legacy mode selections stay
|
||||||
|
// empty, so this fires once per collection like before).
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true)
|
||||||
|
const selObj = Object.fromEntries(
|
||||||
|
Object.entries(selections).map(([f, set]) => [f, [...set]])
|
||||||
|
)
|
||||||
|
listRFCs(pid, cid, { selections: selObj, malformed: malformedOnly })
|
||||||
|
.then(d => { setRfcs(d.items); setFacets(d.facets || {}) })
|
||||||
|
.catch(() => { setRfcs([]); setFacets({}) })
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [version, pid, cid, selections, malformedOnly])
|
||||||
|
|
||||||
// While caps load, fall back to "any authenticated viewer" so the propose
|
// While caps load, fall back to "any authenticated viewer" so the propose
|
||||||
// affordance on the common (default-collection) case doesn't flash off.
|
// affordance on the common (default-collection) case doesn't flash off.
|
||||||
const mayPropose = canContribute === null ? !!viewer : canContribute
|
const mayPropose = canContribute === null ? !!viewer : canContribute
|
||||||
|
|
||||||
|
// §22.4a SLICE-3: faceted mode when the collection declares a non-empty
|
||||||
|
// `fields:` schema; otherwise the legacy state-chip catalog (INV-5).
|
||||||
|
const faceted = !!fields && Object.keys(fields).length > 0
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const needle = search.trim().toLowerCase()
|
const needle = search.trim().toLowerCase()
|
||||||
let items = rfcs.filter(r => {
|
let items = rfcs.filter(r => {
|
||||||
if (activeChips.size > 0 && !activeChips.has(r.state)) return false
|
// In faceted mode the server already filtered by state; the legacy chips
|
||||||
|
// narrow client-side only when there's no schema.
|
||||||
|
if (!faceted && activeChips.size > 0 && !activeChips.has(r.state)) return false
|
||||||
if (!needle) return true
|
if (!needle) return true
|
||||||
const hay = [r.title, r.slug, r.id || ''].join(' ').toLowerCase()
|
const hay = [r.title, r.slug, r.id || ''].join(' ').toLowerCase()
|
||||||
return hay.includes(needle)
|
return hay.includes(needle)
|
||||||
@@ -85,7 +119,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
|||||||
return (b.last_active_at || '').localeCompare(a.last_active_at || '')
|
return (b.last_active_at || '').localeCompare(a.last_active_at || '')
|
||||||
})
|
})
|
||||||
return items
|
return items
|
||||||
}, [rfcs, search, sort, activeChips])
|
}, [rfcs, search, sort, activeChips, faceted])
|
||||||
|
|
||||||
function toggleChip(id) {
|
function toggleChip(id) {
|
||||||
const next = new Set(activeChips)
|
const next = new Set(activeChips)
|
||||||
@@ -93,6 +127,20 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
|||||||
setActiveChips(next)
|
setActiveChips(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// §22.4a SLICE-3 facet selection handlers (faceted mode).
|
||||||
|
function toggleFacet(field, value) {
|
||||||
|
setSelections(prev => {
|
||||||
|
const next = { ...prev }
|
||||||
|
const set = new Set(next[field] || [])
|
||||||
|
set.has(value) ? set.delete(value) : set.add(value)
|
||||||
|
if (set.size === 0) delete next[field]
|
||||||
|
else next[field] = set
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function clearFilters() { setSelections({}); setMalformedOnly(false) }
|
||||||
|
const hasActiveFilters = Object.keys(selections).length > 0 || malformedOnly
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="catalog">
|
<aside className="catalog">
|
||||||
<div className="catalog-search">
|
<div className="catalog-search">
|
||||||
@@ -108,22 +156,45 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
|||||||
{SORT_OPTIONS.map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
|
{SORT_OPTIONS.map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="catalog-chips">
|
{faceted ? (
|
||||||
{STATE_CHIPS.map(chip => (
|
<FacetGroups
|
||||||
<button
|
facets={facets}
|
||||||
key={chip.id}
|
fields={fields}
|
||||||
className={`chip ${activeChips.has(chip.id) ? 'active' : ''}`}
|
selections={selections}
|
||||||
onClick={() => toggleChip(chip.id)}
|
onToggle={toggleFacet}
|
||||||
>
|
malformedOnly={malformedOnly}
|
||||||
{chip.label}
|
onToggleMalformed={() => setMalformedOnly(m => !m)}
|
||||||
</button>
|
onClear={clearFilters}
|
||||||
))}
|
hasActiveFilters={hasActiveFilters}
|
||||||
</div>
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="catalog-chips">
|
||||||
|
{STATE_CHIPS.map(chip => (
|
||||||
|
<button
|
||||||
|
key={chip.id}
|
||||||
|
className={`chip ${activeChips.has(chip.id) ? 'active' : ''}`}
|
||||||
|
onClick={() => toggleChip(chip.id)}
|
||||||
|
>
|
||||||
|
{chip.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="catalog-list">
|
<div className="catalog-list">
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<div style={{ padding: '24px 14px', color: '#999', fontSize: 13 }}>
|
<div style={{ padding: '24px 14px', color: '#999', fontSize: 13 }}>
|
||||||
{rfcs.length === 0
|
{loading
|
||||||
|
? 'Loading…'
|
||||||
|
// §22.4a SLICE-3: faceted mode with active filters → an
|
||||||
|
// explicitly-clearable "no entries match" state (§5.1).
|
||||||
|
: faceted && hasActiveFilters ? (
|
||||||
|
<>
|
||||||
|
No entries match.{' '}
|
||||||
|
<button className="facet-clear" onClick={clearFilters}>Clear filters</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: rfcs.length === 0
|
||||||
// C3.5: a contributor sees a propose-first call to action; a
|
// C3.5: a contributor sees a propose-first call to action; a
|
||||||
// granted viewer without contribute rights sees a bare empty
|
// granted viewer without contribute rights sees a bare empty
|
||||||
// state; an anonymous reader sees the read-only note (the footer
|
// state; an anonymous reader sees the read-only note (the footer
|
||||||
@@ -147,6 +218,9 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
|
|||||||
<span className={`row-id ${isSuper ? 'super' : ''}`}>
|
<span className={`row-id ${isSuper ? 'super' : ''}`}>
|
||||||
{isSuper ? 'super-draft' : (r.id || '—')}
|
{isSuper ? 'super-draft' : (r.id || '—')}
|
||||||
</span>
|
</span>
|
||||||
|
{r.metadata_malformed && (
|
||||||
|
<span className="row-malformed" title="Metadata fails this collection's schema">⚠ malformed</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="row-title">{r.title}</span>
|
<span className="row-title">{r.title}</span>
|
||||||
{r.tags.length > 0 && (
|
{r.tags.length > 0 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user