From 3d865c79721b5de75284c4508a29c76dd1f6c5ca Mon Sep 17 00:00:00 2001 From: Jason Paff Date: Fri, 11 Sep 2026 15:49:32 -0400 Subject: [PATCH 1/2] feat: add a README editor to the Create Bundle wizard Bundles published from the web always shipped without README.md because the wizard hard-coded an empty readme on publish. Add a README step (Metadata > Assets > README > Setup > Review) mirroring the asset Contribute wizard, with a live markdown preview, a "README provided / none" row on the review step, and the README passed through to publishBundle. The README stays a sibling file and never lands in bundle.json. Edit / New version on the bundle page now seeds the wizard with the current README so a new version starts from the existing one. Persisted drafts written before this step still load (readme defaults to ''). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011nojgR3M3ZNo3BfyTZdWTz --- src/__tests__/routes/BundleDetail.test.tsx | 39 ++++++- src/__tests__/routes/CreateBundle.test.tsx | 122 +++++++++++++++++++-- src/routes/BundleDetail.tsx | 3 +- src/routes/CreateBundle.tsx | 81 ++++++++++++-- 4 files changed, 221 insertions(+), 24 deletions(-) diff --git a/src/__tests__/routes/BundleDetail.test.tsx b/src/__tests__/routes/BundleDetail.test.tsx index f05123b..f80b9b4 100644 --- a/src/__tests__/routes/BundleDetail.test.tsx +++ b/src/__tests__/routes/BundleDetail.test.tsx @@ -3,7 +3,7 @@ import type { UseQueryResult } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { fireEvent, render, screen, within } from '@testing-library/react'; import { type ReactNode } from 'react'; -import { MemoryRouter, Route, Routes } from 'react-router'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Bundle, Manifest, Registry } from '@/lib/schemas'; @@ -43,6 +43,12 @@ type BundleQueryShape = Partial>; type ReadmeQueryShape = Partial>; type RegistryQueryShape = Partial>; +/** Stands in for the Create Bundle route so tests can inspect the seed passed via navigation state. */ +function CreateBundleProbe() { + const location = useLocation(); + return
{JSON.stringify(location.state)}
; +} + function renderAt(path: string) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); function Wrapper({ children }: { children: ReactNode }) { @@ -50,6 +56,7 @@ function renderAt(path: string) { + } path='/bundles/new' /> @@ -347,6 +354,36 @@ describe('BundleDetailRoute', () => { expect(screen.getByTestId('bundle-detail-org')).toHaveTextContent('cupay'); }); + it('seeds Edit / New version with the current README so the next version starts from it', () => { + setBundle({ data: FULL_BUNDLE, isSuccess: true }); + setRegistry({ data: loadFixtureRegistry(), isSuccess: true }); + setReadme({ data: '# Feature workflow\n\nOverview.', isSuccess: true }); + renderAt('/bundles/feature-workflow'); + + fireEvent.click(screen.getByTestId('bundle-detail-new-version')); + + const seed = JSON.parse(screen.getByTestId('create-bundle-seed').textContent ?? 'null') as Record; + expect(seed).toMatchObject({ + name: 'feature-workflow', + readme: '# Feature workflow\n\nOverview.', + setupInstructions: FULL_BUNDLE.setupInstructions, + version: '1.1.0', + }); + }); + + it('omits readme from the Edit / New version seed when the bundle has no README', () => { + setBundle({ data: FULL_BUNDLE, isSuccess: true }); + setRegistry({ data: loadFixtureRegistry(), isSuccess: true }); + setReadme({ data: null }); + renderAt('/bundles/feature-workflow'); + + fireEvent.click(screen.getByTestId('bundle-detail-new-version')); + + const seed = JSON.parse(screen.getByTestId('create-bundle-seed').textContent ?? 'null') as Record; + expect(seed).toMatchObject({ name: 'feature-workflow' }); + expect(seed).not.toHaveProperty('readme'); + }); + it('passes the bundle org to the download hook for an org-scoped bundle', () => { const download = vi.fn().mockResolvedValue(undefined); useDownloadBundleMock.mockReturnValueOnce({ download, isDownloading: () => false }); diff --git a/src/__tests__/routes/CreateBundle.test.tsx b/src/__tests__/routes/CreateBundle.test.tsx index cd4a271..880389e 100644 --- a/src/__tests__/routes/CreateBundle.test.tsx +++ b/src/__tests__/routes/CreateBundle.test.tsx @@ -1,7 +1,7 @@ import type { UseQueryResult } from '@tanstack/react-query'; import { Toast } from '@base-ui-components/react/toast'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { type ReactNode } from 'react'; import { MemoryRouter } from 'react-router'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -18,6 +18,8 @@ import { computeBundleVersionConflict, CreateBundleRoute, createInitialBundleDraft, + DRAFT_STORAGE_KEY, + loadBundleDraftFromStorage, validateBundleDraft, } from '@/routes/CreateBundle'; @@ -97,6 +99,29 @@ describe('CreateBundle — helpers', () => { expect(input.tags).toEqual(['workflow']); }); + it('keeps the README out of bundle.json — it is published as a sibling file', () => { + const draft: BundleDraftState = { + ...createInitialBundleDraft('jason'), + assets: [{ name: 'clarification-agent', type: 'agent' }], + description: 'd', + name: 'my-bundle', + readme: '# My bundle\n\nOverview.', + setupInstructions: '## Setup', + }; + const input = buildBundleInput(draft); + expect(input).not.toHaveProperty('readme'); + expect(input.setupInstructions).toBe('## Setup'); + }); + + it('loads a persisted draft written before the README step existed', () => { + const { readme: _readme, versionConflict: _conflict, ...legacy } = createInitialBundleDraft('jason'); + window.sessionStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify({ ...legacy, name: 'old-draft' })); + const loaded = loadBundleDraftFromStorage(); + expect(loaded).not.toBeNull(); + expect(loaded?.name).toBe('old-draft'); + expect(loaded?.readme).toBe(''); + }); + it('validates a well-formed bundle draft and rejects one with no assets', () => { const base: BundleDraftState = { ...createInitialBundleDraft('jason'), @@ -123,15 +148,13 @@ describe('CreateBundle — helpers', () => { }); describe('CreateBundle — wizard flow', () => { - it('walks metadata → assets → review and submits a bundle via publishBundle', async () => { - const publishSpy = vi - .spyOn(publishServiceModule, 'publishBundle') - .mockResolvedValue({ - branchName: 'bundle/my-bundle/1.0.0', - dryRun: false, - prUrl: 'https://x/pull/1', - warnings: [], - }); + it('walks metadata → assets → README → setup → review and submits a bundle via publishBundle', async () => { + const publishSpy = vi.spyOn(publishServiceModule, 'publishBundle').mockResolvedValue({ + branchName: 'bundle/my-bundle/1.0.0', + dryRun: false, + prUrl: 'https://x/pull/1', + warnings: [], + }); renderCreateBundle(); @@ -145,17 +168,92 @@ describe('CreateBundle — wizard flow', () => { expect(screen.getByTestId('bundle-asset-clarification-agent')).toBeInTheDocument(); fireEvent.click(screen.getByTestId('wizard-next')); - // Step 3 — setup (optional) → continue + // Step 3 — README (optional): type markdown and check the live preview + const readme = screen.getByTestId('field-readme'); + expect(screen.getByTestId('readme-preview')).toHaveTextContent(/preview will appear here/i); + fireEvent.change(readme, { target: { value: '# My bundle\n\nWhat it does.' } }); + expect( + within(screen.getByTestId('readme-preview')).getByRole('heading', { level: 1, name: 'My bundle' }), + ).toBeInTheDocument(); + fireEvent.click(screen.getByTestId('wizard-next')); + + // Step 4 — setup (optional) → continue + expect(screen.getByTestId('field-setup')).toBeInTheDocument(); fireEvent.click(screen.getByTestId('wizard-next')); - // Step 4 — review & submit + // Step 5 — review & submit expect(screen.getByTestId('review-valid')).toBeInTheDocument(); + expect(screen.getByTestId('review-readme')).toHaveTextContent(/readme provided/i); + expect(screen.getByTestId('review-bundle')).not.toHaveTextContent('readme'); fireEvent.click(screen.getByTestId('wizard-submit')); await waitFor(() => expect(publishSpy).toHaveBeenCalledTimes(1)); const arg = publishSpy.mock.calls[0]![0]; expect(arg.bundle.name).toBe('my-bundle'); expect(arg.bundle.assets).toEqual([{ name: 'clarification-agent', type: 'agent' }]); + expect(arg.bundle).not.toHaveProperty('readme'); + expect(arg.readme).toBe('# My bundle\n\nWhat it does.'); + }); + + it('publishes an empty README and says so on the review step when the README step is skipped', async () => { + const publishSpy = vi.spyOn(publishServiceModule, 'publishBundle').mockResolvedValue({ + branchName: 'bundle/my-bundle/1.0.0', + dryRun: false, + prUrl: 'https://x/pull/1', + warnings: [], + }); + + renderCreateBundle(); + fireEvent.change(screen.getByTestId('field-name'), { target: { value: 'my-bundle' } }); + fireEvent.change(screen.getByTestId('field-description'), { target: { value: 'A useful bundle' } }); + fireEvent.click(screen.getByTestId('wizard-next')); + fireEvent.click(screen.getByTestId('add-asset-clarification-agent')); + fireEvent.click(screen.getByTestId('wizard-next')); + // README and Setup are both optional. + expect(screen.getByTestId('wizard-next')).toBeEnabled(); + fireEvent.click(screen.getByTestId('wizard-next')); + fireEvent.click(screen.getByTestId('wizard-next')); + + expect(screen.getByTestId('review-readme')).toHaveTextContent(/no readme/i); + fireEvent.click(screen.getByTestId('wizard-submit')); + + await waitFor(() => expect(publishSpy).toHaveBeenCalledTimes(1)); + expect(publishSpy.mock.calls[0]![0].readme).toBe(''); + }); + + it('seeds the README editor from navigation state when editing an existing bundle', () => { + const session = makeSessionValue({ + api: makeTestApiClient(), + status: 'member', + user: { avatarUrl: null, login: 'test-user', name: null }, + }); + render( + + + + + + + , + ); + + fireEvent.click(screen.getByTestId('wizard-next')); + fireEvent.click(screen.getByTestId('wizard-next')); + expect(screen.getByTestId('field-readme')).toHaveValue('# Existing README'); }); it('blocks proceeding past the assets step until at least one asset is selected', () => { diff --git a/src/routes/BundleDetail.tsx b/src/routes/BundleDetail.tsx index 7859fe8..8d0952c 100644 --- a/src/routes/BundleDetail.tsx +++ b/src/routes/BundleDetail.tsx @@ -65,13 +65,14 @@ export function BundleDetailRoute() { description: bundle.description, name: bundle.name, ...(bundle.org ? { org: bundle.org } : {}), + ...(readmeQuery.data ? { readme: readmeQuery.data } : {}), setupInstructions: bundle.setupInstructions, tags: bundle.tags, version: safeBumpMinor(bundle.version), }; navigate('/bundles/new', { state: seed }); }, - [navigate], + [navigate, readmeQuery.data], ); const resolveVersion = useCallback( diff --git a/src/routes/CreateBundle.tsx b/src/routes/CreateBundle.tsx index 55c7cef..411e838 100644 --- a/src/routes/CreateBundle.tsx +++ b/src/routes/CreateBundle.tsx @@ -46,6 +46,8 @@ export interface BundleDraftState { name: string; /** Bare org name (no `@`) for an org-scoped bundle; empty for a global bundle. */ org: string; + /** Markdown published as a sibling `README.md`, never as a field inside `bundle.json`. */ + readme: string; setupInstructions: string; step: number; tags: string[]; @@ -60,6 +62,7 @@ export interface CreateBundleSeed { description: string; name: string; org?: string; + readme?: string; setupInstructions?: string; tags?: string[]; version: string; @@ -73,6 +76,7 @@ export interface VersionConflictState { const STEPS: StepperStep[] = [ { description: 'Name, version, description, tags', id: 'metadata', title: 'Metadata' }, { description: 'Pick the assets to bundle', id: 'assets', title: 'Assets' }, + { description: 'Describe the bundle (optional, markdown)', id: 'readme', title: 'README' }, { description: 'Optional post-install notes', id: 'setup', title: 'Setup' }, { description: 'Validate and submit', id: 'review', title: 'Review' }, ]; @@ -88,6 +92,7 @@ export function createInitialBundleDraft(author = ''): BundleDraftState { description: '', name: '', org: '', + readme: '', setupInstructions: '', step: 0, tags: [], @@ -110,6 +115,8 @@ const PersistedDraftSchema = z.object({ description: z.string(), name: z.string(), org: z.string().optional().default(''), + // Drafts persisted before the README step existed have no `readme` key. + readme: z.string().optional().default(''), setupInstructions: z.string(), step: z .number() @@ -202,6 +209,7 @@ export function CreateBundleRoute() { description: seed.description, name: seed.name, org: seed.org ?? '', + readme: seed.readme ?? '', setupInstructions: seed.setupInstructions ?? '', tags: seed.tags ?? [], version: seed.version, @@ -284,7 +292,7 @@ export function CreateBundleRoute() { client: api, dryRun, onProgress: (event) => setProgress(event), - readme: '', + readme: draft.readme, }); skipNextPersistRef.current = true; clearBundleDraftFromStorage(); @@ -331,8 +339,9 @@ export function CreateBundleRoute() { {draft.step === 1 && ( )} - {draft.step === 2 && } - {draft.step === 3 && } + {draft.step === 2 && } + {draft.step === 3 && } + {draft.step === 4 && } {submitting && progress ? (
0 && draft.versionConflict.status !== 'conflict'; const s1 = draft.assets.length > 0; - const s2 = true; - const canProceedFrom = [s0, s0 && s1, s0 && s1 && s2, false]; + // README and Setup are both optional. + const canProceedFrom = [s0, s0 && s1, s0 && s1, s0 && s1, false]; let highest = 0; for (let i = 0; i < canProceedFrom.length; i++) { if (canProceedFrom[i]) highest = i + 1; @@ -767,6 +776,49 @@ function StepMetadata({ draft, onChange }: StepProps) { ); } +function StepReadme({ draft, onChange }: StepProps) { + return ( + + + Step 3 — README + + + +
+
+ +