§22 S2: collection-scoped frontend path + API helpers
useCollectionId() hook; listRFCs/getRFC/proposeRFC take an optional collection id and target the /collections/<cid>/ routes; add listCollections + createCollection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
+37
-7
@@ -182,22 +182,50 @@ export async function getProject(projectId) {
|
|||||||
return jsonOrThrow(await fetch(`/api/projects/${projectId}`))
|
return jsonOrThrow(await fetch(`/api/projects/${projectId}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
// §22.4 (Plan B): per-project serving. Given a projectId, read the
|
// §22.4 (Plan B) / §22 S2: per-collection serving. With a projectId + a
|
||||||
// project-scoped routes so a non-default project's corpus renders; without
|
// collectionId, read the collection-scoped routes; with only a projectId, the
|
||||||
// one, fall back to the default-project compat path.
|
// project default-collection compat path; with neither, the unscoped path.
|
||||||
export async function listRFCs(projectId) {
|
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'
|
const url = projectId ? `/api/projects/${projectId}/rfcs` : '/api/rfcs'
|
||||||
return jsonOrThrow(await fetch(url))
|
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.
|
// Back-compat: getRFC(slug) (one arg) still hits the unscoped default path.
|
||||||
if (slug === undefined) {
|
if (slug === undefined) {
|
||||||
return jsonOrThrow(await fetch(`/api/rfcs/${projectId}`))
|
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}`))
|
return jsonOrThrow(await fetch(`/api/projects/${projectId}/rfcs/${slug}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// §22 S2: the collections of a project (for the /p/<project>/ 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) {
|
export async function listProposals(projectId) {
|
||||||
const url = projectId ? `/api/projects/${projectId}/proposals` : '/api/proposals'
|
const url = projectId ? `/api/projects/${projectId}/proposals` : '/api/proposals'
|
||||||
return jsonOrThrow(await fetch(url))
|
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
|
// §22.4 (Plan B write): propose into a specific project when projectId is
|
||||||
// given; else the default-project compat path.
|
// given; else the default-project compat path.
|
||||||
export async function proposeRFC(projectId, { title, slug, pitch, tags, proposedUseCase }) {
|
export async function proposeRFC(projectId, { title, slug, pitch, tags, proposedUseCase, collectionId }) {
|
||||||
const url = projectId ? `/api/projects/${projectId}/rfcs/propose` : '/api/rfcs/propose'
|
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, {
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
// project-scoped, so the builders emit the default collection segment; the
|
// project-scoped, so the builders emit the default collection segment; the
|
||||||
// collection-aware link layer (named collections) lands in S2. Components build
|
// collection-aware link layer (named collections) lands in S2. Components build
|
||||||
// links via these helpers so that flip happens in one place.
|
// links via these helpers so that flip happens in one place.
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
import { useProject } from '../components/ProjectLayout.jsx'
|
import { useProject } from '../components/ProjectLayout.jsx'
|
||||||
import { useDeployment } from '../context/DeploymentProvider'
|
import { useDeployment } from '../context/DeploymentProvider'
|
||||||
|
|
||||||
@@ -37,3 +38,10 @@ export function useProjectId() {
|
|||||||
const { defaultProjectId } = useDeployment()
|
const { defaultProjectId } = useDeployment()
|
||||||
return (ctx && ctx.projectId) || defaultProjectId
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user