From 17bdd5fd9a8aaa012938e9b4f392af22840afb61 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Fri, 5 Jun 2026 13:14:18 -0700 Subject: [PATCH] =?UTF-8?q?=C2=A722=20S2:=20collection=20directory=20at=20?= =?UTF-8?q?/p//=20(1=20=E2=86=92=20redirect,=202+=20=E2=86=92=20l?= =?UTF-8?q?ist)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- frontend/src/App.jsx | 18 ++++--- .../src/components/CollectionDirectory.jsx | 51 +++++++++++++++++++ .../components/CollectionDirectory.test.jsx | 48 +++++++++++++++++ 3 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/CollectionDirectory.jsx create mode 100644 frontend/src/components/CollectionDirectory.test.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c1db389..6bdf11b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -15,6 +15,7 @@ import RFCView from './components/RFCView.jsx' import PRView from './components/PRView.jsx' import ProposalView from './components/ProposalView.jsx' import ProposeModal from './components/ProposeModal.jsx' +import CollectionDirectory from './components/CollectionDirectory.jsx' import ContributeRequestForm from './components/ContributeRequestForm.jsx' import Landing from './components/Landing.jsx' import Login from './components/Login.jsx' @@ -364,9 +365,10 @@ export default function App() { />
- {/* §22 three-tier (C3.7): the project landing redirects into - its sole/default collection (S1: the `default` one). */} - } /> + {/* §22 S2: the project landing is the collection directory — + it lists collections, or (C3.7/C3.8) redirects into the + sole visible collection when there is exactly one. */} + } /> {/* Backcompat: the shipped v0.35.0 corpus URLs without a /c// segment redirect into the default collection, so old bookmarks keep working. */} @@ -419,12 +421,12 @@ export default function App() { ) } -// §22 three-tier — corpus redirects mounted under /p/:projectId/*. -// C3.7: the project landing skips the (single-collection) directory and lands in -// the project's default collection. -function DefaultCollectionRedirect() { +// §22 S2 — the project landing at /p/:projectId/ is the collection directory. +// A tiny wrapper reads the route's projectId and hands it to CollectionDirectory +// (which lists collections, or redirects into the sole one — C3.7/C3.8). +function CollectionDirectoryRoute() { const { projectId } = useParams() - return + return } // Backcompat for the shipped v0.35.0 corpus URLs that lacked the diff --git a/frontend/src/components/CollectionDirectory.jsx b/frontend/src/components/CollectionDirectory.jsx new file mode 100644 index 0000000..1b8b762 --- /dev/null +++ b/frontend/src/components/CollectionDirectory.jsx @@ -0,0 +1,51 @@ +// §22 S2 — the project collection directory at `/p//`. Lists the +// project's caller-visible collections as cards linking into each collection's +// `/p//c//` 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
Loading…
+ } + // C3.7/C3.8: a single visible collection skips the directory. + if (cols.length === 1) { + return + } + return ( +
+
+

Collections

+ {cols.length === 0 ? ( +

No collections yet.

+ ) : ( +
    + {cols.map(c => ( +
  • + + {c.name || c.id} + {entryNoun(c.type)}s + +
  • + ))} +
+ )} +
+
+ ) +} diff --git a/frontend/src/components/CollectionDirectory.test.jsx b/frontend/src/components/CollectionDirectory.test.jsx new file mode 100644 index 0000000..ccc10b2 --- /dev/null +++ b/frontend/src/components/CollectionDirectory.test.jsx @@ -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( + + + } /> + collection home} /> + + , + ) +} + +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()) + }) +})