diff --git a/frontend/src/api.collections.test.js b/frontend/src/api.collections.test.js new file mode 100644 index 0000000..f446a8c --- /dev/null +++ b/frontend/src/api.collections.test.js @@ -0,0 +1,48 @@ +// §22 S2 — the API client builds collection-scoped URLs when a collection id is +// supplied, and falls back to the project/default-collection paths otherwise. +import { describe, it, expect, vi, afterEach } from 'vitest' +import { listRFCs, getRFC, proposeRFC, listCollections } from './api.js' + +function mockFetch() { + const fn = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ items: [] }), + })) + global.fetch = fn + return fn +} + +afterEach(() => { vi.restoreAllMocks() }) + +describe('collection-scoped api URLs', () => { + it('listRFCs scopes to a collection when given one', async () => { + const f = mockFetch() + await listRFCs('ohm', 'features') + expect(f).toHaveBeenCalledWith('/api/projects/ohm/collections/features/rfcs') + }) + + it('listRFCs falls back to the project default path without a collection', async () => { + const f = mockFetch() + await listRFCs('ohm') + expect(f).toHaveBeenCalledWith('/api/projects/ohm/rfcs') + }) + + it('getRFC scopes to a collection when given one', async () => { + const f = mockFetch() + await getRFC('ohm', 'login', 'features') + expect(f).toHaveBeenCalledWith('/api/projects/ohm/collections/features/rfcs/login') + }) + + it('proposeRFC targets the collection-scoped propose route', async () => { + const f = mockFetch() + await proposeRFC('ohm', { title: 'T', slug: 's', pitch: 'p', tags: [], collectionId: 'features' }) + expect(f.mock.calls[0][0]).toBe('/api/projects/ohm/collections/features/rfcs/propose') + }) + + it('listCollections hits the project collections route', async () => { + const f = mockFetch() + await listCollections('ohm') + expect(f).toHaveBeenCalledWith('/api/projects/ohm/collections') + }) +}) diff --git a/frontend/src/api.js b/frontend/src/api.js index 35978ca..d64efd8 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -182,22 +182,50 @@ export async function getProject(projectId) { return jsonOrThrow(await fetch(`/api/projects/${projectId}`)) } -// §22.4 (Plan B): per-project serving. Given a projectId, read the -// project-scoped routes so a non-default project's corpus renders; without -// one, fall back to the default-project compat path. -export async function listRFCs(projectId) { +// §22.4 (Plan B) / §22 S2: per-collection serving. With a projectId + a +// collectionId, read the collection-scoped routes; with only a projectId, the +// project default-collection compat path; with neither, the unscoped path. +export async function listRFCs(projectId, collectionId) { + if (projectId && collectionId) { + return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections/${collectionId}/rfcs`)) + } const url = projectId ? `/api/projects/${projectId}/rfcs` : '/api/rfcs' return jsonOrThrow(await fetch(url)) } -export async function getRFC(projectId, slug) { +export async function getRFC(projectId, slug, collectionId) { // Back-compat: getRFC(slug) (one arg) still hits the unscoped default path. if (slug === undefined) { return jsonOrThrow(await fetch(`/api/rfcs/${projectId}`)) } + if (collectionId) { + return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections/${collectionId}/rfcs/${slug}`)) + } return jsonOrThrow(await fetch(`/api/projects/${projectId}/rfcs/${slug}`)) } +// §22 S2: the collections of a project (for the /p// directory). +export async function listCollections(projectId) { + return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections`)) +} + +// §22 S2: create-collection (deployment owner/admin). The backend commits a +// .collection.yaml and re-mirrors the registry, returning the new collection. +export async function createCollection(projectId, { collectionId, type, name, visibility, initialState }) { + const res = await fetch(`/api/projects/${projectId}/collections`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + collection_id: collectionId, + type, + name: name || null, + visibility: visibility || null, + initial_state: initialState || null, + }), + }) + return jsonOrThrow(res) +} + export async function listProposals(projectId) { const url = projectId ? `/api/projects/${projectId}/proposals` : '/api/proposals' return jsonOrThrow(await fetch(url)) @@ -209,8 +237,10 @@ export async function getProposal(prNumber) { // §22.4 (Plan B write): propose into a specific project when projectId is // given; else the default-project compat path. -export async function proposeRFC(projectId, { title, slug, pitch, tags, proposedUseCase }) { - const url = projectId ? `/api/projects/${projectId}/rfcs/propose` : '/api/rfcs/propose' +export async function proposeRFC(projectId, { title, slug, pitch, tags, proposedUseCase, collectionId }) { + const url = (projectId && collectionId) + ? `/api/projects/${projectId}/collections/${collectionId}/rfcs/propose` + : (projectId ? `/api/projects/${projectId}/rfcs/propose` : '/api/rfcs/propose') const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/frontend/src/lib/entryPaths.js b/frontend/src/lib/entryPaths.js index 15c5f53..b780f5a 100644 --- a/frontend/src/lib/entryPaths.js +++ b/frontend/src/lib/entryPaths.js @@ -4,6 +4,7 @@ // project-scoped, so the builders emit the default collection segment; the // collection-aware link layer (named collections) lands in S2. Components build // links via these helpers so that flip happens in one place. +import { useParams } from 'react-router-dom' import { useProject } from '../components/ProjectLayout.jsx' import { useDeployment } from '../context/DeploymentProvider' @@ -37,3 +38,10 @@ export function useProjectId() { const { defaultProjectId } = useDeployment() return (ctx && ctx.projectId) || defaultProjectId } + +// §22 S2 — the collection id a component should scope to: the `/c/:collectionId/` +// route segment when present, else the project's default collection. +export function useCollectionId() { + const { collectionId } = useParams() + return collectionId || DEFAULT_COLLECTION +}