§22 S2: collection directory at /p/<project>/ (1 → redirect, 2+ → list)
Replace DefaultCollectionRedirect with a CollectionDirectory that lists the project's visible collections, or redirects into the sole one when there is exactly one (preserving the S1 C3.7/C3.8 single-collection UX). The create-first-collection empty state is S4. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+10
-8
@@ -15,6 +15,7 @@ import RFCView from './components/RFCView.jsx'
|
|||||||
import PRView from './components/PRView.jsx'
|
import PRView from './components/PRView.jsx'
|
||||||
import ProposalView from './components/ProposalView.jsx'
|
import ProposalView from './components/ProposalView.jsx'
|
||||||
import ProposeModal from './components/ProposeModal.jsx'
|
import ProposeModal from './components/ProposeModal.jsx'
|
||||||
|
import CollectionDirectory from './components/CollectionDirectory.jsx'
|
||||||
import ContributeRequestForm from './components/ContributeRequestForm.jsx'
|
import ContributeRequestForm from './components/ContributeRequestForm.jsx'
|
||||||
import Landing from './components/Landing.jsx'
|
import Landing from './components/Landing.jsx'
|
||||||
import Login from './components/Login.jsx'
|
import Login from './components/Login.jsx'
|
||||||
@@ -364,9 +365,10 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
<main className="main-pane">
|
<main className="main-pane">
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* §22 three-tier (C3.7): the project landing redirects into
|
{/* §22 S2: the project landing is the collection directory —
|
||||||
its sole/default collection (S1: the `default` one). */}
|
it lists collections, or (C3.7/C3.8) redirects into the
|
||||||
<Route path="" element={<DefaultCollectionRedirect />} />
|
sole visible collection when there is exactly one. */}
|
||||||
|
<Route path="" element={<CollectionDirectoryRoute />} />
|
||||||
{/* Backcompat: the shipped v0.35.0 corpus URLs without a
|
{/* Backcompat: the shipped v0.35.0 corpus URLs without a
|
||||||
/c/<collection>/ segment redirect into the default
|
/c/<collection>/ segment redirect into the default
|
||||||
collection, so old bookmarks keep working. */}
|
collection, so old bookmarks keep working. */}
|
||||||
@@ -419,12 +421,12 @@ export default function App() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// §22 three-tier — corpus redirects mounted under /p/:projectId/*.
|
// §22 S2 — the project landing at /p/:projectId/ is the collection directory.
|
||||||
// C3.7: the project landing skips the (single-collection) directory and lands in
|
// A tiny wrapper reads the route's projectId and hands it to CollectionDirectory
|
||||||
// the project's default collection.
|
// (which lists collections, or redirects into the sole one — C3.7/C3.8).
|
||||||
function DefaultCollectionRedirect() {
|
function CollectionDirectoryRoute() {
|
||||||
const { projectId } = useParams()
|
const { projectId } = useParams()
|
||||||
return <Navigate to={`/p/${projectId}/c/${DEFAULT_COLLECTION}/`} replace />
|
return <CollectionDirectory projectId={projectId} />
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backcompat for the shipped v0.35.0 corpus URLs that lacked the
|
// Backcompat for the shipped v0.35.0 corpus URLs that lacked the
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// §22 S2 — the project collection directory at `/p/<project>/`. Lists the
|
||||||
|
// project's caller-visible collections as cards linking into each collection's
|
||||||
|
// `/p/<project>/c/<collection>/` home. When exactly one collection is visible
|
||||||
|
// the directory is skipped and we redirect straight into it (the S1 C3.7/C3.8
|
||||||
|
// single-collection UX, preserved). The role-keyed "Create your first
|
||||||
|
// collection" empty state is S4; S2 shows a minimal note when there are none.
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link, Navigate } from 'react-router-dom'
|
||||||
|
import { listCollections } from '../api'
|
||||||
|
import { collectionHome } from '../lib/entryPaths'
|
||||||
|
import { entryNoun } from './ProjectLayout.jsx'
|
||||||
|
|
||||||
|
export default function CollectionDirectory({ projectId }) {
|
||||||
|
const [cols, setCols] = useState(null)
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true
|
||||||
|
listCollections(projectId)
|
||||||
|
.then(d => { if (live) setCols(d.items) })
|
||||||
|
.catch(() => { if (live) setCols([]) })
|
||||||
|
return () => { live = false }
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
if (cols === null) {
|
||||||
|
return <main className="chrome-pane"><div className="boot">Loading…</div></main>
|
||||||
|
}
|
||||||
|
// C3.7/C3.8: a single visible collection skips the directory.
|
||||||
|
if (cols.length === 1) {
|
||||||
|
return <Navigate to={collectionHome(projectId, cols[0].id)} replace />
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<main className="chrome-pane">
|
||||||
|
<div className="directory">
|
||||||
|
<h1>Collections</h1>
|
||||||
|
{cols.length === 0 ? (
|
||||||
|
<p className="directory-tagline">No collections yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="directory-list">
|
||||||
|
{cols.map(c => (
|
||||||
|
<li key={c.id} className="directory-card">
|
||||||
|
<Link to={collectionHome(projectId, c.id)}>
|
||||||
|
<span className="directory-card-name">{c.name || c.id}</span>
|
||||||
|
<span className="directory-card-type">{entryNoun(c.type)}s</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
||||||
|
|
||||||
|
let mockItems = []
|
||||||
|
vi.mock('../api', () => ({
|
||||||
|
listCollections: vi.fn(async () => ({ items: mockItems })),
|
||||||
|
}))
|
||||||
|
import CollectionDirectory from './CollectionDirectory.jsx'
|
||||||
|
|
||||||
|
beforeEach(() => { mockItems = [] })
|
||||||
|
|
||||||
|
function renderDir(items) {
|
||||||
|
mockItems = items
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={["/p/ohm/"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/p/:projectId/*" element={<CollectionDirectory projectId="ohm" />} />
|
||||||
|
<Route path="/p/:projectId/c/:collectionId/*" element={<div>collection home</div>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CollectionDirectory', () => {
|
||||||
|
it('lists a card per collection with the type-driven noun + link when 2+', async () => {
|
||||||
|
renderDir([
|
||||||
|
{ id: 'default', name: 'Model', type: 'document' },
|
||||||
|
{ id: 'features', name: 'Scenarios', type: 'bdd' },
|
||||||
|
])
|
||||||
|
await waitFor(() => expect(screen.getByText('Scenarios')).toBeInTheDocument())
|
||||||
|
expect(screen.getByText('Model').closest('a')).toHaveAttribute('href', '/p/ohm/c/default/')
|
||||||
|
expect(screen.getByText('Scenarios').closest('a')).toHaveAttribute('href', '/p/ohm/c/features/')
|
||||||
|
expect(screen.getByText('RFCs')).toBeInTheDocument() // document → RFCs
|
||||||
|
expect(screen.getByText('Features')).toBeInTheDocument() // bdd → Features (type noun)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects into the sole collection when exactly one is visible', async () => {
|
||||||
|
renderDir([{ id: 'default', name: 'Model', type: 'document' }])
|
||||||
|
await waitFor(() => expect(screen.getByText('collection home')).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a minimal empty note when there are no collections', async () => {
|
||||||
|
renderDir([])
|
||||||
|
await waitFor(() => expect(screen.getByText('No collections yet.')).toBeInTheDocument())
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user