diff --git a/.changeset/config.json b/.changeset/config.json index ec98e35c..b34a8a7c 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,5 +7,9 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": [] + "bumpVersionsWithWorkspaceProtocolOnly": true, + "ignore": [ + "@usebruno/api-shared", + "@usebruno/api-playground" + ] } diff --git a/.changeset/playground-edits-reach-docs-variables.md b/.changeset/playground-edits-reach-docs-variables.md new file mode 100644 index 00000000..3ac43b3f --- /dev/null +++ b/.changeset/playground-edits-reach-docs-variables.md @@ -0,0 +1,5 @@ +--- +"@usebruno/api-docs": minor +--- + +Docs pages now show edits made in the playground until the page is reloaded. diff --git a/.changeset/playground-loads-only-when-opened.md b/.changeset/playground-loads-only-when-opened.md new file mode 100644 index 00000000..256b5526 --- /dev/null +++ b/.changeset/playground-loads-only-when-opened.md @@ -0,0 +1,5 @@ +--- +"@usebruno/api-docs": patch +--- + +The playground now loads only when opened, so docs pages load less code. diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..cb6ad43f --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,160 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json + +language: 'en-US' +early_access: false +tone_instructions: 'Expert reviewer in TypeScript, React, Emotion, Vite, Vitest and Playwright. Be concise. Favour the simplest code that works; flag abstractions without a second user. Explain more only when asked.' + +knowledge_base: + opt_out: false + code_guidelines: + enabled: true + filePatterns: + - '**/CODING_STANDARDS.md' + learnings: + scope: 'local' + jira: + usage: 'disabled' + +reviews: + profile: 'chill' + high_level_summary: true + poem: false + review_status: true + collapse_walkthrough: false + auto_review: + enabled: true + drafts: false + base_branches: ['main', 'release/.*'] + path_filters: + - '!**/dist/**' + - '!**/dist-standalone/**' + - '!**/dist-cdn/**' + - '!**/playwright-report/**' + - '!**/test-results/**' + - '!**/package-lock.json' + - '!packages/bruno-shared/src/styles/theme.generated.css' + path_instructions: + - path: '**/*' + instructions: | + This repo renders a Bruno API collection as docs with a playground that sends real + requests. Reason as an API client would (requests, responses, auth, headers, bodies, + environments), not as a document viewer. CODING_STANDARDS.md is the source of truth + and ARCHITECTURE.md says who owns what. Its core rule: the simplest code that works, + abstractions only when required. Don't restate ESLint style; the pre-commit hook fixes + it. Focus on correctness, root cause over symptom, the simplicity smells, missing + tests, dead code left behind and comments that narrate the change. + Three packages: bruno-shared, bruno-api-playground, bruno-api-docs. Boundary: shared + imports neither of the others; playground imports only shared; docs only type-imports + the playground, apart from the one lazy import() in + packages/bruno-api-docs/src/components/Playground/load.ts. Cross-package imports use + package names; `@/` exists only in docs. Flag code placed in the wrong package: if + both need it, shared; anything that runs requests or scripts, playground. + - path: 'packages/bruno-shared/**' + instructions: | + Shared code used by docs and the playground (session, document, routing, theme, ui, + shared components, test utils). Flag: + - Any import from @usebruno/api-docs or @usebruno/api-playground, or a relative path + into their folders. + - An `@/` import; shared uses relative imports or its own package name. + - Code only one package uses; it belongs in that package. + - A new React context or module-level state the playground reads, without adding it to + the externals in packages/bruno-api-playground/vite.config.cdn.ts and Bruno.__rt in + packages/bruno-api-docs/src/components/Playground/loadCdn.ts. Without that the CDN + build gets a second copy and the playground reads a different context. + - A single barrel that re-exports everything; it makes Monaco load eagerly. + - path: 'packages/bruno-api-playground/**' + instructions: | + The playground: PlaygroundBody and below, the runner and QuickJS scripting. It is + loaded lazily, as an ES module in dev and as the classic script + api-playground-.js in the CDN build. Flag: + - Any import from @usebruno/api-docs or a relative path into packages/bruno-api-docs. + - An `@/` import; the playground uses relative imports or package names. + - A change to the externals in vite.config.cdn.ts without the matching Bruno.__rt + entry in packages/bruno-api-docs/src/components/Playground/loadCdn.ts, or the other + way round. + - State that must outlive the playground kept inside it instead of in the shared + session or workspace. + - path: 'packages/bruno-api-docs/**' + instructions: | + Docs view, the playground dock shell, the standalone CDN entry and all e2e. Flag: + - A value import of @usebruno/api-playground. Only types may be imported; the one + value import is the lazy import() in src/components/Playground/load.ts. + - Anything that makes the playground load before Try or a deep link. + - Changes to the public Bruno.apiDocs API or the bundle file names. + - Docs code that runs requests or scripts; that belongs in the playground. + - path: 'packages/bruno-shared/src/{session,observable,document}/**' + instructions: | + State rules from CODING_STANDARDS.md §State. Flag: + - A fact with two owners, or a copy of state that could be derived during render. + - An updater that mutates, rebuilds untouched branches, or returns a new object on a + no-op. Updaters are pure `(state, payload) => state`. + - A storage key written outside the owner that persists it, or a renamed key. + - Anything that makes the document writable after load. + - A new store library, generic store layer or option nobody passes. + - path: 'packages/*/src/components/**' + instructions: | + React rules from CODING_STANDARDS.md §React and §Styling. Flag: + - A leaf reading the session or workspace directly instead of taking props and callbacks. + - An effect that computes something derivable during render. + - A handler reading live state at click time instead of a value from render. + - A context hook that doesn't throw outside its provider. + - A component doing two jobs, or a near-duplicate of something in a ui or components + folder of any package. + - Compound components where there are no real slots. + - Colours or fonts not from `--oc-*` CSS variables. + - path: 'packages/bruno-api-docs/e2e/**' + instructions: | + Follow packages/bruno-api-docs/e2e/README.md and CODING_STANDARDS.md §E2E. Flag: + - Locators not by data-testid, or selectors inlined in specs instead of page objects. + - `new` in specs; expects inside page objects or components. + - `page.waitForTimeout`, `page.pause()`, `test.only`. + - Serial mode without a stated reason. + - Assertions that are conditional or check a substring the fixture already has. + - path: 'packages/bruno-api-playground/src/{runner,scripting}/**' + instructions: | + This code builds real HTTP requests and runs user scripts in a QuickJS sandbox. Check + it against what the Bruno desktop app does for the same request. Remember: + - Interpolation and auth are separate stages; a change to one doesn't cover the other. + - Only basic, bearer, apikey and digest reach the wire. awsv4, ntlm and wsse are + silently dropped. Flag code or tests that assume otherwise. + - HTTP is plain browser fetch; every network failure is one TypeError. + - Treat interpolated values, headers, URLs and script output as untrusted: header or + URL injection, new host capabilities exposed to scripts, secrets in logs or errors. + - `x || default` where an empty token, header or param is a real state. + pre_merge_checks: + custom_checks: + - name: 'No abstraction without a second user' + mode: 'warning' + instructions: | + Fail if the PR adds an interface or factory with one implementation, a wrapper that + only forwards, a helper with one caller, an option no caller passes, or an export + nothing imports. Pass if each new abstraction has at least two real users in the + code after this PR. + - name: 'Package change has a changeset' + mode: 'warning' + instructions: | + If the PR changes files under packages/*/src, it must add a markdown file under + .changeset/ for @usebruno/api-docs. Fail if it doesn't. Pass for PRs that only touch docs, tests, + CI or tooling. + - name: 'Behaviour change has a test' + mode: 'warning' + instructions: | + If the PR changes what the app does (new branch, default, UI state, bug fix), it must + add or change a unit spec or an e2e spec that would fail without the change. Fail if + a behaviour change has no test. Pass for pure refactors with existing coverage. + - name: 'No renamed storage keys or public names' + mode: 'warning' + instructions: | + Fail if the PR renames or removes the storage keys oc-docs.theme, oc-docs.env, + oc-docs:docsSidebarWidth, oc-docs:playgroundSidebarWidth or any other oc-docs key, + the .oc-playground class, the dist bundle file names, or the Bruno.apiDocs API + (updateCollection, updateOptions, destroy). + - name: 'E2E conventions' + mode: 'warning' + instructions: | + For changed files under packages/bruno-api-docs/e2e: fail on page.waitForTimeout, + test.only, page.pause, locators not by data-testid, expects outside specs, or serial + mode without a comment saying why. + +chat: + auto_reply: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23b4eac4..3343c34b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,9 @@ jobs: - run: npm run lint - - run: npm run test:run -w packages/bruno-api-docs + - run: npm run typecheck --workspaces --if-present + + - run: npm run test:run --workspaces --if-present # `draft` is null on push events, so that arm is required. - name: Build diff --git a/.gitignore b/.gitignore index 9c530e7a..a8f2f499 100644 --- a/.gitignore +++ b/.gitignore @@ -2,11 +2,13 @@ node_modules dist .DS_Store dist-standalone +dist-cdn test.js # Claude Code .claude/ CLAUDE.md +!/CLAUDE.md test-results !integrations/contract-tests/fixtures/walk-safety/node_modules/ integrations/dist-tarballs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..849c7eb6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,55 @@ +# AGENTS.md + +`@usebruno/api-docs` renders a Bruno OpenCollection as API docs with a playground that sends +real requests. It ships as a CDN bundle (`Bruno.apiDocs(target, { content })`), not on npm. +The code lives in three workspaces under `packages/`; the repo root holds lint, changesets and CI. + +**Read `ARCHITECTURE.md` before changing code. Follow `CODING_STANDARDS.md`.** Its core rule: +the simplest code that works, abstractions only when required. + +## Where code goes + +- `packages/bruno-api-docs` (`@usebruno/api-docs`): docs pages, sidebar, search, the playground + dock shell, the standalone and dev entries, all e2e. +- `packages/bruno-api-playground` (`@usebruno/api-playground`, private): `PlaygroundBody` and below, + the runner, QuickJS scripting. Anything that runs requests or scripts goes here. +- `packages/bruno-shared` (`@usebruno/api-shared`, private): session, document, routing, theme, + styles, ui primitives, shared components, test utils. If docs and playground both need it, it goes here. + +Boundary (lint): shared imports neither; playground imports only shared; docs only type-imports +the playground, apart from the one lazy `import()` in +`packages/bruno-api-docs/src/components/Playground/load.ts`. Cross-package imports use package +names. `@/` exists only in docs. + +## Commands + +From the repo root: + +| Task | Command | +|---|---| +| Unit tests, all packages | `npm run test:run` | +| Unit tests, one package | `npm run test:run -w @usebruno/api-shared` (or `@usebruno/api-playground`, `@usebruno/api-docs`) | +| Lint changed lines | `npm run lint` (whole tree: `npm run lint:all`) | +| Changeset | `npm run changeset` | + +From `packages/bruno-api-docs`: + +| Task | Command | +|---|---| +| Dev server | `npm run dev` | +| E2E, main + standalone | `npm run test:e2e` | +| E2E, standalone only | `npm run test:e2e:standalone` | +| One e2e spec | `npx playwright test e2e/tests//.spec.ts` | +| Build | `npm run build`, `npm run build:standalone` (builds the playground file first) | + +## Rules agents break most + +1. No new abstraction (interface, factory, wrapper, option, helper) without a second real use. +2. PRs target the active `release/vX.Y.0` branch, never `main`. +3. A change to any package needs a changeset (`npm run changeset` at the root). +4. Locally run only the e2e specs your change touches. CI runs the full suite. +5. Never rename storage keys (`oc-docs.theme`, `oc-docs.env`, `oc-docs:*`), the + `.oc-playground` class or bundle names. +6. Zero comments by default. Only what the code cannot say. +7. New files: components `PascalCase.tsx`, everything else in `src` `camelCase.ts`, e2e `kebab-case`. + Don't copy a neighbouring file's naming. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..9bbb95fe --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,274 @@ +# Architecture + +How the renderer is put together. Read this before changing code. `CODING_STANDARDS.md` says +how to write the code; this file says where things live and why. Paths are relative to +`packages/` unless they start at the repo root. + +## What it is + +`@usebruno/api-docs` turns a Bruno OpenCollection into an API documentation site with an +embedded playground that sends real requests. Think of it as a small API client, not a document +viewer. + +It is not published to npm. It ships as a standalone CDN bundle built by +`bruno-api-docs/vite.config.standalone.ts`. A host page calls +`Bruno.apiDocs(target, { content, ... })` and gets back an object with `updateCollection`, +`updateOptions` and `destroy`. + +## Packages + +Three npm workspaces. Only `@usebruno/api-docs` ships; the other two are private and end up +inside its build. Each package declares the dependencies its own code imports, and docs depends +on the other two as workspace packages (`"*"`). Changesets' `bumpVersionsWithWorkspaceProtocolOnly` +keeps that dependency from tripping its ignored-package check. + +| Package | Folder | What lives there | +|---|---|---| +| `@usebruno/api-docs` | `bruno-api-docs/` | The docs view (pages, sidebar, search, topbar), the playground dock shell (`bruno-api-docs/src/components/Playground/`), the standalone CDN entry, the dev entry and all e2e. | +| `@usebruno/api-playground` | `bruno-api-playground/` | `PlaygroundBody` and everything under it, the request runner (`bruno-api-playground/src/runner/`), the QuickJS scripting (`bruno-api-playground/src/scripting/`), the libraries bundle and the CDN build of the playground. | +| `@usebruno/api-shared` | `bruno-shared/` | What both need: session, document, observable, routing, theme and styles (`gen:theme`), ui primitives, shared components, hooks, utils and the unit test utils. | + +### The boundary + +- Shared imports nothing from docs or playground. +- Playground imports only from shared, never from docs. +- Docs imports playground types only. The one value import is the lazy + `import('@usebruno/api-playground')` in `bruno-api-docs/src/components/Playground/load.ts`. + +ESLint enforces all three with `no-restricted-imports` in the repo root `eslint.config.mjs`. +The rule does not see dynamic `import()`, which is why the one lazy import passes. + +Cross-package imports use the package name (`@usebruno/api-shared/session/session`). The +`@/` alias exists only in docs. Shared exports a few barrels (`./hooks`, `./constants`, +`./routing`, `./ui/MenuDropdown`, `./assets/icons`) and otherwise one file per path. There is no +single shared barrel, because it would load Monaco eagerly. + +## Entry points + +| Entry | What it does | +|---|---| +| `bruno-api-docs/src/standalone.ts` | The shipped bundle. Defines `Bruno.apiDocs` (and the deprecated `window.OpenCollection`), parses string content, renders `` into the target. `updateCollection`/`updateOptions` re-render with new props. | +| `bruno-api-docs/src/dev.tsx` | `npm run dev`. Mounts the sample collection, or a fixture from `bruno-api-docs/src/e2eFixtures/` via `?fixture=`. Also reads `?themeMode`, `?hideThemeSwitcher`, `?hidePlayground`, `?nogit`. e2e runs against this. | +| `bruno-api-docs/src/components/OpenCollection/OpenCollection.tsx` | The root component both entries render. Owns the session, the loader and the router. | +| `bruno-api-playground/src/index.ts` | The playground's only export: `PlaygroundBody` as the default. | + +## How the playground loads + +`bruno-api-docs/src/components/Playground/Playground.tsx` is the dock shell. It wraps +`lazy(() => loadPlayground())` in an `ErrorBoundary` and `Suspense`. Nothing loads until the +playground opens, from a Try click or a deep link. While it loads you see `playground-loading`; +if it fails you see `playground-load-error`. + +- **Dev, unit tests, `npm run build`**: `load.ts` is a plain ESM `import()`. One module graph, + so docs and playground share every module automatically. +- **Standalone CDN build**: the `playground-cdn` plugin in + `bruno-api-docs/vite.config.standalone.ts` swaps `./load` for `./loadCdn`, copies the hashed + playground file from `bruno-api-playground/dist-cdn/` into `dist-standalone/`, and defines its + name as `__API_PLAYGROUND_FILE__`. +- `bruno-api-docs/src/components/Playground/loadCdn.ts` fills `window.Bruno.__rt`, adds a + ` + + + +
+
+ + diff --git a/packages/bruno-api-docs/e2e/server/standalone/exported-new.html b/packages/bruno-api-docs/e2e/server/standalone/exported-new.html new file mode 100644 index 00000000..c71ae676 --- /dev/null +++ b/packages/bruno-api-docs/e2e/server/standalone/exported-new.html @@ -0,0 +1,24 @@ + + + + + + General Fixture - API Documentation + + + + + +
+ + + \ No newline at end of file diff --git a/packages/bruno-api-docs/e2e/server/standalone/exported-old.html b/packages/bruno-api-docs/e2e/server/standalone/exported-old.html new file mode 100644 index 00000000..95786501 --- /dev/null +++ b/packages/bruno-api-docs/e2e/server/standalone/exported-old.html @@ -0,0 +1,25 @@ + + + + + + General Fixture - API Documentation + + + + + +
+ + + \ No newline at end of file diff --git a/packages/bruno-api-docs/e2e/server/standalone/index.html b/packages/bruno-api-docs/e2e/server/standalone/index.html new file mode 100644 index 00000000..e8e61209 --- /dev/null +++ b/packages/bruno-api-docs/e2e/server/standalone/index.html @@ -0,0 +1,17 @@ + + + + + Standalone bundle + + + + + +
+
+ + diff --git a/packages/bruno-api-docs/e2e/server/test-server.mjs b/packages/bruno-api-docs/e2e/server/test-server.mjs index 9176a208..b71aa2cb 100644 --- a/packages/bruno-api-docs/e2e/server/test-server.mjs +++ b/packages/bruno-api-docs/e2e/server/test-server.mjs @@ -1,4 +1,6 @@ import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { extname } from 'node:path'; const port = Number(process.env.PORT ?? 3900); @@ -10,6 +12,18 @@ const corsHeaders = (req) => ({ 'Vary': 'Origin' }); +const staticRoots = [new URL('./standalone/', import.meta.url), new URL('../../dist-standalone/', import.meta.url)]; +const contentTypes = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css' }; + +const readStatic = async (name) => { + if (!/^[\w.-]+$/.test(name)) return null; + for (const root of staticRoots) { + const body = await readFile(new URL(name, root)).catch(() => null); + if (body) return body; + } + return null; +}; + const readBody = async (req) => { const chunks = []; for await (const chunk of req) chunks.push(chunk); @@ -49,6 +63,9 @@ const handle = async (req, res) => { return send(res, 200, { ...cors, 'Content-Type': 'application/json' }, JSON.stringify(echoed)); } + const file = await readStatic(url.pathname.replace(/^\/(api-docs\/)?/, '')); + if (file) return send(res, 200, { 'Content-Type': contentTypes[extname(url.pathname)] ?? 'text/plain' }, file); + send(res, 404, { ...cors, 'Content-Type': 'text/plain' }, 'not found'); }; diff --git a/packages/bruno-api-docs/e2e/tests/environments/env-survives-reload.spec.ts b/packages/bruno-api-docs/e2e/tests/environments/env-survives-reload.spec.ts new file mode 100644 index 00000000..6a695f77 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/environments/env-survives-reload.spec.ts @@ -0,0 +1,49 @@ +import { test, expect } from '../../playwright'; +import { EnvSwitcherComponent } from '../../components/layout/env-switcher.component'; + +const ENV_STORAGE_KEY = 'oc-docs.env'; + +test.describe('The selected environment survives a reload in the same tab', () => { + test.beforeEach(async ({ page, envSwitcher }) => { + await page.goto('/?fixture=general'); + await envSwitcher.selectEnvironment('Prod'); + }); + + test('the docs switcher shows the chosen environment after a reload', async ({ page, envSwitcher }) => { + await expect(envSwitcher.trigger).toHaveText('Prod'); + + await page.reload(); + + await expect(envSwitcher.trigger).toHaveText('Prod'); + expect(await page.evaluate((key) => sessionStorage.getItem(key), ENV_STORAGE_KEY)) + .toBe(JSON.stringify({ activeEnvName: 'Prod', showVars: false })); + }); + + test('show vars is kept across a reload with the environment', async ({ page, envSwitcher }) => { + await envSwitcher.toggle(); + await expect(envSwitcher.showVarsToggle).toHaveAttribute('aria-checked', 'true'); + + await page.reload(); + + await expect(envSwitcher.showVarsToggle).toHaveAttribute('aria-checked', 'true'); + await expect(envSwitcher.trigger).toHaveText('Prod'); + }); + + test('the playground opens on the chosen environment after a reload', async ({ page, playground }) => { + await page.goto('/?fixture=general#/?pg=1&dock=bottom'); + await page.reload(); + await playground.runner.waitFor({ state: 'visible' }); + await playground.ensureSidebarOpen(); + + await expect(playground.envSwitcher.trigger).toHaveText('Prod'); + }); + + test('a new tab starts on the first environment', async ({ context, envSwitcher }) => { + await expect(envSwitcher.trigger).toHaveText('Prod'); + + const fresh = await context.newPage(); + await fresh.goto('/?fixture=general'); + + await expect(new EnvSwitcherComponent(fresh).trigger).toHaveText('Dev'); + }); +}); diff --git a/packages/bruno-api-docs/e2e/tests/playground/edits-reach-docs-variables.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/edits-reach-docs-variables.spec.ts new file mode 100644 index 00000000..9acb15d6 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/playground/edits-reach-docs-variables.spec.ts @@ -0,0 +1,161 @@ +import { test, expect } from '../../playwright'; + +test.describe('Docs variables resolve against the playground working copy', () => { + test.use({ viewport: { width: 1400, height: 900 } }); + + test.beforeEach(async ({ page, requestPage, playground }) => { + await page.goto('/?fixture=vars'); + await requestPage.sidebar.open(['Customers', 'Variables Demo']); + await requestPage.root.waitFor({ state: 'visible' }); + await requestPage.urlBar.tryButton.click(); + await playground.view.waitFor({ state: 'visible' }); + await playground.envSwitcher.selectEnvironment('Dev'); + }); + + test('an environment value edited in the Environments view shows on the docs page', async ({ + playground, + envEditor, + requestPage + }) => { + await playground.openEnvironments(); + await envEditor.selectEnvironment('Dev'); + await expect(playground.keyValueTable.nameInputs.first()).toHaveValue('host'); + await playground.keyValueTable.valueInputs.first().fill('https://from-env-view.example.com'); + await playground.close(); + + await requestPage.variableCard.hoverToken('host'); + await expect(requestPage.variableCard.scopeBadge).toHaveText('Environment'); + await expect(requestPage.variableCard.value).toHaveText('https://from-env-view.example.com'); + }); + + test('an environment value edited in the Environments view shows on the docs Environments page', async ({ + playground, + envEditor, + environmentsPage + }) => { + await playground.openEnvironments(); + await envEditor.selectEnvironment('Dev'); + await expect(playground.keyValueTable.nameInputs.first()).toHaveValue('host'); + await playground.keyValueTable.valueInputs.first().fill('https://from-env-view.example.com'); + await playground.close(); + + await environmentsPage.sidebar.environments.click(); + await environmentsPage.tab('Dev').click(); + await expect(environmentsPage.table.valueOf('host').first()).toHaveText('https://from-env-view.example.com'); + }); + + test('a variable edited from the playground hover card shows on the docs page', async ({ + playground, + requestPage + }) => { + await playground.variable.hoverInputToken('host'); + await playground.variable.editTo('https://from-card.example.com'); + await expect(playground.variable.value).toHaveText('https://from-card.example.com'); + await playground.close(); + + await requestPage.variableCard.hoverToken('host'); + await expect(requestPage.variableCard.value).toHaveText('https://from-card.example.com'); + }); + + test('a collection variable edited in collection settings shows on the docs page', async ({ + playground, + collectionSettings, + requestPage + }) => { + await playground.ensureSidebarOpen(); + await collectionSettings.open(); + await collectionSettings.openTab('variables'); + await expect(playground.preRequestVars.nameInputs.first()).toHaveValue('apiVersion'); + await playground.preRequestVars.valueInputs.first().fill('2099-12'); + await playground.close(); + + await requestPage.variableCard.hoverToken('apiVersion'); + await expect(requestPage.variableCard.scopeBadge).toHaveText('Collection'); + await expect(requestPage.variableCard.value).toHaveText('2099-12'); + }); + + test('a collection variable edited in collection settings resolves in its headers tab', async ({ + playground, + collectionSettings + }) => { + await playground.ensureSidebarOpen(); + await collectionSettings.open(); + await collectionSettings.openTab('variables'); + await expect(playground.preRequestVars.nameInputs.first()).toHaveValue('apiVersion'); + await playground.preRequestVars.valueInputs.first().fill('2099-12'); + await collectionSettings.openTab('headers'); + + await playground.variable.hoverInputToken('apiVersion'); + await expect(playground.variable.scopeBadge).toHaveText('Collection'); + await expect(playground.variable.value).toHaveText('2099-12'); + }); + + test('a folder variable edited in folder settings shows on the docs page', async ({ playground, requestPage }) => { + await playground.openTreeItem(['Customers']); + await playground.folderSettingsTab('variables').click(); + await expect(playground.preRequestVars.nameInputs.first()).toHaveValue('folderScope'); + await playground.preRequestVars.valueInputs.first().fill('edited-folder'); + await playground.close(); + + await requestPage.variableCard.hoverToken('folderScope'); + await expect(requestPage.variableCard.scopeBadge).toHaveText('Folder'); + await expect(requestPage.variableCard.value).toHaveText('edited-folder'); + }); + + test('a request variable edited in the playground shows on the docs page', async ({ playground, requestPage }) => { + await playground.selectTab('variables'); + await expect(playground.preRequestVars.nameInputs.first()).toHaveValue('userId'); + await playground.preRequestVars.valueInputs.first().fill('req-99'); + await playground.close(); + + await requestPage.variableCard.hoverToken('userId'); + await expect(requestPage.variableCard.scopeBadge).toHaveText('Request'); + await expect(requestPage.variableCard.value).toHaveText('req-99'); + }); + + test('a request variable edited in the playground shows in the docs page Variables table', async ({ + playground, + requestPage + }) => { + await expect(requestPage.executionContext.variableValue('userId')).toHaveText('req-42'); + await playground.selectTab('variables'); + await expect(playground.preRequestVars.nameInputs.first()).toHaveValue('userId'); + await playground.preRequestVars.valueInputs.first().fill('req-99'); + await playground.close(); + + await expect(requestPage.executionContext.variableValue('userId')).toHaveText('req-99'); + }); + + test('a folder variable edited in folder settings shows on the docs folder page', async ({ + playground, + folderPage + }) => { + await playground.openTreeItem(['Customers']); + await playground.folderSettingsTab('variables').click(); + await expect(playground.preRequestVars.nameInputs.first()).toHaveValue('folderScope'); + await playground.preRequestVars.valueInputs.first().fill('edited-folder'); + await playground.close(); + + await folderPage.sidebar.open(['Customers']); + await folderPage.root.waitFor({ state: 'visible' }); + await expect(folderPage.configuration.varValue('folderScope')).toHaveText('edited-folder'); + }); + + test('a playground URL edit shows on the docs page until reload', async ({ page, playground, requestPage }) => { + const original = '{{host}}/customers/{{userId}}?v={{apiVersion}}&k={{vaultKey}}&s={{unsetSecret}}'; + await playground.urlInput.click(); + await page.keyboard.press('End'); + await page.keyboard.type('&edited=1'); + await expect(playground.urlInput).toHaveValue(`${original}&edited=1`); + await playground.close(); + + await expect(requestPage.urlBar.url).toHaveText(`${original}&edited=1`); + + await page.reload(); + await requestPage.root.waitFor({ state: 'visible' }); + await expect(requestPage.urlBar.url).toHaveText(original); + + await requestPage.urlBar.tryButton.click(); + await expect(playground.urlInput).toHaveValue(original); + }); +}); diff --git a/packages/bruno-api-docs/e2e/tests/playground/layout-persistence.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/layout-persistence.spec.ts index 34c47e4a..43053a8e 100644 --- a/packages/bruno-api-docs/e2e/tests/playground/layout-persistence.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/playground/layout-persistence.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '../../playwright'; -import { DOCK_STORAGE_KEY, HEIGHT_STORAGE_KEY } from '../../../src/utils/playgroundDock'; +import { DOCK_STORAGE_KEY, HEIGHT_STORAGE_KEY } from '../../../../bruno-shared/src/utils/playgroundDock'; const DESKTOP = { width: 1280, height: 900 }; const openAt = (dock: string): string => `/#/?pg=1&dock=${dock}`; diff --git a/packages/bruno-api-docs/e2e/tests/playground/state-survives-navigation.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/state-survives-navigation.spec.ts new file mode 100644 index 00000000..2d6904d3 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/playground/state-survives-navigation.spec.ts @@ -0,0 +1,107 @@ +import type { Page } from '@playwright/test'; +import { test, expect } from '../../playwright'; +import type { PlaygroundComponent } from '../../components/playground.component'; +import type { ResponsePaneComponent } from '../../components/playground/response-pane.component'; + +const DEV_HOST = 'https://api.dev.example.com'; +const PROD_HOST = 'https://api.prod.example.com'; + +const mockEndpoint = (page: Page, path: string, body: string) => + page.route(`**/${path}**`, (route) => + route.fulfill({ status: 200, headers: { 'content-type': 'application/json' }, body }) + ); + +const sendAndCaptureUrl = async (page: Page, responsePane: ResponsePaneComponent): Promise => { + const [request] = await Promise.all([page.waitForRequest('**/alpha**'), responsePane.send()]); + return request.url(); +}; + +const openPlayground = async (page: Page, playground: PlaygroundComponent) => { + await page.goto('/?fixture=general#/?pg=1&dock=bottom'); + await playground.runner.waitFor({ state: 'visible' }); +}; + +test.describe('Playground state survives navigation inside the page', () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test('Try it opens the playground with the environment selected in the docs', async ({ + page, + requestPage, + envSwitcher, + playground, + responsePane + }) => { + await mockEndpoint(page, 'alpha', '{}'); + await page.goto('/?fixture=general'); + await requestPage.sidebar.item('Alpha').click(); + await expect(requestPage.title).toHaveText('Alpha'); + await envSwitcher.selectEnvironment('Prod'); + + await requestPage.urlBar.tryButton.click(); + await playground.view.waitFor({ state: 'visible' }); + + await expect(playground.envSwitcher.trigger).toContainText('Prod'); + expect(await sendAndCaptureUrl(page, responsePane)).toMatch(new RegExp(`^${PROD_HOST}/alpha`)); + }); + + test('switching the environment in the playground changes where the request is sent', async ({ + page, + playground, + responsePane + }) => { + await mockEndpoint(page, 'alpha', '{}'); + await openPlayground(page, playground); + await playground.openRequest('Alpha'); + + await playground.envSwitcher.selectEnvironment('Dev'); + expect(await sendAndCaptureUrl(page, responsePane)).toMatch(new RegExp(`^${DEV_HOST}/alpha`)); + + await playground.envSwitcher.selectEnvironment('Prod'); + expect(await sendAndCaptureUrl(page, responsePane)).toMatch(new RegExp(`^${PROD_HOST}/alpha`)); + }); + + test('a response is kept when moving to another request and back, and after close and reopen', async ({ + page, + playground, + responsePane, + requestPage + }) => { + await mockEndpoint(page, 'alpha', '{"from":"alpha"}'); + await openPlayground(page, playground); + await playground.openRequest('Alpha'); + await playground.envSwitcher.selectEnvironment('Dev'); + await responsePane.send(); + await expect(responsePane.status).toBeVisible(); + await expect.poll(() => responsePane.bodyText()).toContain('"from": "alpha"'); + + await playground.openRequest('Beta'); + await expect(responsePane.emptyHint).toBeVisible(); + + await playground.openRequest('Alpha'); + await expect.poll(() => responsePane.bodyText()).toContain('"from": "alpha"'); + + await playground.close(); + await expect(playground.header).toHaveCount(0); + await requestPage.sidebar.item('Alpha').click(); + await requestPage.urlBar.tryButton.click(); + await playground.view.waitFor({ state: 'visible' }); + await expect.poll(() => responsePane.bodyText()).toContain('"from": "alpha"'); + }); + + test('a URL edit typed just before a dock switch is kept in every dock', async ({ page, playground }) => { + await openPlayground(page, playground); + await playground.openRequest('Alpha'); + + await playground.urlInput.click(); + await page.keyboard.press('End'); + await page.keyboard.type('&docked=1'); + + await playground.selectDock('inline'); + await expect(playground.inlinePanel).toBeVisible(); + await expect(playground.urlInput).toHaveValue(/&docked=1$/); + + await playground.selectDock('modal'); + await expect(playground.modalPanel).toBeVisible(); + await expect(playground.urlInput).toHaveValue(/&docked=1$/); + }); +}); diff --git a/packages/bruno-api-docs/e2e/tests/playground/view-survives-close.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/view-survives-close.spec.ts new file mode 100644 index 00000000..0a163638 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/playground/view-survives-close.spec.ts @@ -0,0 +1,203 @@ +import { test, expect } from '../../playwright'; + +const GET_USERS_URL = '{{host}}/api/users?page=1&limit=10'; +const USERS_BODY = '{"users":[{"id":1,"name":"Ada"}]}'; +const OPEN_NO_REQUEST = /#\/\?pg=1&dock=bottom$/; + +test.describe('Playground selection and view state across close, reopen and dock switches', () => { + test.use({ viewport: { width: 1920, height: 900 } }); + + test.beforeEach(async ({ page, responsePane }) => { + await responsePane.mockUsersResponse(USERS_BODY); + await page.goto('/#/?pg=1&dock=bottom'); + }); + + test('going back to the playground url without a request keeps showing the last request', async ({ + page, + playground + }) => { + await playground.openRequest('get users'); + await expect(page).toHaveURL(/pgReq=/); + + await page.goBack(); + + await expect(page).toHaveURL(OPEN_NO_REQUEST); + await expect(playground.urlInput).toHaveValue(GET_USERS_URL); + }); + + test('reopening the playground without a request in the url shows the request open before closing', async ({ + page, + playground + }) => { + await playground.openRequest('get users'); + await page.goBack(); + await playground.close(); + await expect(playground.header).toHaveCount(0); + + await page.goBack(); + + await expect(page).toHaveURL(OPEN_NO_REQUEST); + await expect(playground.header).toBeVisible(); + await expect(playground.urlInput).toHaveValue(GET_USERS_URL); + }); + + test('reopening the playground without a request in the url shows Environments if it was open before closing', async ({ + page, + playground + }) => { + await playground.gear.click(); + await expect(page).toHaveURL(/pgReq=(~|%7E)environments/); + await page.goBack(); + await playground.close(); + + await page.goBack(); + + await expect(page).toHaveURL(OPEN_NO_REQUEST); + await expect(playground.gear).toHaveClass(/active/); + await expect(playground.urlInput).toHaveCount(0); + }); + + test('a changed response layout is kept after close and reopen', async ({ page, playground, responsePane }) => { + await playground.openRequest('get users'); + await responsePane.send(); + await expect(playground.divider).toHaveAttribute('data-orientation', 'horizontal'); + await responsePane.inlineChangeLayoutButton.click(); + await expect(playground.divider).toHaveAttribute('data-orientation', 'vertical'); + + await playground.close(); + await page.goBack(); + + await expect(playground.urlInput).toHaveValue(GET_USERS_URL); + await expect(playground.divider).toHaveAttribute('data-orientation', 'vertical'); + }); + + test('a changed response layout is kept across a dock switch', async ({ playground, responsePane }) => { + await playground.openRequest('get users'); + await responsePane.send(); + await responsePane.inlineChangeLayoutButton.click(); + await expect(playground.divider).toHaveAttribute('data-orientation', 'vertical'); + + await playground.selectDock('modal'); + + await expect(playground.modalPanel).toBeVisible(); + await expect(playground.divider).toHaveAttribute('data-orientation', 'vertical'); + }); + + test('a chosen response format is kept after close and reopen and across a dock switch', async ({ + page, + playground, + responsePane + }) => { + await playground.openRequest('get users'); + await responsePane.send(); + await expect(responsePane.formatSelector).toHaveText('JSON'); + await responsePane.selectFormat('raw'); + await expect(responsePane.formatSelector).toHaveText('Raw'); + + await playground.close(); + await page.goBack(); + await expect(responsePane.formatSelector).toHaveText('Raw'); + + await playground.selectDock('modal'); + await expect(playground.modalPanel).toBeVisible(); + await expect(responsePane.formatSelector).toHaveText('Raw'); + }); + + test('a turned-on response preview is kept after close and reopen and across a dock switch', async ({ + page, + playground, + responsePane + }) => { + await playground.openRequest('get users'); + await responsePane.send(); + await responsePane.openFormatSelector(); + expect(await responsePane.isPreviewOn()).toBe(false); + await responsePane.togglePreview(); + await expect(responsePane.jsonPreview).toBeVisible(); + await page.keyboard.press('Escape'); + + await playground.close(); + await page.goBack(); + await expect(responsePane.jsonPreview).toBeVisible(); + + await playground.selectDock('modal'); + await expect(playground.modalPanel).toBeVisible(); + await expect(responsePane.jsonPreview).toBeVisible(); + }); + + test('a folder opened from its chevron stays open after close and reopen', async ({ page, playground }) => { + const customers = playground.treeItems.filter({ hasText: /^customers$/ }); + await expect(customers).toHaveCount(0); + await playground.folderChevron('billing').click(); + await expect(customers).toHaveCount(1); + + await playground.close(); + await page.goBack(); + + await expect(playground.header).toBeVisible(); + await expect(customers).toHaveCount(1); + }); + + test('a folder holding the open request, collapsed by hand, stays collapsed across a dock switch', async ({ + page, + playground + }) => { + await page.goto('/#/?pg=1&dock=bottom&pgReq=billing/customers/get-all-customers'); + const request = playground.treeItems.filter({ hasText: 'Get All Customers' }); + await expect(request).toHaveCount(1); + await playground.folderChevron('billing').click(); + await expect(request).toHaveCount(0); + + await playground.selectDock('modal'); + + await expect(playground.modalPanel).toBeVisible(); + await expect(playground.urlInput).toBeVisible(); + await expect(request).toHaveCount(0); + }); +}); + +test.describe('Playground folders when a request is reopened or edited', () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test('Try on the same request after closing re-opens the folders collapsed before closing', async ({ + requestPage, + playground + }) => { + await requestPage.open(['billing', 'customers', 'Get Customers - Filter by Date Range']); + await requestPage.urlBar.tryButton.click(); + const request = playground.treeItems.filter({ hasText: 'Get Customers - Filter by Date Range' }); + await expect(request).toHaveCount(1); + await playground.folderChevron('billing').click(); + await expect(request).toHaveCount(0); + + await playground.close(); + await expect(playground.header).toHaveCount(0); + await requestPage.urlBar.tryButton.click(); + + await expect(playground.header).toBeVisible(); + await expect(request).toHaveCount(1); + }); + + test('a folder holding the open request, collapsed by hand, stays collapsed after a variable edit', async ({ + page, + requestPage, + playground + }) => { + await page.goto('/?fixture=vars'); + await requestPage.sidebar.open(['Customers', 'Variables Demo']); + await requestPage.root.waitFor({ state: 'visible' }); + await requestPage.urlBar.tryButton.click(); + await playground.view.waitFor({ state: 'visible' }); + await playground.envSwitcher.selectEnvironment('Dev'); + const request = playground.treeItems.filter({ hasText: 'Variables Demo' }); + await expect(request).toHaveCount(1); + await playground.folderChevron('Customers').click(); + await expect(request).toHaveCount(0); + + await playground.variable.hoverInputToken('host'); + await playground.variable.editTo('https://edited.example.com'); + await expect(playground.variable.value).toHaveText('https://edited.example.com'); + + await expect(request).toHaveCount(0); + }); +}); diff --git a/packages/bruno-api-docs/e2e/tests/standalone/collection-change-while-mounted.spec.ts b/packages/bruno-api-docs/e2e/tests/standalone/collection-change-while-mounted.spec.ts new file mode 100644 index 00000000..ded5ae30 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/standalone/collection-change-while-mounted.spec.ts @@ -0,0 +1,97 @@ +import { test, expect } from '../../playwright'; +import { generalFixtureCollection, generalOneEnvCollection } from '../../../src/e2eFixtures/generalCollection'; + +const renamedGeneral = { ...generalFixtureCollection, info: { name: 'Renamed', version: '2.0.0' } }; + +test.describe('A different collection passed while mounted', () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test.describe('with the docs on a request page', () => { + test.beforeEach(async ({ standalonePage, sidebar, envSwitcher }) => { + await standalonePage.goto(); + await standalonePage.mount('first', { content: generalFixtureCollection }); + await sidebar.item('Beta').click(); + await envSwitcher.selectEnvironment('Prod'); + }); + + test('a collection that still has the page keeps the page and environment and shows the new name', async ({ + page, + standalonePage, + pageHeader, + requestPage, + sidebar, + envSwitcher + }) => { + await standalonePage.updateCollection('first', renamedGeneral); + + await expect(pageHeader.brandName).toHaveText('Renamed'); + await expect(requestPage.title).toHaveText('Beta'); + await expect(sidebar.active).toHaveText('GETBeta'); + await expect(envSwitcher.trigger).toHaveText('Prod'); + await expect(page).toHaveURL(standalonePage.url('#/beta')); + }); + + test('a collection without the page sends the docs to the overview and picks its first environment', async ({ + page, + standalonePage, + overviewPage, + sidebar, + envSwitcher + }) => { + await standalonePage.updateCollection('first', generalOneEnvCollection); + + await expect(overviewPage.root).toBeVisible(); + await expect(sidebar.items).toHaveText(['GETPing']); + await expect(envSwitcher.trigger).toHaveText('Staging'); + await expect(page).toHaveURL(standalonePage.url('#/')); + expect(await page.evaluate(() => sessionStorage.getItem('oc-docs.env'))) + .toBe(JSON.stringify({ activeEnvName: 'Staging', showVars: false })); + }); + + test('going back to the first collection does not bring back its earlier environment', async ({ + standalonePage, + pageHeader, + envSwitcher + }) => { + await standalonePage.updateCollection('first', generalOneEnvCollection); + await expect(envSwitcher.trigger).toHaveText('Staging'); + + await standalonePage.updateCollection('first', generalFixtureCollection); + + await expect(pageHeader.brandName).toHaveText('General Fixture'); + await expect(envSwitcher.trigger).toHaveText('Dev'); + }); + }); + + test.describe('with the playground open on a request', () => { + test.beforeEach(async ({ standalonePage, playground }) => { + await standalonePage.goto('#/?pg=1&dock=bottom'); + await standalonePage.mount('first', { content: generalFixtureCollection }); + await playground.openRequest('Beta'); + }); + + test('the playground stays open with no request selected, its URL unchanged', async ({ + page, + standalonePage, + playground + }) => { + await expect(playground.urlInput).toHaveValue('{{host}}/beta'); + + await standalonePage.updateCollection('first', renamedGeneral); + + await expect(playground.view.getByText('Select an endpoint from the sidebar to get started.')).toBeVisible(); + await expect(playground.treeItems).toHaveText(['GETAlpha', 'GETBeta']); + await expect(page).toHaveURL(standalonePage.url('#/?pg=1&dock=bottom&pgReq=beta')); + }); + + test('the playground tree lists the new collection\'s requests', async ({ page, standalonePage, playground }) => { + await expect(playground.urlInput).toHaveValue('{{host}}/beta'); + + await standalonePage.updateCollection('first', generalOneEnvCollection); + + await expect(playground.treeItems).toHaveText(['GETPing']); + await expect(playground.view.getByText('Select an endpoint from the sidebar to get started.')).toBeVisible(); + await expect(page).toHaveURL(standalonePage.url('#/?pg=1&dock=bottom&pgReq=beta')); + }); + }); +}); diff --git a/packages/bruno-api-docs/e2e/tests/standalone/collection-inputs.spec.ts b/packages/bruno-api-docs/e2e/tests/standalone/collection-inputs.spec.ts new file mode 100644 index 00000000..55f27da3 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/standalone/collection-inputs.spec.ts @@ -0,0 +1,90 @@ +import { dump } from 'js-yaml'; +import { test, expect } from '../../playwright'; +import { generalFixtureCollection } from '../../../src/e2eFixtures/generalCollection'; + +const YAML = dump(generalFixtureCollection); +const BROKEN_YAML = 'key: [unclosed'; +const COLLECTION_URL = 'https://collections.example.com/general.yml'; + +test.describe('Collection inputs accepted by Bruno.apiDocs', () => { + test.beforeEach(async ({ standalonePage }) => { + await standalonePage.goto(); + }); + + test('a YAML string renders the collection', async ({ standalonePage, pageHeader, sidebar }) => { + await standalonePage.mount('first', { content: YAML }); + + await expect(pageHeader.brandName).toHaveText('General Fixture'); + await expect(sidebar.items).toHaveText(['GETAlpha', 'GETBeta']); + }); + + test('a JSON string renders the collection', async ({ standalonePage, pageHeader, sidebar }) => { + await standalonePage.mount('first', { content: JSON.stringify(generalFixtureCollection) }); + + await expect(pageHeader.brandName).toHaveText('General Fixture'); + await expect(sidebar.items).toHaveText(['GETAlpha', 'GETBeta']); + }); + + test('an unparseable string throws from Bruno.apiDocs and leaves the target empty', async ({ standalonePage }) => { + await expect(standalonePage.mount('first', { content: BROKEN_YAML })) + .rejects.toThrow('Invalid collection format: Failed to parse YAML: unexpected end of the stream within a flow collection'); + + await expect(standalonePage.target('first')).toBeEmpty(); + }); + + test('a string that parses to a plain YAML scalar renders an empty untitled collection', async ({ + standalonePage, + pageHeader, + overviewPage, + sidebar + }) => { + await standalonePage.mount('first', { content: 'just words' }); + + await expect(pageHeader.brandName).toHaveText('API Collection'); + await expect(overviewPage.header.collectionName).toHaveText('Untitled Collection'); + await expect(sidebar.items).toHaveCount(0); + }); + + test('a File renders the collection', async ({ standalonePage, pageHeader, sidebar }) => { + await standalonePage.mountFile('first', YAML, 'general.yml'); + + await expect(pageHeader.brandName).toHaveText('General Fixture'); + await expect(sidebar.items).toHaveText(['GETAlpha', 'GETBeta']); + }); + + test('an unparseable File shows the parse error in the target', async ({ standalonePage }) => { + await standalonePage.mountFile('first', BROKEN_YAML, 'broken.yml'); + + await expect(standalonePage.target('first')).toHaveText('Error: Failed to parse collection as YAML or JSON'); + }); + + test('a URL string shows Loading while the collection is fetched, then renders it', async ({ + page, + standalonePage, + pageHeader, + sidebar + }) => { + let respond!: () => void; + const responded = new Promise((resolve) => { respond = resolve; }); + await page.route(COLLECTION_URL, async (route) => { + await responded; + await route.fulfill({ status: 200, body: YAML }); + }); + + await standalonePage.mount('first', { content: COLLECTION_URL }); + await expect(standalonePage.target('first')).toHaveText('Loading...'); + + respond(); + + await expect(pageHeader.brandName).toHaveText('General Fixture'); + await expect(sidebar.items).toHaveText(['GETAlpha', 'GETBeta']); + }); + + test('a URL that answers 404 shows the fetch error in the target', async ({ page, standalonePage }) => { + await page.route(COLLECTION_URL, (route) => route.fulfill({ status: 404, body: 'nope' })); + + await standalonePage.mount('first', { content: COLLECTION_URL }); + + await expect(standalonePage.target('first')).toHaveText('Error: Failed to fetch collection: Not Found'); + }); +}); diff --git a/packages/bruno-api-docs/e2e/tests/standalone/exported-html.spec.ts b/packages/bruno-api-docs/e2e/tests/standalone/exported-html.spec.ts new file mode 100644 index 00000000..996d26e5 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/standalone/exported-html.spec.ts @@ -0,0 +1,44 @@ +import { test, expect } from '../../playwright'; + +const PROD_HOST = 'https://api.prod.example.com'; + +test.use({ viewport: { width: 1280, height: 900 }, colorScheme: 'light' }); + +for (const variant of ['new', 'old'] as const) { + test.describe(`A generated documentation file (${variant} mount call)`, () => { + test.afterEach(({ exportedHtmlPage }) => { + expect(exportedHtmlPage.consoleErrors).toEqual([]); + }); + + test('renders the overview', async ({ exportedHtmlPage, overviewPage }) => { + await exportedHtmlPage.open(variant); + + await expect(overviewPage.root).toMatchAriaSnapshot({ name: 'overview.aria.yml' }); + }); + + test('renders a request page', async ({ exportedHtmlPage, requestPage }) => { + await exportedHtmlPage.open(variant, '#/alpha'); + + await expect(requestPage.root).toMatchAriaSnapshot({ name: 'request.aria.yml' }); + }); + + test('sends the request with the environment picked in the header', async ({ + page, + exportedHtmlPage, + requestPage, + envSwitcher, + responsePane + }) => { + await exportedHtmlPage.forwardToTestServer(PROD_HOST); + await exportedHtmlPage.open(variant, '#/alpha'); + + await envSwitcher.selectEnvironment('Prod'); + await requestPage.urlBar.tryButton.click(); + const sent = page.waitForRequest(`${PROD_HOST}/**`); + await responsePane.send(); + + expect((await sent).headers()['x-host']).toBe(PROD_HOST); + await expect(responsePane.status).toContainText('200'); + }); + }); +} diff --git a/packages/bruno-api-docs/e2e/tests/standalone/exported-html.spec.ts-snapshots/overview.aria.yml b/packages/bruno-api-docs/e2e/tests/standalone/exported-html.spec.ts-snapshots/overview.aria.yml new file mode 100644 index 00000000..dffe49a2 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/standalone/exported-html.spec.ts-snapshots/overview.aria.yml @@ -0,0 +1,12 @@ +- text: "Version : 1.0.0" +- heading "General Fixture" [level=1] +- text: 2 Requests 0 Folders 2 Environments +- heading "Overview" [level=2] +- paragraph: No overview content yet +- paragraph: "This collection has no description or readme. Add one in Bruno to introduce your API to readers: what it does, who it's for, and how to authenticate." +- heading "Collection Configuration" [level=2] +- paragraph: No configuration set +- paragraph: This collection has no shared headers or auth. Configure them in Bruno and they'll appear here. +- heading "Execution Context" [level=2] +- paragraph: No execution context +- paragraph: This collection has no shared scripts, variables, or tests. \ No newline at end of file diff --git a/packages/bruno-api-docs/e2e/tests/standalone/exported-html.spec.ts-snapshots/request.aria.yml b/packages/bruno-api-docs/e2e/tests/standalone/exported-html.spec.ts-snapshots/request.aria.yml new file mode 100644 index 00000000..c67b50ce --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/standalone/exported-html.spec.ts-snapshots/request.aria.yml @@ -0,0 +1,34 @@ +- navigation "Breadcrumb": + - list: + - listitem: + - button "General Fixture" + - listitem: Alpha +- heading "Alpha" [level=1] +- text: "GET {{host}}/alpha?q={{special}}&m={{onlyInDev}}&t={{apiToken}}" +- button "Copy URL": + - img +- button "Try" +- heading "Body" [level=2] +- text: application/json +- button "Copy code": + - img +- code: "{ \"host\": \"{{host}}\", \"token\": \"{{apiToken}}\", \"note\": \"{{special}}\" }" +- heading "Headers" [level=2] +- term: Authorization +- definition: "Bearer {{apiToken}}" +- term: X-Host +- definition: "{{host}}" +- term: X-Note +- definition: "{{special}}" +- heading "Code Snippet" [level=2] +- tablist "Snippet language": + - tab "cURL" [selected] + - tab "Javascript" + - tab "Python" +- button "Expand code snippet" +- button "Copy code": + - img +- code: "curl --request GET \\ --url '{{host}}/alpha?q={{special}}&m={{onlyInDev}}&t={{apiToken}}' \\ --header 'Authorization: Bearer {{apiToken}}' \\ --header 'Content-Type: application/json' \\ --header 'X-Host: {{host}}' \\ --header 'X-Note: {{special}}' \\ --data ' { \"host\": \"{{host}}\", \"token\": \"{{apiToken}}\", \"note\": \"{{special}}\" } '" +- heading "Execution Context" [level=2] +- paragraph: No execution context +- paragraph: This request has no scripts, variables, asserts, or tests configured. \ No newline at end of file diff --git a/packages/bruno-api-docs/e2e/tests/standalone/playground-after-update.spec.ts b/packages/bruno-api-docs/e2e/tests/standalone/playground-after-update.spec.ts new file mode 100644 index 00000000..c1b896f0 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/standalone/playground-after-update.spec.ts @@ -0,0 +1,93 @@ +import { test, expect } from '../../playwright'; +import { foldersFixtureCollection } from '../../../src/e2eFixtures/foldersCollection'; + +type Node = { items?: Node[] } & Record; + +const withUuids = (items: Node[], prefix = 'item'): Node[] => + items.map((item, index) => ({ + ...item, + uuid: `${prefix}-${index}`, + ...(item.items ? { items: withUuids(item.items, `${prefix}-${index}`) } : {}) + })); + +const foldersWithUuids = { + ...foldersFixtureCollection, + items: withUuids(foldersFixtureCollection.items as unknown as Node[]) +}; + +test.describe('The playground after updateCollection', () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test.beforeEach(async ({ standalonePage }) => { + await standalonePage.goto('#/?pg=1&dock=bottom'); + }); + + test('a folder opened in the playground tree is closed again when its items carry no uuids', async ({ + standalonePage, + playground + }) => { + await standalonePage.mount('first', { content: foldersFixtureCollection }); + const listRooms = playground.treeItems.filter({ hasText: 'List Rooms' }); + await playground.folderChevron('Rooms').click(); + await expect(listRooms).toHaveCount(1); + + await standalonePage.updateCollection('first', foldersFixtureCollection); + + await expect(playground.treeItems.filter({ hasText: /^Rooms$/ })).toHaveCount(1); + await expect(listRooms).toHaveCount(0); + }); + + test('a folder opened in the playground tree stays open when its items keep their uuids', async ({ + standalonePage, + playground + }) => { + await standalonePage.mount('first', { content: foldersWithUuids }); + const listRooms = playground.treeItems.filter({ hasText: 'List Rooms' }); + const login = playground.treeItems.filter({ hasText: 'Login' }); + await playground.folderChevron('Rooms').click(); + await expect(listRooms).toHaveCount(1); + await expect(login).toHaveCount(0); + + await standalonePage.updateCollection('first', foldersWithUuids); + + await expect(listRooms).toHaveCount(1); + await expect(login).toHaveCount(0); + }); + + test('the open request stays selected when its items keep their uuids', async ({ + standalonePage, + playground + }) => { + await standalonePage.mount('first', { content: foldersWithUuids }); + await playground.folderChevron('Rooms').click(); + await playground.openRequest('List Rooms'); + await expect(playground.urlInput).toHaveValue('{{host}}/rooms'); + + await standalonePage.updateCollection('first', foldersWithUuids); + + await expect(playground.urlInput).toHaveValue('{{host}}/rooms'); + }); + + test('the environments view comes back on reopen after the collection is cleared and loaded again', async ({ + standalonePage, + playground, + page + }) => { + await standalonePage.mount('first', { content: foldersFixtureCollection }); + await playground.gear.click(); + await expect(playground.view).toContainText('Environments'); + await playground.closeButton.click(); + await expect(playground.view).toHaveCount(0); + + await standalonePage.updateCollection('first', null); + await expect(standalonePage.root).toContainText('Loading...'); + await standalonePage.updateCollection('first', foldersFixtureCollection); + await expect(standalonePage.root).not.toContainText('Loading...'); + + await page.evaluate(() => { + window.location.hash = '#/?pg=1&dock=bottom'; + }); + await expect(playground.view).toContainText('Environments'); + await expect(playground.view.locator('.prompt')).toHaveCount(0); + }); +}); diff --git a/packages/bruno-api-docs/e2e/tests/standalone/playground-lazy-load.spec.ts b/packages/bruno-api-docs/e2e/tests/standalone/playground-lazy-load.spec.ts new file mode 100644 index 00000000..57cc05d6 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/standalone/playground-lazy-load.spec.ts @@ -0,0 +1,214 @@ +import type { Page } from '@playwright/test'; +import { test, expect } from '../../playwright'; +import { appConfig } from '../../config/app.config'; +import { generalFixtureCollection } from '../../../src/e2eFixtures/generalCollection'; + +const PROD_HOST = 'https://api.prod.example.com'; +const NONCE_CSP = [ + 'script-src \'nonce-n1\' https://cdn.jsdelivr.net \'wasm-unsafe-eval\'', + 'style-src \'self\' \'unsafe-inline\' https://cdn.jsdelivr.net https://fonts.googleapis.com', + 'worker-src \'self\' blob: https://cdn.jsdelivr.net', + 'connect-src \'self\' data: https://cdn.jsdelivr.net https:', + 'font-src *', + 'img-src * data:' +].join('; '); + +const playgroundFileRequests = (page: Page): string[] => { + const urls: string[] = []; + page.on('request', (request) => { + if (/\/api-playground-[\w-]+\.js$/.test(new URL(request.url()).pathname)) urls.push(request.url()); + }); + return urls; +}; + +test.use({ viewport: { width: 1280, height: 900 }, colorScheme: 'light' }); + +for (const variant of ['new', 'old'] as const) { + test(`a generated documentation file (${variant} mount call) loads the playground file only on Try, once`, async ({ + page, + exportedHtmlPage, + sidebar, + requestPage, + envSwitcher, + playground, + responsePane + }) => { + const loads = playgroundFileRequests(page); + await exportedHtmlPage.forwardToTestServer(PROD_HOST); + await exportedHtmlPage.open(variant); + await sidebar.item('Beta').click(); + await expect(requestPage.title).toHaveText('Beta'); + await sidebar.item('Alpha').click(); + await expect(requestPage.title).toHaveText('Alpha'); + expect(loads).toEqual([]); + + await envSwitcher.selectEnvironment('Prod'); + await requestPage.urlBar.tryButton.click(); + const sent = page.waitForRequest(`${PROD_HOST}/**`); + await responsePane.send(); + expect((await sent).headers()['x-host']).toBe(PROD_HOST); + await expect(responsePane.status).toContainText('200'); + + await playground.closeButton.click(); + await expect(playground.view).toHaveCount(0); + await requestPage.urlBar.tryButton.click(); + await expect(playground.view).toBeVisible(); + + expect(loads).toHaveLength(1); + expect(loads[0]).toMatch(new RegExp(`^${appConfig.testServerURL}/api-docs/api-playground-`)); + expect(exportedHtmlPage.consoleErrors).toEqual([]); + }); +} + +test.describe('The playground file on a host page', () => { + test('is loaded once for two instances, and each playground sends', async ({ page, standalonePage }) => { + const loads = playgroundFileRequests(page); + await page.route('**/alpha**', (route) => + route.fulfill({ status: 200, headers: { 'content-type': 'application/json' }, body: '{"ok":1}' }) + ); + await standalonePage.goto(); + await standalonePage.mount('first', { content: generalFixtureCollection }); + await standalonePage.mount('second', { content: generalFixtureCollection }); + const first = standalonePage.instance('first'); + const second = standalonePage.instance('second'); + await first.sidebar.item('Alpha').click(); + await expect(first.requestPage.title).toHaveText('Alpha'); + expect(loads).toEqual([]); + + await first.requestPage.urlBar.tryButton.click(); + await first.responsePane.sendButton.click(); + await expect(first.responsePane.status).toHaveText('200 OK'); + await first.playground.closeButton.click(); + await expect(first.playground.view).toHaveCount(0); + + await second.sidebar.item('Alpha').click(); + await second.requestPage.urlBar.tryButton.click(); + await second.responsePane.sendButton.click(); + await expect(second.responsePane.status).toHaveText('200 OK'); + + expect(loads).toHaveLength(1); + }); + + test('is loaded on first paint by a deep link to an open playground', async ({ page, standalonePage }) => { + const loads = playgroundFileRequests(page); + await standalonePage.goto('#/alpha?pg=1&dock=bottom&pgReq=alpha'); + await standalonePage.mount('first', { content: generalFixtureCollection }); + const first = standalonePage.instance('first'); + + await expect(first.playground.view).toContainText('Alpha'); + expect(loads).toHaveLength(1); + }); + + test('is never loaded with hidePlayground, even for a deep link to an open playground', async ({ + page, + standalonePage + }) => { + const loads = playgroundFileRequests(page); + await standalonePage.goto('#/alpha?pg=1&dock=bottom&pgReq=alpha'); + await standalonePage.mount('first', { content: generalFixtureCollection, hidePlayground: true }); + const first = standalonePage.instance('first'); + + await expect(first.requestPage.title).toHaveText('Alpha'); + await expect(first.requestPage.urlBar.tryButton).toHaveCount(0); + await expect(first.playground.view).toHaveCount(0); + await first.sidebar.item('Beta').click(); + await expect(first.requestPage.title).toHaveText('Beta'); + expect(loads).toEqual([]); + }); + + test('shows the load error when the file is missing and keeps the docs usable', async ({ page, standalonePage }) => { + await page.route('**/api-playground-*', (route) => route.fulfill({ status: 404, body: 'not found' })); + await standalonePage.goto('#/alpha'); + await standalonePage.mount('first', { content: generalFixtureCollection }); + const first = standalonePage.instance('first'); + + await first.requestPage.urlBar.tryButton.click(); + await expect(first.playground.loadError).toBeVisible(); + + await first.sidebar.item('Beta').click(); + await expect(first.requestPage.title).toHaveText('Beta'); + }); + + for (const [failure, response] of [ + ['a failed load', { status: 503, body: 'unavailable' }], + ['a 200 that is not the playground', { status: 200, contentType: 'text/html', body: '

error

' }] + ] as const) { + test(`retries the file on the next open after ${failure}`, async ({ page, standalonePage }) => { + const loads = playgroundFileRequests(page); + let failures = 1; + await page.route('**/api-playground-*', (route) => + failures-- > 0 ? route.fulfill(response) : route.continue() + ); + await page.route('**/alpha**', (route) => + route.fulfill({ status: 200, headers: { 'content-type': 'application/json' }, body: '{"ok":1}' }) + ); + await standalonePage.goto('#/alpha'); + await standalonePage.mount('first', { content: generalFixtureCollection }); + const first = standalonePage.instance('first'); + + await first.requestPage.urlBar.tryButton.click(); + await expect(first.playground.loadError).toBeVisible(); + await first.playground.closeButton.click(); + await expect(first.playground.view).toHaveCount(0); + + await first.requestPage.urlBar.tryButton.click(); + await first.responsePane.sendButton.click(); + await expect(first.responsePane.status).toHaveText('200 OK'); + expect(loads).toHaveLength(2); + }); + } + + test('loads on a page whose CSP allows scripts only by nonce', async ({ page, standalonePage }) => { + const violations: string[] = []; + page.on('console', (message) => { + if (/Content Security Policy/.test(message.text())) violations.push(message.text()); + }); + await page.route('**/alpha**', (route) => + route.fulfill({ status: 200, headers: { 'content-type': 'application/json' }, body: '{"ok":1}' }) + ); + await standalonePage.gotoUnderCsp(NONCE_CSP, '#/alpha'); + await standalonePage.mount('first', { content: generalFixtureCollection }); + const first = standalonePage.instance('first'); + + await first.requestPage.urlBar.tryButton.click(); + await first.responsePane.sendButton.click(); + await expect(first.responsePane.status).toHaveText('200 OK'); + expect(violations).toEqual([]); + }); + + test('leaves the docs snippet highlighting alone when it loads', async ({ page, standalonePage }) => { + await page.route('**/alpha**', (route) => + route.fulfill({ status: 200, headers: { 'content-type': 'application/json' }, body: '{"ok":1}' }) + ); + await standalonePage.goto('#/alpha'); + await standalonePage.mount('first', { content: generalFixtureCollection }); + const first = standalonePage.instance('first'); + const { codeSnippet } = first.requestPage; + const docsTokens = codeSnippet.code.getByTestId(/^variable-token-/); + await expect(docsTokens).toHaveCount(10); + + const playgroundFile = page.waitForResponse(/\/api-playground-[\w-]+\.js$/); + await first.requestPage.urlBar.tryButton.click(); + expect((await (await playgroundFile).text()).includes('highlightAll:function')).toBe(false); + await first.responsePane.sendButton.click(); + await expect(first.responsePane.status).toHaveText('200 OK'); + await expect(docsTokens).toHaveCount(10); + + await first.playground.closeButton.click(); + await codeSnippet.selectLanguage('javascript'); + await expect(codeSnippet.code).toContainText('fetch('); + await expect(docsTokens).toHaveCount(10); + }); + + test('highlights playground snippets with the shared javascript grammar', async ({ standalonePage }) => { + await standalonePage.goto('#/alpha'); + await standalonePage.mount('first', { content: generalFixtureCollection }); + const first = standalonePage.instance('first'); + await first.requestPage.urlBar.tryButton.click(); + const { codeSnippet } = first.playground; + await codeSnippet.openFromIcon(); + await codeSnippet.selectModalLanguage('javascript'); + + await expect.poll(() => codeSnippet.modalTokenTexts('var-name')).toEqual(['options']); + }); +}); diff --git a/packages/bruno-api-docs/e2e/tests/standalone/standalone-lifecycle.spec.ts b/packages/bruno-api-docs/e2e/tests/standalone/standalone-lifecycle.spec.ts new file mode 100644 index 00000000..786a0418 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/standalone/standalone-lifecycle.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from '../../playwright'; +import { generalFixtureCollection, generalOneEnvCollection } from '../../../src/e2eFixtures/generalCollection'; + +test.use({ colorScheme: 'light' }); + +test.describe('Standalone bundle lifecycle', () => { + test.beforeEach(async ({ standalonePage }) => { + await standalonePage.goto(); + }); + + test('Bruno.apiDocs renders the collection into the target', async ({ standalonePage, pageHeader, sidebar }) => { + await standalonePage.mount('first', { content: generalFixtureCollection }); + + await expect(pageHeader.brandName).toHaveText('General Fixture'); + await expect(sidebar.item('Alpha')).toBeVisible(); + await expect(sidebar.item('Beta')).toBeVisible(); + await expect(standalonePage.target('second')).toBeEmpty(); + }); + + test('updateCollection swaps the rendered collection in place', async ({ standalonePage, pageHeader, sidebar }) => { + await standalonePage.mount('first', { content: generalFixtureCollection }); + await expect(pageHeader.brandName).toHaveText('General Fixture'); + + await standalonePage.updateCollection('first', generalOneEnvCollection); + + await expect(pageHeader.brandName).toHaveText('Single Environment'); + await expect(sidebar.item('Ping')).toBeVisible(); + await expect(sidebar.item('Alpha')).toHaveCount(0); + }); + + test('updateOptions applies a new theme to the mounted docs', async ({ standalonePage, themeToggle }) => { + await standalonePage.mount('first', { content: generalFixtureCollection }); + await expect(themeToggle.html).toHaveAttribute('data-theme', 'default-light'); + + await standalonePage.updateOptions('first', { theme: { mode: 'dark' } }); + + await expect(themeToggle.html).toHaveAttribute('data-theme', 'default-dark'); + await expect(themeToggle.button).toHaveAccessibleName('Switch to light theme'); + }); + + test('destroy empties the target and leaves the page theme attribute as it was', async ({ + standalonePage, + pageHeader, + themeToggle + }) => { + await standalonePage.mount('first', { content: generalFixtureCollection, theme: { mode: 'dark' } }); + await expect(pageHeader.brandName).toHaveText('General Fixture'); + + await standalonePage.destroy('first'); + + await expect(standalonePage.target('first')).toBeEmpty(); + await expect(pageHeader.root).toHaveCount(0); + await expect(themeToggle.html).toHaveAttribute('data-theme', 'default-dark'); + }); +}); diff --git a/packages/bruno-api-docs/e2e/tests/standalone/two-instances.spec.ts b/packages/bruno-api-docs/e2e/tests/standalone/two-instances.spec.ts new file mode 100644 index 00000000..36242059 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/standalone/two-instances.spec.ts @@ -0,0 +1,86 @@ +import { test, expect } from '../../playwright'; +import { generalFixtureCollection } from '../../../src/e2eFixtures/generalCollection'; + +const secondCollection = { ...generalFixtureCollection, info: { name: 'Second', version: '1.0.0' } }; + +test.use({ viewport: { width: 1280, height: 900 }, colorScheme: 'light' }); + +test.describe('Two Bruno.apiDocs instances on one page', () => { + test.beforeEach(async ({ standalonePage }) => { + await standalonePage.goto(); + await standalonePage.mount('first', { content: generalFixtureCollection }); + await standalonePage.mount('second', { content: secondCollection }); + }); + + test('switching the environment in one leaves the other on its own', async ({ page, standalonePage }) => { + const first = standalonePage.instance('first'); + const second = standalonePage.instance('second'); + await expect(second.header.brandName).toHaveText('Second'); + + await first.envSwitcher.selectEnvironment('Prod'); + + await expect(first.envSwitcher.trigger).toHaveText('Prod'); + await expect(second.envSwitcher.trigger).toHaveText('Dev'); + expect(await page.evaluate(() => sessionStorage.getItem('oc-docs.env'))) + .toBe(JSON.stringify({ activeEnvName: 'Prod', showVars: false })); + }); + + test('toggling the theme in one darkens the whole page but only flips that instance\'s toggle', async ({ + standalonePage, + themeToggle + }) => { + const first = standalonePage.instance('first'); + const second = standalonePage.instance('second'); + + await first.themeToggle.toggle(); + + await expect(themeToggle.html).toHaveAttribute('data-theme', 'default-dark'); + await expect(first.themeToggle.button).toHaveAccessibleName('Switch to light theme'); + await expect(second.themeToggle.button).toHaveAccessibleName('Switch to dark theme'); + }); + + test('a light theme passed to the other afterwards keeps the page dark and flips its toggle to the saved choice', async ({ + standalonePage, + themeToggle + }) => { + const first = standalonePage.instance('first'); + const second = standalonePage.instance('second'); + await first.themeToggle.toggle(); + await expect(second.themeToggle.button).toHaveAccessibleName('Switch to dark theme'); + + await standalonePage.updateOptions('second', { theme: { mode: 'light' } }); + + await expect(second.themeToggle.button).toHaveAccessibleName('Switch to light theme'); + await expect(themeToggle.html).toHaveAttribute('data-theme', 'default-dark'); + }); + + test('navigating in one changes the shared URL hash but only that instance\'s page', async ({ page, standalonePage }) => { + const first = standalonePage.instance('first'); + const second = standalonePage.instance('second'); + + await first.sidebar.item('Alpha').click(); + + await expect(first.requestPage.title).toHaveText('Alpha'); + await expect(page).toHaveURL(standalonePage.url('#/alpha')); + await expect(second.overviewPage.root).toBeVisible(); + await expect(second.requestPage.title).toHaveCount(0); + }); + + test('a request sent from one playground shows its response only in that instance', async ({ page, standalonePage }) => { + const first = standalonePage.instance('first'); + const second = standalonePage.instance('second'); + await page.route('**/alpha**', (route) => + route.fulfill({ status: 200, headers: { 'content-type': 'application/json' }, body: '{"ok":1}' }) + ); + await first.sidebar.item('Alpha').click(); + await first.requestPage.urlBar.tryButton.click(); + await expect(first.playground.view).toBeVisible(); + + await first.responsePane.sendButton.click(); + + await expect(first.responsePane.status).toHaveText('200 OK'); + await expect(second.playground.view).toHaveCount(0); + await expect(second.responsePane.status).toHaveCount(0); + await expect(page).toHaveURL(standalonePage.url('#/alpha?pg=1&dock=bottom&pgReq=alpha')); + }); +}); diff --git a/packages/bruno-api-docs/e2e/tests/standalone/update-collection-after-edits.spec.ts b/packages/bruno-api-docs/e2e/tests/standalone/update-collection-after-edits.spec.ts new file mode 100644 index 00000000..6738d8de --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/standalone/update-collection-after-edits.spec.ts @@ -0,0 +1,50 @@ +import { test, expect } from '../../playwright'; +import { generalFixtureCollection } from '../../../src/e2eFixtures/generalCollection'; + +const ALPHA_URL = '{{host}}/alpha?q={{special}}&m={{onlyInDev}}&t={{apiToken}}'; +const EDITED_URL = 'https://edited.example.com/alpha'; + +test.describe('updateCollection after playground edits', () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test.beforeEach(async ({ standalonePage, requestPage, envSwitcher, playground }) => { + await standalonePage.goto(); + await standalonePage.mount('first', { content: generalFixtureCollection }); + await requestPage.sidebar.item('Alpha').click(); + await envSwitcher.selectEnvironment('Prod'); + await requestPage.urlBar.tryButton.click(); + await playground.view.waitFor({ state: 'visible' }); + await playground.urlInput.fill(EDITED_URL); + }); + + test('the same collection again empties the open playground request but keeps the URL, docs page and environment', async ({ + page, + standalonePage, + requestPage, + envSwitcher, + playground + }) => { + await expect(playground.urlInput).toHaveValue(EDITED_URL); + + await standalonePage.updateCollection('first', generalFixtureCollection); + + await expect(playground.view.getByText('Select an endpoint from the sidebar to get started.')).toBeVisible(); + await expect(playground.urlInput).toHaveCount(0); + await expect(page).toHaveURL(standalonePage.url('#/alpha?pg=1&dock=bottom&pgReq=alpha')); + await expect(requestPage.title).toHaveText('Alpha'); + await expect(envSwitcher.trigger).toHaveText('Prod'); + await expect(playground.envSwitcher.trigger).toHaveText('Prod'); + }); + + test('the edit is dropped: reopening the request shows the collection\'s URL', async ({ + standalonePage, + playground + }) => { + await expect(playground.urlInput).toHaveValue(EDITED_URL); + + await standalonePage.updateCollection('first', generalFixtureCollection); + await playground.openRequest('Alpha'); + + await expect(playground.urlInput).toHaveValue(ALPHA_URL); + }); +}); diff --git a/packages/bruno-api-docs/e2e/tests/standalone/update-options.spec.ts b/packages/bruno-api-docs/e2e/tests/standalone/update-options.spec.ts new file mode 100644 index 00000000..68dfff55 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/standalone/update-options.spec.ts @@ -0,0 +1,94 @@ +import { test, expect } from '../../playwright'; +import { generalFixtureCollection } from '../../../src/e2eFixtures/generalCollection'; + +const THEME_STORAGE_KEY = 'oc-docs.theme'; +const SAVED_DARK = JSON.stringify({ variant: 'default', mode: 'dark' }); +const GIT_URL = 'https://github.com/usebruno/bruno-testbench.git'; +const FETCH_IN_BRUNO_HREF = `https://fetch.usebruno.com?url=${encodeURIComponent(GIT_URL)}`; + +test.use({ colorScheme: 'light' }); + +test.describe('updateOptions after mount', () => { + test.describe('theme and hideThemeSwitcher, with a saved dark choice', () => { + test.beforeEach(async ({ page, standalonePage }) => { + await page.addInitScript(({ key, value }) => { + localStorage.setItem(key, value); + }, { key: THEME_STORAGE_KEY, value: SAVED_DARK }); + await standalonePage.goto(); + await standalonePage.mount('first', { content: generalFixtureCollection }); + }); + + test('a host theme passed later applies its variant but not its mode over the saved choice', async ({ + standalonePage, + themeToggle + }) => { + await standalonePage.updateOptions('first', { theme: { variant: 'backstage', mode: 'light' } }); + + await expect(themeToggle.html).toHaveAttribute('data-theme', 'backstage-dark'); + await expect(themeToggle.button).toHaveAccessibleName('Switch to light theme'); + }); + + test('hiding the switcher later removes the toggle and follows the OS, ignoring the saved choice', async ({ + page, + standalonePage, + themeToggle, + pageHeader + }) => { + await standalonePage.updateOptions('first', { hideThemeSwitcher: true }); + + await expect(themeToggle.button).toHaveCount(0); + await expect(themeToggle.html).toHaveAttribute('data-theme', 'default-light'); + await expect(pageHeader.brandName).toHaveText('General Fixture'); + expect(await page.evaluate((key) => localStorage.getItem(key), THEME_STORAGE_KEY)).toBe(SAVED_DARK); + }); + + test('showing the switcher again brings back the toggle and the saved choice', async ({ standalonePage, themeToggle }) => { + await standalonePage.updateOptions('first', { hideThemeSwitcher: true }); + await expect(themeToggle.button).toHaveCount(0); + + await standalonePage.updateOptions('first', { hideThemeSwitcher: false }); + + await expect(themeToggle.button).toHaveAccessibleName('Switch to light theme'); + await expect(themeToggle.html).toHaveAttribute('data-theme', 'default-dark'); + }); + }); + + test.describe('gitCollectionUrl', () => { + test.beforeEach(async ({ standalonePage }) => { + await standalonePage.goto(); + await standalonePage.mount('first', { content: generalFixtureCollection }); + }); + + test('setting it later turns Open in Bruno into a Fetch-in-Bruno link', async ({ standalonePage, pageHeader }) => { + await expect(pageHeader.openInBruno).toHaveAttribute('type', 'button'); + + await standalonePage.updateOptions('first', { gitCollectionUrl: GIT_URL }); + + await expect(pageHeader.openInBruno).toHaveAttribute('href', FETCH_IN_BRUNO_HREF); + await expect(pageHeader.openInBruno).toHaveAttribute('target', '_blank'); + }); + + test('clearing it later turns Open in Bruno back into a button', async ({ standalonePage, pageHeader }) => { + await standalonePage.updateOptions('first', { gitCollectionUrl: GIT_URL }); + await expect(pageHeader.openInBruno).toHaveAttribute('href', FETCH_IN_BRUNO_HREF); + + await standalonePage.updateOptions('first', { gitCollectionUrl: null }); + + await expect(pageHeader.openInBruno).toHaveAttribute('type', 'button'); + await expect(pageHeader.openInBruno).not.toHaveAttribute('href', /.*/); + }); + + test('repositoryUrl, accepted by Bruno.apiDocs, is ignored by updateOptions', async ({ + standalonePage, + pageHeader, + themeToggle + }) => { + await standalonePage.updateOptions('first', { repositoryUrl: GIT_URL, theme: { variant: 'backstage' } }); + + await expect(themeToggle.html).toHaveAttribute('data-theme', 'backstage-light'); + await expect(pageHeader.openInBruno).toHaveAttribute('type', 'button'); + await expect(pageHeader.openInBruno).not.toHaveAttribute('href', /.*/); + await expect(pageHeader.brandName).toHaveText('General Fixture'); + }); + }); +}); diff --git a/packages/bruno-api-docs/e2e/tests/theming/theme-follows-components.spec.ts b/packages/bruno-api-docs/e2e/tests/theming/theme-follows-components.spec.ts new file mode 100644 index 00000000..6c5459f5 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/theming/theme-follows-components.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from '../../playwright'; + +const USERS_BODY = '{"data":[{"id":1,"name":"Alice"}]}'; + +test.describe('Components that cannot read CSS variables follow the theme toggle', () => { + test.use({ colorScheme: 'light', viewport: { width: 1280, height: 900 } }); + + test.beforeEach(async ({ playground, responsePane }) => { + await responsePane.mockUsersResponse(USERS_BODY); + await playground.open('bottom'); + await playground.openRequest('get users'); + await responsePane.send(); + await expect(responsePane.status).toBeVisible(); + }); + + test('the code editor switches between its light and dark theme', async ({ themeToggle, responsePane }) => { + await expect(responsePane.bodyEditorCanvas).toHaveClass(/\bvs\b/); + + await themeToggle.toggle(); + await expect(responsePane.bodyEditorCanvas).toHaveClass(/\bvs-dark\b/); + + await themeToggle.toggle(); + await expect(responsePane.bodyEditorCanvas).toHaveClass(/\bvs\b/); + }); + + test('the JSON preview switches between its light and dark theme', async ({ themeToggle, responsePane }) => { + await responsePane.openFormatSelector(); + await responsePane.togglePreview(); + await expect(responsePane.jsonPreview).toBeVisible(); + const icon = responsePane.jsonPreview.locator('.expanded-icon svg').first(); + const colour = () => icon.evaluate((el) => getComputedStyle(el).color); + + const light = await colour(); + await themeToggle.toggle(); + await expect.poll(colour).not.toBe(light); + + await themeToggle.toggle(); + await expect.poll(colour).toBe(light); + }); +}); diff --git a/packages/bruno-api-docs/package.json b/packages/bruno-api-docs/package.json index 8cc0213b..083ed101 100644 --- a/packages/bruno-api-docs/package.json +++ b/packages/bruno-api-docs/package.json @@ -34,15 +34,15 @@ "standalone.d.ts" ], "scripts": { - "gen:theme": "vite-node scripts/generate-theme-css.ts", + "gen:theme": "npm run gen:theme -w @usebruno/api-shared", "predev": "npm run gen:theme && npm run build:lib-bundle", "dev": "vite", "server": "node server.js", "dev:all": "concurrently \"npm run dev\" \"npm run server\"", - "build:lib-bundle": "vite build --config vite.config.libraries-bundle.ts", + "build:lib-bundle": "npm run build:lib-bundle -w @usebruno/api-playground", "prebuild": "npm run gen:theme && npm run build:lib-bundle", - "build": "npm run clean && tsc -p tsconfig.build.json --emitDeclarationOnly && vite build", - "build:standalone": "npm run clean:standalone && vite build --config vite.config.standalone.ts", + "build": "npm run clean && tsc -p tsconfig.build.json --noEmit && vite build", + "build:standalone": "npm run clean:standalone && npm run build:cdn -w @usebruno/api-playground && vite build --config vite.config.standalone.ts", "build:all": "npm run build && npm run build:standalone", "prepublishOnly": "npm run build:all", "preview": "vite preview", @@ -50,68 +50,25 @@ "test": "vitest", "pretest:run": "npm run build:lib-bundle", "test:run": "vitest run", - "test:e2e": "playwright test", + "test:e2e": "playwright test; a=$?; playwright test -c playwright.standalone.config.ts || exit 1; exit $a", + "test:e2e:standalone": "playwright test -c playwright.standalone.config.ts", "test:e2e:ui": "playwright test --ui", "clean": "rimraf dist", "clean:standalone": "rimraf dist-standalone" }, "dependencies": { - "@emotion/css": "^11.13.5", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@faker-js/faker": "^9.7.0", - "@microlink/react-json-view": "^1.31.22", - "@mintlify/httpsnippet": "^1.0.10", - "@monaco-editor/react": "^4.7.0", "@opencollection/types": "0.15.0", - "@prantlf/jsonlint": "^16.0.0", - "@reduxjs/toolkit": "^2.10.1", - "@tabler/icons": "^1.46.0", - "@tailwindcss/vite": "^4.0.9", - "@tippyjs/react": "^4.2.6", "@types/js-yaml": "^4.0.9", - "@types/markdown-it": "^14.1.2", - "@types/prismjs": "^1.26.5", - "ajv": "^8.12.0", - "ajv-formats": "^2.1.1", - "atob": "^2.1.2", - "btoa": "^1.2.1", - "buffer": "^6.0.3", - "chai": "~5.3.3", - "codemirror": "^6.0.2", - "crypto-js": "^4.2.0", - "fast-json-format": "~0.4.0", + "@usebruno/api-playground": "*", + "@usebruno/api-shared": "*", "fuse.js": "^7.5.0", - "highlight.js": "~11.8.0", - "js-md5": "^0.9.2", "js-yaml": "^4.1.0", - "jshint": "^2.13.6", - "jsonc-parser": "^3.3.1", - "jsonpath-plus": "^10.3.0", "lodash-es": "~4.17.21", - "markdown-it": "^14.1.0", - "markdown-it-task-lists": "^2.1.1", - "moment": "^2.30.1", - "monaco-editor": "^0.53.0", - "nanoid": "~3.3.11", - "node-html-parser": "^8.0.4", - "path-browserify": "^1.0.1", - "prettier": "^2.7.1", - "prismjs": "^1.29.0", - "quickjs-emscripten": "~0.32.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react-markdown": "^10.0.0", - "react-pdf": "^9.1.1", - "react-player": "^2.16.1", - "react-redux": "^9.2.0", - "react-router-dom": "^7.3.0", - "remark-gfm": "^4.0.1", - "strip-json-comments": "^3.1.1", - "tippy.js": "^6.3.1", - "tv4": "^1.3.0", - "uuid": "^10.0.0", - "xml-formatter": "^3.5.0" + "react-router-dom": "^7.3.0" }, "devDependencies": { "@emotion/babel-plugin": "~11.13.5", @@ -120,22 +77,19 @@ "@playwright/test": "^1.52.0", "@tailwindcss/postcss": "^4.1.13", "@tailwindcss/typography": "^0.5.10", - "@types/atob": "^2.1.4", - "@types/btoa": "^1.2.5", - "@types/crypto-js": "^4.2.2", "@types/express": "^4.17.21", "@types/lodash-es": "~4.17.12", "@types/node": "^26.2.0", - "@types/path-browserify": "^1.0.3", "@types/prismjs": "^1.26.3", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", - "@types/tv4": "^1.2.33", - "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", + "buffer": "^6.0.3", "concurrently": "^9.1.2", + "node-html-parser": "^8.0.4", "postcss": "^8.5.3", + "prismjs": "^1.29.0", "rimraf": "^6.0.1", "rollup": "^4.50.2", "tailwindcss": "^4.0.9", diff --git a/packages/bruno-api-docs/playwright.config.ts b/packages/bruno-api-docs/playwright.config.ts index 3ad373a3..4294f28b 100644 --- a/packages/bruno-api-docs/playwright.config.ts +++ b/packages/bruno-api-docs/playwright.config.ts @@ -3,6 +3,7 @@ import { appConfig } from './e2e/config/app.config'; export default defineConfig({ testDir: './e2e', + testIgnore: 'tests/standalone/**', tsconfig: './e2e/tsconfig.json', fullyParallel: true, forbidOnly: !!process.env.CI, diff --git a/packages/bruno-api-docs/playwright.standalone.config.ts b/packages/bruno-api-docs/playwright.standalone.config.ts new file mode 100644 index 00000000..93770516 --- /dev/null +++ b/packages/bruno-api-docs/playwright.standalone.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from '@playwright/test'; +import base from './playwright.config'; +import { appConfig } from './e2e/config/app.config'; + +const [, testServer] = base.webServer as NonNullable[]; + +export default defineConfig({ + ...base, + testDir: './e2e/tests/standalone', + testIgnore: undefined, + reporter: [ + ['list'], + ['html', { outputFolder: 'playwright-report/standalone' }], + ['json', { outputFile: 'playwright-report/standalone/results.json' }], + ], + webServer: [ + testServer, + { + command: 'npm run build:lib-bundle && npm run build:standalone && node e2e/server/test-server.mjs', + url: `${appConfig.standaloneURL}/health`, + env: { PORT: new URL(appConfig.standaloneURL).port }, + reuseExistingServer: !process.env.CI, + timeout: 300_000, + }, + ], +}); diff --git a/packages/bruno-api-docs/src/components/AppShell/AppShell.spec.tsx b/packages/bruno-api-docs/src/components/AppShell/AppShell.spec.tsx index 0511e82a..2a14dd68 100644 --- a/packages/bruno-api-docs/src/components/AppShell/AppShell.spec.tsx +++ b/packages/bruno-api-docs/src/components/AppShell/AppShell.spec.tsx @@ -1,13 +1,11 @@ import React from 'react'; -import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; import { describe, it, expect } from 'vitest'; import type { OpenCollection } from '@opencollection/types'; -import { createOpenCollectionStore } from '@/store/store'; -import { setDocsCollection } from '@/store/slices/docs'; -import { hydrateWithUUIDs } from '@/utils/fileUtils'; -import { useRenderToDom } from '@/hooks/useRenderToDom'; -import { getByTestId, queryByTestId } from '@/test-utils/dom'; +import { TestProviders } from '@usebruno/api-shared/test-utils/providers'; +import { hydrateWithUUIDs } from '@usebruno/api-shared/utils/fileUtils'; +import { useRenderToDom } from '@usebruno/api-shared/hooks/useRenderToDom'; +import { getByTestId, queryByTestId } from '@usebruno/api-shared/test-utils/dom'; import AppShell from './AppShell'; const collection = { @@ -21,14 +19,12 @@ const collection = { } as unknown as OpenCollection; const useAppShellAt = (url: string, hidePlayground: boolean) => { - const store = createOpenCollectionStore(); - store.dispatch(setDocsCollection(hydrateWithUUIDs(collection))); return useRenderToDom( - + - + ); }; diff --git a/packages/bruno-api-docs/src/components/AppShell/AppShell.tsx b/packages/bruno-api-docs/src/components/AppShell/AppShell.tsx index 695a85c6..b98f78ec 100644 --- a/packages/bruno-api-docs/src/components/AppShell/AppShell.tsx +++ b/packages/bruno-api-docs/src/components/AppShell/AppShell.tsx @@ -1,19 +1,19 @@ import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useLocation } from 'react-router-dom'; import Topbar from '../Topbar/Topbar'; -import EnvSwitcher from '../EnvSwitcher/EnvSwitcher'; +import EnvSwitcher from '@usebruno/api-shared/components/EnvSwitcher/EnvSwitcher'; import ShowVarsToggle from '../ShowVarsToggle/ShowVarsToggle'; import ThemeToggle from '../ThemeToggle/ThemeToggle'; import Sidebar from '../Docs/Sidebar/Sidebar'; +import { useFolderCollapse } from '../Docs/Sidebar/hooks/useFolderCollapse'; import SidebarDrawer from '../SidebarDrawer/SidebarDrawer'; -import IconButton from '@/ui/IconButton/IconButton'; -import { ChevronLeftIcon, ChevronRightIcon } from '@/assets/icons'; +import IconButton from '@usebruno/api-shared/ui/IconButton/IconButton'; +import { ChevronLeftIcon, ChevronRightIcon } from '@usebruno/api-shared/assets/icons'; import PageRouter from '../PageRouter/PageRouter'; 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, selectCollectionSourceText } from '@/store/slices/app'; +import { usePlaygroundUrlState, useElementWidth, useResizableSidebar } from '@usebruno/api-shared/hooks'; +import { useSearchHotkey } from '@/hooks/useSearchHotkey'; +import { useDocument } from '@usebruno/api-shared/document/document'; import { useActiveResolution } from '@/routing/hooks'; import { layoutModeForWidth } from '@/hooks/useTopbarLayout'; import { buildFetchInBrunoUrl } from '@/utils/buildFetchInBrunoUrl'; @@ -27,17 +27,20 @@ interface AppShellProps { testId?: string; hideThemeSwitcher?: boolean; hidePlayground?: boolean; + gitCollectionUrl?: string; + collectionSourceText?: string | null; } const AppShell: React.FC = ({ logo, testId = 'app-shell', hideThemeSwitcher = false, - hidePlayground = false + hidePlayground = false, + gitCollectionUrl, + collectionSourceText = null }) => { - const collection = useAppSelector(selectDocsCollection); - const gitCollectionUrl = useAppSelector(selectGitCollectionUrl); - const collectionSourceText = useAppSelector(selectCollectionSourceText); + const collection = useDocument(); + const folders = useFolderCollapse(collection); const openInBrunoHref = buildFetchInBrunoUrl(gitCollectionUrl); const [openInBrunoOpen, setOpenInBrunoOpen] = useState(false); const closeOpenInBruno = useCallback(() => setOpenInBrunoOpen(false), []); @@ -161,7 +164,7 @@ const AppShell: React.FC = ({ {isDesktop && !sidebarCollapsed && ( <>
= ({ {!isDesktop && ( - + )} diff --git a/packages/bruno-api-docs/src/components/AuthDetails/AuthDetails.spec.tsx b/packages/bruno-api-docs/src/components/AuthDetails/AuthDetails.spec.tsx index 1cd7f0a6..014a73cd 100644 --- a/packages/bruno-api-docs/src/components/AuthDetails/AuthDetails.spec.tsx +++ b/packages/bruno-api-docs/src/components/AuthDetails/AuthDetails.spec.tsx @@ -3,8 +3,8 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { parse } from 'node-html-parser'; import { describe, it, expect } from 'vitest'; import { AuthDetails } from './AuthDetails'; -import { AUTH_MODE_LABELS } from '@/constants'; -import { SECRET_MASK } from '@/constants'; +import { AUTH_MODE_LABELS } from '@usebruno/api-shared/constants'; +import { SECRET_MASK } from '@usebruno/api-shared/constants'; const renderAuth = (ui: React.ReactElement) => { const html = renderToStaticMarkup(ui); diff --git a/packages/bruno-api-docs/src/components/AuthDetails/AuthDetails.tsx b/packages/bruno-api-docs/src/components/AuthDetails/AuthDetails.tsx index f8355e71..0793122f 100644 --- a/packages/bruno-api-docs/src/components/AuthDetails/AuthDetails.tsx +++ b/packages/bruno-api-docs/src/components/AuthDetails/AuthDetails.tsx @@ -8,8 +8,8 @@ import type { OAuth2Settings, OAuth2AdditionalParameter } from '@opencollection/types/common/auth'; -import { PropertyTable, type PropertyRow } from '../PropertyTable/PropertyTable'; -import { ADDITIONAL_PARAM_GROUPS, AUTH_TYPES } from '@/constants'; +import { PropertyTable, type PropertyRow } from '@usebruno/api-shared/components/PropertyTable/PropertyTable'; +import { ADDITIONAL_PARAM_GROUPS, AUTH_TYPES } from '@usebruno/api-shared/constants'; interface AuthDetailsProps { auth?: Auth; diff --git a/packages/bruno-api-docs/src/components/Code/CodeViewer/CodeViewer.tsx b/packages/bruno-api-docs/src/components/Code/CodeViewer/CodeViewer.tsx deleted file mode 100644 index 0815be3e..00000000 --- a/packages/bruno-api-docs/src/components/Code/CodeViewer/CodeViewer.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, { useEffect, useMemo, useRef } from 'react'; -import { CopyButton } from '@/ui/CopyButton/CopyButton'; -import { StyledWrapper } from './StyledWrapper'; - -import Prism from '@/utils/prism'; - -export interface CodeViewerProps { - code?: string; - language?: string; - showLineNumbers?: boolean; - showCopy?: boolean; - testId?: string; - className?: string; -} - -const LineNumbers: React.FC<{ count: number }> = ({ count }) => ( - -); - -export const CodeViewer: React.FC = ({ - code = '', - language = 'text', - showLineNumbers = false, - showCopy = true, - testId, - className -}) => { - const preRef = useRef(null); - - useEffect(() => { - if (preRef.current) { - Prism.highlightAllUnder(preRef.current); - } - }, [code, language]); - - const lineCount = useMemo(() => (code ? code.split('\n').length : 1), [code]); - - const codeEl = ( -
-      {code}
-    
- ); - - return ( - -
- {showCopy && ( - - )} - - {showLineNumbers ? ( -
- -
{codeEl}
-
- ) : ( -
{codeEl}
- )} -
-
- ); -}; - -export default CodeViewer; diff --git a/packages/bruno-api-docs/src/components/Docs/Method/Method.tsx b/packages/bruno-api-docs/src/components/Docs/Method/Method.tsx deleted file mode 100644 index 00d76b94..00000000 --- a/packages/bruno-api-docs/src/components/Docs/Method/Method.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; -import { getShortMethod } from '@/utils/request'; -import { StyledWrapper } from './StyledWrapper'; - -interface MethodProps { - method: string; - className?: string; -} - -export const Method: React.FC = ({ method, className = '' }) => { - const normalizedMethod = method?.toLowerCase() || 'get'; - const displayMethod = getShortMethod(method || 'GET'); - - return ( - - {displayMethod} - - ); -}; - -export default Method; diff --git a/packages/bruno-api-docs/src/components/Docs/Method/StyledWrapper.ts b/packages/bruno-api-docs/src/components/Docs/Method/StyledWrapper.ts deleted file mode 100644 index db831b0f..00000000 --- a/packages/bruno-api-docs/src/components/Docs/Method/StyledWrapper.ts +++ /dev/null @@ -1,51 +0,0 @@ -import styled from '@emotion/styled'; - -export const StyledWrapper = styled.div` - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0.1rem 0.4rem; - font-size: 9px; - font-weight: 700; - border-radius: var(--oc-radius); - text-transform: uppercase; - font-family: var(--font-sans); - min-width: 36px; - margin-right: 8px; - letter-spacing: 0.05em; - - &.get { - background-color: color-mix(in srgb, var(--oc-request-methods-get) 10%, transparent); - color: var(--oc-request-methods-get); - } - - &.post { - background-color: color-mix(in srgb, var(--oc-request-methods-post) 10%, transparent); - color: var(--oc-request-methods-post); - } - - &.put { - background-color: color-mix(in srgb, var(--oc-request-methods-put) 10%, transparent); - color: var(--oc-request-methods-put); - } - - &.delete { - background-color: color-mix(in srgb, var(--oc-request-methods-delete) 10%, transparent); - color: var(--oc-request-methods-delete); - } - - &.patch { - background-color: color-mix(in srgb, var(--oc-request-methods-patch) 10%, transparent); - color: var(--oc-request-methods-patch); - } - - &.options { - background-color: color-mix(in srgb, var(--oc-request-methods-options) 10%, transparent); - color: var(--oc-request-methods-options); - } - - &.head { - background-color: color-mix(in srgb, var(--oc-request-methods-head) 10%, transparent); - color: var(--oc-request-methods-head); - } -`; 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..53c46a09 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 @@ -1,12 +1,11 @@ import React from 'react'; import { describe, it, expect } from 'vitest'; -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 { useRenderToDom } from '@/hooks/useRenderToDom'; -import { query } from '@/test-utils/dom'; +import { TestProviders } from '@usebruno/api-shared/test-utils/providers'; +import { hydrateWithUUIDs } from '@usebruno/api-shared/utils/fileUtils'; +import { useRenderToDom } from '@usebruno/api-shared/hooks/useRenderToDom'; +import { query } from '@usebruno/api-shared/test-utils/dom'; const collection = { info: { name: 'C' }, @@ -19,14 +18,12 @@ const collection = { // (/) - exactly the URL a sidebar example click // navigates to. const buildSidebar = () => { - const store = createOpenCollectionStore(); - store.dispatch(setDocsCollection(collection)); return ( - + - + true, toggle: () => {}, expand: () => {} }} /> - + ); }; 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..c6e164c4 100644 --- a/packages/bruno-api-docs/src/components/Docs/Sidebar/Sidebar.tsx +++ b/packages/bruno-api-docs/src/components/Docs/Sidebar/Sidebar.tsx @@ -1,27 +1,26 @@ -import React, { useEffect, useMemo, useRef } from 'react'; +import React, { useEffect, useRef } from 'react'; import { useLocation } from 'react-router-dom'; -import SidebarNavLink from './SidebarNavLink/SidebarNavLink'; -import SidebarTree from './SidebarTree/SidebarTree'; -import { CubeIcon, GlobeIcon } from '@/assets/icons'; +import SidebarNavLink from '@usebruno/api-shared/components/Docs/Sidebar/SidebarNavLink/SidebarNavLink'; +import SidebarTree from '@usebruno/api-shared/components/Docs/Sidebar/SidebarTree/SidebarTree'; +import { CubeIcon, GlobeIcon } from '@usebruno/api-shared/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 { getItemUuid } from '@/utils/itemUtils'; -import { useNavModel } from '@/routing/hooks'; +import { useDocument, useNavModel } from '@usebruno/api-shared/document/document'; import { normalizeSlug, resolveSlug } from '@/routing/resolve'; -import { OVERVIEW_SLUG, ENVIRONMENTS_SLUG } from '@/routing/navModel'; -import { useDocsNavigate, useAutoHideScrollbar, useIsMobileDevice } from '@/hooks'; +import { OVERVIEW_SLUG, ENVIRONMENTS_SLUG } from '@usebruno/api-shared/routing/navModel'; +import { useAutoHideScrollbar, useIsMobileDevice } from '@usebruno/api-shared/hooks'; +import { useDocsNavigate } from '@/hooks/useDocsNavigate'; import { useActiveExample } from './hooks/useActiveExample'; +import type { FolderCollapse } from './hooks/useFolderCollapse'; interface SidebarProps { + folders: FolderCollapse; onNavigate?: () => void; testId?: string; } -const Sidebar: React.FC = ({ onNavigate, testId = 'sidebar' }) => { - const dispatch = useAppDispatch(); - const collection = useAppSelector(selectDocsCollection); +const Sidebar: React.FC = ({ folders, onNavigate, testId = 'sidebar' }) => { + const collection = useDocument(); const model = useNavModel(); const docsNavigate = useDocsNavigate(); const { pathname } = useLocation(); @@ -34,16 +33,7 @@ const Sidebar: React.FC = ({ onNavigate, testId = 'sidebar' }) => onNavigate?.(); }; - const uuidToSlug = useMemo>(() => { - const map = new Map(); - for (const entry of model.ordered) { - const uuid = getItemUuid(entry.item); - if (uuid) map.set(uuid, entry.slug); - } - return map; - }, [model]); - - const { activeExample, goToExample } = useActiveExample(model, uuidToSlug, onNavigate); + const { activeExample, goToExample } = useActiveExample(model, onNavigate); // Show the scrollbar while the list is in use, then fade it out after 1s idle. // Shared with the playground sidebar so both behave the same. @@ -54,6 +44,7 @@ const Sidebar: React.FC = ({ onNavigate, testId = 'sidebar' }) => // window size, so a narrow laptop window keeps the normal 13px. const isMobileDevice = useIsMobileDevice(); + const { expand } = folders; const autoRevealedSlug = useRef(null); useEffect(() => { // Open the folders around the active item so it shows up in the tree. On @@ -62,8 +53,8 @@ const Sidebar: React.FC = ({ onNavigate, testId = 'sidebar' }) => // opens the folders then, instead of leaving them closed. const { claim, uuids } = computeAutoReveal(autoRevealedSlug.current, activeSlug, model); if (claim) autoRevealedSlug.current = activeSlug; - if (uuids.length) dispatch(expandFolders(uuids)); - }, [activeSlug, model, dispatch]); + if (uuids.length) expand(uuids); + }, [activeSlug, model, expand]); return ( @@ -95,10 +86,11 @@ const Sidebar: React.FC = ({ onNavigate, testId = 'sidebar' }) => // While an example is highlighted, only the example row is active, // not its parent request row (even though we navigated to that page). activeSlug={activeExample ? '' : activeSlug} - uuidToSlug={uuidToSlug} + uuidToSlug={model.slugByUuid} onNavigate={goTo} - onToggleFolder={(uuid) => dispatch(toggleItem(uuid))} - onExpandFolder={(uuid) => dispatch(expandFolders([uuid]))} + onToggleFolder={folders.toggle} + onExpandFolder={(uuid) => expand([uuid])} + isFolderCollapsed={folders.isCollapsed} activeExample={activeExample} onExampleClick={goToExample} /> diff --git a/packages/bruno-api-docs/src/components/Docs/Sidebar/autoReveal.spec.ts b/packages/bruno-api-docs/src/components/Docs/Sidebar/autoReveal.spec.ts index 75fcfbdf..6bb0c611 100644 --- a/packages/bruno-api-docs/src/components/Docs/Sidebar/autoReveal.spec.ts +++ b/packages/bruno-api-docs/src/components/Docs/Sidebar/autoReveal.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { computeAutoReveal } from './autoReveal'; -import type { NavModel, NavEntry } from '@/routing/types'; +import type { NavModel, NavEntry } from '@usebruno/api-shared/routing/types'; import type { Item as OpenCollectionItem } from '@opencollection/types/collection/item'; const item = (uuid: string): OpenCollectionItem => ({ uuid } as unknown as OpenCollectionItem); @@ -29,11 +29,12 @@ const loadedModel: NavModel = { bySlug: new Map([ [folderEntry.slug, folderEntry], [requestEntry.slug, requestEntry] - ]) + ]), + slugByUuid: new Map() }; // The model before the collection has hydrated (reload): nothing resolves yet. -const emptyModel: NavModel = { ordered: [], bySlug: new Map() }; +const emptyModel: NavModel = { ordered: [], bySlug: new Map(), slugByUuid: new Map() }; describe('computeAutoReveal', () => { it('does NOT claim or expand when the model cannot resolve the slug yet', () => { diff --git a/packages/bruno-api-docs/src/components/Docs/Sidebar/autoReveal.ts b/packages/bruno-api-docs/src/components/Docs/Sidebar/autoReveal.ts index cd94f43f..c85d6bca 100644 --- a/packages/bruno-api-docs/src/components/Docs/Sidebar/autoReveal.ts +++ b/packages/bruno-api-docs/src/components/Docs/Sidebar/autoReveal.ts @@ -1,5 +1,5 @@ -import type { NavModel } from '@/routing/types'; -import { getItemUuid } from '@/utils/itemUtils'; +import type { NavModel } from '@usebruno/api-shared/routing/types'; +import { getItemUuid } from '@usebruno/api-shared/utils/itemUtils'; export interface AutoRevealResult { /** True once the slug can be resolved, telling the caller to remember it as diff --git a/packages/bruno-api-docs/src/components/Docs/Sidebar/hooks/useActiveExample.ts b/packages/bruno-api-docs/src/components/Docs/Sidebar/hooks/useActiveExample.ts index 99e16fc6..e5f0a7f6 100644 --- a/packages/bruno-api-docs/src/components/Docs/Sidebar/hooks/useActiveExample.ts +++ b/packages/bruno-api-docs/src/components/Docs/Sidebar/hooks/useActiveExample.ts @@ -1,11 +1,11 @@ -import { getItemUuid } from '@/utils/itemUtils'; -import { useDocsNavigate } from '@/hooks'; +import { getItemUuid } from '@usebruno/api-shared/utils/itemUtils'; +import { useDocsNavigate } from '@/hooks/useDocsNavigate'; import { useActiveResolution } from '@/routing/hooks'; -import { exampleSlugForIndex } from '@/routing/slug'; -import type { NavModel } from '@/routing'; +import { exampleSlugForIndex } from '@usebruno/api-shared/routing/slug'; +import type { NavModel } from '@usebruno/api-shared/routing'; import type { HttpRequest } from '@opencollection/types/requests/http'; -export function useActiveExample(model: NavModel, uuidToSlug: Map, onNavigate?: () => void) { +export function useActiveExample(model: NavModel, onNavigate?: () => void) { // The active example is read from the route (/), // so it is shareable/deep-linked, survives reload, and back/forward restore it. const resolution = useActiveResolution(); @@ -17,7 +17,7 @@ export function useActiveExample(model: NavModel, uuidToSlug: Map { - const slug = uuidToSlug.get(requestUuid); + const slug = model.slugByUuid.get(requestUuid); if (slug == null) return; const item = model.bySlug.get(slug)?.item as HttpRequest | null; const exampleSlug = exampleSlugForIndex(item, index); diff --git a/packages/bruno-api-docs/src/components/Docs/Sidebar/hooks/useFolderCollapse.spec.tsx b/packages/bruno-api-docs/src/components/Docs/Sidebar/hooks/useFolderCollapse.spec.tsx new file mode 100644 index 00000000..d7a05d2f --- /dev/null +++ b/packages/bruno-api-docs/src/components/Docs/Sidebar/hooks/useFolderCollapse.spec.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect, vi } from 'vitest'; +import type { OpenCollection } from '@opencollection/types'; +import { DocumentProvider, useDocument, useNavModel } from '@usebruno/api-shared/document/document'; +import * as navModel from '@usebruno/api-shared/routing/navModel'; +import { useFolderCollapse } from './useFolderCollapse'; + +vi.mock('@usebruno/api-shared/routing/navModel', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, buildNavModel: vi.fn(actual.buildNavModel) }; +}); + +const collection = { + info: { name: 'C' }, + items: [{ uuid: 'f1', info: { name: 'Folder', type: 'folder' }, items: [] }] +} as unknown as OpenCollection; + +const renderToggling = (toggles: number) => { + const seen: boolean[] = []; + + // A state update during render re-renders the same component with its hooks + // kept, so each toggle here is a real re-render in which memos survive. + const Probe = () => { + useNavModel(); + const document = useDocument(); + const folders = useFolderCollapse(document); + seen.push(folders.isCollapsed(document.items![0])); + if (seen.length <= toggles) folders.toggle('f1'); + return null; + }; + + renderToStaticMarkup(); + return seen; +}; + +describe('useFolderCollapse', () => { + it('toggles a folder without rebuilding the nav model', () => { + vi.mocked(navModel.buildNavModel).mockClear(); + expect(renderToggling(2)).toEqual([true, false, true]); + expect(navModel.buildNavModel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/bruno-api-docs/src/components/Docs/Sidebar/hooks/useFolderCollapse.ts b/packages/bruno-api-docs/src/components/Docs/Sidebar/hooks/useFolderCollapse.ts new file mode 100644 index 00000000..1498411b --- /dev/null +++ b/packages/bruno-api-docs/src/components/Docs/Sidebar/hooks/useFolderCollapse.ts @@ -0,0 +1,58 @@ +import { useCallback, useMemo, useState } from 'react'; +import type { OpenCollection } from '@opencollection/types'; +import type { Item } from '@opencollection/types/collection/item'; +import { findItemByUuid, getItemUuid } from '@usebruno/api-shared/utils/itemUtils'; + +export interface FolderCollapse { + isCollapsed: (folder: Item) => boolean; + toggle: (uuid: string) => void; + expand: (uuids: string[]) => void; +} + +const collapsedByDefault = (folder: Item | null): boolean => + (folder as { isCollapsed?: boolean } | null)?.isCollapsed ?? true; + +export const useFolderCollapse = (document: OpenCollection | null): FolderCollapse => { + const [state, setState] = useState({ document, collapsed: new Map() }); + const collapsed = state.document === document ? state.collapsed : null; + + const update = useCallback( + (apply: (collapsed: Map) => boolean | void) => + setState((prev) => { + const next = new Map(prev.document === document ? prev.collapsed : undefined); + if (apply(next) === false && prev.document === document) return prev; + return { document, collapsed: next }; + }), + [document] + ); + + const toggle = useCallback( + (uuid: string) => + update((next) => { + next.set(uuid, !(next.get(uuid) ?? collapsedByDefault(findItemByUuid(document?.items, uuid)))); + }), + [update, document] + ); + + const expand = useCallback( + (uuids: string[]) => + update((next) => { + const open = (uuid: string) => !(next.get(uuid) ?? collapsedByDefault(findItemByUuid(document?.items, uuid))); + if (uuids.every(open)) return false; + uuids.forEach((uuid) => next.set(uuid, false)); + }), + [update, document] + ); + + return useMemo( + () => ({ + isCollapsed: (folder: Item) => { + const uuid = getItemUuid(folder); + return uuid !== undefined && collapsed?.has(uuid) ? collapsed.get(uuid)! : collapsedByDefault(folder); + }, + toggle, + expand + }), + [collapsed, toggle, expand] + ); +}; diff --git a/packages/bruno-api-docs/src/components/ErrorBoundary/ErrorBoundary.spec.tsx b/packages/bruno-api-docs/src/components/ErrorBoundary/ErrorBoundary.spec.tsx index cdc23bff..3c7f65f3 100644 --- a/packages/bruno-api-docs/src/components/ErrorBoundary/ErrorBoundary.spec.tsx +++ b/packages/bruno-api-docs/src/components/ErrorBoundary/ErrorBoundary.spec.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { describe, it, expect } from 'vitest'; -import { useRenderToDom } from '@/hooks/useRenderToDom'; -import { query } from '@/test-utils/dom'; +import { useRenderToDom } from '@usebruno/api-shared/hooks/useRenderToDom'; +import { query } from '@usebruno/api-shared/test-utils/dom'; import { ErrorBoundary } from './ErrorBoundary'; describe('ErrorBoundary', () => { diff --git a/packages/bruno-api-docs/src/components/Examples/ExampleCard/ExampleCard.tsx b/packages/bruno-api-docs/src/components/Examples/ExampleCard/ExampleCard.tsx index a06a126c..583a1820 100644 --- a/packages/bruno-api-docs/src/components/Examples/ExampleCard/ExampleCard.tsx +++ b/packages/bruno-api-docs/src/components/Examples/ExampleCard/ExampleCard.tsx @@ -1,23 +1,23 @@ import React, { Fragment, useEffect, useId, useMemo, useRef, useState } from 'react'; import type { HttpRequestExample } from '@opencollection/types/requests/http'; -import { MethodBadge } from '../../MethodBadge/MethodBadge'; +import { MethodBadge } from '@usebruno/api-shared/components/MethodBadge/MethodBadge'; import { ChevronArrow } from '../../ChevronArrow/ChevronArrow'; -import { CopyButton } from '@/ui/CopyButton/CopyButton'; -import { PropertyTable } from '../../PropertyTable/PropertyTable'; -import { TruncatedText } from '../../TruncatedText/TruncatedText'; -import { VariableText } from '../../VariableText/VariableText'; -import { useResolvedVariables } from '@/hooks/useVariableResolver'; -import { Description } from '../../Description/Description'; -import { RequestParams } from '../../Request/RequestParams/RequestParams'; -import { RequestBody } from '../../Request/RequestBody/RequestBody'; -import { Code } from '../../Code/Code'; -import { resolvePathAndQueryParams } from '@/utils/pathParams'; -import { getBodyView, getDescription, headerRows } from '@/utils/request'; -import { computeBodySize, formatBytes, responseBodyLanguage, responseBodyContentType, statusCodePhrase } from '@/utils/exampleResponse'; -import { statusToneColor } from '@/utils/common'; +import { CopyButton } from '@usebruno/api-shared/ui/CopyButton/CopyButton'; +import { PropertyTable } from '@usebruno/api-shared/components/PropertyTable/PropertyTable'; +import { TruncatedText } from '@usebruno/api-shared/components/TruncatedText/TruncatedText'; +import { VariableText } from '@usebruno/api-shared/components/VariableText/VariableText'; +import { useResolvedVariables } from '@usebruno/api-shared/hooks/useVariableResolver'; +import { Description } from '@usebruno/api-shared/components/Description/Description'; +import { RequestParams } from '@usebruno/api-shared/components/Request/RequestParams/RequestParams'; +import { RequestBody } from '@usebruno/api-shared/components/Request/RequestBody/RequestBody'; +import { Code } from '@usebruno/api-shared/components/Code/Code'; +import { resolvePathAndQueryParams } from '@usebruno/api-shared/utils/pathParams'; +import { getBodyView, getDescription, headerRows } from '@usebruno/api-shared/utils/request'; +import { computeBodySize, formatBytes, responseBodyLanguage, responseBodyContentType, statusCodePhrase } from '@usebruno/api-shared/utils/exampleResponse'; +import { statusToneColor } from '@usebruno/api-shared/utils/common'; import { StyledWrapper } from './StyledWrapper'; import type { Auth } from '@opencollection/types/common/auth'; -import { CodeSnippetTabs } from '../../CodeSnippetTabs/CodeSnippetTabs'; +import { CodeSnippetTabs } from '@usebruno/api-shared/components/CodeSnippetTabs/CodeSnippetTabs'; interface ExampleCardProps { example: HttpRequestExample; diff --git a/packages/bruno-api-docs/src/components/Examples/Examples.spec.tsx b/packages/bruno-api-docs/src/components/Examples/Examples.spec.tsx index 6f7dc3dc..1b9e34dd 100644 --- a/packages/bruno-api-docs/src/components/Examples/Examples.spec.tsx +++ b/packages/bruno-api-docs/src/components/Examples/Examples.spec.tsx @@ -3,8 +3,8 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { describe, it, expect } from 'vitest'; import { Examples } from './Examples'; import type { HttpRequestExample } from '@opencollection/types/requests/http'; -import { useRenderToDom } from '@/hooks/useRenderToDom'; -import { query } from '@/test-utils/dom'; +import { useRenderToDom } from '@usebruno/api-shared/hooks/useRenderToDom'; +import { query } from '@usebruno/api-shared/test-utils/dom'; const examples: HttpRequestExample[] = [ { name: 'Happy path', response: { status: 200, body: { type: 'json', data: '{}' } } }, diff --git a/packages/bruno-api-docs/src/components/ExecutionContext/AssertList/AssertList.spec.tsx b/packages/bruno-api-docs/src/components/ExecutionContext/AssertList/AssertList.spec.tsx index c50a3dbe..f5a85ab2 100644 --- a/packages/bruno-api-docs/src/components/ExecutionContext/AssertList/AssertList.spec.tsx +++ b/packages/bruno-api-docs/src/components/ExecutionContext/AssertList/AssertList.spec.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { describe, it, expect } from 'vitest'; import { AssertList } from './AssertList'; -import { useRenderToDom } from '@/hooks/useRenderToDom'; +import { useRenderToDom } from '@usebruno/api-shared/hooks/useRenderToDom'; import type { AssertionRow } from '@/utils/assertions'; const binary: AssertionRow = { level: 'request', expression: 'res.status', operatorLabel: 'equals', value: '200', isUnary: false, description: 'Status must be OK' }; diff --git a/packages/bruno-api-docs/src/components/ExecutionContext/AssertList/AssertList.tsx b/packages/bruno-api-docs/src/components/ExecutionContext/AssertList/AssertList.tsx index 697e750b..c2d93bf5 100644 --- a/packages/bruno-api-docs/src/components/ExecutionContext/AssertList/AssertList.tsx +++ b/packages/bruno-api-docs/src/components/ExecutionContext/AssertList/AssertList.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { ScopeTag } from '../ScopeTag/ScopeTag'; -import { DisabledBadge } from '../../DisabledBadge/DisabledBadge'; -import { VariableText } from '../../VariableText/VariableText'; -import { TruncatedText } from '../../TruncatedText/TruncatedText'; -import { Description } from '../../Description/Description'; +import { DisabledBadge } from '@usebruno/api-shared/components/DisabledBadge/DisabledBadge'; +import { VariableText } from '@usebruno/api-shared/components/VariableText/VariableText'; +import { TruncatedText } from '@usebruno/api-shared/components/TruncatedText/TruncatedText'; +import { Description } from '@usebruno/api-shared/components/Description/Description'; import type { AssertionRow } from '@/utils/assertions'; import { StyledWrapper } from './StyledWrapper'; diff --git a/packages/bruno-api-docs/src/components/ExecutionContext/ExecutionContext.spec.tsx b/packages/bruno-api-docs/src/components/ExecutionContext/ExecutionContext.spec.tsx index f39abdd5..4861edfb 100644 --- a/packages/bruno-api-docs/src/components/ExecutionContext/ExecutionContext.spec.tsx +++ b/packages/bruno-api-docs/src/components/ExecutionContext/ExecutionContext.spec.tsx @@ -1,11 +1,11 @@ import React from 'react'; import { describe, it, expect } from 'vitest'; import { ExecutionContext } from './ExecutionContext'; -import { useRenderToDom } from '@/hooks/useRenderToDom'; -import { query } from '@/test-utils/dom'; -import type { ScriptChainStep } from '@/utils/request'; +import { useRenderToDom } from '@usebruno/api-shared/hooks/useRenderToDom'; +import { query } from '@usebruno/api-shared/test-utils/dom'; +import type { ScriptChainStep } from '@usebruno/api-shared/utils/request'; import type { AssertionRow } from '@/utils/assertions'; -import type { TestRow, RawTestScript } from '@/utils/fileUtils'; +import type { TestRow, RawTestScript } from '@usebruno/api-shared/utils/fileUtils'; const scriptChain: ScriptChainStep[] = [ { level: 'collection', phase: 'before-request', label: 'Collection Pre-Request', sourceName: 'API', code: 'bru.setVar("x", 1)', order: 0 }, diff --git a/packages/bruno-api-docs/src/components/ExecutionContext/ExecutionContext.tsx b/packages/bruno-api-docs/src/components/ExecutionContext/ExecutionContext.tsx index ae884caf..62864f4f 100644 --- a/packages/bruno-api-docs/src/components/ExecutionContext/ExecutionContext.tsx +++ b/packages/bruno-api-docs/src/components/ExecutionContext/ExecutionContext.tsx @@ -1,11 +1,11 @@ import React from 'react'; -import { Tabs, type Tab } from '@/ui/Tabs/Tabs'; +import { Tabs, type Tab } from '@usebruno/api-shared/ui/Tabs/Tabs'; import { ScriptChain } from './ScriptChain/ScriptChain'; import { VariablesPanel } from './VariablesPanel/VariablesPanel'; import { AssertList } from './AssertList/AssertList'; import { TestList } from './TestList/TestList'; import { ViewAllTests } from './ViewAllTests/ViewAllTests'; -import { ContentTypeBadge } from '../ContentTypeBadge/ContentTypeBadge'; +import { ContentTypeBadge } from '@usebruno/api-shared/components/ContentTypeBadge/ContentTypeBadge'; import { StyledWrapper } from './StyledWrapper'; import { inheritedCountLabel, @@ -15,9 +15,9 @@ import { type PostResponseVarRow, type InheritedPreRequestVarRow, type InheritedPostResponseVarRow -} from '@/utils/request'; +} from '@usebruno/api-shared/utils/request'; import type { AssertionRow } from '@/utils/assertions'; -import type { TestRow, RawTestScript } from '@/utils/fileUtils'; +import type { TestRow, RawTestScript } from '@usebruno/api-shared/utils/fileUtils'; interface ExecutionContextProps { scriptChain: ScriptChainStep[]; diff --git a/packages/bruno-api-docs/src/components/ExecutionContext/ScriptChain/ScriptChain.spec.tsx b/packages/bruno-api-docs/src/components/ExecutionContext/ScriptChain/ScriptChain.spec.tsx index 5b91eb5d..1ac55e10 100644 --- a/packages/bruno-api-docs/src/components/ExecutionContext/ScriptChain/ScriptChain.spec.tsx +++ b/packages/bruno-api-docs/src/components/ExecutionContext/ScriptChain/ScriptChain.spec.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, it, expect } from 'vitest'; import { ScriptChain } from './ScriptChain'; -import type { ScriptChainStep } from '@/utils/request'; +import type { ScriptChainStep } from '@usebruno/api-shared/utils/request'; const postChain: ScriptChainStep[] = [ { level: 'collection', phase: 'after-response', label: 'Collection Post-Response', code: 'a', order: 0 }, diff --git a/packages/bruno-api-docs/src/components/ExecutionContext/ScriptChain/ScriptChain.tsx b/packages/bruno-api-docs/src/components/ExecutionContext/ScriptChain/ScriptChain.tsx index 714b8091..dd8f8cec 100644 --- a/packages/bruno-api-docs/src/components/ExecutionContext/ScriptChain/ScriptChain.tsx +++ b/packages/bruno-api-docs/src/components/ExecutionContext/ScriptChain/ScriptChain.tsx @@ -1,6 +1,6 @@ import React, { useMemo } from 'react'; import { ScriptStep } from '../ScriptStep/ScriptStep'; -import type { ScriptChainStep, ScriptFlow } from '@/utils/request'; +import type { ScriptChainStep, ScriptFlow } from '@usebruno/api-shared/utils/request'; import { StyledWrapper } from './StyledWrapper'; interface ScriptChainProps { diff --git a/packages/bruno-api-docs/src/components/ExecutionContext/ScriptStep/ScriptStep.spec.tsx b/packages/bruno-api-docs/src/components/ExecutionContext/ScriptStep/ScriptStep.spec.tsx index fdb13f6b..3b0d58f3 100644 --- a/packages/bruno-api-docs/src/components/ExecutionContext/ScriptStep/ScriptStep.spec.tsx +++ b/packages/bruno-api-docs/src/components/ExecutionContext/ScriptStep/ScriptStep.spec.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, it, expect } from 'vitest'; import { ScriptStep } from './ScriptStep'; -import type { ScriptChainStep } from '@/utils/request'; +import type { ScriptChainStep } from '@usebruno/api-shared/utils/request'; const step: ScriptChainStep = { level: 'request', diff --git a/packages/bruno-api-docs/src/components/ExecutionContext/ScriptStep/ScriptStep.tsx b/packages/bruno-api-docs/src/components/ExecutionContext/ScriptStep/ScriptStep.tsx index 32dc5f1a..8df16956 100644 --- a/packages/bruno-api-docs/src/components/ExecutionContext/ScriptStep/ScriptStep.tsx +++ b/packages/bruno-api-docs/src/components/ExecutionContext/ScriptStep/ScriptStep.tsx @@ -1,9 +1,9 @@ import React, { useState } from 'react'; -import { Code } from '../../Code/Code'; +import { Code } from '@usebruno/api-shared/components/Code/Code'; import { ChevronArrow } from '../../ChevronArrow/ChevronArrow'; import { Collapse } from '@/ui/Collapse/Collapse'; -import { TruncatedText } from '../../TruncatedText/TruncatedText'; -import type { ScriptChainStep } from '@/utils/request'; +import { TruncatedText } from '@usebruno/api-shared/components/TruncatedText/TruncatedText'; +import type { ScriptChainStep } from '@usebruno/api-shared/utils/request'; import { StyledWrapper } from './StyledWrapper'; interface ScriptStepProps { diff --git a/packages/bruno-api-docs/src/components/ExecutionContext/TestList/TestList.spec.tsx b/packages/bruno-api-docs/src/components/ExecutionContext/TestList/TestList.spec.tsx index ba1abf3e..2099b110 100644 --- a/packages/bruno-api-docs/src/components/ExecutionContext/TestList/TestList.spec.tsx +++ b/packages/bruno-api-docs/src/components/ExecutionContext/TestList/TestList.spec.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, it, expect } from 'vitest'; import { TestList } from './TestList'; -import type { TestRow } from '@/utils/fileUtils'; +import type { TestRow } from '@usebruno/api-shared/utils/fileUtils'; const tests: TestRow[] = [ { level: 'collection', name: 'is authenticated', sourceName: 'API', code: 'test(\'is authenticated\', () => { expect(ok).to.equal(true); })' }, diff --git a/packages/bruno-api-docs/src/components/ExecutionContext/TestList/TestList.tsx b/packages/bruno-api-docs/src/components/ExecutionContext/TestList/TestList.tsx index 488e0336..16c9cac4 100644 --- a/packages/bruno-api-docs/src/components/ExecutionContext/TestList/TestList.tsx +++ b/packages/bruno-api-docs/src/components/ExecutionContext/TestList/TestList.tsx @@ -1,9 +1,9 @@ import React, { useState } from 'react'; import { ScopeTag } from '../ScopeTag/ScopeTag'; -import { Code } from '../../Code/Code'; +import { Code } from '@usebruno/api-shared/components/Code/Code'; import { Collapse } from '@/ui/Collapse/Collapse'; -import { TruncatedText } from '../../TruncatedText/TruncatedText'; -import type { TestRow } from '@/utils/fileUtils'; +import { TruncatedText } from '@usebruno/api-shared/components/TruncatedText/TruncatedText'; +import type { TestRow } from '@usebruno/api-shared/utils/fileUtils'; import { StyledWrapper } from './StyledWrapper'; interface TestListProps { diff --git a/packages/bruno-api-docs/src/components/ExecutionContext/VariablesPanel/VariablesPanel.tsx b/packages/bruno-api-docs/src/components/ExecutionContext/VariablesPanel/VariablesPanel.tsx index ef34871d..fea1f682 100644 --- a/packages/bruno-api-docs/src/components/ExecutionContext/VariablesPanel/VariablesPanel.tsx +++ b/packages/bruno-api-docs/src/components/ExecutionContext/VariablesPanel/VariablesPanel.tsx @@ -1,13 +1,13 @@ import React, { useMemo } from 'react'; -import { PropertyTable, type PropertyRow } from '../../PropertyTable/PropertyTable'; -import { SubHeading } from '../../SubHeading/SubHeading'; +import { PropertyTable, type PropertyRow } from '@usebruno/api-shared/components/PropertyTable/PropertyTable'; +import { SubHeading } from '@usebruno/api-shared/components/SubHeading/SubHeading'; import { preVarRows, postVarRows } from '../../PropertyTable/inheritedRows'; import type { PreRequestVarRow, PostResponseVarRow, InheritedPreRequestVarRow, InheritedPostResponseVarRow -} from '@/utils/request'; +} from '@usebruno/api-shared/utils/request'; import { StyledWrapper } from './StyledWrapper'; interface VariablesPanelProps { diff --git a/packages/bruno-api-docs/src/components/ExecutionContext/ViewAllTests/ViewAllTests.spec.tsx b/packages/bruno-api-docs/src/components/ExecutionContext/ViewAllTests/ViewAllTests.spec.tsx index 3458c021..c8610961 100644 --- a/packages/bruno-api-docs/src/components/ExecutionContext/ViewAllTests/ViewAllTests.spec.tsx +++ b/packages/bruno-api-docs/src/components/ExecutionContext/ViewAllTests/ViewAllTests.spec.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, it, expect } from 'vitest'; import { ViewAllTests } from './ViewAllTests'; -import type { RawTestScript } from '@/utils/fileUtils'; +import type { RawTestScript } from '@usebruno/api-shared/utils/fileUtils'; const withCode: RawTestScript[] = [{ level: 'request', code: 'const x = 1;\ntest(\'t1\', () => { runIt(); })' }]; const noCode: RawTestScript[] = [{ level: 'request', code: ' ' }]; diff --git a/packages/bruno-api-docs/src/components/ExecutionContext/ViewAllTests/ViewAllTests.tsx b/packages/bruno-api-docs/src/components/ExecutionContext/ViewAllTests/ViewAllTests.tsx index 6651866b..d93931a3 100644 --- a/packages/bruno-api-docs/src/components/ExecutionContext/ViewAllTests/ViewAllTests.tsx +++ b/packages/bruno-api-docs/src/components/ExecutionContext/ViewAllTests/ViewAllTests.tsx @@ -1,8 +1,8 @@ import React, { Fragment, useMemo, useState } from 'react'; -import { Code } from '../../Code/Code'; -import { Modal } from '@/ui/Modal/Modal'; -import { SectionLabel } from '../../SectionLabel/SectionLabel'; -import type { RawTestScript } from '@/utils/fileUtils'; +import { Code } from '@usebruno/api-shared/components/Code/Code'; +import { Modal } from '@usebruno/api-shared/ui/Modal/Modal'; +import { SectionLabel } from '@usebruno/api-shared/components/SectionLabel/SectionLabel'; +import type { RawTestScript } from '@usebruno/api-shared/utils/fileUtils'; import { StyledWrapper } from './StyledWrapper'; interface ViewAllTestsProps { diff --git a/packages/bruno-api-docs/src/components/FolderConfiguration/FolderConfiguration.spec.tsx b/packages/bruno-api-docs/src/components/FolderConfiguration/FolderConfiguration.spec.tsx index 75c53533..0d09e23d 100644 --- a/packages/bruno-api-docs/src/components/FolderConfiguration/FolderConfiguration.spec.tsx +++ b/packages/bruno-api-docs/src/components/FolderConfiguration/FolderConfiguration.spec.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { describe, it, expect } from 'vitest'; -import { useRenderToDom } from '@/hooks/useRenderToDom'; -import { query, getByTestId, queryByTestId } from '@/test-utils/dom'; +import { useRenderToDom } from '@usebruno/api-shared/hooks/useRenderToDom'; +import { query, getByTestId, queryByTestId } from '@usebruno/api-shared/test-utils/dom'; import { FolderConfiguration } from './FolderConfiguration'; import type { FolderConfig } from '@/utils/folder'; diff --git a/packages/bruno-api-docs/src/components/FolderConfiguration/FolderConfiguration.tsx b/packages/bruno-api-docs/src/components/FolderConfiguration/FolderConfiguration.tsx index 56f5877a..3939681a 100644 --- a/packages/bruno-api-docs/src/components/FolderConfiguration/FolderConfiguration.tsx +++ b/packages/bruno-api-docs/src/components/FolderConfiguration/FolderConfiguration.tsx @@ -1,11 +1,11 @@ import React from 'react'; import type { FolderConfig } from '@/utils/folder'; -import { headerRows, inheritedCountLabel } from '@/utils/request'; -import { Code } from '../Code/Code'; -import { SectionLabel } from '../SectionLabel/SectionLabel'; -import { PropertyTable, type PropertyRow } from '../PropertyTable/PropertyTable'; +import { headerRows, inheritedCountLabel } from '@usebruno/api-shared/utils/request'; +import { Code } from '@usebruno/api-shared/components/Code/Code'; +import { SectionLabel } from '@usebruno/api-shared/components/SectionLabel/SectionLabel'; +import { PropertyTable, type PropertyRow } from '@usebruno/api-shared/components/PropertyTable/PropertyTable'; import { AuthDetails } from '../AuthDetails/AuthDetails'; -import { ContentTypeBadge } from '../ContentTypeBadge/ContentTypeBadge'; +import { ContentTypeBadge } from '@usebruno/api-shared/components/ContentTypeBadge/ContentTypeBadge'; import { InheritedAuthBadge } from '../InheritedAuthBadge/InheritedAuthBadge'; import { inheritedHeaderRows, preVarRows, postVarRows } from '../PropertyTable/inheritedRows'; import { StyledWrapper } from './StyledWrapper'; diff --git a/packages/bruno-api-docs/src/components/InheritedAuthBadge/InheritedAuthBadge.tsx b/packages/bruno-api-docs/src/components/InheritedAuthBadge/InheritedAuthBadge.tsx index 48418687..bb7cf888 100644 --- a/packages/bruno-api-docs/src/components/InheritedAuthBadge/InheritedAuthBadge.tsx +++ b/packages/bruno-api-docs/src/components/InheritedAuthBadge/InheritedAuthBadge.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { canNavigateToSource, inheritedSourceLabel, type InheritedSource } from '@/utils/request'; -import { ContentTypeBadge } from '../ContentTypeBadge/ContentTypeBadge'; +import { canNavigateToSource, inheritedSourceLabel, type InheritedSource } from '@usebruno/api-shared/utils/request'; +import { ContentTypeBadge } from '@usebruno/api-shared/components/ContentTypeBadge/ContentTypeBadge'; interface InheritedAuthBadgeProps { source: InheritedSource; diff --git a/packages/bruno-api-docs/src/components/InitialsAvatar/InitialsAvatar.tsx b/packages/bruno-api-docs/src/components/InitialsAvatar/InitialsAvatar.tsx index 17ee3290..2520673b 100644 --- a/packages/bruno-api-docs/src/components/InitialsAvatar/InitialsAvatar.tsx +++ b/packages/bruno-api-docs/src/components/InitialsAvatar/InitialsAvatar.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { getInitials } from '@/utils/common'; +import { getInitials } from '@usebruno/api-shared/utils/common'; import { StyledWrapper } from './StyledWrapper'; export interface InitialsAvatarProps { diff --git a/packages/bruno-api-docs/src/components/OpenCollection/OpenCollection.tsx b/packages/bruno-api-docs/src/components/OpenCollection/OpenCollection.tsx index dee00642..5d109836 100644 --- a/packages/bruno-api-docs/src/components/OpenCollection/OpenCollection.tsx +++ b/packages/bruno-api-docs/src/components/OpenCollection/OpenCollection.tsx @@ -1,37 +1,17 @@ -import React, { useRef, useEffect } from 'react'; +import React, { useRef, useEffect, useState } 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 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, - setCollectionSourceText -} from '@/store/slices/app'; -import { createOpenCollectionStore, type AppStore } from '@/store/store'; -import { VariableResolverProvider } from '@/hooks'; -import { setTheme } from '@/store/slices/theme'; -import type { ThemeState } from '@/theme/types'; +import { parseYaml } from '@usebruno/api-shared/utils/yamlUtils'; +import { hydrateWithUUIDs } from '@usebruno/api-shared/utils/fileUtils'; +import { VariableResolverProvider } from '@usebruno/api-shared/hooks'; +import { DocumentProvider } from '@usebruno/api-shared/document/document'; +import { createObservable, useObservable } from '@usebruno/api-shared/observable/observable'; +import { createSession, SessionProvider, useSession, type Session } from '@usebruno/api-shared/session/session'; +import type { ThemeState } from '@usebruno/api-shared/theme/types'; import { isEqual } from 'lodash-es'; -import { getApiDocsTheme } from '@/utils/theme'; +import { getApiDocsTheme } from '@usebruno/api-shared/utils/theme'; const isFileInstance = (value: unknown): value is File => typeof File !== 'undefined' && value instanceof File; @@ -48,6 +28,15 @@ const parseCollectionContent = (content: string): OpenCollectionCollection => { } }; +interface LoadState { + status: 'idle' | 'loading' | 'succeeded' | 'failed'; + error: string | null; + sourceText: string | null; + document: OpenCollectionCollection | null; +} + +const IDLE: LoadState = { status: 'idle', error: null, sourceText: null, document: null }; + interface ResolvedCollection { collection: OpenCollectionCollection; sourceText: string | null; @@ -86,61 +75,38 @@ export interface OpenCollectionProps { hidePlayground?: boolean; } -const OpenCollectionContent: React.FC = ({ - collection, - logo, - gitCollectionUrl, - theme, - hideThemeSwitcher, - hidePlayground = false -}) => { - const dispatch = useAppDispatch(); - const docsCollection = useAppSelector(selectDocsCollection); - const collectionStatus = useAppSelector(selectCollectionStatus); - const collectionError = useAppSelector(selectCollectionError); - - useEffect(() => { - dispatch(setGitCollectionUrl(gitCollectionUrl ?? null)); - }, [gitCollectionUrl, dispatch]); - - const lastThemeProp = useRef(theme); - const lastHideThemeSwitcherProp = useRef(hideThemeSwitcher); - useEffect(() => { - if (isEqual(theme, lastThemeProp.current) && hideThemeSwitcher === lastHideThemeSwitcherProp.current) return; - lastThemeProp.current = theme; - lastHideThemeSwitcherProp.current = hideThemeSwitcher; - dispatch(setTheme(getApiDocsTheme(theme, hideThemeSwitcher))); - }, [theme, dispatch, hideThemeSwitcher]); +const useCollectionLoader = (collection: OpenCollectionProps['collection']): LoadState => { + const { playground: { selection, folders }, workspace } = useSession(); + const [loadState] = useState(() => createObservable({ initial: IDLE })); useEffect(() => { let isActive = true; const load = async () => { - dispatch(setCollectionLoading()); + loadState.set({ ...loadState.get(), status: 'loading', error: null }); try { const { collection: resolved, sourceText } = await resolveCollectionSource(collection); if (!isActive) return; - dispatch(setCollectionSourceText(sourceText)); const hydrated = hydrateWithUUIDs(resolved); - dispatch(setDocsCollection(hydrated)); - dispatch(setPlaygroundCollection(hydrated)); - dispatch(setCollectionSucceeded()); + folders.seed(hydrated.items); + workspace.replaceCollection(hydrated); + loadState.set({ status: 'succeeded', error: null, sourceText, document: hydrated }); } catch (err) { if (!isActive) return; const message = err instanceof Error ? err.message : 'Failed to load API collection'; - dispatch(setCollectionFailed(message)); - dispatch(clearDocsCollection()); - dispatch(setCollectionSourceText(null)); - dispatch(clearPlaygroundCollection()); + folders.seed(undefined); + workspace.clear(); + selection.clearRequest(); + loadState.set({ ...IDLE, status: 'failed', error: message }); } }; if (collection == null) { - dispatch(clearDocsCollection()); - dispatch(setCollectionSourceText(null)); - dispatch(clearPlaygroundCollection()); - dispatch(resetCollectionState()); + folders.seed(undefined); + workspace.clear(); + selection.clearRequest(); + loadState.set(IDLE); return () => { isActive = false; }; } @@ -148,47 +114,77 @@ const OpenCollectionContent: React.FC = ({ void load(); } else { const hydrated = hydrateWithUUIDs(collection as OpenCollectionCollection); - dispatch(setDocsCollection(hydrated)); - dispatch(setCollectionSourceText(null)); - dispatch(setPlaygroundCollection(hydrated)); - dispatch(setCollectionSucceeded()); + folders.seed(hydrated.items); + workspace.replaceCollection(hydrated); + loadState.set({ status: 'succeeded', error: null, sourceText: null, document: hydrated }); } return () => { isActive = false; }; - }, [collection, dispatch]); + }, [collection, loadState, selection, folders, workspace]); + + return useObservable(loadState); +}; + +const OpenCollectionContent: React.FC = ({ + collection, + logo, + gitCollectionUrl, + theme, + hideThemeSwitcher, + hidePlayground = false +}) => { + const session = useSession(); + + const lastThemeProp = useRef(theme); + const lastHideThemeSwitcherProp = useRef(hideThemeSwitcher); + useEffect(() => { + if (isEqual(theme, lastThemeProp.current) && hideThemeSwitcher === lastHideThemeSwitcherProp.current) return; + lastThemeProp.current = theme; + lastHideThemeSwitcherProp.current = hideThemeSwitcher; + session.theme.set(getApiDocsTheme(theme, hideThemeSwitcher)); + }, [theme, session, hideThemeSwitcher]); + + const { status: collectionStatus, error: collectionError, sourceText, document } = useCollectionLoader(collection); - const isInitialLoad = collectionStatus === 'idle' && !docsCollection; - const isLoading = collectionStatus === 'loading' || isInitialLoad; + const isLoading = collectionStatus === 'idle' || collectionStatus === 'loading'; if (isLoading) { return
Loading...
; } - if (collectionError) { + if (collectionError || !document) { return
Error: {collectionError}
; } return ( -
- -
+ + +
+ +
+
+
); }; const OpenCollection: React.FC = (props) => { - const storeRef = useRef(null); + const sessionRef = useRef(null); - if (!storeRef.current) { - storeRef.current = createOpenCollectionStore({ theme: getApiDocsTheme(props.theme, props.hideThemeSwitcher) }); + if (!sessionRef.current) { + sessionRef.current = createSession({ theme: getApiDocsTheme(props.theme, props.hideThemeSwitcher) }); } return ( - - - - - + + + ); }; diff --git a/packages/bruno-api-docs/src/components/OpenInBrunoButton/OpenInBrunoButton.tsx b/packages/bruno-api-docs/src/components/OpenInBrunoButton/OpenInBrunoButton.tsx index 5e4ce846..ef434ddf 100644 --- a/packages/bruno-api-docs/src/components/OpenInBrunoButton/OpenInBrunoButton.tsx +++ b/packages/bruno-api-docs/src/components/OpenInBrunoButton/OpenInBrunoButton.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { BrunoGlyph } from '@/assets/icons'; +import { BrunoGlyph } from '@usebruno/api-shared/assets/icons'; import { StyledWrapper } from './StyledWrapper'; export interface OpenInBrunoButtonProps { diff --git a/packages/bruno-api-docs/src/components/OpenInBrunoModal/OpenInBrunoModal.spec.tsx b/packages/bruno-api-docs/src/components/OpenInBrunoModal/OpenInBrunoModal.spec.tsx index adf30458..4edd2461 100644 --- a/packages/bruno-api-docs/src/components/OpenInBrunoModal/OpenInBrunoModal.spec.tsx +++ b/packages/bruno-api-docs/src/components/OpenInBrunoModal/OpenInBrunoModal.spec.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { describe, it, expect, vi } from 'vitest'; import { OpenInBrunoModal, BRUNO_DOWNLOAD_URL } from './OpenInBrunoModal'; -import { useRenderToDom } from '@/hooks/useRenderToDom'; -import { getByTestId, queryByTestId } from '@/test-utils/dom'; +import { useRenderToDom } from '@usebruno/api-shared/hooks/useRenderToDom'; +import { getByTestId, queryByTestId } from '@usebruno/api-shared/test-utils/dom'; -vi.mock('@/ui/Portal/Portal', () => ({ +vi.mock('@usebruno/api-shared/ui/Portal/Portal', () => ({ Portal: ({ children }: { children: React.ReactNode }) => children })); diff --git a/packages/bruno-api-docs/src/components/OpenInBrunoModal/OpenInBrunoModal.tsx b/packages/bruno-api-docs/src/components/OpenInBrunoModal/OpenInBrunoModal.tsx index 50beb38b..6fe7b1e5 100644 --- a/packages/bruno-api-docs/src/components/OpenInBrunoModal/OpenInBrunoModal.tsx +++ b/packages/bruno-api-docs/src/components/OpenInBrunoModal/OpenInBrunoModal.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { BrunoGlyph, DownloadIcon, GoToIcon } from '@/assets/icons'; -import { SectionLabel } from '@/components/SectionLabel/SectionLabel'; +import { BrunoGlyph, DownloadIcon, GoToIcon } from '@usebruno/api-shared/assets/icons'; +import { SectionLabel } from '@usebruno/api-shared/components/SectionLabel/SectionLabel'; import { StyledWrapper } from './StyledWrapper'; export const BRUNO_DOWNLOAD_URL = 'https://www.usebruno.com/downloads'; diff --git a/packages/bruno-api-docs/src/components/OpenInBrunoModal/StyledWrapper.ts b/packages/bruno-api-docs/src/components/OpenInBrunoModal/StyledWrapper.ts index f4e79f38..cecb35b0 100644 --- a/packages/bruno-api-docs/src/components/OpenInBrunoModal/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/OpenInBrunoModal/StyledWrapper.ts @@ -1,5 +1,5 @@ import styled from '@emotion/styled'; -import { Modal } from '@/ui/Modal/Modal'; +import { Modal } from '@usebruno/api-shared/ui/Modal/Modal'; export const StyledWrapper = styled(Modal)` .oib-title { diff --git a/packages/bruno-api-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.spec.tsx b/packages/bruno-api-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.spec.tsx index 9d40519f..c5db546d 100644 --- a/packages/bruno-api-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.spec.tsx +++ b/packages/bruno-api-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.spec.tsx @@ -3,10 +3,10 @@ import { describe, it, expect } from 'vitest'; import type { HttpRequestHeader } from '@opencollection/types/requests/http'; import { CollectionConfiguration } from './CollectionConfiguration'; import type { Auth } from '@opencollection/types/common/auth'; -import { AUTH_MODE_LABELS } from '@/constants'; -import { useRenderToDom } from '@/hooks/useRenderToDom'; -import { getByTestId, queryByTestId } from '@/test-utils/dom'; -import type { PreRequestVarRow } from '@/utils/request'; +import { AUTH_MODE_LABELS } from '@usebruno/api-shared/constants'; +import { useRenderToDom } from '@usebruno/api-shared/hooks/useRenderToDom'; +import { getByTestId, queryByTestId } from '@usebruno/api-shared/test-utils/dom'; +import type { PreRequestVarRow } from '@usebruno/api-shared/utils/request'; describe('CollectionConfiguration', () => { it('renders nothing when there is no configuration', () => { diff --git a/packages/bruno-api-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.tsx b/packages/bruno-api-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.tsx index 5ca03333..7ed10db8 100644 --- a/packages/bruno-api-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.tsx +++ b/packages/bruno-api-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.tsx @@ -1,12 +1,12 @@ import React from 'react'; import type { HttpRequestHeader } from '@opencollection/types/requests/http'; import type { Auth } from '@opencollection/types/common/auth'; -import { Code } from '../Code/Code'; -import { SubHeading } from '../SubHeading/SubHeading'; -import { PropertyTable, type PropertyRow } from '../PropertyTable/PropertyTable'; +import { Code } from '@usebruno/api-shared/components/Code/Code'; +import { SubHeading } from '@usebruno/api-shared/components/SubHeading/SubHeading'; +import { PropertyTable, type PropertyRow } from '@usebruno/api-shared/components/PropertyTable/PropertyTable'; import { AuthDetails } from '../AuthDetails/AuthDetails'; import { VariablesPanel } from '../ExecutionContext/VariablesPanel/VariablesPanel'; -import { getDescription, type PreRequestVarRow, type PostResponseVarRow } from '@/utils/request'; +import { getDescription, type PreRequestVarRow, type PostResponseVarRow } from '@usebruno/api-shared/utils/request'; import { hasConfiguredAuth } from '@/utils/collectionOverview'; import { StyledWrapper } from './StyledWrapper'; diff --git a/packages/bruno-api-docs/src/components/OverviewEnvironments/EnvironmentSummary/EnvironmentSummary.spec.tsx b/packages/bruno-api-docs/src/components/OverviewEnvironments/EnvironmentSummary/EnvironmentSummary.spec.tsx deleted file mode 100644 index d1943e60..00000000 --- a/packages/bruno-api-docs/src/components/OverviewEnvironments/EnvironmentSummary/EnvironmentSummary.spec.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, it, expect } from 'vitest'; -import type { Environment } from '@opencollection/types/config/environments'; -import { EnvironmentSummary } from './EnvironmentSummary'; - -const env = (name: string): Environment => ({ name, variables: [] }); - -describe('EnvironmentSummary', () => { - it('renders nothing when there are no environments', () => { - expect(renderToStaticMarkup()).toBe(''); - }); - - it('renders an item for each environment', () => { - const html = renderToStaticMarkup( - - ); - expect(html).toContain('Production'); - expect(html).toContain('Staging'); - }); -}); diff --git a/packages/bruno-api-docs/src/components/OverviewEnvironments/EnvironmentSummary/EnvironmentSummary.tsx b/packages/bruno-api-docs/src/components/OverviewEnvironments/EnvironmentSummary/EnvironmentSummary.tsx deleted file mode 100644 index 5f4dc648..00000000 --- a/packages/bruno-api-docs/src/components/OverviewEnvironments/EnvironmentSummary/EnvironmentSummary.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; -import type { Environment } from '@opencollection/types/config/environments'; -import { EnvironmentSummaryItem } from '../EnvironmentSummaryItem/EnvironmentSummaryItem'; -import { StyledWrapper } from './StyledWrapper'; - -interface EnvironmentSummaryProps { - environments: Environment[]; - testId?: string; - itemTestId?: string; -} - -export const EnvironmentSummary: React.FC = ({ environments, testId = 'environment-summary', itemTestId }) => { - if (!environments.length) { - return null; - } - - return ( - - {environments.map((environment, index) => ( - - ))} - - ); -}; - -export default EnvironmentSummary; diff --git a/packages/bruno-api-docs/src/components/OverviewEnvironments/EnvironmentSummary/StyledWrapper.ts b/packages/bruno-api-docs/src/components/OverviewEnvironments/EnvironmentSummary/StyledWrapper.ts deleted file mode 100644 index 7f20c59e..00000000 --- a/packages/bruno-api-docs/src/components/OverviewEnvironments/EnvironmentSummary/StyledWrapper.ts +++ /dev/null @@ -1,9 +0,0 @@ -import styled from '@emotion/styled'; - -export const StyledWrapper = styled.ul` - display: flex; - flex-direction: column; - list-style: none; - margin: 0; - padding: 0; -`; diff --git a/packages/bruno-api-docs/src/components/OverviewEnvironments/EnvironmentSummaryItem/EnvironmentSummaryItem.spec.tsx b/packages/bruno-api-docs/src/components/OverviewEnvironments/EnvironmentSummaryItem/EnvironmentSummaryItem.spec.tsx deleted file mode 100644 index bbc3e705..00000000 --- a/packages/bruno-api-docs/src/components/OverviewEnvironments/EnvironmentSummaryItem/EnvironmentSummaryItem.spec.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, it, expect } from 'vitest'; -import type { Environment } from '@opencollection/types/config/environments'; -import { EnvironmentSummaryItem } from './EnvironmentSummaryItem'; - -const env = (name: string, variableCount: number): Environment => ({ - name, - variables: Array.from({ length: variableCount }, (_, i) => ({ name: `var${i}`, value: '' })) -}); - -describe('EnvironmentSummaryItem', () => { - it('renders the environment name', () => { - const html = renderToStaticMarkup(); - expect(html).toContain('Production'); - }); - - it('pluralises the variable count', () => { - expect(renderToStaticMarkup()).toContain('1 variable'); - expect(renderToStaticMarkup()).toContain('8 variables'); - expect(renderToStaticMarkup()).toContain('0 variables'); - }); -}); diff --git a/packages/bruno-api-docs/src/components/OverviewEnvironments/EnvironmentSummaryItem/EnvironmentSummaryItem.tsx b/packages/bruno-api-docs/src/components/OverviewEnvironments/EnvironmentSummaryItem/EnvironmentSummaryItem.tsx deleted file mode 100644 index 1eb7502a..00000000 --- a/packages/bruno-api-docs/src/components/OverviewEnvironments/EnvironmentSummaryItem/EnvironmentSummaryItem.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import type { Environment } from '@opencollection/types/config/environments'; -import { EnvironmentLabel } from '../../EnvironmentLabel/EnvironmentLabel'; -import { StyledWrapper } from './StyledWrapper'; - -const formatVariableCount = (count: number): string => `${count} variable${count === 1 ? '' : 's'}`; - -interface EnvironmentSummaryItemProps { - environment: Environment; - testId?: string; -} - -/** A single environment row: color dot, name, and variable count. Renders an `
  • `. */ -export const EnvironmentSummaryItem: React.FC = ({ environment, testId = 'environment-summary-item' }) => ( - - - - - {formatVariableCount(environment.variables?.length ?? 0)} - - -); - -export default EnvironmentSummaryItem; diff --git a/packages/bruno-api-docs/src/components/OverviewEnvironments/EnvironmentSummaryItem/StyledWrapper.ts b/packages/bruno-api-docs/src/components/OverviewEnvironments/EnvironmentSummaryItem/StyledWrapper.ts deleted file mode 100644 index 6102385e..00000000 --- a/packages/bruno-api-docs/src/components/OverviewEnvironments/EnvironmentSummaryItem/StyledWrapper.ts +++ /dev/null @@ -1,27 +0,0 @@ -import styled from '@emotion/styled'; - -export const StyledWrapper = styled.li` - display: flex; - align-items: center; - gap: 0.625rem; - padding: 0.3rem 0; - font-family: var(--font-sans); - font-weight: 400; - font-size: 0.75rem; - line-height: 1.125rem; - letter-spacing: normal; - color: var(--oc-text); - - &:last-child { - border-bottom: none; - } - - .environment-summary-spacer { - flex: 1; - } - - .environment-summary-vars { - color: var(--text-tertiary); - flex-shrink: 0; - } -`; diff --git a/packages/bruno-api-docs/src/components/PageRouter/PageRouter.tsx b/packages/bruno-api-docs/src/components/PageRouter/PageRouter.tsx index 26a02127..4da46d2e 100644 --- a/packages/bruno-api-docs/src/components/PageRouter/PageRouter.tsx +++ b/packages/bruno-api-docs/src/components/PageRouter/PageRouter.tsx @@ -2,13 +2,13 @@ import React, { useMemo, useRef } from 'react'; import { Navigate } from 'react-router-dom'; import type { GraphQLRequest } from '@opencollection/types/requests/graphql'; import type { ScriptFile, Folder as FolderItem } from '@opencollection/types/collection/item'; -import type { RequestItem } from '@/utils/schemaHelpers'; -import { useActiveResolution, useNavModel } from '@/routing/hooks'; -import { useAppSelector } from '@/store/hooks'; -import { selectDocsCollection } from '@/store/slices/docs'; -import { getItemUuid } from '@/utils/itemUtils'; -import { getAncestorsByUuid } from '@/utils/fileUtils'; -import { ItemVariableResolverProvider } from '@/hooks'; +import type { RequestItem } from '@usebruno/api-shared/utils/schemaHelpers'; +import { useActiveResolution } from '@/routing/hooks'; +import { useDocument, useNavModel } from '@usebruno/api-shared/document/document'; +import { findItemByUuid, getItemUuid } from '@usebruno/api-shared/utils/itemUtils'; +import { getAncestorsByUuid } from '@usebruno/api-shared/utils/fileUtils'; +import { useWorkingCollection } from '@usebruno/api-shared/session/session'; +import { ItemVariableResolverProvider } from '@usebruno/api-shared/hooks'; import type { Item } from '@opencollection/types/collection/item'; import PrevNext from '../PrevNext/PrevNext'; import SectionNav from '../SectionNav/SectionNav'; @@ -22,8 +22,8 @@ import GraphqlRequest from '@/pages/GraphqlRequest/GraphqlRequest'; import Script from '@/pages/Script/Script'; import Folder from '@/pages/Folder/Folder'; import Environments from '@/pages/Environments/Environments'; -import type { PageProps, PageType } from '@/routing/types'; -import { useDocsNavigate } from '@/hooks'; +import type { PageProps, PageType } from '@usebruno/api-shared/routing/types'; +import { useDocsNavigate } from '@/hooks/useDocsNavigate'; interface PageRouterProps { onOpenPlayground?: () => void; @@ -35,29 +35,20 @@ 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 loaded = useDocument(); + const collection = useWorkingCollection() ?? loaded; const docsNavigate = useDocsNavigate(); const pageBodyRef = useRef(null); - // Map each item's runtime uuid -> its stable slug so breadcrumb clicks - // navigate by URL (the same mapping the sidebar uses). - const uuidToSlug = useMemo(() => { - const map = new Map(); - for (const entry of model.ordered) { - const uuid = getItemUuid(entry.item); - if (uuid) map.set(uuid, entry.slug); - } - return map; - }, [model]); - - const item = resolution?.entry?.item ?? null; - const ancestry = useMemo( - () => (item && collection ? getAncestorsByUuid(collection, getItemUuid(item) ?? '') : []), - [collection, item] + const navItem = resolution?.entry?.item ?? null; + const itemUuid = getItemUuid(navItem) ?? ''; + const item = useMemo( + () => findItemByUuid(collection?.items, itemUuid) ?? navItem, + [collection, itemUuid, navItem] ); + const ancestry = useMemo(() => getAncestorsByUuid(collection, itemUuid), [collection, itemUuid]); if (!resolution) return ; - if (!collection) return null; const { entry, prev, next } = resolution; const pageProps: PageProps = { node: entry, prev, next, collection, onOpenPlayground }; @@ -66,7 +57,7 @@ const PageRouter: React.FC = ({ onOpenPlayground, testId = 'pag const sectionNavTitle = entry.name; const goToUuid = (uuid: string) => { - const slug = uuidToSlug.get(uuid); + const slug = model.slugByUuid.get(uuid); // A known item navigates to its slug; the leading collection crumb (and any // unknown uuid) falls back to the overview. docsNavigate(slug ?? ''); @@ -80,7 +71,7 @@ const PageRouter: React.FC = ({ onOpenPlayground, testId = 'pag return ; case 'folder': return item ? ( - + ) : ( @@ -92,7 +83,7 @@ const PageRouter: React.FC = ({ onOpenPlayground, testId = 'pag ) : null; case 'graphql': return item ? ( - + = ({ onOpenPlayground, testId = 'pag case 'request': default: return item ? ( - + import('./PlaygroundBody/PlaygroundBody')); +// A failed load swaps in a fresh lazy, since React caches the rejection, so the next open retries. +const lazyBody = () => + lazy(() => + loadPlayground().catch((error: unknown) => { + PlaygroundBody = lazyBody(); + throw error; + }) + ); +let PlaygroundBody = lazyBody(); const playgroundLoadError = (
    = ({ openNonce }) => { - const dispatch = useAppDispatch(); + const { workspace } = useSession(); const { open, dock, requestSlug, exampleSlug, setDock, closePlayground } = usePlaygroundUrlState(); const isPhone = useIsMobilePhone(); // On a phone the playground is always the fullscreen MobileDock, and its @@ -61,8 +69,8 @@ const Playground: React.FC = ({ openNonce }) => { useEffect(() => () => { playgroundRuns.abortAll(PLAYGROUND_CLOSED); - dispatch(clearAllPlaygroundRequestsStarted()); - }, [dispatch]); + workspace.clearAllRequests(); + }, [workspace]); if (!open) return null; diff --git a/packages/bruno-api-docs/src/components/Playground/PlaygroundBody/PlaygroundBody.tsx b/packages/bruno-api-docs/src/components/Playground/PlaygroundBody/PlaygroundBody.tsx deleted file mode 100644 index c60e01b6..00000000 --- a/packages/bruno-api-docs/src/components/Playground/PlaygroundBody/PlaygroundBody.tsx +++ /dev/null @@ -1,271 +0,0 @@ -import React, { useEffect, useMemo, useRef } from 'react'; -import type { HttpRequest } from '@opencollection/types/requests/http'; -import type { Folder, ScriptFile } from '@opencollection/types/collection/item'; -import { useAppDispatch, useAppSelector } from '@/store/hooks'; -import { - selectHydratedCollection, - selectViewMode, - selectSelectedItemId, - selectSelectedExampleIndex, - selectResponsePaneOrientation, - setViewMode, - setSelectedItemId, - setSelectedExampleIndex, - toggleFolderCollapse, - expandFolders -} from '@/store/slices/playground'; -import { selectActiveEnvName } from '@/store/slices/env'; -import type { ExampleHighlight } from '../../Docs/Sidebar/SidebarTree/SidebarTree'; -import { useNavModel } from '@/routing/hooks'; -import { usePlaygroundUrlState, useElementWidth, useResizableSidebar, useClickOutside } from '@/hooks'; -import { getItemUuid, findItemByUuid } from '@/utils/itemUtils'; -import { isFolder, isScriptFile } from '@/utils/schemaHelpers'; -import { exampleIndexForSlug, exampleSlugForIndex } from '@/routing/slug'; -import PlaygroundView from '../Content/Views/PlaygroundView/PlaygroundView'; -import FolderSettingsView from '../Content/Views/FolderSettingsView/FolderSettingsView'; -import EnvironmentsView from '../Content/Views/EnvironmentsView/EnvironmentsView'; -import CollectionSettingsView from '../Content/Views/CollectionSettingsView/CollectionSettingsView'; -import ExampleView from '../Content/Views/ExampleView/ExampleView'; -import ScriptFileView from '../Content/Views/ScriptFileView/ScriptFileView'; -import PlaygroundSidebar from '../PlaygroundSidebar/PlaygroundSidebar'; -import type { DockMode } from '@/utils/playgroundDock'; -import { - resolvePlaygroundTarget, - PLAYGROUND_ENVIRONMENTS_SLUG, - PLAYGROUND_COLLECTION_SLUG -} from './resolvePlaygroundTarget'; -import { StyledWrapper } from './StyledWrapper'; - -const ORIENTATION_BREAKPOINT = 640; - -const applyKey = (requestSlug: string | null, exampleSlug: string | null): string => - exampleSlug ? `${requestSlug} ${exampleSlug}` : requestSlug ?? ''; - -interface PlaygroundBodyProps { - requestSlug: string | null; - exampleSlug: string | null; - sidebarOpen: boolean; - dock: DockMode; - onCloseSidebar: () => void; - onOpenSidebar: () => void; - // Tracks the applied request (+example) key across dock-switch remounts; owned - // by Playground so it survives a dock switch but resets on close (see there). - appliedSlugRef: React.MutableRefObject; -} - -const PlaygroundBody: React.FC = ({ - requestSlug, - exampleSlug, - sidebarOpen, - dock, - onCloseSidebar, - onOpenSidebar, - appliedSlugRef -}) => { - 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 uuidToSlug = useMemo>(() => { - const map = new Map(); - for (const entry of model.ordered) { - const uuid = getItemUuid(entry.item); - if (uuid) map.set(uuid, entry.slug); - } - return map; - }, [model]); - - const selectedItem = useMemo( - () => findItemByUuid(collection?.items, selectedItemId), - [collection, selectedItemId] - ); - - const activeSlug = viewMode !== 'example' && selectedItemId ? uuidToSlug.get(selectedItemId) ?? '' : ''; - - const exampleCount - = selectedItem && !isFolder(selectedItem) ? ((selectedItem as HttpRequest).examples?.length ?? 0) : 0; - const activeExample: ExampleHighlight | null - = viewMode === 'example' - && selectedItemId != null - && selectedExampleIndex != null - && selectedExampleIndex < exampleCount - ? { requestUuid: selectedItemId, index: selectedExampleIndex } - : null; - - const viewRef = useRef(null); - const viewWidth = useElementWidth(viewRef); - const { width: sidebarWidth, dragging: sidebarDragging, startDrag: startSidebarResize } - = useResizableSidebar('oc-docs:playgroundSidebarWidth', onCloseSidebar, onOpenSidebar); - const orientation = orientationOverride ?? (viewWidth > 0 && viewWidth < ORIENTATION_BREAKPOINT ? 'vertical' : 'horizontal'); - - const sidebarRef = useRef(null); - useClickOutside( - sidebarRef, - onCloseSidebar, - sidebarOpen && dock === 'inline', - '[data-testid="playground-sidebar-toggle"], [data-testid="playground-sidebar-resizer"], [data-tippy-root]' - ); - - // Reopen whatever the URL says was last open. `pgReq` holds a request, a - // folder, or the environments / collection-settings view, so a deep link, a - // Try, or a reload all bring back the same thing. Runs once per URL value. - useEffect(() => { - const key = applyKey(requestSlug, exampleSlug); - if (!requestSlug || appliedSlugRef.current === key) return; - const target = resolvePlaygroundTarget(requestSlug, model); - if (!target) return; - if (target.uuid && !collection?.items) return; - const item = target.uuid ? findItemByUuid(collection?.items, target.uuid) : undefined; - const exampleIndex - = exampleSlug && item && !isFolder(item) ? exampleIndexForSlug(item as HttpRequest, exampleSlug) : null; - appliedSlugRef.current = key; - dispatch(setSelectedItemId(target.uuid)); - dispatch(setSelectedExampleIndex(exampleIndex)); - dispatch(setViewMode(exampleIndex != null ? 'example' : target.view)); - if (target.expandUuids.length) dispatch(expandFolders(target.expandUuids)); - }, [requestSlug, exampleSlug, model, collection, dispatch, appliedSlugRef]); - - const closeSidebarIfInline = () => { - if (dock === 'inline') onCloseSidebar(); - }; - - const handleNavigate = (slug: string) => { - const target = resolvePlaygroundTarget(slug, model); - if (!target || !target.uuid) return; - dispatch(setSelectedItemId(target.uuid)); - dispatch(setSelectedExampleIndex(null)); - dispatch(setViewMode(target.view)); - if (target.expandUuids.length) dispatch(expandFolders(target.expandUuids)); - appliedSlugRef.current = slug; - setRequestSlug(slug); - closeSidebarIfInline(); - }; - - const handleToggleFolder = (uuid: string) => dispatch(toggleFolderCollapse(uuid)); - const handleExpandFolder = (uuid: string) => dispatch(expandFolders([uuid])); - - const handleExampleClick = (requestUuid: string, index: number) => { - dispatch(setSelectedItemId(requestUuid)); - dispatch(setSelectedExampleIndex(index)); - dispatch(setViewMode('example')); - const slug = uuidToSlug.get(requestUuid); - if (slug) { - const item = findItemByUuid(collection?.items, requestUuid); - const request = item && !isFolder(item) ? (item as HttpRequest) : null; - const nextExampleSlug = exampleSlugForIndex(request, index); - appliedSlugRef.current = applyKey(slug, nextExampleSlug); - setRequestExample(slug, nextExampleSlug); - } - closeSidebarIfInline(); - }; - - const openEnvironments = () => { - dispatch(setViewMode('environments')); - dispatch(setSelectedItemId(null)); - dispatch(setSelectedExampleIndex(null)); - appliedSlugRef.current = PLAYGROUND_ENVIRONMENTS_SLUG; - setRequestSlug(PLAYGROUND_ENVIRONMENTS_SLUG); // persist so a reload restores it - closeSidebarIfInline(); - }; - - const openCollection = () => { - dispatch(setViewMode('collection-settings')); - dispatch(setSelectedItemId(null)); - dispatch(setSelectedExampleIndex(null)); - appliedSlugRef.current = PLAYGROUND_COLLECTION_SLUG; - setRequestSlug(PLAYGROUND_COLLECTION_SLUG); // persist so a reload restores it - closeSidebarIfInline(); - }; - - const view = (() => { - if (viewMode === 'collection-settings' && collection) return ; - if (viewMode === 'environments' && collection) return ; - if (viewMode === 'folder-settings' && selectedItem && isFolder(selectedItem) && collection) { - return ( - undefined} /> - ); - } - if ( - viewMode === 'example' - && selectedItem - && !isFolder(selectedItem) - && selectedExampleIndex != null - ) { - const example = ((selectedItem as HttpRequest).examples ?? [])[selectedExampleIndex]; - if (example) { - return ; - } - } - if (viewMode === 'playground' && selectedItem && isScriptFile(selectedItem)) { - return ; - } - if ((viewMode === 'playground' || viewMode === 'example') && selectedItem && !isFolder(selectedItem) && collection) { - return ( - - ); - } - return
    Select an endpoint from the sidebar to get started.
    ; - })(); - - return ( - - {sidebarOpen && dock === 'inline' && ( -