diff --git a/.gitignore b/.gitignore index 3a885038..bffc87dd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ node_modules dist .DS_Store dist-standalone +dist-docs +dist-playground test.js # Claude Code diff --git a/examples/exported/docs.html b/examples/exported/docs.html new file mode 100644 index 00000000..ca5c5662 --- /dev/null +++ b/examples/exported/docs.html @@ -0,0 +1,24 @@ + + + + + + Docs only - Exported Fixture + + + + + +
+ + + diff --git a/examples/exported/index.html b/examples/exported/index.html new file mode 100644 index 00000000..ad52888d --- /dev/null +++ b/examples/exported/index.html @@ -0,0 +1,25 @@ + + + + + + Exported Fixture - API Documentation + + + + + +
+ + + diff --git a/examples/exported/playground.html b/examples/exported/playground.html new file mode 100644 index 00000000..2c3083f7 --- /dev/null +++ b/examples/exported/playground.html @@ -0,0 +1,24 @@ + + + + + + Playground only - Exported Fixture + + + + + +
+ + + diff --git a/examples/standalone-html/docs-only.html b/examples/standalone-html/docs-only.html new file mode 100644 index 00000000..6febe10b --- /dev/null +++ b/examples/standalone-html/docs-only.html @@ -0,0 +1,46 @@ + + + + + + OpenCollection - Docs only + + + + + +
+ + + + diff --git a/examples/standalone-html/playground-only.html b/examples/standalone-html/playground-only.html new file mode 100644 index 00000000..0502bb95 --- /dev/null +++ b/examples/standalone-html/playground-only.html @@ -0,0 +1,46 @@ + + + + + + OpenCollection - Playground only + + + + + +
+ + + + diff --git a/packages/bruno-api-docs/package.json b/packages/bruno-api-docs/package.json index f024d439..5e9ed4e2 100644 --- a/packages/bruno-api-docs/package.json +++ b/packages/bruno-api-docs/package.json @@ -53,7 +53,11 @@ "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "clean": "rimraf dist", - "clean:standalone": "rimraf dist-standalone" + "clean:standalone": "rimraf dist-standalone", + "build:docs": "vite build --config vite.config.docs.ts", + "build:playground": "vite build --config vite.config.playground.ts", + "build:surfaces": "npm run build:docs && npm run build:playground", + "serve:local": "node scripts/serve-local.mjs" }, "dependencies": { "@emotion/css": "^11.13.5", diff --git a/packages/bruno-api-docs/scripts/serve-local.mjs b/packages/bruno-api-docs/scripts/serve-local.mjs new file mode 100644 index 00000000..61456848 --- /dev/null +++ b/packages/bruno-api-docs/scripts/serve-local.mjs @@ -0,0 +1,60 @@ +// Serve exported API docs against the LOCAL renderer build instead of the CDN. +// +// node scripts/serve-local.mjs [--dir ] [--port 4600] +// +// Every exported document hard-codes `https://cdn.usebruno.com/api-docs/api-docs.js`. +// This server answers `/api-docs/*` from the local dist folders and rewrites the CDN +// origin to itself inside any HTML it serves, so an export renders against whatever +// you just built, with the file on disk untouched. +import { createServer } from 'node:http'; +import { readFile, stat } from 'node:fs/promises'; +import { extname, join, resolve, normalize } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const args = process.argv.slice(2); +const arg = (name, fallback) => { const i = args.indexOf(name); return i === -1 ? fallback : args[i + 1]; }; +const port = Number(arg('--port', 4600)); +const pkg = resolve(fileURLToPath(new URL('..', import.meta.url))); +const docsDir = resolve(arg('--dir', join(pkg, '../../examples/exported'))); +const CDN = 'https://cdn.usebruno.com'; + +// The literal paths the documents and the two split shells load. +const bundles = { + '/api-docs/api-docs.js': join(pkg, 'dist-standalone/api-docs.js'), + '/api-docs/api-docs.css': join(pkg, 'dist-standalone/api-docs.css'), + '/api-docs/docs.js': join(pkg, 'dist-docs/docs.js'), + '/api-docs/docs.css': join(pkg, 'dist-docs/docs.css'), + '/api-docs/playground.js': join(pkg, 'dist-playground/playground.js'), + '/api-docs/playground.css': join(pkg, 'dist-playground/playground.css') +}; +const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.yml': 'text/yaml' }; + +const server = createServer(async (req, res) => { + const path = decodeURIComponent(new URL(req.url, 'http://x').pathname); + const origin = `http://localhost:${port}`; + let file = bundles[path]; + if (!file) { + const safe = normalize(path).replace(/^(\.\.[/\\])+/, ''); + file = join(docsDir, safe === '/' ? 'index.html' : safe); + } + try { + if ((await stat(file)).isDirectory()) file = join(file, 'index.html'); + let body = await readFile(file); + const ext = extname(file); + if (ext === '.html') body = Buffer.from(body.toString('utf8').replaceAll(CDN, origin)); + res.writeHead(200, { 'content-type': types[ext] ?? 'application/octet-stream', 'cache-control': 'no-store' }); + res.end(body); + } catch { + res.writeHead(404, { 'content-type': 'text/plain' }); + res.end(`not found: ${path}`); + } +}); + +server.listen(port, async () => { + console.log(`serving exported docs from ${docsDir}`); + console.log(`rewriting ${CDN} -> http://localhost:${port}`); + for (const [route, file] of Object.entries(bundles)) { + try { const s = await stat(file); console.log(` ${route.padEnd(28)} ${(s.size / 1024).toFixed(0).padStart(6)} kB built ${s.mtime.toISOString()}`); } + catch { console.log(` ${route.padEnd(28)} MISSING (run the matching build)`); } + } +}); diff --git a/packages/bruno-api-docs/src/components/AppShell/AppShell.spec.tsx b/packages/bruno-api-docs/src/components/AppShell/AppShell.spec.tsx new file mode 100644 index 00000000..a7d4925a --- /dev/null +++ b/packages/bruno-api-docs/src/components/AppShell/AppShell.spec.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import AppShell from './AppShell'; +import { createOpenCollectionStore } from '@/store/store'; +import { collectionLoaded } from '@/store/slices/collection'; +import { useRenderToDom } from '@/hooks/useRenderToDom'; +import { queryByTestId } from '@/test-utils/dom'; + +const collection = { + info: { name: 'C' }, + items: [{ type: 'http', name: 'Login', method: 'POST', url: 'https://example.test/login' }] +} as any; + +const render = (renderPlayground?: (openNonce: number) => React.ReactNode, path = '/login') => { + const store = createOpenCollectionStore(); + store.dispatch(collectionLoaded(collection)); + return useRenderToDom( + + + + + + ); +}; + +describe('AppShell playground slot', () => { + it('offers Try when a playground is supplied', () => { + const root = render(() => null); + expect(queryByTestId(root, 'app-shell')).not.toBeNull(); + expect(queryByTestId(root, 'request-try-button')).not.toBeNull(); + }); + + it('drops Try when no playground is supplied', () => { + const root = render(); + expect(queryByTestId(root, 'app-shell')).not.toBeNull(); + expect(queryByTestId(root, 'request-try-button')).toBeNull(); + }); + + // `?pg=1` is the playground's own open flag: a docs-only build must ignore it + // rather than reach for a playground it does not have. + it('ignores the playground open flag in the URL when no playground is supplied', () => { + const root = render(undefined, '/login?pg=1&pgReq=login'); + expect(queryByTestId(root, 'app-shell')).not.toBeNull(); + expect(queryByTestId(root, 'request-try-button')).toBeNull(); + }); +}); diff --git a/packages/bruno-api-docs/src/components/AppShell/AppShell.tsx b/packages/bruno-api-docs/src/components/AppShell/AppShell.tsx index b0db7094..4e324a1d 100644 --- a/packages/bruno-api-docs/src/components/AppShell/AppShell.tsx +++ b/packages/bruno-api-docs/src/components/AppShell/AppShell.tsx @@ -9,12 +9,11 @@ import SidebarDrawer from '../SidebarDrawer/SidebarDrawer'; import IconButton from '@/ui/IconButton/IconButton'; import { ChevronLeftIcon, ChevronRightIcon } from '@/assets/icons'; import PageRouter from '../PageRouter/PageRouter'; -import Playground from '../Playground/Playground'; import SearchBar from '../Search/SearchBar/SearchBar'; import { useSearchHotkey, usePlaygroundUrlState, useElementWidth, useResizableSidebar } from '@/hooks'; import { useAppSelector } from '@/store/hooks'; -import { selectDocsCollection } from '@/store/slices/docs'; -import { selectGitCollectionUrl } from '@/store/slices/app'; +import { selectCollection } from '@/store/slices/collection'; +import { selectGitCollectionUrl } from '@/store/slices/collection'; import { useActiveResolution } from '@/routing/hooks'; import { layoutModeForWidth } from '@/hooks/useTopbarLayout'; import { buildFetchInBrunoUrl } from '@/utils/buildFetchInBrunoUrl'; @@ -23,10 +22,15 @@ import { StyledWrapper } from './StyledWrapper'; interface AppShellProps { logo?: React.ReactNode; testId?: string; + /** + * Renders the playground when it is open. Omitted means this build has no + * playground at all - the Try affordance goes with it. + */ + renderPlayground?: (openNonce: number) => React.ReactNode; } -const AppShell: React.FC = ({ logo, testId = 'app-shell' }) => { - const collection = useAppSelector(selectDocsCollection); +const AppShell: React.FC = ({ logo, testId = 'app-shell', renderPlayground }) => { + const collection = useAppSelector(selectCollection); const gitCollectionUrl = useAppSelector(selectGitCollectionUrl); const resolution = useActiveResolution(); @@ -168,13 +172,13 @@ const AppShell: React.FC = ({ logo, testId = 'app-shell' }) => { )}
- +
- {playgroundOpen && } + {renderPlayground && playgroundOpen && renderPlayground(playgroundOpenNonce)} {!isDesktop && ( diff --git a/packages/bruno-api-docs/src/components/CollectionRoot/CollectionRoot.tsx b/packages/bruno-api-docs/src/components/CollectionRoot/CollectionRoot.tsx new file mode 100644 index 00000000..8f98adcf --- /dev/null +++ b/packages/bruno-api-docs/src/components/CollectionRoot/CollectionRoot.tsx @@ -0,0 +1,161 @@ +import React, { useRef, useEffect } from 'react'; +import { HashRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import type { OpenCollection as OpenCollectionCollection } from '@opencollection/types'; +import type { OpenCollection as IOpenCollection } from '@opencollection/types'; +import { parseYaml } from '@/utils/yamlUtils'; +import { hydrateWithUUIDs } from '@/utils/fileUtils'; +import { useAppDispatch, useAppSelector } from '@/store/hooks'; +import { + selectCollection, + selectCollectionStatus, + selectCollectionError, + collectionLoading, + collectionLoaded, + collectionFailed, + collectionCleared, + setGitCollectionUrl +} from '@/store/slices/collection'; +import type { ReducersMapObject } from '@reduxjs/toolkit'; +import { createOpenCollectionStore, type AppStore } from '@/store/store'; +import { VariableResolverProvider } from '@/hooks'; +import { applyTheme } from '@/theme/applyTheme'; + +// Set data-theme on the root element before the component first paints to avoid a flash. +applyTheme(); + +const isFileInstance = (value: unknown): value is File => + typeof File !== 'undefined' && value instanceof File; + +const parseCollectionContent = (content: string): OpenCollectionCollection => { + try { + return parseYaml(content) as OpenCollectionCollection; + } catch { + try { + return JSON.parse(content) as OpenCollectionCollection; + } catch { + throw new Error('Failed to parse collection as YAML or JSON'); + } + } +}; + +const resolveCollectionSource = async ( + source: OpenCollectionCollection | string | File +): Promise => { + if (isFileInstance(source)) { + const text = await source.text(); + return parseCollectionContent(text); + } + + if (typeof source === 'string') { + if (source.startsWith('http://') || source.startsWith('https://')) { + const response = await fetch(source); + if (!response.ok) { + throw new Error(`Failed to fetch collection: ${response.statusText}`); + } + const text = await response.text(); + return parseCollectionContent(text); + } + + return parseCollectionContent(source); + } + + return source; +}; + +export interface CollectionRootProps { + collection: IOpenCollection | string | File; + gitCollectionUrl?: string; + /** The slices owned by the surfaces this root mounts. Core slices are always present. */ + reducers?: ReducersMapObject; + /** The surface to mount over the loaded collection. */ + children: React.ReactNode; +} + +const CollectionRootContent: React.FC> = ({ + collection, + gitCollectionUrl, + children +}) => { + const dispatch = useAppDispatch(); + const document = useAppSelector(selectCollection); + const collectionStatus = useAppSelector(selectCollectionStatus); + const collectionError = useAppSelector(selectCollectionError); + + useEffect(() => { + gitCollectionUrl && dispatch(setGitCollectionUrl(gitCollectionUrl)); + }, [gitCollectionUrl, dispatch]); + + useEffect(() => { + let isActive = true; + + const load = async () => { + dispatch(collectionLoading()); + + try { + const resolved = await resolveCollectionSource(collection); + if (!isActive) return; + const hydrated = hydrateWithUUIDs(resolved); + dispatch(collectionLoaded(hydrated)); + } catch (err) { + if (!isActive) return; + const message = err instanceof Error ? err.message : 'Failed to load API collection'; + dispatch(collectionFailed(message)); + } + }; + + if (collection == null) { + dispatch(collectionCleared()); + return () => { isActive = false; }; + } + + if (isFileInstance(collection) || typeof collection === 'string') { + void load(); + } else { + const hydrated = hydrateWithUUIDs(collection as OpenCollectionCollection); + dispatch(collectionLoaded(hydrated)); + } + + return () => { isActive = false; }; + }, [collection, dispatch]); + + const isInitialLoad = collectionStatus === 'idle' && !document; + const isLoading = collectionStatus === 'loading' || isInitialLoad; + + if (isLoading) { + return
Loading...
; + } + + if (collectionError) { + return
Error: {collectionError}
; + } + + return
{children}
; +}; + +/** + * Everything a surface needs and neither surface owns: the store, the router, the + * variable resolver, and the parsed collection. Docs and playground compose over + * this rather than through each other. + */ +const CollectionRoot: React.FC = ({ reducers, ...props }) => { + const storeRef = useRef(null); + + if (!storeRef.current) { + // Typed as the core store on purpose: shared code selects core state only, + // and each surface types its own slice through its own selector hook. + storeRef.current = createOpenCollectionStore(reducers) as AppStore; + } + + return ( + + + + + + + + ); +}; + +export default CollectionRoot; diff --git a/packages/bruno-api-docs/src/components/Docs/Sidebar/Sidebar.spec.tsx b/packages/bruno-api-docs/src/components/Docs/Sidebar/Sidebar.spec.tsx index d5c79e60..0544037e 100644 --- a/packages/bruno-api-docs/src/components/Docs/Sidebar/Sidebar.spec.tsx +++ b/packages/bruno-api-docs/src/components/Docs/Sidebar/Sidebar.spec.tsx @@ -4,7 +4,7 @@ import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; import Sidebar from './Sidebar'; import { createOpenCollectionStore } from '@/store/store'; -import { setDocsCollection } from '@/store/slices/docs'; +import { collectionLoaded } from '@/store/slices/collection'; import { useRenderToDom } from '@/hooks/useRenderToDom'; import { query } from '@/test-utils/dom'; @@ -20,7 +20,7 @@ const collection = { // navigates to. const buildSidebar = () => { const store = createOpenCollectionStore(); - store.dispatch(setDocsCollection(collection)); + store.dispatch(collectionLoaded(collection)); return ( diff --git a/packages/bruno-api-docs/src/components/Docs/Sidebar/Sidebar.tsx b/packages/bruno-api-docs/src/components/Docs/Sidebar/Sidebar.tsx index ccf09c94..d8306bfc 100644 --- a/packages/bruno-api-docs/src/components/Docs/Sidebar/Sidebar.tsx +++ b/packages/bruno-api-docs/src/components/Docs/Sidebar/Sidebar.tsx @@ -6,7 +6,7 @@ import { CubeIcon, GlobeIcon } from '@/assets/icons'; import { StyledWrapper } from './StyledWrapper'; import { computeAutoReveal } from './autoReveal'; import { useAppDispatch, useAppSelector } from '@/store/hooks'; -import { toggleItem, expandFolders, selectDocsCollection } from '@/store/slices/docs'; +import { toggleItem, expandFolders, selectCollection } from '@/store/slices/collection'; import { getItemUuid } from '@/utils/itemUtils'; import { useNavModel } from '@/routing/hooks'; import { normalizeSlug, resolveSlug } from '@/routing/resolve'; @@ -21,7 +21,7 @@ interface SidebarProps { const Sidebar: React.FC = ({ onNavigate, testId = 'sidebar' }) => { const dispatch = useAppDispatch(); - const collection = useAppSelector(selectDocsCollection); + const collection = useAppSelector(selectCollection); const model = useNavModel(); const docsNavigate = useDocsNavigate(); const { pathname } = useLocation(); diff --git a/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.spec.tsx b/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.spec.tsx index 4a07b627..97ac9dac 100644 --- a/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.spec.tsx +++ b/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.spec.tsx @@ -6,7 +6,7 @@ import { describe, it, expect } from 'vitest'; import type { OpenCollection } from '@opencollection/types'; import type { Environment } from '@opencollection/types/config/environments'; import { createOpenCollectionStore } from '@/store/store'; -import { setDocsCollection } from '@/store/slices/docs'; +import { collectionLoaded } from '@/store/slices/collection'; import { setActiveEnv } from '@/store/slices/env'; import { getByTestId } from '@/test-utils/dom'; import EnvSwitcher from './EnvSwitcher'; @@ -21,7 +21,7 @@ const render = ( props?: { testId?: string } ) => { const store = createOpenCollectionStore(); - store.dispatch(setDocsCollection(collection)); + store.dispatch(collectionLoaded(collection)); configure?.(store); const root = parse( renderToStaticMarkup( diff --git a/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.tsx b/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.tsx index b783fe0d..94f7b87e 100644 --- a/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.tsx +++ b/packages/bruno-api-docs/src/components/EnvSwitcher/EnvSwitcher.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo } from 'react'; import type { Environment } from '@opencollection/types/config/environments'; import { useAppDispatch, useAppSelector } from '@/store/hooks'; -import { selectDocsCollection } from '@/store/slices/docs'; +import { selectCollection } from '@/store/slices/collection'; import { selectActiveEnvName, setActiveEnv } from '@/store/slices/env'; import { ChevronDownIcon } from '@/assets/icons'; import { EnvironmentLabel } from '../EnvironmentLabel/EnvironmentLabel'; @@ -24,7 +24,7 @@ export interface EnvSwitcherProps { */ const EnvSwitcher: React.FC = ({ testId = 'env-switcher' }) => { const dispatch = useAppDispatch(); - const collection = useAppSelector(selectDocsCollection); + const collection = useAppSelector(selectCollection); const activeEnvName = useAppSelector(selectActiveEnvName); const environments = useMemo( diff --git a/packages/bruno-api-docs/src/components/OpenCollection/OpenCollection.tsx b/packages/bruno-api-docs/src/components/OpenCollection/OpenCollection.tsx index 45ad4a46..170526c1 100644 --- a/packages/bruno-api-docs/src/components/OpenCollection/OpenCollection.tsx +++ b/packages/bruno-api-docs/src/components/OpenCollection/OpenCollection.tsx @@ -1,75 +1,9 @@ -import React, { useRef, useEffect } from 'react'; -import { HashRouter } from 'react-router-dom'; -import { Provider } from 'react-redux'; -import type { OpenCollection as OpenCollectionCollection } from '@opencollection/types'; +import React from 'react'; import type { OpenCollection as IOpenCollection } from '@opencollection/types'; +import CollectionRoot from '../CollectionRoot/CollectionRoot'; import AppShell from '../AppShell/AppShell'; -import { parseYaml } from '@/utils/yamlUtils'; -import { hydrateWithUUIDs } from '@/utils/fileUtils'; -import { useAppDispatch, useAppSelector } from '@/store/hooks'; -import { - selectDocsCollection, - setDocsCollection, - clearDocsCollection -} from '@/store/slices/docs'; -import { - setPlaygroundCollection, - clearPlaygroundCollection -} from '@/store/slices/playground'; -import { - selectCollectionStatus, - selectCollectionError, - setCollectionLoading, - setCollectionSucceeded, - setCollectionFailed, - resetCollectionState, - setGitCollectionUrl -} from '@/store/slices/app'; -import { createOpenCollectionStore, type AppStore } from '@/store/store'; -import { VariableResolverProvider } from '@/hooks'; -import { applyTheme } from '@/theme/applyTheme'; - -// Set data-theme on the root element before the component first paints to avoid a flash. -applyTheme(); - -const isFileInstance = (value: unknown): value is File => - typeof File !== 'undefined' && value instanceof File; - -const parseCollectionContent = (content: string): OpenCollectionCollection => { - try { - return parseYaml(content) as OpenCollectionCollection; - } catch { - try { - return JSON.parse(content) as OpenCollectionCollection; - } catch { - throw new Error('Failed to parse collection as YAML or JSON'); - } - } -}; - -const resolveCollectionSource = async ( - source: OpenCollectionCollection | string | File -): Promise => { - if (isFileInstance(source)) { - const text = await source.text(); - return parseCollectionContent(text); - } - - if (typeof source === 'string') { - if (source.startsWith('http://') || source.startsWith('https://')) { - const response = await fetch(source); - if (!response.ok) { - throw new Error(`Failed to fetch collection: ${response.statusText}`); - } - const text = await response.text(); - return parseCollectionContent(text); - } - - return parseCollectionContent(source); - } - - return source; -}; +import Playground from '../Playground/Playground'; +import playgroundReducer from '@/store/slices/playground'; export interface OpenCollectionProps { collection: IOpenCollection | string | File; @@ -77,95 +11,15 @@ export interface OpenCollectionProps { gitCollectionUrl?: string; } -const OpenCollectionContent: React.FC = ({ - collection, - logo, - gitCollectionUrl -}) => { - const dispatch = useAppDispatch(); - const docsCollection = useAppSelector(selectDocsCollection); - const collectionStatus = useAppSelector(selectCollectionStatus); - const collectionError = useAppSelector(selectCollectionError); - - useEffect(() => { - gitCollectionUrl && dispatch(setGitCollectionUrl(gitCollectionUrl)); - }, [gitCollectionUrl, dispatch]); - - useEffect(() => { - let isActive = true; - - const load = async () => { - dispatch(setCollectionLoading()); - - try { - const resolved = await resolveCollectionSource(collection); - if (!isActive) return; - const hydrated = hydrateWithUUIDs(resolved); - dispatch(setDocsCollection(hydrated)); - dispatch(setPlaygroundCollection(hydrated)); - dispatch(setCollectionSucceeded()); - } catch (err) { - if (!isActive) return; - const message = err instanceof Error ? err.message : 'Failed to load API collection'; - dispatch(setCollectionFailed(message)); - dispatch(clearDocsCollection()); - dispatch(clearPlaygroundCollection()); - } - }; - - if (collection == null) { - dispatch(clearDocsCollection()); - dispatch(clearPlaygroundCollection()); - dispatch(resetCollectionState()); - return () => { isActive = false; }; - } - - if (isFileInstance(collection) || typeof collection === 'string') { - void load(); - } else { - const hydrated = hydrateWithUUIDs(collection as OpenCollectionCollection); - dispatch(setDocsCollection(hydrated)); - dispatch(setPlaygroundCollection(hydrated)); - dispatch(setCollectionSucceeded()); - } - - return () => { isActive = false; }; - }, [collection, dispatch]); - - const isInitialLoad = collectionStatus === 'idle' && !docsCollection; - const isLoading = collectionStatus === 'loading' || isInitialLoad; - - if (isLoading) { - return
Loading...
; - } - - if (collectionError) { - return
Error: {collectionError}
; - } - - return ( -
- -
- ); -}; - -const OpenCollection: React.FC = (props) => { - const storeRef = useRef(null); - - if (!storeRef.current) { - storeRef.current = createOpenCollectionStore(); - } - - return ( - - - - - - - - ); -}; +/** Docs with the playground available behind Try. Both surfaces in one mount. */ +const OpenCollection: React.FC = ({ collection, logo, gitCollectionUrl }) => ( + + } /> + +); export default OpenCollection; diff --git a/packages/bruno-api-docs/src/components/OpenCollectionDocs/OpenCollectionDocs.tsx b/packages/bruno-api-docs/src/components/OpenCollectionDocs/OpenCollectionDocs.tsx new file mode 100644 index 00000000..0a782148 --- /dev/null +++ b/packages/bruno-api-docs/src/components/OpenCollectionDocs/OpenCollectionDocs.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import CollectionRoot from '../CollectionRoot/CollectionRoot'; +import AppShell from '../AppShell/AppShell'; +import type { OpenCollectionProps } from '../OpenCollection/OpenCollection'; + +/** + * Docs alone. Passing no playground is what keeps it out: the Try affordance and + * every playground module disappear from the build, not just from the render. + */ +const OpenCollectionDocs: React.FC = ({ collection, logo, gitCollectionUrl }) => ( + + + +); + +export default OpenCollectionDocs; diff --git a/packages/bruno-api-docs/src/components/OpenCollectionPlayground/OpenCollectionPlayground.tsx b/packages/bruno-api-docs/src/components/OpenCollectionPlayground/OpenCollectionPlayground.tsx new file mode 100644 index 00000000..8efb77d8 --- /dev/null +++ b/packages/bruno-api-docs/src/components/OpenCollectionPlayground/OpenCollectionPlayground.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import CollectionRoot from '../CollectionRoot/CollectionRoot'; +import Playground from '../Playground/Playground'; +import playgroundReducer from '@/store/slices/playground'; +import type { OpenCollectionProps } from '../OpenCollection/OpenCollection'; + +/** The playground alone, filling its host. No docs shell, no docs modules. */ +const OpenCollectionPlayground: React.FC> = ({ + collection, + gitCollectionUrl +}) => ( + + + +); + +export default OpenCollectionPlayground; diff --git a/packages/bruno-api-docs/src/components/PageRouter/PageRouter.tsx b/packages/bruno-api-docs/src/components/PageRouter/PageRouter.tsx index 26a02127..8df01ab8 100644 --- a/packages/bruno-api-docs/src/components/PageRouter/PageRouter.tsx +++ b/packages/bruno-api-docs/src/components/PageRouter/PageRouter.tsx @@ -5,7 +5,7 @@ import type { ScriptFile, Folder as FolderItem } from '@opencollection/types/col import type { RequestItem } from '@/utils/schemaHelpers'; import { useActiveResolution, useNavModel } from '@/routing/hooks'; import { useAppSelector } from '@/store/hooks'; -import { selectDocsCollection } from '@/store/slices/docs'; +import { selectCollection } from '@/store/slices/collection'; import { getItemUuid } from '@/utils/itemUtils'; import { getAncestorsByUuid } from '@/utils/fileUtils'; import { ItemVariableResolverProvider } from '@/hooks'; @@ -35,7 +35,7 @@ const PAGES_WITHOUT_SECTION_NAV = new Set(['environments']); const PageRouter: React.FC = ({ onOpenPlayground, testId = 'page' }) => { const resolution = useActiveResolution(); const model = useNavModel(); - const collection = useAppSelector(selectDocsCollection); + const collection = useAppSelector(selectCollection); const docsNavigate = useDocsNavigate(); const pageBodyRef = useRef(null); diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/PlaygroundView.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/PlaygroundView.tsx index dd5d1e50..f90d1e25 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/PlaygroundView.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/PlaygroundView.tsx @@ -4,17 +4,19 @@ import type { OpenCollection as OpenCollectionCollection } from '@opencollection import type { Item } from '@opencollection/types/collection/item'; import type { Auth } from '@opencollection/types/common/auth'; import { getAncestorsByUuid } from '@/utils/fileUtils'; -import { ItemVariableResolverProvider } from '@/hooks'; +import { ItemVariableResolverProvider, type VariableChange } from '@/hooks'; import TitleLabel from '@/components/TitleLabel/TitleLabel'; import QueryBar from './QueryBar/QueryBar'; import RequestPane from './RequestPane/RequestPane'; import ResponsePane from './ResponsePane/ResponsePane'; -import { useAppDispatch, useAppSelector } from '@/store/hooks'; +import { useAppDispatch } from '@/store/hooks'; import { updatePlaygroundItem, setPlaygroundResponse, selectPlaygroundResponse, - applyScriptVariableChanges + applyScriptVariableChanges, + setPlaygroundVariable, + usePlaygroundSelector } from '@/store/slices/playground'; import { getItemName, isPlaygroundUnsupported, getRequestAuth, getRequestHeaders } from '@/utils/schemaHelpers'; import { getInheritedAuthSummary, resolveInheritedAuth, getInheritedHeaders } from '@/utils/request'; @@ -33,9 +35,10 @@ interface PlaygroundViewProps { const HttpRequestPlaygroundView: React.FC = ({ item, collection, selectedEnvironment = '', orientation = 'horizontal' }) => { const dispatch = useAppDispatch(); const [editableItem, setEditableItem] = useState(item); + const updateVariable = useCallback((change: VariableChange) => dispatch(setPlaygroundVariable(change)), [dispatch]); const itemName = getItemName(editableItem) || 'Untitled Request'; const itemUuid = (item as any).uuid; - const response = useAppSelector((state) => selectPlaygroundResponse(state, itemUuid)); + const response = usePlaygroundSelector((state) => selectPlaygroundResponse(state, itemUuid)); const [isLoading, setIsLoading] = useState(false); // The request/response split is one draggable divider whose axis follows the // orientation: horizontal layout resizes width, vertical layout resizes height. @@ -145,7 +148,7 @@ const HttpRequestPlaygroundView: React.FC = ({ item, collec collection={collection} ancestry={ancestry} item={editableItem as unknown as Item} - writable + onUpdateVariable={updateVariable} >
{itemName} diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/hooks/useResponseFormatter.ts b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/hooks/useResponseFormatter.ts index 9d557148..b561cbf7 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/hooks/useResponseFormatter.ts +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/ResponsePane/ResponseFormatter/hooks/useResponseFormatter.ts @@ -3,21 +3,22 @@ import type { ResponseBodyFormat } from '@/constants'; import { useInitialResponseFormat } from './useInitialResponseFormat'; import type { RunRequestResponse } from '@/runner'; import { getResponseFormatOptions } from '@/utils/response'; -import { useAppDispatch, useAppSelector } from '@/store/hooks'; +import { useAppDispatch } from '@/store/hooks'; import { selectResponseFormat, selectShowResponsePreview, selectSelectedItemId, setResponseFormat, - setShowResponsePreview + setShowResponsePreview, + usePlaygroundSelector } from '@/store/slices/playground'; export function useResponseFormatter( response: RunRequestResponse ) { - const selectedItemId = useAppSelector(selectSelectedItemId); - const selectedResponseFormat = useAppSelector(selectResponseFormat(selectedItemId)); - const showResponsePreview = useAppSelector(selectShowResponsePreview(selectedItemId)); + const selectedItemId = usePlaygroundSelector(selectSelectedItemId); + const selectedResponseFormat = usePlaygroundSelector(selectResponseFormat(selectedItemId)); + const showResponsePreview = usePlaygroundSelector(selectShowResponsePreview(selectedItemId)); const dispatch = useAppDispatch(); const { format, view, detectedContentType, headerContentType, contentType } = useInitialResponseFormat(response); diff --git a/packages/bruno-api-docs/src/components/Playground/Playground.tsx b/packages/bruno-api-docs/src/components/Playground/Playground.tsx index 9f2fd9b8..0f4411d9 100644 --- a/packages/bruno-api-docs/src/components/Playground/Playground.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Playground.tsx @@ -32,11 +32,14 @@ const playgroundLoadError = ( interface PlaygroundProps { /** Bumped on each Try click, so the bottom sheet can re-expand from collapsed. */ openNonce?: number; + /** Mounted without docs: always open, and there is nothing to close back to. */ + standalone?: boolean; } -const Playground: React.FC = ({ openNonce }) => { +const Playground: React.FC = ({ openNonce, standalone = false }) => { const dispatch = useAppDispatch(); - const { open, dock, requestSlug, exampleSlug, setDock, closePlayground } = usePlaygroundUrlState(); + const { open: urlOpen, dock, requestSlug, exampleSlug, setDock, closePlayground } = usePlaygroundUrlState(); + const open = standalone || urlOpen; const isPhone = useIsMobilePhone(); // On a phone the playground is always the fullscreen MobileDock, and its // sidebar behaves like the inline dock's overlay. Feed that dock downstream so @@ -88,7 +91,7 @@ const Playground: React.FC = ({ openNonce }) => { ); - if (isPhone) return {body}; + if (isPhone || standalone) return {body}; if (dock === 'inline') return {body}; if (dock === 'modal') return {body}; return {body}; diff --git a/packages/bruno-api-docs/src/components/Playground/PlaygroundBody/PlaygroundBody.spec.tsx b/packages/bruno-api-docs/src/components/Playground/PlaygroundBody/PlaygroundBody.spec.tsx index 6b729d2d..de6f1af6 100644 --- a/packages/bruno-api-docs/src/components/Playground/PlaygroundBody/PlaygroundBody.spec.tsx +++ b/packages/bruno-api-docs/src/components/Playground/PlaygroundBody/PlaygroundBody.spec.tsx @@ -5,6 +5,7 @@ import { MemoryRouter } from 'react-router-dom'; import PlaygroundBody from './PlaygroundBody'; import { createOpenCollectionStore } from '@/store/store'; import { useRenderToDom } from '@/hooks/useRenderToDom'; +import playgroundReducer from '@/store/slices/playground'; import { setPlaygroundCollection, setSelectedItemId, @@ -28,7 +29,7 @@ const collection = { describe('PlaygroundBody example view', () => { it('renders ExampleView when viewMode is example', () => { - const store = createOpenCollectionStore(); + const store = createOpenCollectionStore({ playground: playgroundReducer }); store.dispatch(setPlaygroundCollection(collection)); const requestUuid = getItemUuid((store.getState().playground.hydratedCollection as any).items[0])!; store.dispatch(setSelectedItemId(requestUuid)); diff --git a/packages/bruno-api-docs/src/components/Playground/PlaygroundBody/PlaygroundBody.tsx b/packages/bruno-api-docs/src/components/Playground/PlaygroundBody/PlaygroundBody.tsx index 7a149ca6..765113cc 100644 --- a/packages/bruno-api-docs/src/components/Playground/PlaygroundBody/PlaygroundBody.tsx +++ b/packages/bruno-api-docs/src/components/Playground/PlaygroundBody/PlaygroundBody.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useRef } from 'react'; import type { HttpRequest } from '@opencollection/types/requests/http'; import type { Folder } from '@opencollection/types/collection/item'; -import { useAppDispatch, useAppSelector } from '@/store/hooks'; +import { useAppDispatch } from '@/store/hooks'; import { selectHydratedCollection, selectViewMode, @@ -12,7 +12,8 @@ import { setSelectedItemId, setSelectedExampleIndex, toggleFolderCollapse, - expandFolders + expandFolders, + usePlaygroundSelector } from '@/store/slices/playground'; import { selectActiveEnvName } from '@/store/slices/env'; import type { ExampleHighlight } from '../../Docs/Sidebar/SidebarTree/SidebarTree'; @@ -64,12 +65,12 @@ const PlaygroundBody: React.FC = ({ const dispatch = useAppDispatch(); const model = useNavModel(); const { setRequestSlug, setRequestExample } = usePlaygroundUrlState(); - const collection = useAppSelector(selectHydratedCollection); - const viewMode = useAppSelector(selectViewMode); - const selectedItemId = useAppSelector(selectSelectedItemId); - const selectedExampleIndex = useAppSelector(selectSelectedExampleIndex); - const activeEnvName = useAppSelector(selectActiveEnvName); - const orientationOverride = useAppSelector(selectResponsePaneOrientation); + const collection = usePlaygroundSelector(selectHydratedCollection); + const viewMode = usePlaygroundSelector(selectViewMode); + const selectedItemId = usePlaygroundSelector(selectSelectedItemId); + const selectedExampleIndex = usePlaygroundSelector(selectSelectedExampleIndex); + const activeEnvName = usePlaygroundSelector(selectActiveEnvName); + const orientationOverride = usePlaygroundSelector(selectResponsePaneOrientation); const uuidToSlug = useMemo>(() => { const map = new Map(); diff --git a/packages/bruno-api-docs/src/components/Playground/PlaygroundHeader/PlaygroundHeader.tsx b/packages/bruno-api-docs/src/components/Playground/PlaygroundHeader/PlaygroundHeader.tsx index 5683f638..17ba8023 100644 --- a/packages/bruno-api-docs/src/components/Playground/PlaygroundHeader/PlaygroundHeader.tsx +++ b/packages/bruno-api-docs/src/components/Playground/PlaygroundHeader/PlaygroundHeader.tsx @@ -9,6 +9,7 @@ interface PlaygroundHeaderProps { dock: DockMode; onDockChange: (dock: DockMode) => void; showDockSwitcher?: boolean; + showClose?: boolean; sidebarOpen?: boolean; onToggleSidebar: () => void; onClose: () => void; @@ -21,6 +22,7 @@ const PlaygroundHeader: React.FC = ({ dock, onDockChange, showDockSwitcher = true, + showClose = true, sidebarOpen = false, onToggleSidebar, onClose, @@ -58,9 +60,11 @@ const PlaygroundHeader: React.FC = ({ )} - - - + {showClose && ( + + + + )}
); diff --git a/packages/bruno-api-docs/src/components/Playground/docks/MobileDock/MobileDock.tsx b/packages/bruno-api-docs/src/components/Playground/docks/MobileDock/MobileDock.tsx index dabcb415..01a20623 100644 --- a/packages/bruno-api-docs/src/components/Playground/docks/MobileDock/MobileDock.tsx +++ b/packages/bruno-api-docs/src/components/Playground/docks/MobileDock/MobileDock.tsx @@ -11,10 +11,19 @@ interface MobileDockProps { sidebarOpen: boolean; onToggleSidebar: () => void; onClose: () => void; + showClose?: boolean; children: React.ReactNode; } -const MobileDock: React.FC = ({ dock, onDockChange, sidebarOpen, onToggleSidebar, onClose, children }) => { +const MobileDock: React.FC = ({ + dock, + onDockChange, + sidebarOpen, + onToggleSidebar, + onClose, + showClose = true, + children +}) => { // Full-screen phone presentation: lock the docs scroll behind it. No dock // switcher or collapse - there is nowhere to dock on a phone. useLockBodyScroll(); @@ -29,6 +38,7 @@ const MobileDock: React.FC = ({ dock, onDockChange, sidebarOpen onToggleSidebar={onToggleSidebar} onClose={onClose} showDockSwitcher={false} + showClose={showClose} />
{children} diff --git a/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.spec.tsx b/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.spec.tsx index 746567d3..82e5d35f 100644 --- a/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.spec.tsx +++ b/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.spec.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Provider } from 'react-redux'; import { describe, it, expect } from 'vitest'; import { createOpenCollectionStore } from '@/store/store'; -import { setDocsCollection } from '@/store/slices/docs'; +import { collectionLoaded } from '@/store/slices/collection'; import { setActiveEnv } from '@/store/slices/env'; import { VariableResolverProvider, ItemVariableResolverProvider } from '@/hooks'; import { useRenderToDom } from '@/hooks/useRenderToDom'; @@ -45,7 +45,7 @@ const collection: any = { const cardTree = (name: string) => { const store = createOpenCollectionStore(); - store.dispatch(setDocsCollection(collection)); + store.dispatch(collectionLoaded(collection)); store.dispatch(setActiveEnv('Dev')); return ( @@ -151,13 +151,13 @@ describe('VariableInfoCard', () => { }); }); -// Editing needs a writable resolver, which only ItemVariableResolverProvider supplies. +// Editing needs a writer, which only ItemVariableResolverProvider accepts. const editableCardTree = (name: string) => { const store = createOpenCollectionStore(); store.dispatch(setActiveEnv('Dev')); return ( - + {}}> @@ -184,7 +184,7 @@ describe('VariableInfoCard (editable)', () => { it('stays read-only when the resolver cannot write, even with editable set', () => { const store = createOpenCollectionStore(); - store.dispatch(setDocsCollection(collection)); + store.dispatch(collectionLoaded(collection)); store.dispatch(setActiveEnv('Dev')); const tree = ( diff --git a/packages/bruno-api-docs/src/components/VariableText/VariableText.spec.tsx b/packages/bruno-api-docs/src/components/VariableText/VariableText.spec.tsx index 8c815ec5..8f94e0c0 100644 --- a/packages/bruno-api-docs/src/components/VariableText/VariableText.spec.tsx +++ b/packages/bruno-api-docs/src/components/VariableText/VariableText.spec.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Provider } from 'react-redux'; import { describe, it, expect } from 'vitest'; import { createOpenCollectionStore } from '@/store/store'; -import { setDocsCollection } from '@/store/slices/docs'; +import { collectionLoaded } from '@/store/slices/collection'; import { setActiveEnv, setShowVars } from '@/store/slices/env'; import { VariableResolverProvider } from '@/hooks'; import { useRenderToDom } from '@/hooks/useRenderToDom'; @@ -14,7 +14,7 @@ const collection: any = { const tree = (value: string, configure?: (store: ReturnType) => void) => { const store = createOpenCollectionStore(); - store.dispatch(setDocsCollection(collection)); + store.dispatch(collectionLoaded(collection)); configure?.(store); return ( diff --git a/packages/bruno-api-docs/src/dev.tsx b/packages/bruno-api-docs/src/dev.tsx index 42a90cb4..14ab4a25 100644 --- a/packages/bruno-api-docs/src/dev.tsx +++ b/packages/bruno-api-docs/src/dev.tsx @@ -6,6 +6,8 @@ import './styles/index.css'; // Import Prism (with our token customizations) to ensure it's bundled import Prism from './utils/prism'; import OpenCollection from './components/OpenCollection/OpenCollection'; +import OpenCollectionDocs from './components/OpenCollectionDocs/OpenCollectionDocs'; +import OpenCollectionPlayground from './components/OpenCollectionPlayground/OpenCollectionPlayground'; import { createOpenCollectionStore } from './store/store'; import { sampleCollectionYaml } from './sampleCollection'; import { foldersFixtureCollection } from './e2eFixtures/foldersCollection'; @@ -27,6 +29,16 @@ const devCollection ? qaFixtureCollection : sampleCollectionYaml; +// `?surfaces=docs` / `?surfaces=playground` mounts the single-surface component +// each split bundle ships; default is the combined one. +const surfacesParam = new URLSearchParams(window.location.search).get('surfaces'); +const Surface + = surfacesParam === 'docs' + ? OpenCollectionDocs + : surfacesParam === 'playground' + ? OpenCollectionPlayground + : OpenCollection; + // Ensure Prism is available globally for any code that might access it if (typeof window !== 'undefined') { (window as any).Prism = Prism; @@ -39,7 +51,7 @@ const DevApp: React.FC = () => { return (
- diff --git a/packages/bruno-api-docs/src/entries/docs.ts b/packages/bruno-api-docs/src/entries/docs.ts new file mode 100644 index 00000000..63c3dd85 --- /dev/null +++ b/packages/bruno-api-docs/src/entries/docs.ts @@ -0,0 +1,18 @@ +import '../styles/index.css'; +import Prism from '../utils/prism'; +import OpenCollectionDocs from '../components/OpenCollectionDocs/OpenCollectionDocs'; +import { createRendererClass, type OpenCollectionOptions } from '../renderer'; + +if (typeof window !== 'undefined') { + (window as any).Prism = Prism; +} + +export type { OpenCollectionOptions }; + +export const OpenCollectionDocsRenderer = createRendererClass(OpenCollectionDocs); + +export default OpenCollectionDocsRenderer; + +if (typeof window !== 'undefined') { + (window as any).OpenCollectionDocs = OpenCollectionDocsRenderer; +} diff --git a/packages/bruno-api-docs/src/entries/playground.ts b/packages/bruno-api-docs/src/entries/playground.ts new file mode 100644 index 00000000..c02c512a --- /dev/null +++ b/packages/bruno-api-docs/src/entries/playground.ts @@ -0,0 +1,18 @@ +import '../styles/index.css'; +import Prism from '../utils/prism'; +import OpenCollectionPlayground from '../components/OpenCollectionPlayground/OpenCollectionPlayground'; +import { createRendererClass, type OpenCollectionOptions } from '../renderer'; + +if (typeof window !== 'undefined') { + (window as any).Prism = Prism; +} + +export type { OpenCollectionOptions }; + +export const OpenCollectionPlaygroundRenderer = createRendererClass(OpenCollectionPlayground); + +export default OpenCollectionPlaygroundRenderer; + +if (typeof window !== 'undefined') { + (window as any).OpenCollectionPlayground = OpenCollectionPlaygroundRenderer; +} diff --git a/packages/bruno-api-docs/src/hooks/index.ts b/packages/bruno-api-docs/src/hooks/index.ts index 43a69202..56eaa372 100644 --- a/packages/bruno-api-docs/src/hooks/index.ts +++ b/packages/bruno-api-docs/src/hooks/index.ts @@ -26,7 +26,8 @@ export { ItemVariableResolverProvider, ShowVarsOverrideProvider, type VariableResolver, - type VariableLookup + type VariableLookup, + type VariableChange } from './useVariableResolver'; export { usePlaygroundUrlState, type PlaygroundUrlApi } from './usePlaygroundUrlState'; export { useDocsNavigate } from './useDocsNavigate'; diff --git a/packages/bruno-api-docs/src/hooks/useVariableResolver.spec.tsx b/packages/bruno-api-docs/src/hooks/useVariableResolver.spec.tsx index 3f9af104..d47e3edd 100644 --- a/packages/bruno-api-docs/src/hooks/useVariableResolver.spec.tsx +++ b/packages/bruno-api-docs/src/hooks/useVariableResolver.spec.tsx @@ -3,7 +3,7 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { Provider } from 'react-redux'; import { describe, it, expect } from 'vitest'; import { createOpenCollectionStore } from '@/store/store'; -import { setDocsCollection } from '@/store/slices/docs'; +import { collectionLoaded } from '@/store/slices/collection'; import { setActiveEnv, setShowVars } from '@/store/slices/env'; import { useVariableResolver, useResolvedVariables, ItemVariableResolverProvider } from './useVariableResolver'; @@ -37,7 +37,7 @@ const Probe: React.FC = () => { const render = (configure: (store: ReturnType) => void): string => { const store = createOpenCollectionStore(); - store.dispatch(setDocsCollection(collection)); + store.dispatch(collectionLoaded(collection)); configure(store); return renderToStaticMarkup( @@ -98,7 +98,7 @@ describe('lookup (variable hover card)', () => { const renderLookup = (name: string, showVars = false): string => { const store = createOpenCollectionStore(); - store.dispatch(setDocsCollection(collection)); + store.dispatch(collectionLoaded(collection)); store.dispatch(setActiveEnv('Dev')); if (showVars) store.dispatch(setShowVars(true)); return renderToStaticMarkup( @@ -165,13 +165,13 @@ describe('nested variable resolution', () => { it('follows a variable that points at another variable, for display and for interpolation', () => { const store = createOpenCollectionStore(); - store.dispatch(setDocsCollection(nested)); + store.dispatch(collectionLoaded(nested)); store.dispatch(setActiveEnv('Dev')); store.dispatch(setShowVars(true)); const html = renderToStaticMarkup( - + {}}> @@ -183,13 +183,13 @@ describe('nested variable resolution', () => { it('gates resolve on showVars but never interpolate', () => { const store = createOpenCollectionStore(); - store.dispatch(setDocsCollection(nested)); + store.dispatch(collectionLoaded(nested)); store.dispatch(setActiveEnv('Dev')); store.dispatch(setShowVars(false)); const html = renderToStaticMarkup( - + {}}> @@ -198,4 +198,44 @@ describe('nested variable resolution', () => { expect(html).toContain('{{endpoint}}'); expect(html).toContain('https://api.test/v1'); }); + + const WriteProbe: React.FC = () => { + const r = useResolvedVariables(); + r.updateVariable('host', 'https://edited.test'); + return {String(r.canWrite)}; + }; + + it('hands the change to the injected writer instead of writing the store itself', () => { + const store = createOpenCollectionStore(); + store.dispatch(collectionLoaded(nested)); + store.dispatch(setActiveEnv('Dev')); + const changes: unknown[] = []; + + const html = renderToStaticMarkup( + + changes.push(c)}> + + + + ); + + expect(html).toContain('true'); + expect(changes).toEqual([{ scope: 'collection', name: 'host', value: 'https://edited.test' }]); + }); + + it('is read-only with no writer, as the docs pages mount it', () => { + const store = createOpenCollectionStore(); + store.dispatch(collectionLoaded(nested)); + store.dispatch(setActiveEnv('Dev')); + + const html = renderToStaticMarkup( + + + + + + ); + + expect(html).toContain('false'); + }); }); diff --git a/packages/bruno-api-docs/src/hooks/useVariableResolver.tsx b/packages/bruno-api-docs/src/hooks/useVariableResolver.tsx index f07b9d64..dc32adb6 100644 --- a/packages/bruno-api-docs/src/hooks/useVariableResolver.tsx +++ b/packages/bruno-api-docs/src/hooks/useVariableResolver.tsx @@ -3,13 +3,12 @@ import type { OpenCollection } from '@opencollection/types'; import type { Environment } from '@opencollection/types/config/environments'; import type { Item } from '@opencollection/types/collection/item'; import type { Variable, SecretVariable } from '@opencollection/types/common/variables'; -import { useAppDispatch, useAppSelector } from '@/store/hooks'; -import { selectDocsCollection } from '@/store/slices/docs'; -import { setPlaygroundVariable } from '@/store/slices/playground'; +import { useAppSelector } from '@/store/hooks'; +import { selectCollection } from '@/store/slices/collection'; import { selectActiveEnvName, selectShowVars } from '@/store/slices/env'; import { getRequestVariables, isFolder } from '@/utils/schemaHelpers'; import { getItemUuid } from '@/utils/itemUtils'; -import { mockDataFunctions, timeBasedDynamicVars } from '@/runner/utils/faker-functions'; +import { mockDataFunctions, timeBasedDynamicVars } from '@/utils/faker-functions'; import { buildScopedVariableModel, resolveValueDeep, @@ -56,6 +55,16 @@ const classifyDynamic = (name: string): DynamicVariableKind => { * exactly a secret reference is reported by `secretRefName()` so the caller * can mask the whole cell. */ +// The one write this hook can request. Named here, at the boundary, so the +// surface that owns the state supplies the writer instead of the hook importing it. +export interface VariableChange { + scope: 'environment' | 'collection' | 'folder' | 'request' | '$secrets'; + name: string; + value: string; + envName?: string; + itemUuid?: string; +} + export interface VariableResolver { showVars: boolean; activeEnvName: string | null; @@ -145,7 +154,7 @@ const itemSource = (item: Item): VariableSource => : { scope: 'request', variables: getRequestVariables(item as never) as (Variable | SecretVariable)[] }; export const useVariableResolver = (): VariableResolver => { - const collection = useAppSelector(selectDocsCollection) as OpenCollection | null; + const collection = useAppSelector(selectCollection) as OpenCollection | null; const activeEnvName = useAppSelector(selectActiveEnvName); const showVars = useAppSelector(selectShowVars); @@ -221,15 +230,15 @@ export const ItemVariableResolverProvider: React.FC<{ collection: OpenCollection | null; ancestry: Item[]; item: Item | null; - writable?: boolean; + onUpdateVariable?: (change: VariableChange) => void; children: React.ReactNode; -}> = ({ collection, ancestry, item, writable = false, children }) => { - const dispatch = useAppDispatch(); +}> = ({ collection, ancestry, item, onUpdateVariable, children }) => { + const writable = onUpdateVariable !== undefined; const activeEnvName = useAppSelector(selectActiveEnvName); const showVars = useAppSelector(selectShowVars); // Both the docs pages and the playground mount this provider; only the - // playground passes `writable`, and only it can supply an external secret. + // playground supplies a writer, and only it can supply an external secret. const model = useMemo(() => { const sources: VariableSource[] = collectionAndEnvSources(collection, activeEnvName, writable); for (const folder of ancestry) { @@ -245,21 +254,21 @@ export const ItemVariableResolverProvider: React.FC<{ (name: string, value: string) => { const { name: varName, scope } = resolver.lookup(name); if (scope === 'environment' || scope === '$secrets') { - if (activeEnvName) dispatch(setPlaygroundVariable({ scope, name: varName, value, envName: activeEnvName })); + if (activeEnvName) onUpdateVariable?.({ scope, name: varName, value, envName: activeEnvName }); } else if (scope === 'collection') { - dispatch(setPlaygroundVariable({ scope, name: varName, value })); + onUpdateVariable?.({ scope, name: varName, value }); } else if (scope === 'request') { const itemUuid = getItemUuid(item); - if (itemUuid) dispatch(setPlaygroundVariable({ scope, name: varName, value, itemUuid })); + if (itemUuid) onUpdateVariable?.({ scope, name: varName, value, itemUuid }); } else if (scope === 'folder') { const owner = [...ancestry].reverse().find((folder) => folderVariables(folder).some((v) => v.name === varName && !v.disabled) ); const itemUuid = getItemUuid(owner); - if (itemUuid) dispatch(setPlaygroundVariable({ scope, name: varName, value, itemUuid })); + if (itemUuid) onUpdateVariable?.({ scope, name: varName, value, itemUuid }); } }, - [resolver, dispatch, activeEnvName, item, ancestry] + [resolver, onUpdateVariable, activeEnvName, item, ancestry] ); const interpolateWithSecrets = useCallback( diff --git a/packages/bruno-api-docs/src/renderer.ts b/packages/bruno-api-docs/src/renderer.ts new file mode 100644 index 00000000..423c818e --- /dev/null +++ b/packages/bruno-api-docs/src/renderer.ts @@ -0,0 +1,119 @@ +import React from 'react'; +import type { Root } from 'react-dom/client'; +import { createRoot } from 'react-dom/client'; +import type { OpenCollection as IOpenCollection } from '@opencollection/types'; +import { parseCollectionContent } from './utils/yamlUtils'; + +export interface OpenCollectionOptions { + target: HTMLElement; + opencollection: any; + logo?: string; + gitCollectionUrl?: string; +} + +type SurfaceComponent = React.ComponentType<{ + collection: IOpenCollection; + logo?: React.ReactNode; + gitCollectionUrl?: string; +}>; + +export interface OpenCollectionRenderer { + updateCollection(opencollection: any): void; + destroy(): void; +} + +export type OpenCollectionRendererClass = new (options: OpenCollectionOptions) => OpenCollectionRenderer; + +/** + * The mount/update/destroy shell every standalone bundle needs, over whichever + * surface that bundle ships. The surface is the only difference between them. + */ +export const createRendererClass = (Surface: SurfaceComponent): OpenCollectionRendererClass => + class { + private root: Root | null = null; + private options: OpenCollectionOptions; + + constructor(options: OpenCollectionOptions) { + this.options = options; + this.init(); + } + + private injectInterFont() { + // Only inject if not already present + if (!document.querySelector('link[href*="fonts.googleapis.com/css2?family=Inter"]')) { + const links = [ + { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, + { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossOrigin: 'anonymous' }, + { + rel: 'stylesheet', + href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=JetBrains+Mono:wght@400;500;600;700&display=swap' + } + ]; + + links.forEach((linkProps) => { + const link = document.createElement('link'); + Object.entries(linkProps).forEach(([key, value]) => { + link.setAttribute(key, value); + }); + document.head.appendChild(link); + }); + } + } + + private init() { + if (!this.options.target) { + throw new Error('Target element is required'); + } + + this.injectInterFont(); + this.root = createRoot(this.options.target); + this.render(); + } + + private convertCollection(opencollection: any): IOpenCollection { + if (typeof opencollection === 'string') { + try { + return parseCollectionContent(opencollection) as IOpenCollection; + } catch (error) { + console.error('Failed to parse collection:', error); + throw new Error(`Invalid collection format: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + return opencollection as IOpenCollection; + } + + private createLogoElement(): React.ReactNode { + if (!this.options.logo) return undefined; + + return React.createElement('img', { + src: this.options.logo, + alt: 'Logo', + style: { height: '32px', width: 'auto' } + }); + } + + private render() { + if (!this.root) return; + + const collection = this.convertCollection(this.options.opencollection); + + this.root.render(React.createElement(Surface, { + collection, + logo: this.createLogoElement(), + gitCollectionUrl: this.options.gitCollectionUrl + })); + } + + public updateCollection(opencollection: any) { + this.options.opencollection = opencollection; + this.render(); + } + + public destroy() { + if (this.root) { + this.root.unmount(); + this.root = null; + } + } + }; diff --git a/packages/bruno-api-docs/src/routing/hooks.ts b/packages/bruno-api-docs/src/routing/hooks.ts index b4525218..61ca7232 100644 --- a/packages/bruno-api-docs/src/routing/hooks.ts +++ b/packages/bruno-api-docs/src/routing/hooks.ts @@ -1,14 +1,14 @@ import { useMemo } from 'react'; import { useLocation } from 'react-router-dom'; import { useAppSelector } from '@/store/hooks'; -import { selectDocsCollection } from '@/store/slices/docs'; +import { selectCollection } from '@/store/slices/collection'; import { buildNavModel } from './navModel'; import { resolveSlug, type Resolution } from './resolve'; import type { NavModel } from './types'; /** Memoised nav model for the currently loaded collection. */ export const useNavModel = (): NavModel => { - const collection = useAppSelector(selectDocsCollection); + const collection = useAppSelector(selectCollection); return useMemo(() => buildNavModel(collection), [collection]); }; diff --git a/packages/bruno-api-docs/src/runner/utils/variable-interpolator.ts b/packages/bruno-api-docs/src/runner/utils/variable-interpolator.ts index 7b9caf58..438af49f 100644 --- a/packages/bruno-api-docs/src/runner/utils/variable-interpolator.ts +++ b/packages/bruno-api-docs/src/runner/utils/variable-interpolator.ts @@ -2,7 +2,7 @@ import type { HttpRequest, HttpRequestHeader, HttpRequestParam } from '@opencoll import { isPlainObject } from 'lodash-es'; import { getRequestUrl, getHttpMethod, getHttpHeaders, getHttpBody, getHttpParams, getRequestAuth } from '@/utils/schemaHelpers'; import { templateVariableGlobalRegex } from '@/utils/common'; -import { mockDataFunctions } from './faker-functions'; +import { mockDataFunctions } from '@/utils/faker-functions'; export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; export type Variables = Record; diff --git a/packages/bruno-api-docs/src/standalone.ts b/packages/bruno-api-docs/src/standalone.ts index 254da928..27b3e441 100644 --- a/packages/bruno-api-docs/src/standalone.ts +++ b/packages/bruno-api-docs/src/standalone.ts @@ -1,113 +1,17 @@ -import React from 'react'; -import type { Root } from 'react-dom/client'; -import { createRoot } from 'react-dom/client'; import './styles/index.css'; // Import Prism (with our token customizations) to ensure it's bundled import Prism from './utils/prism'; import OpenCollection from './components/OpenCollection/OpenCollection'; -import type { OpenCollection as IOpenCollection } from '@opencollection/types'; -import { parseCollectionContent } from './utils/yamlUtils'; +import { createRendererClass, type OpenCollectionOptions } from './renderer'; // Ensure Prism is available globally for any code that might access it if (typeof window !== 'undefined') { (window as any).Prism = Prism; } -export interface OpenCollectionOptions { - target: HTMLElement; - opencollection: any; - logo?: string; - gitCollectionUrl?: string; -} - -export class OpenCollectionRenderer { - private root: Root | null = null; - private options: OpenCollectionOptions; - - constructor(options: OpenCollectionOptions) { - this.options = options; - this.init(); - } - - private injectInterFont() { - // Only inject if not already present - if (!document.querySelector('link[href*="fonts.googleapis.com/css2?family=Inter"]')) { - const links = [ - { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, - { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossOrigin: 'anonymous' }, - { - rel: 'stylesheet', - href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=JetBrains+Mono:wght@400;500;600;700&display=swap' - } - ]; - - links.forEach((linkProps) => { - const link = document.createElement('link'); - Object.entries(linkProps).forEach(([key, value]) => { - link.setAttribute(key, value); - }); - document.head.appendChild(link); - }); - } - } - - private init() { - if (!this.options.target) { - throw new Error('Target element is required'); - } - - this.injectInterFont(); - this.root = createRoot(this.options.target); - this.render(); - } - - private convertCollection(opencollection: any): IOpenCollection { - if (typeof opencollection === 'string') { - try { - return parseCollectionContent(opencollection) as IOpenCollection; - } catch (error) { - console.error('Failed to parse collection:', error); - throw new Error(`Invalid collection format: ${error instanceof Error ? error.message : 'Unknown error'}`); - } - } +export type { OpenCollectionOptions }; - return opencollection as IOpenCollection; - } - - private createLogoElement(): React.ReactNode { - if (!this.options.logo) return undefined; - - return React.createElement('img', { - src: this.options.logo, - alt: 'Logo', - style: { height: '32px', width: 'auto' } - }); - } - - private render() { - if (!this.root) return; - - const collection = this.convertCollection(this.options.opencollection); - - this.root.render(React.createElement(OpenCollection, { - collection, - logo: this.createLogoElement(), - gitCollectionUrl: this.options.gitCollectionUrl - })); - } - - public updateCollection(opencollection: any) { - this.options.opencollection = opencollection; - this.render(); - } - - public destroy() { - if (this.root) { - this.root.unmount(); - this.root = null; - } - } -} +export const OpenCollectionRenderer = createRendererClass(OpenCollection); export default OpenCollectionRenderer; diff --git a/packages/bruno-api-docs/src/store/slices/app.ts b/packages/bruno-api-docs/src/store/slices/app.ts deleted file mode 100644 index d09b6908..00000000 --- a/packages/bruno-api-docs/src/store/slices/app.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; -import type { RootState } from '@/store/store'; - -export type CollectionStatus = 'idle' | 'loading' | 'succeeded' | 'failed'; - -export interface AppState { - collectionStatus: CollectionStatus; - collectionError: string | null; - gitCollectionUrl: string | null; -} - -const initialState: AppState = { - collectionStatus: 'idle', - collectionError: null, - gitCollectionUrl: null -}; - -const appSlice = createSlice({ - name: 'app', - initialState, - reducers: { - setCollectionLoading: (state: AppState) => { - state.collectionStatus = 'loading'; - state.collectionError = null; - }, - setCollectionSucceeded: (state: AppState) => { - state.collectionStatus = 'succeeded'; - state.collectionError = null; - }, - setCollectionFailed: (state: AppState, action: PayloadAction) => { - state.collectionStatus = 'failed'; - state.collectionError = action.payload; - }, - resetCollectionState: (state: AppState) => { - state.collectionStatus = 'idle'; - state.collectionError = null; - }, - setGitCollectionUrl: (state: AppState, action: PayloadAction) => { - state.gitCollectionUrl = action.payload; - } - } -}); - -export const { - setCollectionLoading, - setCollectionSucceeded, - setCollectionFailed, - resetCollectionState, - setGitCollectionUrl -} = appSlice.actions; -export default appSlice.reducer; - -export const selectCollectionStatus = (state: RootState) => state.app.collectionStatus; -export const selectCollectionError = (state: RootState) => state.app.collectionError; -export const selectGitCollectionUrl = (state: RootState) => state.app.gitCollectionUrl; diff --git a/packages/bruno-api-docs/src/store/slices/collection.ts b/packages/bruno-api-docs/src/store/slices/collection.ts new file mode 100644 index 00000000..c49557c3 --- /dev/null +++ b/packages/bruno-api-docs/src/store/slices/collection.ts @@ -0,0 +1,103 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import type { OpenCollection } from '@opencollection/types'; +import type { Item, Folder } from '@opencollection/types/collection/item'; +import { hydrateWithUUIDs, findAndUpdateItem } from '@/utils/fileUtils'; +import { isFolder } from '@/utils/schemaHelpers'; + +export type CollectionStatus = 'idle' | 'loading' | 'succeeded' | 'failed'; + +// The document every surface reads. Loaded once by CollectionRoot; the playground +// forks its own working copy from it and never writes back. +export interface CollectionState { + document: OpenCollection | null; + status: CollectionStatus; + error: string | null; + gitCollectionUrl: string | null; +} + +const initialState: CollectionState = { + document: null, + status: 'idle', + error: null, + gitCollectionUrl: null +}; + +const initializeCollapsedState = (items: Item[] | undefined): void => { + if (!items) return; + for (const item of items) { + if (isFolder(item)) { + if ((item as any).isCollapsed === undefined) { + (item as any).isCollapsed = true; + } + const folder = item as Folder; + if (folder.items) initializeCollapsedState(folder.items); + } + } +}; + +const collectionSlice = createSlice({ + name: 'collection', + initialState, + reducers: { + collectionLoading: (state: CollectionState) => { + state.status = 'loading'; + state.error = null; + }, + collectionLoaded: (state: CollectionState, action: PayloadAction) => { + const document = hydrateWithUUIDs(action.payload); + initializeCollapsedState(document.items); + state.document = document; + state.status = 'succeeded'; + state.error = null; + }, + collectionFailed: (state: CollectionState, action: PayloadAction) => { + state.document = null; + state.status = 'failed'; + state.error = action.payload; + }, + collectionCleared: (state: CollectionState) => { + state.document = null; + state.status = 'idle'; + state.error = null; + }, + setGitCollectionUrl: (state: CollectionState, action: PayloadAction) => { + state.gitCollectionUrl = action.payload; + }, + // Sidebar expansion still rides on the tree nodes. Moving it to a side table + // keyed by uuid is the remaining step that makes the document read-only. + toggleItem: (state: CollectionState, action: PayloadAction) => { + if (!state.document?.items) return; + findAndUpdateItem(state.document.items, action.payload, (item) => { + const currentCollapsed = (item as any).isCollapsed ?? true; + (item as any).isCollapsed = !currentCollapsed; + }); + }, + // Expand-only: reveal the active item's ancestors without fighting a folder + // the user closed by hand. + expandFolders: (state: CollectionState, action: PayloadAction) => { + if (!state.document?.items || action.payload.length === 0) return; + for (const uuid of new Set(action.payload)) { + findAndUpdateItem(state.document.items, uuid, (item) => { + (item as { isCollapsed?: boolean }).isCollapsed = false; + }); + } + } + } +}); + +export const { + collectionLoading, + collectionLoaded, + collectionFailed, + collectionCleared, + setGitCollectionUrl, + toggleItem, + expandFolders +} = collectionSlice.actions; +export default collectionSlice.reducer; + +type WithCollection = { collection: CollectionState }; +export const selectCollection = (state: WithCollection) => state.collection.document; +export const selectCollectionStatus = (state: WithCollection) => state.collection.status; +export const selectCollectionError = (state: WithCollection) => state.collection.error; +export const selectGitCollectionUrl = (state: WithCollection) => state.collection.gitCollectionUrl; diff --git a/packages/bruno-api-docs/src/store/slices/docs.ts b/packages/bruno-api-docs/src/store/slices/docs.ts deleted file mode 100644 index 5c0c2d55..00000000 --- a/packages/bruno-api-docs/src/store/slices/docs.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; -import type { OpenCollection as OpenCollectionCollection } from '@opencollection/types'; -import type { Item as OpenCollectionItem, Folder } from '@opencollection/types/collection/item'; -import type { RootState } from '@/store/store'; -import { hydrateWithUUIDs, findAndUpdateItem } from '@/utils/fileUtils'; -import { isFolder } from '@/utils/schemaHelpers'; - -export interface DocsState { - collection: OpenCollectionCollection | null; - selectedItemId: string | null; -} - -const initialState: DocsState = { - collection: null, - selectedItemId: null -}; - -// Helper function to initialize isCollapsed for folders -const initializeCollapsedState = (items: OpenCollectionItem[] | undefined): void => { - if (!items) return; - - for (const item of items) { - if (isFolder(item)) { - // Initialize isCollapsed to true (collapsed) if not already set - if ((item as any).isCollapsed === undefined) { - (item as any).isCollapsed = true; - } - const folder = item as Folder; - if (folder.items) { - initializeCollapsedState(folder.items); - } - } - } -}; - -const docsSlice = createSlice({ - name: 'docs', - initialState, - reducers: { - setDocsCollection: (state: DocsState, action: PayloadAction) => { - // Hydrate collection with UUIDs if collection is provided - const collection = action.payload ? hydrateWithUUIDs(action.payload) : null; - state.collection = collection; - // Initialize isCollapsed for all folders - if (state.collection && state.collection.items) { - initializeCollapsedState(state.collection.items); - } - // Reset selected item when collection changes - state.selectedItemId = null; - }, - clearDocsCollection: (state: DocsState) => { - state.collection = null; - state.selectedItemId = null; - }, - toggleItem: (state: DocsState, action: PayloadAction) => { - const uuid = action.payload; - if (state.collection && state.collection.items) { - findAndUpdateItem(state.collection.items, uuid, (item) => { - // Treat undefined as true (collapsed), then toggle - const currentCollapsed = (item as any).isCollapsed ?? true; - (item as any).isCollapsed = !currentCollapsed; - }); - } - }, - selectItem: (state: DocsState, action: PayloadAction) => { - state.selectedItemId = action.payload; - }, - // Expand-only: force the given folders open (used to reveal the active - // item's ancestors on navigation/deep-link). Never collapses, so it does - // not fight a folder the user manually closed. - expandFolders: (state: DocsState, action: PayloadAction) => { - if (!state.collection?.items || action.payload.length === 0) return; - const targets = new Set(action.payload); - for (const uuid of targets) { - findAndUpdateItem(state.collection.items, uuid, (item) => { - (item as { isCollapsed?: boolean }).isCollapsed = false; - }); - } - } - } -}); - -export const { setDocsCollection, clearDocsCollection, toggleItem, selectItem, expandFolders } = docsSlice.actions; -export default docsSlice.reducer; - -export const selectDocsCollection = (state: RootState) => state.docs.collection; -export const selectSelectedItemId = (state: RootState) => state.docs.selectedItemId; diff --git a/packages/bruno-api-docs/src/store/slices/playground.spec.ts b/packages/bruno-api-docs/src/store/slices/playground.spec.ts index 3ce2833b..255b7aad 100644 --- a/packages/bruno-api-docs/src/store/slices/playground.spec.ts +++ b/packages/bruno-api-docs/src/store/slices/playground.spec.ts @@ -5,7 +5,6 @@ import reducer, { updatePlaygroundItem, resetPlaygroundEnvironments, selectHydratedCollection, - selectPlaygroundCollection, setViewMode, setSelectedExampleIndex, clearPlaygroundCollection, @@ -38,7 +37,7 @@ const envExternalSecrets = (store: ReturnType) describe('resetPlaygroundEnvironments', () => { it('restores the original environments after an edit', () => { - const store = createOpenCollectionStore(); + const store = createOpenCollectionStore({ playground: reducer }); store.dispatch(setPlaygroundCollection(makeCollection())); const edited = makeCollection(); @@ -51,7 +50,7 @@ describe('resetPlaygroundEnvironments', () => { }); it('keeps the restore independent of later edits (cloned, not shared)', () => { - const store = createOpenCollectionStore(); + const store = createOpenCollectionStore({ playground: reducer }); store.dispatch(setPlaygroundCollection(makeCollection())); store.dispatch(resetPlaygroundEnvironments()); @@ -89,7 +88,7 @@ describe('updatePlaygroundItem', () => { select(store.getState())!.items![0] as unknown as { uuid: string; http: { url: string } }; it('updates the item in the hydrated collection (what the UI renders) as well as the base collection', () => { - const store = createOpenCollectionStore(); + const store = createOpenCollectionStore({ playground: reducer }); store.dispatch(setPlaygroundCollection(withRequest())); const updated = { type: 'http', uuid: 'r1', name: 'Req', http: { url: 'new', method: 'GET' } }; @@ -98,20 +97,17 @@ describe('updatePlaygroundItem', () => { // The tree the UI reads must reflect the edit, with the uuid preserved so findItemByUuid resolves. expect(firstItem(store, selectHydratedCollection).http.url).toBe('new'); expect(firstItem(store, selectHydratedCollection).uuid).toBe('r1'); - expect(firstItem(store, selectPlaygroundCollection).http.url).toBe('new'); }); }); describe('setPlaygroundVariable', () => { - it('edits an environment variable in both the hydrated and base collections', () => { - const store = createOpenCollectionStore(); + it('edits an environment variable in the collection the UI reads', () => { + const store = createOpenCollectionStore({ playground: reducer }); store.dispatch(setPlaygroundCollection(makeCollection())); store.dispatch(setPlaygroundVariable({ scope: 'environment', name: 'a', value: '99', envName: 'Dev' })); expect((envVariables(store).find((v) => v.name === 'a') as unknown as { value: string }).value).toBe('99'); - const base = selectPlaygroundCollection(store.getState())!.config!.environments![0].variables!; - expect((base.find((v) => v.name === 'a') as unknown as { value: string }).value).toBe('99'); }); it('edits the last enabled duplicate, matching the resolver', () => { @@ -121,7 +117,7 @@ describe('setPlaygroundVariable', () => { { name: 'dup', value: 'shadowed', disabled: true }, { name: 'dup', value: 'winner' } ]; - const store = createOpenCollectionStore(); + const store = createOpenCollectionStore({ playground: reducer }); store.dispatch(setPlaygroundCollection(collection)); store.dispatch(setPlaygroundVariable({ scope: 'environment', name: 'dup', value: 'edited', envName: 'Dev' })); @@ -135,7 +131,7 @@ describe('setPlaygroundVariable', () => { it('edits a collection variable', () => { const collection = makeCollection(); collection.request = { variables: [{ name: 'cv', value: 'x' }] }; - const store = createOpenCollectionStore(); + const store = createOpenCollectionStore({ playground: reducer }); store.dispatch(setPlaygroundCollection(collection)); store.dispatch(setPlaygroundVariable({ scope: 'collection', name: 'cv', value: 'y' })); @@ -151,7 +147,7 @@ describe('setPlaygroundVariable', () => { { type: 'http', uuid: 'r1', name: 'Req', http: { url: 'u', method: 'GET' }, variables: [{ name: 'rv', value: '1' }] } ] } as unknown as OpenCollectionCollection; - const store = createOpenCollectionStore(); + const store = createOpenCollectionStore({ playground: reducer }); store.dispatch(setPlaygroundCollection(collection)); store.dispatch(setPlaygroundVariable({ scope: 'request', name: 'rv', value: '2', itemUuid: 'r1' })); @@ -163,7 +159,7 @@ describe('setPlaygroundVariable', () => { it('writes a session value to a secret variable, keeping it marked secret', () => { const collection = makeCollection(); collection.config.environments[0].variables.push({ name: 'sec', secret: true }); - const store = createOpenCollectionStore(); + const store = createOpenCollectionStore({ playground: reducer }); store.dispatch(setPlaygroundCollection(collection)); store.dispatch(setPlaygroundVariable({ scope: 'environment', name: 'sec', value: 'typed', envName: 'Dev' })); @@ -179,7 +175,7 @@ describe('setPlaygroundVariable', () => { type: 'aws-secrets-manager', variables: [{ name: 'vaultKey', secretName: 'prod/api-key' }] }; - const store = createOpenCollectionStore(); + const store = createOpenCollectionStore({ playground: reducer }); store.dispatch(setPlaygroundCollection(collection)); store.dispatch(setPlaygroundVariable({ scope: '$secrets', name: 'vaultKey', value: 'typed', envName: 'Dev' })); @@ -200,7 +196,7 @@ describe('playground folder collapse', () => { selectHydratedCollection(store.getState())!.items![0] as { isCollapsed?: boolean }; it('expandFolders reveals a collapsed folder', () => { - const store = createOpenCollectionStore(); + const store = createOpenCollectionStore({ playground: reducer }); store.dispatch(setPlaygroundCollection(withFolder())); store.dispatch(toggleFolderCollapse('f1')); expect(folder(store).isCollapsed).toBe(true); @@ -210,7 +206,7 @@ describe('playground folder collapse', () => { }); it('expandFolders keeps an already-open folder open (never collapses)', () => { - const store = createOpenCollectionStore(); + const store = createOpenCollectionStore({ playground: reducer }); store.dispatch(setPlaygroundCollection(withFolder())); store.dispatch(expandFolders(['f1'])); expect(folder(store).isCollapsed).toBe(false); @@ -243,7 +239,7 @@ describe('applyScriptVariableChanges', () => { view(store).config.environments[0].variables; it('reconciles environment variables onto the current collection', () => { - const store = createOpenCollectionStore(); + const store = createOpenCollectionStore({ playground: reducer }); store.dispatch(setPlaygroundCollection(withRequestAndEnv())); store.dispatch(applyScriptVariableChanges({ @@ -254,7 +250,7 @@ describe('applyScriptVariableChanges', () => { }); it('reconciles collection variables onto the current collection', () => { - const store = createOpenCollectionStore(); + const store = createOpenCollectionStore({ playground: reducer }); store.dispatch(setPlaygroundCollection(withRequestAndEnv())); store.dispatch(applyScriptVariableChanges({ collectionVariables: { variables: { c: 'changed', d: '2' }, deleted: [] } })); @@ -263,7 +259,7 @@ describe('applyScriptVariableChanges', () => { }); it('deletes only the variables named in deleted and leaves the rest', () => { - const store = createOpenCollectionStore(); + const store = createOpenCollectionStore({ playground: reducer }); store.dispatch(setPlaygroundCollection(withRequestAndEnv())); store.dispatch(applyScriptVariableChanges({ @@ -276,7 +272,7 @@ describe('applyScriptVariableChanges', () => { }); it('leaves store variables the delta never mentions untouched (upsert-only, not a full replace)', () => { - const store = createOpenCollectionStore(); + const store = createOpenCollectionStore({ playground: reducer }); store.dispatch(setPlaygroundCollection(withRequestAndEnv())); store.dispatch(applyScriptVariableChanges({ @@ -289,7 +285,7 @@ describe('applyScriptVariableChanges', () => { }); it('keeps a request edit made while the request was in flight', () => { - const store = createOpenCollectionStore(); + const store = createOpenCollectionStore({ playground: reducer }); store.dispatch(setPlaygroundCollection(withRequestAndEnv())); const inFlightItem = view(store).items[0]; diff --git a/packages/bruno-api-docs/src/store/slices/playground.ts b/packages/bruno-api-docs/src/store/slices/playground.ts index 8313c294..0a3d76a3 100644 --- a/packages/bruno-api-docs/src/store/slices/playground.ts +++ b/packages/bruno-api-docs/src/store/slices/playground.ts @@ -5,6 +5,7 @@ import type { Environment } from '@opencollection/types/config/environments'; import type { Item as OpenCollectionItem, Folder } from '@opencollection/types/collection/item'; import type { HttpRequest } from '@opencollection/types/requests/http'; import type { Variable, SecretVariable } from '@opencollection/types/common/variables'; +import { useSelector, type TypedUseSelectorHook } from 'react-redux'; import type { RootState } from '@/store/store'; import { hydrateWithUUIDs, findAndUpdateItem } from '@/utils/fileUtils'; import { isFolder, getRequestVariables } from '@/utils/schemaHelpers'; @@ -12,11 +13,12 @@ import { applyScriptEnvVars } from '@/utils/environments'; import { reconcileScriptVariables } from '@/utils/scriptVariables'; import type { Variables } from '@/runner/utils/variable-interpolator'; import type { ResponseBodyFormat } from '@/constants'; +import type { VariableChange } from '@/hooks/useVariableResolver'; +import { collectionLoaded, collectionCleared, collectionFailed } from '@/store/slices/collection'; export type ViewMode = 'playground' | 'environments' | 'folder-settings' | 'collection-settings' | 'example'; export interface PlaygroundState { - collection: OpenCollectionCollection | null; hydratedCollection: OpenCollectionCollection | null; pristineEnvironments: Environment[] | null; responses: Record; // Store responses by item UUID @@ -30,7 +32,6 @@ export interface PlaygroundState { } const initialState: PlaygroundState = { - collection: null, hydratedCollection: null, pristineEnvironments: null, responses: {}, @@ -124,44 +125,47 @@ const preserveCollapsedState = ( } }; +// The working copy: forked from the document on load, edited here, never +// written back. Re-hydrating makes fresh item objects, which matters because the +// document reducer has already stored (and Immer has frozen) its own copy. +const seedWorkingCopy = (state: PlaygroundState, document: OpenCollectionCollection) => { + const envs = readEnvironments(document); + state.pristineEnvironments = envs ? cloneDeep(envs) : null; + + const hydrated = hydrateWithUUIDs(document); + + if (state.hydratedCollection?.items && hydrated.items) { + preserveCollapsedState(hydrated.items, state.hydratedCollection.items); + } else if (hydrated.items) { + initializeCollapsedState(hydrated.items); + } + + state.hydratedCollection = hydrated; +}; + +const clearWorkingCopy = (state: PlaygroundState) => { + state.hydratedCollection = null; + state.pristineEnvironments = null; + state.responses = {}; + state.selectedItemId = null; + state.selectedExampleIndex = null; +}; + const playgroundSlice = createSlice({ name: 'playground', initialState, reducers: { setPlaygroundCollection: (state: PlaygroundState, action: PayloadAction) => { - state.collection = action.payload; - if (!action.payload) { state.hydratedCollection = null; state.pristineEnvironments = null; return; } - - const envs = readEnvironments(action.payload); - state.pristineEnvironments = envs ? cloneDeep(envs) : null; - - const hydrated = hydrateWithUUIDs(action.payload); - - // Preserve existing collapsed states from previous hydrated collection - if (state.hydratedCollection?.items && hydrated.items) { - preserveCollapsedState(hydrated.items, state.hydratedCollection.items); - } else if (hydrated.items) { - initializeCollapsedState(hydrated.items); - } - - state.hydratedCollection = hydrated; - }, - clearPlaygroundCollection: (state: PlaygroundState) => { - state.collection = null; - state.hydratedCollection = null; - state.pristineEnvironments = null; - state.responses = {}; - state.selectedItemId = null; - state.selectedExampleIndex = null; + seedWorkingCopy(state, action.payload); }, + clearPlaygroundCollection: clearWorkingCopy, updatePlaygroundItem: (state: PlaygroundState, action: PayloadAction<{ uuid: string; item: HttpRequest }>) => { const { uuid, item } = action.payload; - if (state.collection?.items) findAndUpdateItemInCollection(state.collection.items, uuid, item); if (state.hydratedCollection?.items) findAndUpdateItemInCollection(state.hydratedCollection.items, uuid, item); }, setPlaygroundResponse: (state: PlaygroundState, action: PayloadAction<{ uuid: string; response: any }>) => { @@ -208,11 +212,9 @@ const playgroundSlice = createSlice({ } }, updateCollectionSettings: (state: PlaygroundState, action: PayloadAction) => { - state.collection = action.payload; state.hydratedCollection = action.payload; }, updateCollectionEnvironments: (state: PlaygroundState, action: PayloadAction) => { - state.collection = action.payload; state.hydratedCollection = action.payload; }, applyScriptVariableChanges: ( @@ -243,7 +245,6 @@ const playgroundSlice = createSlice({ } }; - applyTo(state.collection); applyTo(state.hydratedCollection); }, updateFolderInCollection: (state: PlaygroundState, action: PayloadAction<{ uuid: string; folder: Folder }>) => { @@ -253,28 +254,14 @@ const playgroundSlice = createSlice({ findAndUpdateItem(state.hydratedCollection.items, uuid, (item) => { Object.assign(item, folder); }); - - // Also update the base collection - if (state.collection?.items) { - findAndUpdateItem(state.collection.items, uuid, (item) => { - Object.assign(item, folder); - }); - } }, resetPlaygroundEnvironments: (state: PlaygroundState) => { const environments = state.pristineEnvironments ? cloneDeep(state.pristineEnvironments) : null; if (state.hydratedCollection) writeEnvironments(state.hydratedCollection, environments); - if (state.collection) writeEnvironments(state.collection, environments); }, setPlaygroundVariable: ( state: PlaygroundState, - action: PayloadAction<{ - scope: 'environment' | 'collection' | 'folder' | 'request' | '$secrets'; - name: string; - value: string; - envName?: string; - itemUuid?: string; - }> + action: PayloadAction ) => { const { scope, name, value, envName, itemUuid } = action.payload; // Secret variables are writable. Their values only ever live on this @@ -304,7 +291,6 @@ const playgroundSlice = createSlice({ } }; apply(state.hydratedCollection); - apply(state.collection); }, setResponseFormat: (state: PlaygroundState, action: PayloadAction<{ uuid: PlaygroundState['selectedItemId']; @@ -324,6 +310,13 @@ const playgroundSlice = createSlice({ if (uuid != null) state.showResponsePreview[uuid] = showResponsePreview; } + }, + // The playground follows the document's lifecycle; nothing has to tell it. + extraReducers: (builder) => { + builder + .addCase(collectionLoaded, (state, action) => seedWorkingCopy(state, action.payload)) + .addCase(collectionCleared, clearWorkingCopy) + .addCase(collectionFailed, clearWorkingCopy); } }); @@ -350,19 +343,21 @@ export const { } = playgroundSlice.actions; // Selectors -export const selectPlaygroundCollection = (state: RootState) => state.playground.collection; -export const selectHydratedCollection = (state: RootState) => state.playground.hydratedCollection; -export const selectPlaygroundResponses = (state: RootState) => state.playground.responses; -export const selectPlaygroundResponse = (state: RootState, uuid: string) => state.playground.responses[uuid]; -export const selectViewMode = (state: RootState) => state.playground.viewMode; -export const selectSelectedItemId = (state: RootState) => state.playground.selectedItemId; -export const selectSelectedExampleIndex = (state: RootState) => state.playground.selectedExampleIndex; -export const selectResponsePaneOrientation = (state: RootState) => state.playground.responsePaneOrientation; +type WithPlayground = { playground: PlaygroundState }; +export const usePlaygroundSelector: TypedUseSelectorHook = useSelector; + +export const selectHydratedCollection = (state: WithPlayground) => state.playground.hydratedCollection; +export const selectPlaygroundResponses = (state: WithPlayground) => state.playground.responses; +export const selectPlaygroundResponse = (state: WithPlayground, uuid: string) => state.playground.responses[uuid]; +export const selectViewMode = (state: WithPlayground) => state.playground.viewMode; +export const selectSelectedItemId = (state: WithPlayground) => state.playground.selectedItemId; +export const selectSelectedExampleIndex = (state: WithPlayground) => state.playground.selectedExampleIndex; +export const selectResponsePaneOrientation = (state: WithPlayground) => state.playground.responsePaneOrientation; export const selectResponseFormat = (uuid: PlaygroundState['selectedItemId']) => - (state: RootState) => uuid ? state.playground.selectedResponseFormat[uuid] : null; + (state: WithPlayground) => uuid ? state.playground.selectedResponseFormat[uuid] : null; export const selectShowResponsePreview = (uuid: PlaygroundState['selectedItemId']) => - (state: RootState) => uuid ? state.playground.showResponsePreview[uuid] : null; + (state: WithPlayground) => uuid ? state.playground.showResponsePreview[uuid] : null; export default playgroundSlice.reducer; diff --git a/packages/bruno-api-docs/src/store/store.ts b/packages/bruno-api-docs/src/store/store.ts index 8061c8cc..f3c8c9da 100644 --- a/packages/bruno-api-docs/src/store/store.ts +++ b/packages/bruno-api-docs/src/store/store.ts @@ -1,19 +1,21 @@ -import { configureStore } from '@reduxjs/toolkit'; -import appReducer from '@/store/slices/app'; -import docsReducer from '@/store/slices/docs'; +import { configureStore, type ReducersMapObject, type StateFromReducersMapObject } from '@reduxjs/toolkit'; +import collectionReducer from '@/store/slices/collection'; import envReducer, { persistEnv } from '@/store/slices/env'; -import playgroundReducer from '@/store/slices/playground'; import themeReducer, { persistThemeMode } from '@/store/slices/theme'; -export const createOpenCollectionStore = () => { +// What every surface may read. A surface's own slice is registered by the root +// that mounts it and is typed by that surface, never here. +const coreReducers = { + collection: collectionReducer, + env: envReducer, + theme: themeReducer +}; + +export type RootState = StateFromReducersMapObject; + +export const createOpenCollectionStore = >(surfaces?: S) => { const store = configureStore({ - reducer: { - app: appReducer, - docs: docsReducer, - env: envReducer, - playground: playgroundReducer, - theme: themeReducer - } + reducer: { ...coreReducers, ...surfaces } }); // Persist theme changes (localStorage + root data-theme) outside the reducer. @@ -38,4 +40,3 @@ export const createOpenCollectionStore = () => { export type AppStore = ReturnType; export type AppDispatch = AppStore['dispatch']; -export type RootState = ReturnType; diff --git a/packages/bruno-api-docs/src/runner/utils/faker-functions.ts b/packages/bruno-api-docs/src/utils/faker-functions.ts similarity index 100% rename from packages/bruno-api-docs/src/runner/utils/faker-functions.ts rename to packages/bruno-api-docs/src/utils/faker-functions.ts diff --git a/packages/bruno-api-docs/src/utils/variableAutocomplete.ts b/packages/bruno-api-docs/src/utils/variableAutocomplete.ts index 90e6499a..0c27f936 100644 --- a/packages/bruno-api-docs/src/utils/variableAutocomplete.ts +++ b/packages/bruno-api-docs/src/utils/variableAutocomplete.ts @@ -1,4 +1,4 @@ -import { mockDataFunctions } from '@/runner/utils/faker-functions'; +import { mockDataFunctions } from '@/utils/faker-functions'; /** The `$`-prefixed mock/dynamic function hints, e.g. `$randomUUID` (Bruno's MOCK_DATA_HINTS). */ const MOCK_HINTS = Object.keys(mockDataFunctions).map((key) => `$${key}`); diff --git a/packages/bruno-api-docs/src/utils/variableHighlight.ts b/packages/bruno-api-docs/src/utils/variableHighlight.ts index f137b7da..9fdc3b5d 100644 --- a/packages/bruno-api-docs/src/utils/variableHighlight.ts +++ b/packages/bruno-api-docs/src/utils/variableHighlight.ts @@ -1,4 +1,4 @@ -import { mockDataFunctions } from '@/runner/utils/faker-functions'; +import { mockDataFunctions } from '@/utils/faker-functions'; export type VariableTokenClass = 'variable-valid' | 'variable-invalid' | 'variable-prompt'; diff --git a/packages/bruno-api-docs/vite.config.docs.ts b/packages/bruno-api-docs/vite.config.docs.ts new file mode 100644 index 00000000..008b3fd3 --- /dev/null +++ b/packages/bruno-api-docs/vite.config.docs.ts @@ -0,0 +1,55 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); + +// One surface per bundle. Same shape as vite.config.standalone.ts, which builds +// both surfaces together; only the entry differs. +export default defineConfig({ + resolve: { + alias: { + '@slices': resolve(__dirname, 'src/store/slices'), + '@': resolve(__dirname, 'src') + } + }, + plugins: [react()], + define: { + 'process.env.NODE_ENV': '"production"' + }, + build: { + lib: { + entry: resolve(__dirname, 'src/entries/docs.ts'), + name: 'OpenCollectionDocs', + fileName: (format) => format === 'umd' ? 'docs.js' : 'docs.esm.js', + formats: ['umd', 'es'] + }, + cssCodeSplit: false, + rollupOptions: { + output: { + inlineDynamicImports: true, + manualChunks: undefined, + globals: {}, + exports: 'named', + assetFileNames: (assetInfo) => { + if (assetInfo.name && assetInfo.name.endsWith('.css')) { + return 'docs.css'; + } + return assetInfo.name || 'asset'; + } + } + }, + outDir: 'dist-docs', + minify: 'terser', + terserOptions: { + compress: { + drop_console: true, + drop_debugger: true + } + } + }, + css: { + postcss: './postcss.config.cjs' + } +}); diff --git a/packages/bruno-api-docs/vite.config.playground.ts b/packages/bruno-api-docs/vite.config.playground.ts new file mode 100644 index 00000000..73d2a1c7 --- /dev/null +++ b/packages/bruno-api-docs/vite.config.playground.ts @@ -0,0 +1,55 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); + +// One surface per bundle. Same shape as vite.config.standalone.ts, which builds +// both surfaces together; only the entry differs. +export default defineConfig({ + resolve: { + alias: { + '@slices': resolve(__dirname, 'src/store/slices'), + '@': resolve(__dirname, 'src') + } + }, + plugins: [react()], + define: { + 'process.env.NODE_ENV': '"production"' + }, + build: { + lib: { + entry: resolve(__dirname, 'src/entries/playground.ts'), + name: 'OpenCollectionPlayground', + fileName: (format) => format === 'umd' ? 'playground.js' : 'playground.esm.js', + formats: ['umd', 'es'] + }, + cssCodeSplit: false, + rollupOptions: { + output: { + inlineDynamicImports: true, + manualChunks: undefined, + globals: {}, + exports: 'named', + assetFileNames: (assetInfo) => { + if (assetInfo.name && assetInfo.name.endsWith('.css')) { + return 'playground.css'; + } + return assetInfo.name || 'asset'; + } + } + }, + outDir: 'dist-playground', + minify: 'terser', + terserOptions: { + compress: { + drop_console: true, + drop_debugger: true + } + } + }, + css: { + postcss: './postcss.config.cjs' + } +});