From e3b2496eaac2de4ce7dc8a05cff49c24a693b1cd Mon Sep 17 00:00:00 2001 From: bruno-sachin Date: Wed, 17 Jun 2026 13:41:17 +0530 Subject: [PATCH 01/51] COnflicts resolved --- .../Docs/CodeSnippets/CodeSnippets.tsx | 2 +- packages/oc-docs/src/components/Docs/Docs.tsx | 76 +++---------------- .../oc-docs/src/components/Docs/Item/Item.tsx | 2 +- .../components/Docs/Item/Scripts/Scripts.tsx | 2 +- packages/oc-docs/src/sampleCollection.ts | 72 ++++++++++++++++++ packages/oc-docs/src/styles/index.css | 75 +++++++++++++----- packages/oc-docs/src/ui/Code/Code.tsx | 62 --------------- packages/oc-docs/src/ui/Code/StyledWrapper.ts | 72 ------------------ packages/oc-docs/src/utils/common.ts | 35 +++++++++ packages/oc-docs/src/utils/itemUtils.ts | 2 +- packages/oc-docs/src/utils/items.ts | 2 +- 11 files changed, 179 insertions(+), 223 deletions(-) delete mode 100644 packages/oc-docs/src/ui/Code/Code.tsx delete mode 100644 packages/oc-docs/src/ui/Code/StyledWrapper.ts diff --git a/packages/oc-docs/src/components/Docs/CodeSnippets/CodeSnippets.tsx b/packages/oc-docs/src/components/Docs/CodeSnippets/CodeSnippets.tsx index 2cf350d..fc893c3 100644 --- a/packages/oc-docs/src/components/Docs/CodeSnippets/CodeSnippets.tsx +++ b/packages/oc-docs/src/components/Docs/CodeSnippets/CodeSnippets.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { TabGroup } from '../../../ui/MinimalComponents'; -import { Code } from '../../../ui/Code/Code'; +import { Code } from '../../Code'; import { StyledWrapper } from './StyledWrapper'; import { generateCurlCommand, generateJavaScriptCode, generatePythonCode } from './generateCodeSnippets'; diff --git a/packages/oc-docs/src/components/Docs/Docs.tsx b/packages/oc-docs/src/components/Docs/Docs.tsx index 03a2146..162d857 100644 --- a/packages/oc-docs/src/components/Docs/Docs.tsx +++ b/packages/oc-docs/src/components/Docs/Docs.tsx @@ -1,15 +1,13 @@ import React, { useMemo, useEffect, useRef } from 'react'; import type { OpenCollection as OpenCollectionCollection } from '@opencollection/types'; -import type { StructuredText } from '@opencollection/types/common/description'; import Sidebar from './Sidebar/Sidebar'; import Item from './Item/Item'; -import FetchInBrunoButton from './Sidebar/FetchInBrunoButton'; +import Overview from '../../pages/Overview'; import { getItemId, generateSafeId } from '../../utils/itemUtils'; import { isFolder, getItemName } from '../../utils/schemaHelpers'; import { useAppSelector, useAppDispatch } from '../../store/hooks'; import { selectSelectedItemId, selectItem } from '../../store/slices/docs'; import { selectGitCollectionUrl } from '../../store/slices/app'; -import { useMarkdownRenderer } from '../../hooks'; interface DocsProps { docsCollection: OpenCollectionCollection | null; @@ -25,7 +23,6 @@ const Docs: React.FC = ({ const dispatch = useAppDispatch(); const selectedItemId = useAppSelector(selectSelectedItemId); const gitCollectionUrl = useAppSelector(selectGitCollectionUrl); - const md = useMarkdownRenderer(); const isInitialMount = useRef(true); // Scroll to selected item when it changes (but not on initial load) @@ -118,66 +115,13 @@ const Docs: React.FC = ({
-
- {docsCollection?.info?.name && ( -
-

- {docsCollection.info.name} -

- {gitCollectionUrl && ( - - - - )} -
- )} - - {/* Collection-level documentation/introduction */} - {docsCollection?.docs && ( -
-
-
- )} + {docsCollection && ( + + )} + {/*
*/} {/* Render all collection items */} - {allItems.map((item, index) => { + {/* {allItems.map((item, index) => { const itemId = getItemId(item); const itemUuid = (item as any).uuid || itemId; // Use UUID if available, fallback to itemId const safeId = generateSafeId(itemId); @@ -240,9 +184,9 @@ const Docs: React.FC = ({ />
); - })} + })} */} - {allItems.length === 0 && ( + {/* {allItems.length === 0 && (
@@ -252,8 +196,8 @@ const Docs: React.FC = ({

No endpoints or pages found in this collection.

- )} -
+ )} */} + {/*
*/}
); diff --git a/packages/oc-docs/src/components/Docs/Item/Item.tsx b/packages/oc-docs/src/components/Docs/Item/Item.tsx index 363ae0c..8f4ed47 100644 --- a/packages/oc-docs/src/components/Docs/Item/Item.tsx +++ b/packages/oc-docs/src/components/Docs/Item/Item.tsx @@ -29,7 +29,7 @@ import { MinimalDataTable, StatusBadge } from '../../../ui/MinimalComponents'; -import { Code } from '../../../ui/Code/Code'; +import { Code } from '../../Code'; import { CodeSnippets } from '../CodeSnippets/CodeSnippets'; import { StyledWrapper } from './StyledWrapper'; import { Scripts } from './Scripts/Scripts'; diff --git a/packages/oc-docs/src/components/Docs/Item/Scripts/Scripts.tsx b/packages/oc-docs/src/components/Docs/Item/Scripts/Scripts.tsx index 0868ae0..f238a35 100644 --- a/packages/oc-docs/src/components/Docs/Item/Scripts/Scripts.tsx +++ b/packages/oc-docs/src/components/Docs/Item/Scripts/Scripts.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { TabGroup } from '../../../../ui/MinimalComponents'; -import { Code } from '../../../../ui/Code/Code'; +import { Code } from '../../../Code'; import { StyledWrapper } from './StyledWrapper'; interface ScriptsProps { diff --git a/packages/oc-docs/src/sampleCollection.ts b/packages/oc-docs/src/sampleCollection.ts index 3c4f065..d45d5c0 100644 --- a/packages/oc-docs/src/sampleCollection.ts +++ b/packages/oc-docs/src/sampleCollection.ts @@ -30,6 +30,28 @@ request: auth: type: "bearer" token: "{{bearer_auth_token}}" + scripts: + - type: before-request + code: |- + // used by \`scripting/js/folder-collection script-tests\` + const shouldTestCollectionScripts = bru.getVar('should-test-collection-scripts'); + if(shouldTestCollectionScripts) { + bru.setVar('collection-var-set-by-collection-script', 'collection-var-value-set-by-collection-script'); + } + - type: after-response + code: wefewfewfewfewfwefwefewfewfewfewfewfewfewfewf + - type: tests + code: |- + // used by \`scripting/js/folder-collection script-tests\` + const shouldTestCollectionScripts = bru.getVar('should-test-collection-scripts'); + const collectionVar = bru.getVar("collection-var-set-by-collection-script"); + if (shouldTestCollectionScripts && collectionVar) { + test("collection level test - should get the var that was set by the collection script", function() { + expect(collectionVar).to.equal("collection-var-value-set-by-collection-script"); + }); + bru.setVar('collection-var-set-by-collection-script', null); + bru.setVar('should-test-collection-scripts', null); + } docs: content: | This is a comprehensive API collection for testing **OpenCollection** features. @@ -619,4 +641,54 @@ items: "total": 1 } + - name: "Jokes" + type: "http" + seq: 11 + method: "GET" + url: "https://jsonplaceholder.typicode.com/posts/:postId" + params: + - name: "postId" + value: "1" + type: "path" + headers: + - name: "Accept" + value: "application/json" + docs: "Fetch a single post by its ID. The postId is supplied as a path parameter in the URL." + examples: + - name: "Single Post" + request: + params: + - name: "postId" + value: "1" + type: "path" + headers: + - name: "Accept" + value: "application/json" + response: + status: 200 + statusText: "OK" + headers: + - name: "Content-Type" + value: "application/json; charset=utf-8" + body: + type: "json" + data: | + { + "userId": 1, + "id": 1, + "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit", + "body": "quia et suscipit suscipit recusandae consequuntur expedita et cum" + } + - name: "Not Found" + response: + status: 404 + statusText: "Not Found" + headers: + - name: "Content-Type" + value: "application/json; charset=utf-8" + body: + type: "json" + data: | + {} + `; diff --git a/packages/oc-docs/src/styles/index.css b/packages/oc-docs/src/styles/index.css index 6acc526..102c119 100644 --- a/packages/oc-docs/src/styles/index.css +++ b/packages/oc-docs/src/styles/index.css @@ -33,6 +33,7 @@ /* Border colors */ --border-color: var(--oc-border-border1); + --border-light: #EFEFEF; /* Code colors */ --code-bg: var(--oc-background-crust); /* approx: old #f6f8fa */ @@ -72,6 +73,23 @@ /* Fonts */ --font-mono: "JetBrains Mono", "SF Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; + + /* Theme variables */ + --background-color: var(--background-light); + --text-primary: var(--text-light); + --text-secondary: #6b6b6b; + --text-tertiary: #838383; + --border-color: var(--border-light); + --code-bg: var(--code-bg-light); + --code-text: var(--code-text-light); + --prose-code-bg: #f5f5f5; + --prose-code-text: #d97706; + --table-header-bg: var(--table-header-bg-light); + --table-row-odd-bg: var(--table-row-odd-bg-light); + --table-row-even-bg: var(--table-row-even-bg-light); + --input-bg: var(--input-bg-light); + --badge-bg: var(--badge-bg-light); + --badge-text: var(--badge-text-light); } /* ================================================================ @@ -646,8 +664,13 @@ tr.themed-row:nth-child(even) { .markdown-documentation { color: var(--text-primary) !important; max-width: none !important; - line-height: 1.7 !important; - font-size: 0.9375rem !important; + /* Body text: Inter, Regular 400, 12px (0.75rem), 18px (1.125rem) line-height, 0 tracking. + Headings, code and strong text override this via their own rules below. */ + font-family: var(--font-sans) !important; + font-weight: 400 !important; + font-size: 0.75rem !important; + line-height: 1.125rem !important; + letter-spacing: normal !important; } .markdown-documentation h1, @@ -659,36 +682,32 @@ tr.themed-row:nth-child(even) { color: var(--text-primary) !important; margin-top: 1.5rem !important; margin-bottom: 0.625rem !important; + /* Shared heading typography: Inter, Semi Bold 600, 13px (0.8125rem), + 12px (0.75rem) line-height, 0 letter-spacing. */ + font-family: var(--font-sans) !important; font-weight: 600 !important; - letter-spacing: -0.02em !important; - line-height: 1.3 !important; + font-size: 0.8125rem !important; + line-height: 0.75rem !important; + letter-spacing: normal !important; } +/* Per-level rules only adjust spacing; every level shares the typography above. */ .markdown-documentation h1.heading-1 { - font-size: 1.5rem !important; - font-weight: 700 !important; margin-top: 0 !important; margin-bottom: 0.75rem !important; - letter-spacing: -0.025em !important; } .markdown-documentation h2.heading-2 { - font-size: 1.25rem !important; - font-weight: 600 !important; - margin-top: 1.5rem !important; + margin-top: 1rem !important; margin-bottom: 0.5rem !important; } .markdown-documentation h3.heading-3 { - font-size: 1.0625rem !important; - font-weight: 600 !important; margin-top: 1.25rem !important; margin-bottom: 0.5rem !important; } .markdown-documentation h4.heading-4 { - font-size: 0.9375rem !important; - font-weight: 600 !important; margin-top: 1rem !important; margin-bottom: 0.4rem !important; } @@ -742,13 +761,33 @@ tr.themed-row:nth-child(even) { border-radius: 8px !important; } -.markdown-documentation ul, .markdown-documentation ol { - margin-left: 1.25em !important; - margin-top: 0.5em !important; - margin-bottom: 0.75em !important; +.markdown-documentation ul, +.markdown-documentation ol { + /* Tailwind's preflight resets lists to `list-style: none` with zero padding, which + hides the markers. Restore the markers + the padding they need to render. */ + margin: 0.5em 0 0.75em !important; + padding-left: 1.5em !important; + list-style-position: outside !important; +} + +.markdown-documentation ul { + list-style-type: disc !important; +} + +.markdown-documentation ol { + list-style-type: decimal !important; +} + +/* Distinct markers for nested unordered lists (browser defaults, also reset by Tailwind). */ +.markdown-documentation ul ul { + list-style-type: circle !important; +} +.markdown-documentation ul ul ul { + list-style-type: square !important; } .markdown-documentation li { + display: list-item !important; margin-top: 0.2em !important; margin-bottom: 0.2em !important; } diff --git a/packages/oc-docs/src/ui/Code/Code.tsx b/packages/oc-docs/src/ui/Code/Code.tsx deleted file mode 100644 index 76550b2..0000000 --- a/packages/oc-docs/src/ui/Code/Code.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { StyledWrapper } from './StyledWrapper'; - -import Prism from 'prismjs'; -import 'prismjs/components/prism-javascript'; -import 'prismjs/components/prism-bash'; -import 'prismjs/components/prism-python'; -import 'prismjs/components/prism-json'; -import 'prismjs/components/prism-xml-doc'; - -interface CodeProps { - code?: string; - language?: string; -} - -export const Code: React.FC = ({ - code, - language = 'text' -}) => { - const [copied, setCopied] = useState(false); - const codeRef = useRef(null); - - useEffect(() => { - if (codeRef.current) { - Prism.highlightAllUnder(codeRef.current); - } - }, [code, language]); - - const handleCopy = () => { - if (code) { - navigator.clipboard.writeText(code); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - }; - - return ( - -
- -
-
-            
-              {code || ''}
-            
-          
-
-
-
- ); -}; \ No newline at end of file diff --git a/packages/oc-docs/src/ui/Code/StyledWrapper.ts b/packages/oc-docs/src/ui/Code/StyledWrapper.ts deleted file mode 100644 index 3d08cd7..0000000 --- a/packages/oc-docs/src/ui/Code/StyledWrapper.ts +++ /dev/null @@ -1,72 +0,0 @@ -import styled from '@emotion/styled'; - -export const StyledWrapper = styled.div` - background-color: var(--code-bg); - border: 1px solid var(--oc-border-border1); - border-radius: 8px; - - .code-copy-floating { - position: absolute; - top: 8px; - right: 8px; - z-index: 1; - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0.3rem; - border-radius: 4px; - border: none; - color: var(--oc-colors-text-muted); - background-color: var(--oc-background-surface0); - cursor: pointer; - opacity: 0; - transition: all 0.15s ease; - } - - &:hover .code-copy-floating { - opacity: 1; - } - - .code-copy-floating:hover { - color: var(--oc-text); - background-color: var(--oc-background-surface1); - } - - .code-content { - background-color: var(--code-bg); - color: var(--text-primary); - } - - .code-content::-webkit-scrollbar { - width: 6px; - height: 6px; - } - - .code-content::-webkit-scrollbar-track { - background: transparent; - } - - .code-content::-webkit-scrollbar-thumb { - background-color: color-mix(in srgb, var(--oc-text) 10%, transparent); - border-radius: 4px; - } - - .code-content:hover::-webkit-scrollbar-thumb { - background-color: color-mix(in srgb, var(--oc-text) 20%, transparent); - } - - .code-content::-webkit-scrollbar-thumb:hover { - background-color: color-mix(in srgb, var(--oc-text) 30%, transparent); - } - - .code-content pre { - font-size: 13px; - color: var(--text-primary); - line-height: 1.65; - } - - .code-content code { - color: var(--text-primary); - font-size: 13px; - } -`; \ No newline at end of file diff --git a/packages/oc-docs/src/utils/common.ts b/packages/oc-docs/src/utils/common.ts index 819f73e..798fc7f 100644 --- a/packages/oc-docs/src/utils/common.ts +++ b/packages/oc-docs/src/utils/common.ts @@ -8,3 +8,38 @@ export const uuid = () => { return customNanoId(); }; + +export const DEFAULT_COLLECTION_VERSION = 'v1.0.0'; + +/** + * Formats a raw collection version for consistent display across the UI. + * Numeric versions are padded to a full major.minor.patch and prefixed with "v" + * ("1" -> "v1.0.0", "2.1" -> "v2.1.0"); an existing "v"/"V" is preserved (no double "v"). + * Non-numeric / pre-release versions are shown as-is (only prefixed), and an unset + * version falls back to the default. + */ +export const formatCollectionVersion = (version?: string | number | null): string => { + if (version === null || version === undefined) return DEFAULT_COLLECTION_VERSION; + + const raw = String(version).trim(); + if (!raw) return DEFAULT_COLLECTION_VERSION; + + // Drop an existing leading "v"/"V" so we never end up with "vv...". + const core = raw.replace(/^v/i, '').trim(); + if (!core) return DEFAULT_COLLECTION_VERSION; + + const segments = core.split('.'); + const isNumeric = segments.every((segment) => /^\d+$/.test(segment)); + + // Only pad versions made up entirely of numeric segments; anything else + // (pre-release, build metadata, etc.) is shown as-is to stay precise. + if (!isNumeric) { + return `v${core}`; + } + + while (segments.length < 3) { + segments.push('0'); + } + + return `v${segments.join('.')}`; +}; diff --git a/packages/oc-docs/src/utils/itemUtils.ts b/packages/oc-docs/src/utils/itemUtils.ts index 9453f1e..6765669 100644 --- a/packages/oc-docs/src/utils/itemUtils.ts +++ b/packages/oc-docs/src/utils/itemUtils.ts @@ -3,7 +3,7 @@ */ import type { Item as OpenCollectionItem } from '@opencollection/types/collection/item'; -import { getItemName, getItemType, isFolder } from './schemaHelpers'; +import { getItemName, isFolder } from './schemaHelpers'; /** * Generate a safe HTML ID from an item name or ID diff --git a/packages/oc-docs/src/utils/items.ts b/packages/oc-docs/src/utils/items.ts index a774e24..16e7a58 100644 --- a/packages/oc-docs/src/utils/items.ts +++ b/packages/oc-docs/src/utils/items.ts @@ -1,6 +1,6 @@ import type { OpenCollection as OpenCollectionCollection } from '@opencollection/types'; import type { Item as OpenCollectionItem, Folder } from '@opencollection/types/collection/item'; -import { getItemType, isFolder } from './schemaHelpers'; +import { isFolder } from './schemaHelpers'; /** * Helper function to find and update an item by UUID From 085fba159ce419131e84544a3ac800784518254a Mon Sep 17 00:00:00 2001 From: bruno-sachin Date: Wed, 17 Jun 2026 16:17:21 +0530 Subject: [PATCH 02/51] BRU-2571 Overview revamp changes --- packages/oc-docs/e2e/collection-docs.spec.ts | 77 --------- .../overview/overview-documentation.spec.ts | 56 +++++++ .../oc-docs/e2e/overview/overview.spec.ts | 109 +++++++++++++ packages/oc-docs/e2e/utils/index.ts | 18 ++ packages/oc-docs/e2e/utils/locators.ts | 69 ++++++++ packages/oc-docs/src/assets/icons.tsx | 32 ++++ .../oc-docs/src/components/Code/Code.spec.tsx | 31 ++++ packages/oc-docs/src/components/Code/Code.tsx | 122 ++++++++++++++ .../src/components/Code/StyledWrapper.ts | 94 +++++++++++ packages/oc-docs/src/components/Code/index.ts | 2 + .../CollectionConfiguration.spec.tsx | 59 +++++++ .../CollectionConfiguration.tsx | 154 ++++++++++++++++++ .../CollectionConfiguration/StyledWrapper.ts | 87 ++++++++++ .../CollectionConfiguration/index.ts | 2 + .../CollectionStats/CollectionStats.spec.tsx | 24 +++ .../CollectionStats/CollectionStats.tsx | 22 +++ .../CollectionStats/StyledWrapper.ts | 13 ++ .../src/components/CollectionStats/index.ts | 3 + .../components/CopyButton/CopyButton.spec.tsx | 18 ++ .../src/components/CopyButton/CopyButton.tsx | 76 +++++++++ .../components/CopyButton/StyledWrapper.ts | 28 ++++ .../src/components/CopyButton/index.ts | 2 + .../components/EmptyState/EmptyState.spec.tsx | 28 ++++ .../src/components/EmptyState/EmptyState.tsx | 29 ++++ .../components/EmptyState/StyledWrapper.ts | 49 ++++++ .../src/components/EmptyState/index.ts | 2 + .../EnvironmentSummary.spec.tsx | 21 +++ .../EnvironmentSummary/EnvironmentSummary.tsx | 25 +++ .../EnvironmentSummary/StyledWrapper.ts | 9 + .../components/EnvironmentSummary/index.ts | 2 + .../EnvironmentSummaryItem.spec.tsx | 23 +++ .../EnvironmentSummaryItem.tsx | 25 +++ .../EnvironmentSummaryItem/StyledWrapper.ts | 38 +++++ .../EnvironmentSummaryItem/index.ts | 2 + .../src/components/Heading/Heading.spec.tsx | 18 ++ .../src/components/Heading/Heading.tsx | 24 +++ .../src/components/Heading/StyledWrapper.ts | 16 ++ .../oc-docs/src/components/Heading/index.ts | 2 + .../PageWrapper/PageWrapper.spec.tsx | 15 ++ .../components/PageWrapper/PageWrapper.tsx | 21 +++ .../components/PageWrapper/StyledWrapper.ts | 9 + .../src/components/PageWrapper/index.ts | 2 + .../SecretValue/SecretValue.spec.tsx | 12 ++ .../components/SecretValue/SecretValue.tsx | 52 ++++++ .../components/SecretValue/StyledWrapper.ts | 28 ++++ .../src/components/SecretValue/index.ts | 2 + .../src/components/Section/Section.spec.tsx | 16 ++ .../src/components/Section/Section.tsx | 24 +++ .../src/components/Section/StyledWrapper.ts | 11 ++ .../oc-docs/src/components/Section/index.ts | 2 + .../SectionLabel/SectionLabel.spec.tsx | 18 ++ .../components/SectionLabel/SectionLabel.tsx | 24 +++ .../components/SectionLabel/StyledWrapper.ts | 17 ++ .../src/components/SectionLabel/index.ts | 2 + .../oc-docs/src/components/Stat/Stat.spec.tsx | 12 ++ packages/oc-docs/src/components/Stat/Stat.tsx | 17 ++ .../src/components/Stat/StyledWrapper.ts | 25 +++ packages/oc-docs/src/components/Stat/index.ts | 3 + .../components/SubHeading/StyledWrapper.ts | 16 ++ .../components/SubHeading/SubHeading.spec.tsx | 18 ++ .../src/components/SubHeading/SubHeading.tsx | 24 +++ .../src/components/SubHeading/index.ts | 2 + packages/oc-docs/src/constants/constants.ts | 14 ++ packages/oc-docs/src/constants/index.ts | 1 + .../src/pages/Overview/Overview.spec.tsx | 39 +++++ .../oc-docs/src/pages/Overview/Overview.tsx | 132 +++++++++++++++ .../src/pages/Overview/StyledWrapper.ts | 52 ++++++ packages/oc-docs/src/pages/Overview/index.ts | 2 + packages/oc-docs/src/styles/index.css | 18 -- .../src/utils/collectionConfiguration.spec.ts | 28 ++++ .../src/utils/collectionConfiguration.ts | 23 +++ .../oc-docs/src/utils/collectionStats.spec.ts | 52 ++++++ packages/oc-docs/src/utils/collectionStats.ts | 45 +++++ packages/oc-docs/src/utils/common.spec.ts | 35 ++++ 74 files changed, 2129 insertions(+), 95 deletions(-) delete mode 100644 packages/oc-docs/e2e/collection-docs.spec.ts create mode 100644 packages/oc-docs/e2e/overview/overview-documentation.spec.ts create mode 100644 packages/oc-docs/e2e/overview/overview.spec.ts create mode 100644 packages/oc-docs/e2e/utils/index.ts create mode 100644 packages/oc-docs/e2e/utils/locators.ts create mode 100644 packages/oc-docs/src/assets/icons.tsx create mode 100644 packages/oc-docs/src/components/Code/Code.spec.tsx create mode 100644 packages/oc-docs/src/components/Code/Code.tsx create mode 100644 packages/oc-docs/src/components/Code/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/Code/index.ts create mode 100644 packages/oc-docs/src/components/CollectionConfiguration/CollectionConfiguration.spec.tsx create mode 100644 packages/oc-docs/src/components/CollectionConfiguration/CollectionConfiguration.tsx create mode 100644 packages/oc-docs/src/components/CollectionConfiguration/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/CollectionConfiguration/index.ts create mode 100644 packages/oc-docs/src/components/CollectionStats/CollectionStats.spec.tsx create mode 100644 packages/oc-docs/src/components/CollectionStats/CollectionStats.tsx create mode 100644 packages/oc-docs/src/components/CollectionStats/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/CollectionStats/index.ts create mode 100644 packages/oc-docs/src/components/CopyButton/CopyButton.spec.tsx create mode 100644 packages/oc-docs/src/components/CopyButton/CopyButton.tsx create mode 100644 packages/oc-docs/src/components/CopyButton/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/CopyButton/index.ts create mode 100644 packages/oc-docs/src/components/EmptyState/EmptyState.spec.tsx create mode 100644 packages/oc-docs/src/components/EmptyState/EmptyState.tsx create mode 100644 packages/oc-docs/src/components/EmptyState/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/EmptyState/index.ts create mode 100644 packages/oc-docs/src/components/EnvironmentSummary/EnvironmentSummary.spec.tsx create mode 100644 packages/oc-docs/src/components/EnvironmentSummary/EnvironmentSummary.tsx create mode 100644 packages/oc-docs/src/components/EnvironmentSummary/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/EnvironmentSummary/index.ts create mode 100644 packages/oc-docs/src/components/EnvironmentSummaryItem/EnvironmentSummaryItem.spec.tsx create mode 100644 packages/oc-docs/src/components/EnvironmentSummaryItem/EnvironmentSummaryItem.tsx create mode 100644 packages/oc-docs/src/components/EnvironmentSummaryItem/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/EnvironmentSummaryItem/index.ts create mode 100644 packages/oc-docs/src/components/Heading/Heading.spec.tsx create mode 100644 packages/oc-docs/src/components/Heading/Heading.tsx create mode 100644 packages/oc-docs/src/components/Heading/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/Heading/index.ts create mode 100644 packages/oc-docs/src/components/PageWrapper/PageWrapper.spec.tsx create mode 100644 packages/oc-docs/src/components/PageWrapper/PageWrapper.tsx create mode 100644 packages/oc-docs/src/components/PageWrapper/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/PageWrapper/index.ts create mode 100644 packages/oc-docs/src/components/SecretValue/SecretValue.spec.tsx create mode 100644 packages/oc-docs/src/components/SecretValue/SecretValue.tsx create mode 100644 packages/oc-docs/src/components/SecretValue/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/SecretValue/index.ts create mode 100644 packages/oc-docs/src/components/Section/Section.spec.tsx create mode 100644 packages/oc-docs/src/components/Section/Section.tsx create mode 100644 packages/oc-docs/src/components/Section/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/Section/index.ts create mode 100644 packages/oc-docs/src/components/SectionLabel/SectionLabel.spec.tsx create mode 100644 packages/oc-docs/src/components/SectionLabel/SectionLabel.tsx create mode 100644 packages/oc-docs/src/components/SectionLabel/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/SectionLabel/index.ts create mode 100644 packages/oc-docs/src/components/Stat/Stat.spec.tsx create mode 100644 packages/oc-docs/src/components/Stat/Stat.tsx create mode 100644 packages/oc-docs/src/components/Stat/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/Stat/index.ts create mode 100644 packages/oc-docs/src/components/SubHeading/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/SubHeading/SubHeading.spec.tsx create mode 100644 packages/oc-docs/src/components/SubHeading/SubHeading.tsx create mode 100644 packages/oc-docs/src/components/SubHeading/index.ts create mode 100644 packages/oc-docs/src/constants/constants.ts create mode 100644 packages/oc-docs/src/constants/index.ts create mode 100644 packages/oc-docs/src/pages/Overview/Overview.spec.tsx create mode 100644 packages/oc-docs/src/pages/Overview/Overview.tsx create mode 100644 packages/oc-docs/src/pages/Overview/StyledWrapper.ts create mode 100644 packages/oc-docs/src/pages/Overview/index.ts create mode 100644 packages/oc-docs/src/utils/collectionConfiguration.spec.ts create mode 100644 packages/oc-docs/src/utils/collectionConfiguration.ts create mode 100644 packages/oc-docs/src/utils/collectionStats.spec.ts create mode 100644 packages/oc-docs/src/utils/collectionStats.ts create mode 100644 packages/oc-docs/src/utils/common.spec.ts diff --git a/packages/oc-docs/e2e/collection-docs.spec.ts b/packages/oc-docs/e2e/collection-docs.spec.ts deleted file mode 100644 index 2dbabc1..0000000 --- a/packages/oc-docs/e2e/collection-docs.spec.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test.describe('Collection-level documentation', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/'); - await page.waitForSelector('.collection-docs'); - }); - - test('renders at the top of the docs page', async ({ page }) => { - const collectionDocs = page.locator('.collection-docs'); - await expect(collectionDocs).toBeVisible(); - - // Should appear before any endpoint sections - const firstEndpoint = page.locator('.endpoint-section').first(); - const docsBox = await collectionDocs.boundingBox(); - const endpointBox = await firstEndpoint.boundingBox(); - expect(docsBox!.y).toBeLessThan(endpointBox!.y); - }); - - test('renders markdown headings', async ({ page }) => { - const docs = page.locator('.collection-docs'); - await expect(docs.getByRole('heading', { name: 'Getting Started', level: 2 })).toBeVisible(); - await expect(docs.getByRole('heading', { name: 'Authentication', level: 2 })).toBeVisible(); - await expect(docs.getByRole('heading', { name: 'Rate Limits', level: 2 })).toBeVisible(); - }); - - test('renders collection name as page header above docs', async ({ page }) => { - const heading = page - .locator('.playground-content') - .getByRole('heading', { name: 'Bruno Testbench', level: 1 }); - await expect(heading).toBeVisible(); - - const headingBox = await heading.boundingBox(); - const docsBox = await page.locator('.collection-docs').boundingBox(); - expect(headingBox!.y).toBeLessThan(docsBox!.y); - }); - - test('renders markdown paragraphs with inline formatting', async ({ page }) => { - const docs = page.locator('.collection-docs'); - await expect(docs.getByText('comprehensive API collection for testing')).toBeVisible(); - // Bold text - await expect(docs.locator('strong', { hasText: 'OpenCollection' })).toBeVisible(); - }); - - test('renders ordered list', async ({ page }) => { - const docs = page.locator('.collection-docs'); - await expect(docs.getByText('Select an environment')).toBeVisible(); - await expect(docs.getByText('Try out the various API endpoints')).toBeVisible(); - await expect(docs.getByText('Check the response examples')).toBeVisible(); - }); - - test('renders markdown table', async ({ page }) => { - const docs = page.locator('.collection-docs'); - const table = docs.locator('table'); - await expect(table).toBeVisible(); - // Table headers - await expect(table.getByRole('columnheader', { name: 'Environment' })).toBeVisible(); - await expect(table.getByRole('columnheader', { name: 'Base URL' })).toBeVisible(); - await expect(table.getByRole('columnheader', { name: 'Auth' })).toBeVisible(); - // Table data - await expect(table.getByRole('cell', { name: 'Local', exact: true })).toBeVisible(); - await expect(table.getByRole('cell', { name: 'Prod', exact: true })).toBeVisible(); - }); - - test('renders code blocks', async ({ page }) => { - const docs = page.locator('.collection-docs'); - await expect(docs.locator('code', { hasText: 'curl -H' })).toBeVisible(); - }); - - test('renders blockquote', async ({ page }) => { - const docs = page.locator('.collection-docs'); - const blockquote = docs.locator('blockquote'); - await expect(blockquote).toBeVisible(); - await expect(blockquote.getByText('Note')).toBeVisible(); - await expect(blockquote.locator('code', { hasText: 'X-RateLimit-Remaining' })).toBeVisible(); - }); -}); diff --git a/packages/oc-docs/e2e/overview/overview-documentation.spec.ts b/packages/oc-docs/e2e/overview/overview-documentation.spec.ts new file mode 100644 index 0000000..90bf728 --- /dev/null +++ b/packages/oc-docs/e2e/overview/overview-documentation.spec.ts @@ -0,0 +1,56 @@ +import { test, expect, buildOverviewLocators, gotoOverview } from '../utils'; + +/** + * Markdown rendering inside the Overview's documentation section. The sample + * collection's `docs` exercises headings, inline formatting, lists, a table, + * a code block and a blockquote. + */ +test.describe('Overview documentation (markdown)', () => { + test.beforeEach(async ({ page }) => { + await gotoOverview(page); + }); + + test('renders markdown headings', async ({ page }) => { + const { docs } = buildOverviewLocators(page); + await expect(docs.heading('Getting Started')).toBeVisible(); + await expect(docs.heading('Authentication')).toBeVisible(); + await expect(docs.heading('Rate Limits')).toBeVisible(); + }); + + test('renders paragraphs with inline formatting', async ({ page }) => { + const { docs } = buildOverviewLocators(page); + await expect(docs.content().getByText('comprehensive API collection for testing')).toBeVisible(); + await expect(docs.content().locator('strong', { hasText: 'OpenCollection' })).toBeVisible(); + }); + + test('renders an ordered list', async ({ page }) => { + const { docs } = buildOverviewLocators(page); + await expect(docs.content().getByText('Select an environment')).toBeVisible(); + await expect(docs.content().getByText('Try out the various API endpoints')).toBeVisible(); + await expect(docs.content().getByText('Check the response examples')).toBeVisible(); + }); + + test('renders a markdown table', async ({ page }) => { + const { docs } = buildOverviewLocators(page); + const table = docs.table(); + await expect(table).toBeVisible(); + await expect(table.getByRole('columnheader', { name: 'Environment' })).toBeVisible(); + await expect(table.getByRole('columnheader', { name: 'Base URL' })).toBeVisible(); + await expect(table.getByRole('columnheader', { name: 'Auth' })).toBeVisible(); + await expect(table.getByRole('cell', { name: 'Local', exact: true })).toBeVisible(); + await expect(table.getByRole('cell', { name: 'Prod', exact: true })).toBeVisible(); + }); + + test('renders a code block', async ({ page }) => { + const { docs } = buildOverviewLocators(page); + await expect(docs.content().locator('code', { hasText: 'curl -H' })).toBeVisible(); + }); + + test('renders a blockquote', async ({ page }) => { + const { docs } = buildOverviewLocators(page); + const blockquote = docs.content().locator('blockquote'); + await expect(blockquote).toBeVisible(); + await expect(blockquote.getByText('Note')).toBeVisible(); + await expect(blockquote.locator('code', { hasText: 'X-RateLimit-Remaining' })).toBeVisible(); + }); +}); diff --git a/packages/oc-docs/e2e/overview/overview.spec.ts b/packages/oc-docs/e2e/overview/overview.spec.ts new file mode 100644 index 0000000..16169d2 --- /dev/null +++ b/packages/oc-docs/e2e/overview/overview.spec.ts @@ -0,0 +1,109 @@ +import { test, expect, buildOverviewLocators, gotoOverview } from '../utils'; + +/** + * Overview flow for the bundled sample collection ("Bruno Testbench"): + * version + name header, stat counters, environments list, and the + * collection configuration (headers, auth, scripts, tests). + */ +test.describe('Collection Overview', () => { + test.beforeEach(async ({ page }) => { + await gotoOverview(page); + }); + + test('renders the version and collection name in the header', async ({ page }) => { + const overview = buildOverviewLocators(page); + + await test.step('shows the v-prefixed collection version', async () => { + await expect(overview.header.version()).toHaveText('v1.0.0'); + }); + + await test.step('shows the collection name as the page title', async () => { + await expect(overview.header.title()).toHaveText('Bruno Testbench'); + }); + }); + + test('shows request, folder and environment counts', async ({ page }) => { + const overview = buildOverviewLocators(page); + + await expect(overview.stats.all()).toHaveCount(3); + await expect(overview.stats.value('Requests')).toHaveText('10'); + await expect(overview.stats.value('Folders')).toHaveText('0'); + await expect(overview.stats.value('Environments')).toHaveText('2'); + }); + + test('lists each environment with its variable count', async ({ page }) => { + const overview = buildOverviewLocators(page); + + await test.step('shows the Environments section', async () => { + await expect(overview.sectionLabel('Environments')).toBeVisible(); + }); + + await test.step('lists Local and Prod', async () => { + await expect(overview.environments.items()).toHaveCount(2); + await expect(overview.environments.item('Local')).toBeVisible(); + await expect(overview.environments.item('Prod')).toBeVisible(); + }); + + await test.step('shows each environment variable count', async () => { + await expect(overview.environments.variableCount('Local')).toHaveText('2 variables'); + await expect(overview.environments.variableCount('Prod')).toHaveText('2 variables'); + }); + }); + + test('renders the overview documentation section', async ({ page }) => { + const overview = buildOverviewLocators(page); + + await expect(overview.sectionLabel('Overview')).toBeVisible(); + await expect(overview.docs.content()).toBeVisible(); + await expect(overview.docs.heading('Getting Started')).toBeVisible(); + }); + + test.describe('Collection Configuration', () => { + test('renders the headers, auth, script and tests groups', async ({ page }) => { + const overview = buildOverviewLocators(page); + + await expect(overview.sectionLabel('Collection Configuration')).toBeVisible(); + + await test.step('Headers group shows the collection header', async () => { + await expect(overview.configuration.subHeading('Headers')).toBeVisible(); + await expect(overview.configuration.rowValue('collection-header')).toHaveText('collection-header-value'); + }); + + await test.step('Auth group shows the resolved auth mode', async () => { + await expect(overview.configuration.subHeading('Auth')).toBeVisible(); + await expect(overview.configuration.rowValue('Mode')).toHaveText('Bearer Token'); + }); + + await test.step('Script and Tests groups are present', async () => { + await expect(overview.configuration.subHeading('Script')).toBeVisible(); + await expect(overview.configuration.subHeading('Tests')).toBeVisible(); + }); + }); + + test('masks the auth token until the reveal toggle is clicked', async ({ page }) => { + const overview = buildOverviewLocators(page); + const secret = overview.configuration.secret(); + + await test.step('the token is masked by default', async () => { + await expect(secret).toContainText('•'); + await expect(secret).not.toHaveText('{{bearer_auth_token}}'); + }); + + await test.step('clicking the toggle reveals the raw token', async () => { + await overview.configuration.revealSecretButton().click(); + await expect(secret).toHaveText('{{bearer_auth_token}}'); + }); + }); + + test('copies a code snippet to the clipboard', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + const overview = buildOverviewLocators(page); + const copyButton = overview.configuration.copyButtons().first(); + + await test.step('clicking copy confirms with the "Copied" label', async () => { + await copyButton.click(); + await expect(copyButton).toHaveAttribute('aria-label', 'Copied'); + }); + }); + }); +}); diff --git a/packages/oc-docs/e2e/utils/index.ts b/packages/oc-docs/e2e/utils/index.ts new file mode 100644 index 0000000..c609b99 --- /dev/null +++ b/packages/oc-docs/e2e/utils/index.ts @@ -0,0 +1,18 @@ +import { test, expect } from '@playwright/test'; +import type { Page } from '@playwright/test'; + +// Single import point for specs (mirrors how the bruno tests import `test`, +// `expect` and helpers from one place). +export { test, expect }; +export * from './locators'; + +/** + * Navigate to the docs app and wait for the Overview to finish rendering. + * The dev server renders the bundled sample collection at `/`. + */ +export const gotoOverview = async (page: Page): Promise => { + await test.step('Open the docs and wait for the Overview to render', async () => { + await page.goto('/'); + await page.locator('.oc-overview').waitFor({ state: 'visible' }); + }); +}; diff --git a/packages/oc-docs/e2e/utils/locators.ts b/packages/oc-docs/e2e/utils/locators.ts new file mode 100644 index 0000000..7bb1758 --- /dev/null +++ b/packages/oc-docs/e2e/utils/locators.ts @@ -0,0 +1,69 @@ +import type { Page } from '@playwright/test'; + +/** + * Locators for the collection Overview page, grouped by UI area. + * + * Mirrors the `buildCommonLocators` pattern from the bruno tests: a single + * builder returns thunks organised by section, so specs read declaratively and + * every selector lives in one place. Prefers semantic role/text queries and + * falls back to the components' own stable class hooks. + */ +export const buildOverviewLocators = (page: Page) => { + const root = () => page.locator('.oc-overview'); + const stat = (label: string) => root().locator('.collection-stats .stat').filter({ hasText: label }); + const environment = (name: string) => root().locator('.environment-summary-item').filter({ hasText: name }); + const configuration = () => root().locator('.collection-configuration'); + + return { + /** The Overview page root. */ + root, + + /** Headline: version label + collection name. */ + header: { + version: () => root().locator('.overview-version'), + title: () => root().locator('.overview-headline').getByRole('heading', { level: 1 }) + }, + + /** Stat counters (Requests / Folders / Environments). */ + stats: { + all: () => root().locator('.collection-stats .stat'), + item: stat, + value: (label: string) => stat(label).locator('.stat-value') + }, + + /** An uppercase section heading (e.g. "Environments", "Collection Configuration"). */ + sectionLabel: (name: string) => root().getByRole('heading', { level: 2, name }), + + /** Environments list. */ + environments: { + list: () => root().locator('.environment-summary'), + items: () => root().locator('.environment-summary-item'), + item: environment, + variableCount: (name: string) => environment(name).locator('.environment-summary-vars') + }, + + /** Rendered markdown documentation. */ + docs: { + content: () => root().locator('.overview-markdown'), + heading: (name: string) => root().locator('.overview-markdown').getByRole('heading', { name }), + table: () => root().locator('.overview-markdown table') + }, + + /** Collection configuration: headers, auth, scripts and tests. */ + configuration: { + root: configuration, + subHeading: (name: string) => configuration().getByRole('heading', { level: 3, name, exact: true }), + row: (key: string) => configuration().locator('.config-row').filter({ hasText: key }), + rowValue: (key: string) => configuration().locator('.config-row').filter({ hasText: key }).locator('.config-value-cell'), + emptyMessages: () => configuration().locator('.config-empty-message'), + copyButtons: () => configuration().locator('.copy-button'), + secret: () => configuration().locator('.secret-value-text'), + revealSecretButton: () => configuration().locator('.secret-value-toggle') + }, + + /** Dashed empty-state placeholders (shown when a whole section has no data). */ + emptyState: { + headings: () => root().locator('.empty-state-heading') + } + }; +}; diff --git a/packages/oc-docs/src/assets/icons.tsx b/packages/oc-docs/src/assets/icons.tsx new file mode 100644 index 0000000..e16bc72 --- /dev/null +++ b/packages/oc-docs/src/assets/icons.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +/** Shared stroke styling for the empty-state icons. `currentColor` lets the icon + * inherit the surrounding theme colour, so it adapts when the theme changes. */ +const baseIconProps: React.SVGProps = { + width: 20, + height: 20, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: 2, + strokeLinecap: 'round', + strokeLinejoin: 'round', + 'aria-hidden': true +}; + +/** Globe — empty Environments. */ +export const GlobeIcon: React.FC = () => ( + + + + + +); + +/** Book — empty Overview (readme) and empty Collection Configuration. */ +export const BookIcon: React.FC = () => ( + + + + +); diff --git a/packages/oc-docs/src/components/Code/Code.spec.tsx b/packages/oc-docs/src/components/Code/Code.spec.tsx new file mode 100644 index 0000000..82b4890 --- /dev/null +++ b/packages/oc-docs/src/components/Code/Code.spec.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import { Code } from './Code'; + +describe('Code (read-only viewer)', () => { + it('renders the code content', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('const a = 1;'); + }); + + it('renders one line number per line when showLineNumbers is set', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('1'); + expect(html).toContain('2'); + expect(html).toContain('3'); + expect(html).not.toContain('4'); + }); + + it('omits the line-number gutter by default', () => { + const html = renderToStaticMarkup(); + // With no gutter, the line-number spans (1, …) are not rendered. + expect(html).not.toContain('1'); + expect(html).not.toContain('2'); + }); + + it('shows a copy button by default and hides it when showCopy is false', () => { + expect(renderToStaticMarkup()).toContain(')).not.toContain(' import('../../ui/CodeEditor/CodeEditor')); + +interface CodeProps { + /** Source to display (read-only) or edit. */ + code?: string; + /** Language id for highlighting (e.g. "javascript", "json", "bash"). */ + language?: string; + /** Read-only viewer (default) or an editable editor. */ + readOnly?: boolean; + /** Called with the new value in editable mode. */ + onChange?: (value: string) => void; + /** Show a line-number gutter (read-only viewer). */ + showLineNumbers?: boolean; + /** Show the copy-to-clipboard button (read-only viewer). */ + showCopy?: boolean; + /** Editor height in editable mode. */ + height?: string; + className?: string; +} + +type CodeViewerProps = Pick; + +/** + * Read-only, Prism-highlighted code. Lightweight and SSR-safe (highlighting runs + * on the client; the raw code and line numbers render server-side). Optionally + * shows a line-number gutter and a copy-to-clipboard button. + */ +const CodeViewer: React.FC = ({ + code = '', + language = 'text', + showLineNumbers = false, + showCopy = true, + 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 wrapperClassName = ['code-content-wrapper overflow-hidden', className].filter(Boolean).join(' '); + const codeEl = ( +
+      {code}
+    
+ ); + + return ( + +
+ {showCopy && } + + {showLineNumbers ? ( +
+ +
{codeEl}
+
+ ) : ( +
{codeEl}
+ )} +
+
+ ); +}; + +/** + * Code surface used across the docs. Read-only by default (lightweight, + * Prism-highlighted, SSR-safe); pass `readOnly={false}` with `onChange` to get a + * full editor instead. The editor is loaded on demand, so read-only usage stays + * cheap. Reuse anywhere a code block (viewer or editor) is needed. + */ +export const Code: React.FC = ({ + code = '', + language = 'text', + readOnly = true, + onChange, + showLineNumbers = false, + showCopy = true, + height = '200px', + className +}) => { + if (!readOnly) { + return ( + }> + {})} language={language} height={height} /> + + ); + } + + return ( + + ); +}; + +export default Code; diff --git a/packages/oc-docs/src/components/Code/StyledWrapper.ts b/packages/oc-docs/src/components/Code/StyledWrapper.ts new file mode 100644 index 0000000..891c17f --- /dev/null +++ b/packages/oc-docs/src/components/Code/StyledWrapper.ts @@ -0,0 +1,94 @@ +import styled from '@emotion/styled'; + +export const StyledWrapper = styled.div` + background-color: var(--code-bg); + border: 1px solid #ebeef1; + border-radius: 8px; + + /* The copy button owns its own look (see CopyButton); here we only place it + and reveal it on hover/keyboard focus. */ + .code-copy-floating { + position: absolute; + top: 8px; + right: 8px; + z-index: 1; + opacity: 0; + transition: opacity 0.15s ease; + } + &:hover .code-copy-floating, + .code-copy-floating:focus-visible { + opacity: 1; + } + + .code-content { + background-color: var(--code-bg); + color: var(--text-primary); + } + + .code-content::-webkit-scrollbar { + width: 6px; + height: 6px; + } + .code-content::-webkit-scrollbar-track { + background: transparent; + } + .code-content::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.1); + border-radius: 4px; + } + .code-content:hover::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.2); + } + .code-content::-webkit-scrollbar-thumb:hover { + background-color: rgba(0, 0, 0, 0.3); + } + + .code-content pre { + font-size: 13px; + color: var(--text-primary); + line-height: 1.65; + } + .code-content code { + color: var(--text-primary); + font-size: 13px; + } + + /* Line-numbered variant (collection-config code snippets): a light background + with Fira Code 12px text. The gutter shares the exact same font metrics + + top padding as the code so the numbers line up with their rows; only the + code scrolls horizontally. */ + .code-content-numbered { + display: flex; + align-items: stretch; + background-color: var(--oc-bg); + } + .code-line-numbers { + flex-shrink: 0; + padding: 1rem 0 1rem 1rem; + text-align: right; + user-select: none; + color: var(--text-tertiary); + } + .code-line-numbers span { + display: block; + font-family: 'Fira Code', var(--font-mono); + font-weight: 400; + font-size: 0.75rem; + line-height: 1.65; + letter-spacing: normal; + } + .code-content--numbered { + flex: 1; + min-width: 0; + padding: 1rem 1rem 1rem 0.75rem; + background-color: var(--background-light); + } + .code-content--numbered pre, + .code-content--numbered code { + font-family: 'Fira Code', var(--font-mono); + font-weight: 400; + font-size: 0.75rem; + line-height: 1.65; + letter-spacing: normal; + } +`; diff --git a/packages/oc-docs/src/components/Code/index.ts b/packages/oc-docs/src/components/Code/index.ts new file mode 100644 index 0000000..5bb8a8b --- /dev/null +++ b/packages/oc-docs/src/components/Code/index.ts @@ -0,0 +1,2 @@ +export { Code } from './Code'; +export { default } from './Code'; diff --git a/packages/oc-docs/src/components/CollectionConfiguration/CollectionConfiguration.spec.tsx b/packages/oc-docs/src/components/CollectionConfiguration/CollectionConfiguration.spec.tsx new file mode 100644 index 0000000..8789727 --- /dev/null +++ b/packages/oc-docs/src/components/CollectionConfiguration/CollectionConfiguration.spec.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import type { HttpRequestHeader } from '@opencollection/types/requests/http'; +import { CollectionConfiguration } from './CollectionConfiguration'; +import { AUTH_MODE_LABELS } from '../../constants'; + +describe('CollectionConfiguration', () => { + it('renders nothing when there is no configuration', () => { + const html = renderToStaticMarkup(); + expect(html).toBe(''); + }); + + it('renders enabled headers, masks secret auth values and shows script/test code', () => { + const headers: HttpRequestHeader[] = [ + { name: 'Accept', value: 'application/json' }, + { name: 'X-Disabled', value: 'nope', disabled: true } + ]; + + const html = renderToStaticMarkup( + {})' }} + authModeLabels={AUTH_MODE_LABELS} + /> + ); + + // Enabled headers shown, disabled ones filtered out + expect(html).toContain('Accept'); + expect(html).toContain('application/json'); + expect(html).not.toContain('X-Disabled'); + + // Auth mode resolved via the supplied labels; username shown, password masked + expect(html).toContain('Basic Auth'); + expect(html).toContain('user@example.com'); + expect(html).not.toContain('s3cr3t'); + + // Script and test sections render + expect(html).toContain('Pre-Request'); + expect(html).toContain('Tests'); + }); + + it('falls back to the raw auth type when no label is supplied', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('bearer'); + }); + + it('shows an empty hint for each subsection that has no items (when some config exists)', () => { + // Only auth is configured: headers, script and tests fall back to their hints. + const html = renderToStaticMarkup(); + expect(html).toContain('Add headers to inherit in all requests in the collection'); + expect(html).toContain('Add scripts to run for all requests in the collection'); + expect(html).toContain('Add tests to run for all requests in the collection'); + // Auth has data, so it shows rows rather than a hint. + expect(html).toContain('bearer'); + expect(html).not.toContain('Add authentication to inherit'); + }); +}); diff --git a/packages/oc-docs/src/components/CollectionConfiguration/CollectionConfiguration.tsx b/packages/oc-docs/src/components/CollectionConfiguration/CollectionConfiguration.tsx new file mode 100644 index 0000000..9ae0c88 --- /dev/null +++ b/packages/oc-docs/src/components/CollectionConfiguration/CollectionConfiguration.tsx @@ -0,0 +1,154 @@ +import React from 'react'; +import type { HttpRequestHeader } from '@opencollection/types/requests/http'; +import type { Auth } from '@opencollection/types/common/auth'; +import { Code } from '../Code'; +import { SecretValue } from '../SecretValue'; +import { SubHeading } from '../SubHeading'; +import { CollectionConfigurationWrapper } from './StyledWrapper'; + +interface CollectionScripts { + preRequest?: string; + postResponse?: string; + tests?: string; +} + +interface CollectionConfigurationProps { + headers?: HttpRequestHeader[]; + auth?: Auth; + /** Pre-normalised scripts ({ preRequest, postResponse, tests }) supplied by the host. */ + scripts?: CollectionScripts; + /** Maps an auth `type` to a display label (e.g. basic -> "Basic Auth"); supplied by the host. */ + authModeLabels?: Record; +} + +const containsVariable = (value: string): boolean => value.includes('{{'); + +const resolveAuthMode = (auth: Auth, labels: Record): string => + auth === 'inherit' ? 'Inherit' : labels[auth.type] || auth.type; + +const ConfigRow: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => ( +
+
{label}
+
{children}
+
+); + +const PlainValue: React.FC<{ value: string }> = ({ value }) => ( + {value} +); + +/** Italic placeholder shown for a configuration subsection that has no items yet. */ +const EmptyMessage: React.FC<{ children: React.ReactNode }> = ({ children }) => ( +

{children}

+); + +/** Read-only rows for the collection-level auth, derived from the auth `type`. */ +const AuthRows: React.FC<{ auth: Auth; labels: Record }> = ({ auth, labels }) => { + const rows: React.ReactNode[] = [ + + ]; + + if (auth !== 'inherit') { + switch (auth.type) { + case 'basic': + case 'digest': + case 'ntlm': + if (auth.username) rows.push(); + if (auth.password) rows.push(); + break; + case 'bearer': + if (auth.token) rows.push(); + break; + case 'apikey': + if (auth.key) rows.push(); + if (auth.value) rows.push(); + if (auth.placement) rows.push(); + break; + default: + break; + } + } + + return
{rows}
; +}; + +/** + * Read-only view of a collection's request defaults — headers, auth, scripts and tests. + * Fully prop-driven (no app constants/utils) so it can be lifted into a component package. + * Reuses `SecretValue` for sensitive values and `Code` for script/test snippets. + */ +export const CollectionConfiguration: React.FC = ({ + headers = [], + auth, + scripts = {}, + authModeLabels = {} +}) => { + const visibleHeaders = headers.filter((header) => header && header.name && header.disabled !== true); + const hasScripts = Boolean(scripts.preRequest || scripts.postResponse); + const hasConfig = visibleHeaders.length > 0 || Boolean(auth) || hasScripts || Boolean(scripts.tests); + + if (!hasConfig) { + return null; + } + + return ( + +
+ Headers + {visibleHeaders.length > 0 ? ( +
+ {visibleHeaders.map((header, index) => ( + + + + ))} +
+ ) : ( + Add headers to inherit in all requests in the collection + )} +
+ +
+ Auth + {auth ? ( + + ) : ( + Add authentication to inherit in all requests in the collection + )} +
+ +
+ Script + {hasScripts ? ( + <> + {scripts.preRequest && ( +
+

Pre-Request

+ +
+ )} + {scripts.postResponse && ( +
+

Post-Response

+ +
+ )} + + ) : ( + Add scripts to run for all requests in the collection + )} +
+ +
+ Tests + {scripts.tests ? ( + + ) : ( + Add tests to run for all requests in the collection + )} +
+
+ ); +}; + +export default CollectionConfiguration; diff --git a/packages/oc-docs/src/components/CollectionConfiguration/StyledWrapper.ts b/packages/oc-docs/src/components/CollectionConfiguration/StyledWrapper.ts new file mode 100644 index 0000000..1681c54 --- /dev/null +++ b/packages/oc-docs/src/components/CollectionConfiguration/StyledWrapper.ts @@ -0,0 +1,87 @@ +import styled from '@emotion/styled'; + +export const CollectionConfigurationWrapper = styled.div` + .config-group + .config-group { + margin-top: 1.75rem; + } + + .config-empty-message { + margin: 0; + font-family: var(--font-sans); + font-weight: 500; + font-style: italic; + font-size: 0.8125rem; + line-height: 1; + letter-spacing: normal; + color: var(--text-secondary); + } + + .config-box { + margin: 0; + border: 1px solid var(--border-color); + border-radius: var(--oc-radius); + overflow: hidden; + } + + .config-row { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.375rem 0.5rem; + border-bottom: 1px solid var(--border-color); + font-size: 0.8125rem; + min-height: 2.25rem; + } + .config-row:last-child { + border-bottom: none; + } + + .config-key { + font-family: var(--font-sans); + font-weight: 400; + font-size: 0.75rem; + line-height: 1; + letter-spacing: normal; + color: var(--text-secondary); + min-width: 7rem; + flex-shrink: 0; + } + .config-value-cell { + margin: 0; + flex: 1; + min-width: 0; + /* Single source of truth for value typography — every value rendered in the + cell (plain, variable, muted, and the masked SecretValue) inherits it. */ + font-family: 'Fira Code', var(--font-mono); + font-weight: 400; + font-size: 0.75rem; + line-height: 1; + letter-spacing: normal; + color: var(--text-primary); + } + .config-value { + word-break: break-all; + } + .config-value--var { + color: var(--primary-color); + } + /* SecretValue defaults to the app mono token; re-point it to the cell's font + so secret values match the rest of the section (size/weight inherit). The + descendant selector keeps specificity above SecretValue's own rule. */ + .config-value-cell .secret-value-text { + font-family: inherit; + } + + .script-block + .script-block { + margin-top: 1rem; + } + .script-label { + font-family: var(--font-sans); + font-weight: 500; + font-size: 0.625rem; + line-height: 1; + letter-spacing: 0.0525rem; + color: var(--text-tertiary); + margin: 0 0 0.375rem 0; + } +`; diff --git a/packages/oc-docs/src/components/CollectionConfiguration/index.ts b/packages/oc-docs/src/components/CollectionConfiguration/index.ts new file mode 100644 index 0000000..9fb80c8 --- /dev/null +++ b/packages/oc-docs/src/components/CollectionConfiguration/index.ts @@ -0,0 +1,2 @@ +export { CollectionConfiguration } from './CollectionConfiguration'; +export { default } from './CollectionConfiguration'; diff --git a/packages/oc-docs/src/components/CollectionStats/CollectionStats.spec.tsx b/packages/oc-docs/src/components/CollectionStats/CollectionStats.spec.tsx new file mode 100644 index 0000000..3d0d0d2 --- /dev/null +++ b/packages/oc-docs/src/components/CollectionStats/CollectionStats.spec.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import { CollectionStats } from './CollectionStats'; + +describe('CollectionStats', () => { + it('renders each stat value with its label', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('30'); + expect(html).toContain('Requests'); + expect(html).toContain('7'); + expect(html).toContain('Folders'); + expect(html).toContain('3'); + expect(html).toContain('Environments'); + }); +}); diff --git a/packages/oc-docs/src/components/CollectionStats/CollectionStats.tsx b/packages/oc-docs/src/components/CollectionStats/CollectionStats.tsx new file mode 100644 index 0000000..0a3f332 --- /dev/null +++ b/packages/oc-docs/src/components/CollectionStats/CollectionStats.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Stat } from '../Stat'; +import type { StatItem } from '../Stat'; +import { CollectionStatsWrapper } from './StyledWrapper'; + +interface CollectionStatsProps { + stats: StatItem[]; +} + +/** + * A row of labelled stats. Fully prop-driven (labels + values supplied by the host) + * and composed from the atomic `Stat` component. + */ +export const CollectionStats: React.FC = ({ stats }) => ( + + {stats.map((stat, index) => ( + + ))} + +); + +export default CollectionStats; diff --git a/packages/oc-docs/src/components/CollectionStats/StyledWrapper.ts b/packages/oc-docs/src/components/CollectionStats/StyledWrapper.ts new file mode 100644 index 0000000..a9be8e7 --- /dev/null +++ b/packages/oc-docs/src/components/CollectionStats/StyledWrapper.ts @@ -0,0 +1,13 @@ +import styled from '@emotion/styled'; + +export const CollectionStatsWrapper = styled.div` + display: flex; + align-items: stretch; + + /* Thin vertical divider between adjacent stats (equal spacing each side). */ + .stat + .stat { + margin-left: 1.5rem; + padding-left: 1.5rem; + border-left: 1px solid var(--border-color); + } +`; diff --git a/packages/oc-docs/src/components/CollectionStats/index.ts b/packages/oc-docs/src/components/CollectionStats/index.ts new file mode 100644 index 0000000..2b77195 --- /dev/null +++ b/packages/oc-docs/src/components/CollectionStats/index.ts @@ -0,0 +1,3 @@ +export { CollectionStats } from './CollectionStats'; +export type { StatItem } from '../Stat'; +export { default } from './CollectionStats'; diff --git a/packages/oc-docs/src/components/CopyButton/CopyButton.spec.tsx b/packages/oc-docs/src/components/CopyButton/CopyButton.spec.tsx new file mode 100644 index 0000000..2151fd7 --- /dev/null +++ b/packages/oc-docs/src/components/CopyButton/CopyButton.spec.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import { CopyButton } from './CopyButton'; + +describe('CopyButton', () => { + it('renders an accessible button with the default copy label', () => { + const html = renderToStaticMarkup(); + expect(html).toContain(' { + const html = renderToStaticMarkup(); + expect(html).toContain('aria-label="Copy code"'); + }); +}); diff --git a/packages/oc-docs/src/components/CopyButton/CopyButton.tsx b/packages/oc-docs/src/components/CopyButton/CopyButton.tsx new file mode 100644 index 0000000..c8ef61b --- /dev/null +++ b/packages/oc-docs/src/components/CopyButton/CopyButton.tsx @@ -0,0 +1,76 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { CopyButtonWrapper } from './StyledWrapper'; + +interface CopyButtonProps { + /** Text written to the clipboard when pressed. */ + text: string; + /** Accessible label in the idle state. */ + label?: string; + /** Accessible label shown briefly after a successful copy. */ + copiedLabel?: string; + /** How long (ms) the confirmation state persists. */ + resetAfterMs?: number; + className?: string; +} + +const CopyGlyph: React.FC = () => ( + +); + +const CheckGlyph: React.FC = () => ( + +); + +/** + * Accessible copy-to-clipboard button. Copies `text` on press and briefly swaps + * to a confirmation icon, with the accessible label switching to match. Fully + * prop-driven and unaware of where it sits, so it can be reused anywhere. + */ +export const CopyButton: React.FC = ({ + text, + label = 'Copy', + copiedLabel = 'Copied', + resetAfterMs = 2000, + className +}) => { + const [copied, setCopied] = useState(false); + const timeoutRef = useRef | undefined>(undefined); + + // Cancel a pending reset on unmount so we never set state on a gone component. + useEffect( + () => () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }, + [] + ); + + const handleCopy = useCallback(async () => { + if (!text || !navigator.clipboard) return; + try { + await navigator.clipboard.writeText(text); + setCopied(true); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setCopied(false), resetAfterMs); + } catch { + // Clipboard unavailable (e.g. insecure context) — fail silently. + } + }, [text, resetAfterMs]); + + return ( + + {copied ? : } + + ); +}; + +export default CopyButton; diff --git a/packages/oc-docs/src/components/CopyButton/StyledWrapper.ts b/packages/oc-docs/src/components/CopyButton/StyledWrapper.ts new file mode 100644 index 0000000..e0a9cb0 --- /dev/null +++ b/packages/oc-docs/src/components/CopyButton/StyledWrapper.ts @@ -0,0 +1,28 @@ +import styled from '@emotion/styled'; + +/** + * Icon-only copy button. Owns its own look (the caller decides placement), so it + * stays reusable wherever a copy affordance is needed. + */ +export const CopyButtonWrapper = styled.button` + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.3rem; + border: 1px solid var(--border-color); + border-radius: var(--oc-radius); + color: var(--text-tertiary); + background-color: var(--background-light); + cursor: pointer; + transition: color 0.15s ease, background-color 0.15s ease; + + &:hover { + color: var(--text-secondary); + background-color: var(--badge-bg); + } + + &:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 1px; + } +`; diff --git a/packages/oc-docs/src/components/CopyButton/index.ts b/packages/oc-docs/src/components/CopyButton/index.ts new file mode 100644 index 0000000..b18e434 --- /dev/null +++ b/packages/oc-docs/src/components/CopyButton/index.ts @@ -0,0 +1,2 @@ +export { CopyButton } from './CopyButton'; +export { default } from './CopyButton'; diff --git a/packages/oc-docs/src/components/EmptyState/EmptyState.spec.tsx b/packages/oc-docs/src/components/EmptyState/EmptyState.spec.tsx new file mode 100644 index 0000000..67a5d51 --- /dev/null +++ b/packages/oc-docs/src/components/EmptyState/EmptyState.spec.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import { EmptyState } from './EmptyState'; + +describe('EmptyState', () => { + it('renders the icon, heading and subheading', () => { + const html = renderToStaticMarkup( + } + heading="No environments yet" + subheading="Add one in Bruno to manage base URLs and variables." + /> + ); + + expect(html).toContain('No environments yet'); + expect(html).toContain('Add one in Bruno to manage base URLs and variables.'); + expect(html).toContain('data-testid="icon"'); + }); + + it('marks the icon as decorative for assistive technology', () => { + const html = renderToStaticMarkup( + } heading="Heading" subheading="Subheading" /> + ); + + expect(html).toContain('aria-hidden="true"'); + }); +}); diff --git a/packages/oc-docs/src/components/EmptyState/EmptyState.tsx b/packages/oc-docs/src/components/EmptyState/EmptyState.tsx new file mode 100644 index 0000000..13cc0e1 --- /dev/null +++ b/packages/oc-docs/src/components/EmptyState/EmptyState.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { EmptyStateWrapper } from './StyledWrapper'; + +interface EmptyStateProps { + /** Icon shown in the circular badge (decorative — hidden from assistive tech). */ + icon: React.ReactNode; + /** Bold primary message, e.g. "No environments yet". */ + heading: React.ReactNode; + /** Supporting description shown beneath the heading. */ + subheading: React.ReactNode; + className?: string; +} + +/** + * Placeholder shown when a section has no content. Renders a dashed card with a + * circular icon badge, a heading and a subheading. Icon-agnostic and fully + * prop-driven, so it can be reused for any empty section across pages. + */ +export const EmptyState: React.FC = ({ icon, heading, subheading, className }) => ( + + +

{heading}

+

{subheading}

+
+); + +export default EmptyState; diff --git a/packages/oc-docs/src/components/EmptyState/StyledWrapper.ts b/packages/oc-docs/src/components/EmptyState/StyledWrapper.ts new file mode 100644 index 0000000..0331535 --- /dev/null +++ b/packages/oc-docs/src/components/EmptyState/StyledWrapper.ts @@ -0,0 +1,49 @@ +import styled from '@emotion/styled'; + +/** + * Dashed placeholder card for an empty section: a circular icon badge stacked + * above a heading and a supporting subheading, all centered. Styling is driven + * entirely by the docs theme variables so it stays portable across pages. + */ +export const EmptyStateWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + min-height: 16rem; + padding: 2.5rem 2rem; + border: 1px dashed var(--border-color); + border-radius: var(--oc-radius); + + .empty-state-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 3rem; + height: 3rem; + margin-bottom: 1.25rem; + border-radius: 50%; + background: var(--badge-bg); + color: var(--text-tertiary); + } + + .empty-state-heading { + margin: 0 0 0.5rem 0; + font-family: var(--font-sans); + font-weight: 600; + font-size: 0.9375rem; + line-height: 1.3; + color: var(--text-primary); + } + + .empty-state-subheading { + margin: 0; + max-width: 22rem; + font-family: var(--font-sans); + font-weight: 400; + font-size: 0.8125rem; + line-height: 1.5; + color: var(--text-secondary); + } +`; diff --git a/packages/oc-docs/src/components/EmptyState/index.ts b/packages/oc-docs/src/components/EmptyState/index.ts new file mode 100644 index 0000000..aca0492 --- /dev/null +++ b/packages/oc-docs/src/components/EmptyState/index.ts @@ -0,0 +1,2 @@ +export { EmptyState } from './EmptyState'; +export { default } from './EmptyState'; diff --git a/packages/oc-docs/src/components/EnvironmentSummary/EnvironmentSummary.spec.tsx b/packages/oc-docs/src/components/EnvironmentSummary/EnvironmentSummary.spec.tsx new file mode 100644 index 0000000..d1943e6 --- /dev/null +++ b/packages/oc-docs/src/components/EnvironmentSummary/EnvironmentSummary.spec.tsx @@ -0,0 +1,21 @@ +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/oc-docs/src/components/EnvironmentSummary/EnvironmentSummary.tsx b/packages/oc-docs/src/components/EnvironmentSummary/EnvironmentSummary.tsx new file mode 100644 index 0000000..8cf3fbd --- /dev/null +++ b/packages/oc-docs/src/components/EnvironmentSummary/EnvironmentSummary.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import type { Environment } from '@opencollection/types/config/environments'; +import { EnvironmentSummaryItem } from '../EnvironmentSummaryItem'; +import { EnvironmentSummaryWrapper } from './StyledWrapper'; + +interface EnvironmentSummaryProps { + environments: Environment[]; +} + +/** Read-only list of a collection's environments, composed from `EnvironmentSummaryItem`. */ +export const EnvironmentSummary: React.FC = ({ environments }) => { + if (!environments.length) { + return null; + } + + return ( + + {environments.map((environment, index) => ( + + ))} + + ); +}; + +export default EnvironmentSummary; diff --git a/packages/oc-docs/src/components/EnvironmentSummary/StyledWrapper.ts b/packages/oc-docs/src/components/EnvironmentSummary/StyledWrapper.ts new file mode 100644 index 0000000..8f5176a --- /dev/null +++ b/packages/oc-docs/src/components/EnvironmentSummary/StyledWrapper.ts @@ -0,0 +1,9 @@ +import styled from '@emotion/styled'; + +export const EnvironmentSummaryWrapper = styled.ul` + display: flex; + flex-direction: column; + list-style: none; + margin: 0; + padding: 0; +`; diff --git a/packages/oc-docs/src/components/EnvironmentSummary/index.ts b/packages/oc-docs/src/components/EnvironmentSummary/index.ts new file mode 100644 index 0000000..2ed7d81 --- /dev/null +++ b/packages/oc-docs/src/components/EnvironmentSummary/index.ts @@ -0,0 +1,2 @@ +export { EnvironmentSummary } from './EnvironmentSummary'; +export { default } from './EnvironmentSummary'; diff --git a/packages/oc-docs/src/components/EnvironmentSummaryItem/EnvironmentSummaryItem.spec.tsx b/packages/oc-docs/src/components/EnvironmentSummaryItem/EnvironmentSummaryItem.spec.tsx new file mode 100644 index 0000000..bbc3e70 --- /dev/null +++ b/packages/oc-docs/src/components/EnvironmentSummaryItem/EnvironmentSummaryItem.spec.tsx @@ -0,0 +1,23 @@ +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/oc-docs/src/components/EnvironmentSummaryItem/EnvironmentSummaryItem.tsx b/packages/oc-docs/src/components/EnvironmentSummaryItem/EnvironmentSummaryItem.tsx new file mode 100644 index 0000000..30d80d7 --- /dev/null +++ b/packages/oc-docs/src/components/EnvironmentSummaryItem/EnvironmentSummaryItem.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import type { Environment } from '@opencollection/types/config/environments'; +import { EnvironmentSummaryItemWrapper } from './StyledWrapper'; + +/** Human-readable variable count, e.g. "1 variable" / "5 variables". */ +const formatVariableCount = (count: number): string => `${count} variable${count === 1 ? '' : 's'}`; + +interface EnvironmentSummaryItemProps { + environment: Environment; +} + +/** A single environment row: color dot, name, and variable count. Renders an `
  • `. */ +export const EnvironmentSummaryItem: React.FC = ({ environment }) => ( + + + {environment.name} + + {formatVariableCount(environment.variables?.length ?? 0)} + +); + +export default EnvironmentSummaryItem; diff --git a/packages/oc-docs/src/components/EnvironmentSummaryItem/StyledWrapper.ts b/packages/oc-docs/src/components/EnvironmentSummaryItem/StyledWrapper.ts new file mode 100644 index 0000000..49cf223 --- /dev/null +++ b/packages/oc-docs/src/components/EnvironmentSummaryItem/StyledWrapper.ts @@ -0,0 +1,38 @@ +import styled from '@emotion/styled'; + +export const EnvironmentSummaryItemWrapper = 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; + + &:last-child { + border-bottom: none; + } + + .environment-summary-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + background: var(--text-tertiary); + } + + .environment-summary-name { + color: var(--text-primary); + } + + .environment-summary-spacer { + flex: 1; + } + + .environment-summary-vars { + color: var(--text-tertiary); + flex-shrink: 0; + } +`; diff --git a/packages/oc-docs/src/components/EnvironmentSummaryItem/index.ts b/packages/oc-docs/src/components/EnvironmentSummaryItem/index.ts new file mode 100644 index 0000000..71e131f --- /dev/null +++ b/packages/oc-docs/src/components/EnvironmentSummaryItem/index.ts @@ -0,0 +1,2 @@ +export { EnvironmentSummaryItem } from './EnvironmentSummaryItem'; +export { default } from './EnvironmentSummaryItem'; diff --git a/packages/oc-docs/src/components/Heading/Heading.spec.tsx b/packages/oc-docs/src/components/Heading/Heading.spec.tsx new file mode 100644 index 0000000..393e2d7 --- /dev/null +++ b/packages/oc-docs/src/components/Heading/Heading.spec.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import { Heading } from './Heading'; + +describe('Heading', () => { + it('renders its children as an h1 by default', () => { + const html = renderToStaticMarkup(Hotel Booking API); + expect(html).toContain('Hotel Booking API'); + expect(html).toContain(' { + const html = renderToStaticMarkup(Section); + expect(html).toContain(' = ({ children, as = 'h1', className }) => ( + + {children} + +); + +export default Heading; diff --git a/packages/oc-docs/src/components/Heading/StyledWrapper.ts b/packages/oc-docs/src/components/Heading/StyledWrapper.ts new file mode 100644 index 0000000..23825f2 --- /dev/null +++ b/packages/oc-docs/src/components/Heading/StyledWrapper.ts @@ -0,0 +1,16 @@ +import styled from '@emotion/styled'; + +/** + * Shared heading typography: + * Inter · Semi Bold (600) · 20px (1.25rem) · 100% line-height · -0.5px letter-spacing. + * Font size is in rem so it scales with the root font size. + */ +export const HeadingWrapper = styled.h1` + margin: 0; + font-family: var(--font-sans); + font-weight: 600; + font-size: 1.25rem; + line-height: 1; + letter-spacing: -0.5px; + color: var(--text-primary); +`; diff --git a/packages/oc-docs/src/components/Heading/index.ts b/packages/oc-docs/src/components/Heading/index.ts new file mode 100644 index 0000000..492b779 --- /dev/null +++ b/packages/oc-docs/src/components/Heading/index.ts @@ -0,0 +1,2 @@ +export { Heading } from './Heading'; +export { default } from './Heading'; diff --git a/packages/oc-docs/src/components/PageWrapper/PageWrapper.spec.tsx b/packages/oc-docs/src/components/PageWrapper/PageWrapper.spec.tsx new file mode 100644 index 0000000..3b34d0e --- /dev/null +++ b/packages/oc-docs/src/components/PageWrapper/PageWrapper.spec.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import { PageWrapper } from './PageWrapper'; + +describe('PageWrapper', () => { + it('renders its children', () => { + const html = renderToStaticMarkup( + + page content + + ); + expect(html).toContain('page content'); + }); +}); diff --git a/packages/oc-docs/src/components/PageWrapper/PageWrapper.tsx b/packages/oc-docs/src/components/PageWrapper/PageWrapper.tsx new file mode 100644 index 0000000..0bc6666 --- /dev/null +++ b/packages/oc-docs/src/components/PageWrapper/PageWrapper.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { PageWrapperContainer } from './StyledWrapper'; + +interface PageWrapperProps { + children: React.ReactNode; + /** Optional extra class for the page (merged with the base `page-wrapper` class). */ + className?: string; +} + +/** + * Common layout wrapper for pages. Applies the shared page padding so every page has + * consistent gutters; pages render their own content inside it. Presentational and + * prop-driven, so it can be lifted into a component package. + */ +export const PageWrapper: React.FC = ({ children, className }) => ( + + {children} + +); + +export default PageWrapper; diff --git a/packages/oc-docs/src/components/PageWrapper/StyledWrapper.ts b/packages/oc-docs/src/components/PageWrapper/StyledWrapper.ts new file mode 100644 index 0000000..16f5c5e --- /dev/null +++ b/packages/oc-docs/src/components/PageWrapper/StyledWrapper.ts @@ -0,0 +1,9 @@ +import styled from '@emotion/styled'; + +/** + * Shared page padding: 15px top/bottom (0.9375rem), 48px left/right (3rem). + * Expressed in rems so it scales with the root font size. + */ +export const PageWrapperContainer = styled.div` + padding: 0.9375rem 3.5rem; +`; diff --git a/packages/oc-docs/src/components/PageWrapper/index.ts b/packages/oc-docs/src/components/PageWrapper/index.ts new file mode 100644 index 0000000..e97c548 --- /dev/null +++ b/packages/oc-docs/src/components/PageWrapper/index.ts @@ -0,0 +1,2 @@ +export { PageWrapper } from './PageWrapper'; +export { default } from './PageWrapper'; diff --git a/packages/oc-docs/src/components/SecretValue/SecretValue.spec.tsx b/packages/oc-docs/src/components/SecretValue/SecretValue.spec.tsx new file mode 100644 index 0000000..9ad91fb --- /dev/null +++ b/packages/oc-docs/src/components/SecretValue/SecretValue.spec.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import { SecretValue, SECRET_MASK } from './SecretValue'; + +describe('SecretValue', () => { + it('masks the value by default and never renders the real value', () => { + const html = renderToStaticMarkup(); + expect(html).not.toContain('s3cr3t-token'); + expect(html).toContain(SECRET_MASK); + }); +}); diff --git a/packages/oc-docs/src/components/SecretValue/SecretValue.tsx b/packages/oc-docs/src/components/SecretValue/SecretValue.tsx new file mode 100644 index 0000000..e8946ed --- /dev/null +++ b/packages/oc-docs/src/components/SecretValue/SecretValue.tsx @@ -0,0 +1,52 @@ +import React, { useState } from 'react'; +import { SecretValueWrapper } from './StyledWrapper'; + +/** Fixed-length mask so the real length of the secret is never leaked. */ +export const SECRET_MASK = '•'.repeat(12); + +interface SecretValueProps { + value: string; +} + +const EyeIcon: React.FC<{ off?: boolean }> = ({ off }) => ( + +); + +/** + * A masked, reveal-on-demand value (used for passwords/tokens in read-only config). + * The masked form is a fixed length so the secret's true length is not exposed. + */ +export const SecretValue: React.FC = ({ value }) => { + const [revealed, setRevealed] = useState(false); + + return ( + + {/* While masked, hide the placeholder dots from assistive tech so they aren't + read out as "bullet" repeatedly; the toggle's label conveys it's hidden. */} + {revealed ? value : SECRET_MASK} + + + ); +}; + +export default SecretValue; diff --git a/packages/oc-docs/src/components/SecretValue/StyledWrapper.ts b/packages/oc-docs/src/components/SecretValue/StyledWrapper.ts new file mode 100644 index 0000000..66e6550 --- /dev/null +++ b/packages/oc-docs/src/components/SecretValue/StyledWrapper.ts @@ -0,0 +1,28 @@ +import styled from '@emotion/styled'; + +export const SecretValueWrapper = styled.span` + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + + .secret-value-text { + font-family: var(--font-mono); + color: var(--text-primary); + word-break: break-all; + } + + .secret-value-toggle { + display: inline-flex; + align-items: center; + padding: 0; + background: none; + border: none; + cursor: pointer; + color: var(--text-tertiary); + flex-shrink: 0; + } + .secret-value-toggle:hover { + color: var(--text-secondary); + } +`; diff --git a/packages/oc-docs/src/components/SecretValue/index.ts b/packages/oc-docs/src/components/SecretValue/index.ts new file mode 100644 index 0000000..3ea9d93 --- /dev/null +++ b/packages/oc-docs/src/components/SecretValue/index.ts @@ -0,0 +1,2 @@ +export { SecretValue, SECRET_MASK } from './SecretValue'; +export { default } from './SecretValue'; diff --git a/packages/oc-docs/src/components/Section/Section.spec.tsx b/packages/oc-docs/src/components/Section/Section.spec.tsx new file mode 100644 index 0000000..9b28deb --- /dev/null +++ b/packages/oc-docs/src/components/Section/Section.spec.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import { Section } from './Section'; + +describe('Section', () => { + it('renders the label and its content', () => { + const html = renderToStaticMarkup( +
    +

    List content

    +
    + ); + expect(html).toContain('Environments'); + expect(html).toContain('List content'); + }); +}); diff --git a/packages/oc-docs/src/components/Section/Section.tsx b/packages/oc-docs/src/components/Section/Section.tsx new file mode 100644 index 0000000..e3abd4f --- /dev/null +++ b/packages/oc-docs/src/components/Section/Section.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { SectionLabel } from '../SectionLabel'; +import { SectionWrapper } from './StyledWrapper'; + +interface SectionProps { + /** Heading shown above the content (rendered through `SectionLabel`). */ + label: React.ReactNode; + children: React.ReactNode; + className?: string; +} + +/** + * A labelled content section: a `SectionLabel` heading followed by its content. + * Owns the spacing between consecutive sections, so callers can stack them + * without managing margins. Reusable across pages. + */ +export const Section: React.FC = ({ label, children, className }) => ( + + {label} + {children} + +); + +export default Section; diff --git a/packages/oc-docs/src/components/Section/StyledWrapper.ts b/packages/oc-docs/src/components/Section/StyledWrapper.ts new file mode 100644 index 0000000..f1b0375 --- /dev/null +++ b/packages/oc-docs/src/components/Section/StyledWrapper.ts @@ -0,0 +1,11 @@ +import styled from '@emotion/styled'; + +/** + * Wrapper for a labelled page section. Owns the spacing between consecutive + * sections (`& + &`) so callers can stack them without managing margins. + */ +export const SectionWrapper = styled.section` + & + & { + margin-top: 2rem; + } +`; diff --git a/packages/oc-docs/src/components/Section/index.ts b/packages/oc-docs/src/components/Section/index.ts new file mode 100644 index 0000000..6d43f65 --- /dev/null +++ b/packages/oc-docs/src/components/Section/index.ts @@ -0,0 +1,2 @@ +export { Section } from './Section'; +export { default } from './Section'; diff --git a/packages/oc-docs/src/components/SectionLabel/SectionLabel.spec.tsx b/packages/oc-docs/src/components/SectionLabel/SectionLabel.spec.tsx new file mode 100644 index 0000000..9d36c3c --- /dev/null +++ b/packages/oc-docs/src/components/SectionLabel/SectionLabel.spec.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import { SectionLabel } from './SectionLabel'; + +describe('SectionLabel', () => { + it('renders its children as an h2 by default', () => { + const html = renderToStaticMarkup(Environments); + expect(html).toContain('Environments'); + expect(html).toContain(' { + const html = renderToStaticMarkup(Headers); + expect(html).toContain(' = ({ children, as = 'h2', className }) => ( + + {children} + +); + +export default SectionLabel; diff --git a/packages/oc-docs/src/components/SectionLabel/StyledWrapper.ts b/packages/oc-docs/src/components/SectionLabel/StyledWrapper.ts new file mode 100644 index 0000000..6a31a82 --- /dev/null +++ b/packages/oc-docs/src/components/SectionLabel/StyledWrapper.ts @@ -0,0 +1,17 @@ +import styled from '@emotion/styled'; + +/** + * Section label typography: + * Inter · Semi Bold (600) · 11px (0.6875rem) · 100% line-height · 1.4px tracking · uppercase. + * Font size is in rem so it scales with the root font size. + */ +export const SectionLabelWrapper = styled.h2` + margin: 0 0 0.75rem 0; + font-family: var(--font-sans); + font-weight: 600; + font-size: 0.6875rem; + line-height: 1; + letter-spacing: 1.4px; + text-transform: uppercase; + color: var(--text-tertiary); +`; diff --git a/packages/oc-docs/src/components/SectionLabel/index.ts b/packages/oc-docs/src/components/SectionLabel/index.ts new file mode 100644 index 0000000..29f8b0f --- /dev/null +++ b/packages/oc-docs/src/components/SectionLabel/index.ts @@ -0,0 +1,2 @@ +export { SectionLabel } from './SectionLabel'; +export { default } from './SectionLabel'; diff --git a/packages/oc-docs/src/components/Stat/Stat.spec.tsx b/packages/oc-docs/src/components/Stat/Stat.spec.tsx new file mode 100644 index 0000000..46697e7 --- /dev/null +++ b/packages/oc-docs/src/components/Stat/Stat.spec.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import { Stat } from './Stat'; + +describe('Stat', () => { + it('renders the value and label', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('30'); + expect(html).toContain('Requests'); + }); +}); diff --git a/packages/oc-docs/src/components/Stat/Stat.tsx b/packages/oc-docs/src/components/Stat/Stat.tsx new file mode 100644 index 0000000..dd3bbbc --- /dev/null +++ b/packages/oc-docs/src/components/Stat/Stat.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { StatWrapper } from './StyledWrapper'; + +export interface StatItem { + label: string; + value: number | string; +} + +/** A single labelled stat — a large value with a caption below. Reusable anywhere. */ +export const Stat: React.FC = ({ label, value }) => ( + + {value} + {label} + +); + +export default Stat; diff --git a/packages/oc-docs/src/components/Stat/StyledWrapper.ts b/packages/oc-docs/src/components/Stat/StyledWrapper.ts new file mode 100644 index 0000000..3491340 --- /dev/null +++ b/packages/oc-docs/src/components/Stat/StyledWrapper.ts @@ -0,0 +1,25 @@ +import styled from '@emotion/styled'; + +export const StatWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 0.25rem; + + .stat-value { + font-family: var(--font-sans); + font-weight: 700; + font-size: 1.25rem; + line-height: 1; + letter-spacing: normal; + color: var(--text-primary); + } + + .stat-label { + font-family: var(--font-sans); + font-weight: 400; + font-size: 0.78125rem; + line-height: 1; + letter-spacing: normal; + color: var(--text-secondary); + } +`; diff --git a/packages/oc-docs/src/components/Stat/index.ts b/packages/oc-docs/src/components/Stat/index.ts new file mode 100644 index 0000000..acda6a6 --- /dev/null +++ b/packages/oc-docs/src/components/Stat/index.ts @@ -0,0 +1,3 @@ +export { Stat } from './Stat'; +export type { StatItem } from './Stat'; +export { default } from './Stat'; diff --git a/packages/oc-docs/src/components/SubHeading/StyledWrapper.ts b/packages/oc-docs/src/components/SubHeading/StyledWrapper.ts new file mode 100644 index 0000000..d6efae2 --- /dev/null +++ b/packages/oc-docs/src/components/SubHeading/StyledWrapper.ts @@ -0,0 +1,16 @@ +import styled from '@emotion/styled'; + +/** + * Sub-heading typography: + * Inter · Semi Bold (600) · 13px (0.8125rem) · 12px (0.75rem) line-height · 0 letter-spacing. + * Font size is in rem so it scales with the root font size. + */ +export const SubHeadingWrapper = styled.h3` + margin: 0 0 0.625rem 0; + font-family: var(--font-sans); + font-weight: 600; + font-size: 0.8125rem; + line-height: 0.75rem; + letter-spacing: normal; + color: var(--text-primary); +`; diff --git a/packages/oc-docs/src/components/SubHeading/SubHeading.spec.tsx b/packages/oc-docs/src/components/SubHeading/SubHeading.spec.tsx new file mode 100644 index 0000000..0693ea7 --- /dev/null +++ b/packages/oc-docs/src/components/SubHeading/SubHeading.spec.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import { SubHeading } from './SubHeading'; + +describe('SubHeading', () => { + it('renders its children as an h3 by default', () => { + const html = renderToStaticMarkup(Headers); + expect(html).toContain('Headers'); + expect(html).toContain(' { + const html = renderToStaticMarkup(Auth); + expect(html).toContain(' = ({ children, as = 'h3', className }) => ( + + {children} + +); + +export default SubHeading; diff --git a/packages/oc-docs/src/components/SubHeading/index.ts b/packages/oc-docs/src/components/SubHeading/index.ts new file mode 100644 index 0000000..fc86bf4 --- /dev/null +++ b/packages/oc-docs/src/components/SubHeading/index.ts @@ -0,0 +1,2 @@ +export { SubHeading } from './SubHeading'; +export { default } from './SubHeading'; diff --git a/packages/oc-docs/src/constants/constants.ts b/packages/oc-docs/src/constants/constants.ts new file mode 100644 index 0000000..d1ea163 --- /dev/null +++ b/packages/oc-docs/src/constants/constants.ts @@ -0,0 +1,14 @@ +/** + * Human-readable labels for collection/request auth modes. + */ +export const AUTH_MODE_LABELS: Record = { + basic: 'Basic Auth', + bearer: 'Bearer Token', + apikey: 'API Key', + oauth2: 'OAuth 2.0', + oauth1: 'OAuth 1.0', + digest: 'Digest Auth', + awsv4: 'AWS Signature v4', + ntlm: 'NTLM', + wsse: 'WSSE' +}; diff --git a/packages/oc-docs/src/constants/index.ts b/packages/oc-docs/src/constants/index.ts new file mode 100644 index 0000000..30a9cca --- /dev/null +++ b/packages/oc-docs/src/constants/index.ts @@ -0,0 +1 @@ +export { AUTH_MODE_LABELS } from './constants'; diff --git a/packages/oc-docs/src/pages/Overview/Overview.spec.tsx b/packages/oc-docs/src/pages/Overview/Overview.spec.tsx new file mode 100644 index 0000000..bd8d690 --- /dev/null +++ b/packages/oc-docs/src/pages/Overview/Overview.spec.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import type { OpenCollection } from '@opencollection/types'; +import { Overview } from './Overview'; + +describe('Overview', () => { + it('renders the headline, stats, environments, docs and configuration', () => { + const collection: OpenCollection = { + info: { name: 'Hotel Booking API', version: '1.0.0' }, + config: { environments: [{ name: 'Development', variables: [{ name: 'baseUrl', value: 'x' }] }] }, + request: { headers: [{ name: 'Accept', value: 'application/json' }] }, + docs: '# Getting started\nUse this API.' + }; + + const html = renderToStaticMarkup(); + + expect(html).toContain('Hotel Booking API'); + expect(html).toContain('v1.0.0'); // version is "v"-prefixed for display + expect(html).toContain('Development'); + expect(html).toContain('1 variable'); + expect(html).toContain('Accept'); + expect(html).toContain('application/json'); + expect(html).toContain('Getting started'); // collection docs rendered + expect(html).toContain('Collection Configuration'); + }); + + it('renders an empty-state placeholder for each section when the collection is bare', () => { + const collection: OpenCollection = { + info: { name: 'Empty API', version: '1.0.0' } + }; + + const html = renderToStaticMarkup(); + + expect(html).toContain('No environments yet'); + expect(html).toContain('No overview content yet'); + expect(html).toContain('No configuration set'); + }); +}); diff --git a/packages/oc-docs/src/pages/Overview/Overview.tsx b/packages/oc-docs/src/pages/Overview/Overview.tsx new file mode 100644 index 0000000..50701e9 --- /dev/null +++ b/packages/oc-docs/src/pages/Overview/Overview.tsx @@ -0,0 +1,132 @@ +import React, { useMemo } from 'react'; +import type { OpenCollection } from '@opencollection/types'; +import type { StructuredText } from '@opencollection/types/common/description'; +import { useMarkdownRenderer } from '../../hooks'; +import { getCollectionStats } from '../../utils/collectionStats'; +import { hasCollectionConfiguration } from '../../utils/collectionConfiguration'; +import { scriptsArrayToObject } from '../../utils/schemaHelpers'; +import { formatCollectionVersion } from '../../utils/common'; +import { AUTH_MODE_LABELS } from '../../constants'; +import { CollectionStats } from '../../components/CollectionStats'; +import { EnvironmentSummary } from '../../components/EnvironmentSummary'; +import { CollectionConfiguration } from '../../components/CollectionConfiguration'; +import { EmptyState } from '../../components/EmptyState'; +import { PageWrapper } from '../../components/PageWrapper'; +import { Heading } from '../../components/Heading'; +import { Section } from '../../components/Section'; +import { GlobeIcon, BookIcon } from '../../assets/icons'; +import { OverviewWrapper } from './StyledWrapper'; + +/** Extracts the markdown string from a collection's `docs` (string or structured text). */ +const getDocsContent = (docs: OpenCollection['docs']): string => { + if (!docs) return ''; + return typeof docs === 'string' ? docs : (docs as StructuredText)?.content || ''; +}; + +interface OverviewProps { + collection: OpenCollection; +} + +/** + * The collection Overview page. Acts as the composition root: it reads app constants + * and utils, then passes plain data + config down to the atomic, package-ready + * components (which import nothing app-specific). + */ +export const Overview: React.FC = ({ collection }) => { + const md = useMarkdownRenderer(); + + const counts = useMemo(() => getCollectionStats(collection), [collection]); + const stats = useMemo( + () => [ + { label: 'Requests', value: counts.requestCount }, + { label: 'Folders', value: counts.folderCount }, + { label: 'Environments', value: counts.environmentCount } + ], + [counts] + ); + const scripts = useMemo(() => scriptsArrayToObject(collection.request?.scripts), [collection.request]); + const version = formatCollectionVersion(collection.info?.version); + const name = collection.info?.name || 'Untitled Collection'; + const environments = collection.config?.environments ?? []; + + const docsHtml = useMemo(() => { + const content = getDocsContent(collection.docs); + return content ? md.render(content) : ''; + }, [collection.docs, md]); + + const hasEnvironments = environments.length > 0; + const hasOverview = Boolean(docsHtml); + const hasConfig = useMemo( + () => hasCollectionConfiguration(collection.request?.headers, collection.request?.auth, scripts), + [collection.request, scripts] + ); + + return ( + + +
    +
    + {version &&
    {version}
    } + {name} +
    +
    + +
    + +
    + +
    +
    +
    + {hasEnvironments ? ( + + ) : ( + } + heading="No environments yet" + subheading="This collection has no environments configured. Add one in Bruno to manage base URLs and variables." + /> + )} +
    + +
    + {hasOverview ? ( +
    + ) : ( + } + heading="No overview content yet" + subheading="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." + /> + )} +
    +
    + +
    +
    + {hasConfig ? ( + + ) : ( + } + heading="No configuration set" + subheading="This collection has no shared headers, auth, scripts, variables, or tests. Configure them in Bruno and they'll appear here." + /> + )} +
    +
    +
    +
    +
    + ); +}; + +export default Overview; diff --git a/packages/oc-docs/src/pages/Overview/StyledWrapper.ts b/packages/oc-docs/src/pages/Overview/StyledWrapper.ts new file mode 100644 index 0000000..5a253ca --- /dev/null +++ b/packages/oc-docs/src/pages/Overview/StyledWrapper.ts @@ -0,0 +1,52 @@ +import styled from '@emotion/styled'; + +/** + * Layout for the collection Overview page. Pure styled-component (Emotion) driven by + * the docs theme CSS variables, so it stays portable if extracted into a package. + * Content styling lives in the individual components; this owns page-level layout. + */ +export const OverviewWrapper = styled.div` + max-width: 1100px; + margin: 0 auto; + color: var(--text-primary); + padding-bottom: 2rem; + border-bottom: 1px solid var(--border-color); + + .overview-headline { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + } + .overview-version { + font-family: var(--font-sans); + font-size: 0.75rem; + color: var(--text-tertiary); + margin-bottom: 0.3rem; + font-weight: 600; + } + .overview-stats-row { + margin-top: 1.5rem; + } + + /* The divider lives on the body, so it only appears when there are sections to show. */ + .overview-body { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 3rem; + margin-top: 2rem; + padding-top: 2rem; + border-top: 1px solid var(--border-color); + } + + @media (max-width: 1100px) { + .overview-body { + grid-template-columns: 1fr; + gap: 2rem; + } + } + + .overview-markdown { + margin-top: 0.25rem; + } +`; diff --git a/packages/oc-docs/src/pages/Overview/index.ts b/packages/oc-docs/src/pages/Overview/index.ts new file mode 100644 index 0000000..234bebc --- /dev/null +++ b/packages/oc-docs/src/pages/Overview/index.ts @@ -0,0 +1,2 @@ +export { Overview } from './Overview'; +export { default } from './Overview'; diff --git a/packages/oc-docs/src/styles/index.css b/packages/oc-docs/src/styles/index.css index 102c119..c825a3a 100644 --- a/packages/oc-docs/src/styles/index.css +++ b/packages/oc-docs/src/styles/index.css @@ -33,7 +33,6 @@ /* Border colors */ --border-color: var(--oc-border-border1); - --border-light: #EFEFEF; /* Code colors */ --code-bg: var(--oc-background-crust); /* approx: old #f6f8fa */ @@ -73,23 +72,6 @@ /* Fonts */ --font-mono: "JetBrains Mono", "SF Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; - - /* Theme variables */ - --background-color: var(--background-light); - --text-primary: var(--text-light); - --text-secondary: #6b6b6b; - --text-tertiary: #838383; - --border-color: var(--border-light); - --code-bg: var(--code-bg-light); - --code-text: var(--code-text-light); - --prose-code-bg: #f5f5f5; - --prose-code-text: #d97706; - --table-header-bg: var(--table-header-bg-light); - --table-row-odd-bg: var(--table-row-odd-bg-light); - --table-row-even-bg: var(--table-row-even-bg-light); - --input-bg: var(--input-bg-light); - --badge-bg: var(--badge-bg-light); - --badge-text: var(--badge-text-light); } /* ================================================================ diff --git a/packages/oc-docs/src/utils/collectionConfiguration.spec.ts b/packages/oc-docs/src/utils/collectionConfiguration.spec.ts new file mode 100644 index 0000000..361eb6e --- /dev/null +++ b/packages/oc-docs/src/utils/collectionConfiguration.spec.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { hasCollectionConfiguration } from './collectionConfiguration'; + +describe('hasCollectionConfiguration', () => { + it('is false for an empty collection', () => { + expect(hasCollectionConfiguration()).toBe(false); + expect(hasCollectionConfiguration([], undefined, {})).toBe(false); + }); + + it('ignores disabled or nameless headers', () => { + expect(hasCollectionConfiguration([{ name: '', value: 'x' }])).toBe(false); + expect(hasCollectionConfiguration([{ name: 'Accept', value: 'json', disabled: true }])).toBe(false); + }); + + it('is true when an enabled, named header is present', () => { + expect(hasCollectionConfiguration([{ name: 'Accept', value: 'application/json' }])).toBe(true); + }); + + it('is true when auth is configured', () => { + expect(hasCollectionConfiguration([], { type: 'bearer', token: 't' })).toBe(true); + }); + + it('is true when any script is present', () => { + expect(hasCollectionConfiguration([], undefined, { preRequest: 'x' })).toBe(true); + expect(hasCollectionConfiguration([], undefined, { postResponse: 'y' })).toBe(true); + expect(hasCollectionConfiguration([], undefined, { tests: 'z' })).toBe(true); + }); +}); diff --git a/packages/oc-docs/src/utils/collectionConfiguration.ts b/packages/oc-docs/src/utils/collectionConfiguration.ts new file mode 100644 index 0000000..e51d0b1 --- /dev/null +++ b/packages/oc-docs/src/utils/collectionConfiguration.ts @@ -0,0 +1,23 @@ +import type { HttpRequestHeader } from '@opencollection/types/requests/http'; +import type { Auth } from '@opencollection/types/common/auth'; + +export interface CollectionScripts { + preRequest?: string; + postResponse?: string; + tests?: string; +} + +/** + * True when a collection exposes any shared request defaults worth showing: + * an enabled, named header, an auth scheme, or a pre-request / post-response / + * test script. Used by the Overview page to decide whether the + * "Collection Configuration" section should appear at all. + */ +export const hasCollectionConfiguration = ( + headers: HttpRequestHeader[] = [], + auth?: Auth, + scripts: CollectionScripts = {} +): boolean => + headers.some((header) => header && header.name && header.disabled !== true) || + Boolean(auth) || + Boolean(scripts.preRequest || scripts.postResponse || scripts.tests); diff --git a/packages/oc-docs/src/utils/collectionStats.spec.ts b/packages/oc-docs/src/utils/collectionStats.spec.ts new file mode 100644 index 0000000..191e9b7 --- /dev/null +++ b/packages/oc-docs/src/utils/collectionStats.spec.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import type { OpenCollection } from '@opencollection/types'; +import type { Item as OpenCollectionItem } from '@opencollection/types/collection/item'; +import { countItems, getCollectionStats } from './collectionStats'; + +describe('countItems', () => { + it('counts requests and folders recursively at every depth', () => { + const items = [ + { + type: 'folder', + name: 'Auth', + items: [ + { info: { type: 'http' }, http: {} }, + { info: { type: 'http' }, http: {} } + ] + }, + { + type: 'folder', + name: 'Hotels', + items: [{ type: 'folder', name: 'Nested', items: [{ info: { type: 'http' }, http: {} }] }] + }, + { info: { type: 'http' }, http: {} } + ] as unknown as OpenCollectionItem[]; + + // Folders: Auth, Hotels, Nested -> 3. Requests: 2 + 1 + 1 -> 4. + expect(countItems(items)).toEqual({ requestCount: 4, folderCount: 3 }); + }); + + it('returns zero counts for empty/missing items', () => { + expect(countItems(undefined)).toEqual({ requestCount: 0, folderCount: 0 }); + expect(countItems([])).toEqual({ requestCount: 0, folderCount: 0 }); + }); +}); + +describe('getCollectionStats', () => { + it('includes the environment count from config', () => { + const collection = { + items: [{ info: { type: 'http' }, http: {} }], + config: { environments: [{ name: 'Dev' }, { name: 'Prod' }] } + } as unknown as OpenCollection; + + expect(getCollectionStats(collection)).toEqual({ + requestCount: 1, + folderCount: 0, + environmentCount: 2 + }); + }); + + it('handles a null collection', () => { + expect(getCollectionStats(null)).toEqual({ requestCount: 0, folderCount: 0, environmentCount: 0 }); + }); +}); diff --git a/packages/oc-docs/src/utils/collectionStats.ts b/packages/oc-docs/src/utils/collectionStats.ts new file mode 100644 index 0000000..742c09e --- /dev/null +++ b/packages/oc-docs/src/utils/collectionStats.ts @@ -0,0 +1,45 @@ +import type { Item as OpenCollectionItem } from '@opencollection/types/collection/item'; +import type { OpenCollection } from '@opencollection/types'; +import { isFolder } from './schemaHelpers'; + +export interface CollectionStats { + requestCount: number; + folderCount: number; + environmentCount: number; +} + +/** + * Counts requests and folders in a collection's item tree, recursively at every depth. + * Folders are identified via `isFolder`; everything else is treated as a request. + */ +export const countItems = ( + items: OpenCollectionItem[] | undefined +): { requestCount: number; folderCount: number } => { + let requestCount = 0; + let folderCount = 0; + + const walk = (list: OpenCollectionItem[] | undefined): void => { + if (!list?.length) return; + for (const item of list) { + if (isFolder(item)) { + folderCount += 1; + walk((item as { items?: OpenCollectionItem[] }).items); + } else { + requestCount += 1; + } + } + }; + + walk(items); + return { requestCount, folderCount }; +}; + +/** Summarises a collection: request, folder and environment counts. */ +export const getCollectionStats = (collection: OpenCollection | null | undefined): CollectionStats => { + const { requestCount, folderCount } = countItems(collection?.items); + return { + requestCount, + folderCount, + environmentCount: collection?.config?.environments?.length ?? 0 + }; +}; diff --git a/packages/oc-docs/src/utils/common.spec.ts b/packages/oc-docs/src/utils/common.spec.ts new file mode 100644 index 0000000..ef9365e --- /dev/null +++ b/packages/oc-docs/src/utils/common.spec.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { formatCollectionVersion, DEFAULT_COLLECTION_VERSION } from './common'; + +describe('formatCollectionVersion', () => { + it('pads numeric versions to a full major.minor.patch with a "v" prefix', () => { + expect(formatCollectionVersion('1')).toBe('v1.0.0'); + expect(formatCollectionVersion('2.1')).toBe('v2.1.0'); + expect(formatCollectionVersion('1.0.0')).toBe('v1.0.0'); + expect(formatCollectionVersion('3.4.5')).toBe('v3.4.5'); + }); + + it('does not double-prefix an existing "v"/"V"', () => { + expect(formatCollectionVersion('v2.1')).toBe('v2.1.0'); + expect(formatCollectionVersion('V3')).toBe('v3.0.0'); + }); + + it('coerces numbers to a normalised version', () => { + expect(formatCollectionVersion(1)).toBe('v1.0.0'); + }); + + it('keeps extra numeric segments without truncating', () => { + expect(formatCollectionVersion('1.2.3.4')).toBe('v1.2.3.4'); + }); + + it('shows non-numeric / pre-release versions as-is (only prefixed)', () => { + expect(formatCollectionVersion('1.0.0-beta')).toBe('v1.0.0-beta'); + }); + + it('falls back to the default when no version is set', () => { + expect(formatCollectionVersion(undefined)).toBe(DEFAULT_COLLECTION_VERSION); + expect(formatCollectionVersion(null)).toBe(DEFAULT_COLLECTION_VERSION); + expect(formatCollectionVersion('')).toBe(DEFAULT_COLLECTION_VERSION); + expect(formatCollectionVersion(' ')).toBe(DEFAULT_COLLECTION_VERSION); + }); +}); From 3eb91b6c5ffca17b0d8da3c03bf47328cbfb641d Mon Sep 17 00:00:00 2001 From: bruno-sachin Date: Wed, 17 Jun 2026 16:46:54 +0530 Subject: [PATCH 03/51] BRU-2571 Theme color changes --- packages/oc-docs/src/components/Code/StyledWrapper.ts | 2 +- .../src/components/EnvironmentSummaryItem/StyledWrapper.ts | 2 +- packages/oc-docs/src/components/SectionLabel/StyledWrapper.ts | 2 +- packages/oc-docs/src/components/Stat/StyledWrapper.ts | 2 +- packages/oc-docs/src/styles/index.css | 1 + 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/oc-docs/src/components/Code/StyledWrapper.ts b/packages/oc-docs/src/components/Code/StyledWrapper.ts index 891c17f..b1e6b4c 100644 --- a/packages/oc-docs/src/components/Code/StyledWrapper.ts +++ b/packages/oc-docs/src/components/Code/StyledWrapper.ts @@ -2,7 +2,7 @@ import styled from '@emotion/styled'; export const StyledWrapper = styled.div` background-color: var(--code-bg); - border: 1px solid #ebeef1; + border: 1px solid var(--oc-table-border); border-radius: 8px; /* The copy button owns its own look (see CopyButton); here we only place it diff --git a/packages/oc-docs/src/components/EnvironmentSummaryItem/StyledWrapper.ts b/packages/oc-docs/src/components/EnvironmentSummaryItem/StyledWrapper.ts index 49cf223..7e42c80 100644 --- a/packages/oc-docs/src/components/EnvironmentSummaryItem/StyledWrapper.ts +++ b/packages/oc-docs/src/components/EnvironmentSummaryItem/StyledWrapper.ts @@ -24,7 +24,7 @@ export const EnvironmentSummaryItemWrapper = styled.li` } .environment-summary-name { - color: var(--text-primary); + color: var(--oc-text); } .environment-summary-spacer { diff --git a/packages/oc-docs/src/components/SectionLabel/StyledWrapper.ts b/packages/oc-docs/src/components/SectionLabel/StyledWrapper.ts index 6a31a82..49c0dc3 100644 --- a/packages/oc-docs/src/components/SectionLabel/StyledWrapper.ts +++ b/packages/oc-docs/src/components/SectionLabel/StyledWrapper.ts @@ -13,5 +13,5 @@ export const SectionLabelWrapper = styled.h2` line-height: 1; letter-spacing: 1.4px; text-transform: uppercase; - color: var(--text-tertiary); + color: var(--oc-colors-text-subtext1); `; diff --git a/packages/oc-docs/src/components/Stat/StyledWrapper.ts b/packages/oc-docs/src/components/Stat/StyledWrapper.ts index 3491340..5552262 100644 --- a/packages/oc-docs/src/components/Stat/StyledWrapper.ts +++ b/packages/oc-docs/src/components/Stat/StyledWrapper.ts @@ -20,6 +20,6 @@ export const StatWrapper = styled.div` font-size: 0.78125rem; line-height: 1; letter-spacing: normal; - color: var(--text-secondary); + color: var(--oc-colors-text-subtext0); } `; diff --git a/packages/oc-docs/src/styles/index.css b/packages/oc-docs/src/styles/index.css index c825a3a..6f77e8e 100644 --- a/packages/oc-docs/src/styles/index.css +++ b/packages/oc-docs/src/styles/index.css @@ -696,6 +696,7 @@ tr.themed-row:nth-child(even) { .markdown-documentation p { margin-bottom: 0.75rem !important; + color: var(--text-primary); } .markdown-documentation a { From 2a38ca51a17255e6f3c2695d21c2e0577d0539b8 Mon Sep 17 00:00:00 2001 From: bruno-sachin Date: Wed, 17 Jun 2026 17:07:26 +0530 Subject: [PATCH 04/51] BRU-2571 Theme color changes --- packages/oc-docs/src/components/EmptyState/StyledWrapper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/oc-docs/src/components/EmptyState/StyledWrapper.ts b/packages/oc-docs/src/components/EmptyState/StyledWrapper.ts index 0331535..9abeb44 100644 --- a/packages/oc-docs/src/components/EmptyState/StyledWrapper.ts +++ b/packages/oc-docs/src/components/EmptyState/StyledWrapper.ts @@ -24,7 +24,7 @@ export const EmptyStateWrapper = styled.div` height: 3rem; margin-bottom: 1.25rem; border-radius: 50%; - background: var(--badge-bg); + background: var(--oc-border-border0); color: var(--text-tertiary); } From 20082120984a5bc1f28c107350893b0409af89d7 Mon Sep 17 00:00:00 2001 From: bruno-sachin Date: Wed, 17 Jun 2026 21:52:33 +0530 Subject: [PATCH 05/51] BRU-2571 E2E test case fix --- packages/oc-docs/e2e/examples.spec.ts | 11 ++++++++--- packages/oc-docs/e2e/requests.spec.ts | 20 +++++++++++++------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/packages/oc-docs/e2e/examples.spec.ts b/packages/oc-docs/e2e/examples.spec.ts index 3b417e3..653b4e4 100644 --- a/packages/oc-docs/e2e/examples.spec.ts +++ b/packages/oc-docs/e2e/examples.spec.ts @@ -1,6 +1,11 @@ import { test, expect } from '@playwright/test'; -test.describe('Request/response examples', () => { +// Skipped: the BRU-2571 Overview revamp renders only the Overview at `/` and +// disabled the all-endpoints view in Docs.tsx, so `.examples-container` and the +// per-endpoint example tabs no longer exist on the page. Re-enable these specs +// (drop the `.skip`) once the endpoints view is restored or relocated. + +test.describe.skip('Request/response examples', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await page.waitForSelector('.examples-container'); @@ -56,7 +61,7 @@ test.describe('Request/response examples', () => { }); }); -test.describe('Multiple examples per request (tabs)', () => { +test.describe.skip('Multiple examples per request (tabs)', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await page.waitForSelector('.examples-container'); @@ -118,7 +123,7 @@ test.describe('Multiple examples per request (tabs)', () => { }); }); -test.describe('Body/Headers toggle within examples', () => { +test.describe.skip('Body/Headers toggle within examples', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await page.waitForSelector('.examples-container'); diff --git a/packages/oc-docs/e2e/requests.spec.ts b/packages/oc-docs/e2e/requests.spec.ts index 15e19bc..78c5a11 100644 --- a/packages/oc-docs/e2e/requests.spec.ts +++ b/packages/oc-docs/e2e/requests.spec.ts @@ -1,5 +1,11 @@ import { test, expect, type Page } from '@playwright/test'; +// Skipped: the BRU-2571 Overview revamp renders only the Overview at `/` and +// disabled the all-endpoints view in Docs.tsx, so `.endpoint-section` (and the +// method badges, headers, bodies, params, docs and code snippets nested in it) +// no longer exist on the page. Re-enable these specs (drop the `.skip`) once the +// endpoints view is restored or relocated. + /** * Helper to locate an endpoint section by its h1 title. * Using heading role avoids case-insensitive hasText matching @@ -11,7 +17,7 @@ function endpointSection(page: Page, name: string) { }); } -test.describe('HTTP method badges and URLs', () => { +test.describe.skip('HTTP method badges and URLs', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await page.waitForSelector('.endpoint-section'); @@ -49,7 +55,7 @@ test.describe('HTTP method badges and URLs', () => { }); }); -test.describe('Request headers table', () => { +test.describe.skip('Request headers table', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await page.waitForSelector('.endpoint-section'); @@ -84,7 +90,7 @@ test.describe('Request headers table', () => { }); }); -test.describe('Request body rendering', () => { +test.describe.skip('Request body rendering', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await page.waitForSelector('.endpoint-section'); @@ -157,7 +163,7 @@ test.describe('Request body rendering', () => { }); }); -test.describe('Query parameters table', () => { +test.describe.skip('Query parameters table', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await page.waitForSelector('.endpoint-section'); @@ -191,7 +197,7 @@ test.describe('Query parameters table', () => { }); }); -test.describe('Request documentation', () => { +test.describe.skip('Request documentation', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await page.waitForSelector('.endpoint-section'); @@ -230,7 +236,7 @@ test.describe('Request documentation', () => { }); }); -test.describe('Code snippets', () => { +test.describe.skip('Code snippets', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await page.waitForSelector('.endpoint-section'); @@ -263,7 +269,7 @@ test.describe('Code snippets', () => { }); }); -test.describe('Examples for new request types', () => { +test.describe.skip('Examples for new request types', () => { test.beforeEach(async ({ page }) => { await page.goto('/'); await page.waitForSelector('.examples-container'); From 10663ee2138041c4d76effcec2fe79e78c253687 Mon Sep 17 00:00:00 2001 From: bruno-sachin Date: Thu, 18 Jun 2026 11:58:48 +0530 Subject: [PATCH 06/51] BRU-2571 Overview revamp changes --- .../src/components/PageWrapper/StyledWrapper.ts | 11 +++++++++-- packages/oc-docs/src/pages/Overview/StyledWrapper.ts | 1 - packages/oc-docs/src/styles/index.css | 8 ++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/oc-docs/src/components/PageWrapper/StyledWrapper.ts b/packages/oc-docs/src/components/PageWrapper/StyledWrapper.ts index 16f5c5e..a0c9416 100644 --- a/packages/oc-docs/src/components/PageWrapper/StyledWrapper.ts +++ b/packages/oc-docs/src/components/PageWrapper/StyledWrapper.ts @@ -1,9 +1,16 @@ import styled from '@emotion/styled'; /** - * Shared page padding: 15px top/bottom (0.9375rem), 48px left/right (3rem). - * Expressed in rems so it scales with the root font size. + * Shared page padding: 15px top/bottom (0.9375rem) with 56px (3.5rem) left/right + * gutters on desktop, tightened to 20px (1.25rem) on small screens so content + * stays readable without horizontal overflow. In rems so it scales with the + * root font size. Every page renders inside this, so the responsive gutters + * apply consistently across the app. */ export const PageWrapperContainer = styled.div` padding: 0.9375rem 3.5rem; + + @media (max-width: 768px) { + padding: 0.9375rem 1.25rem; + } `; diff --git a/packages/oc-docs/src/pages/Overview/StyledWrapper.ts b/packages/oc-docs/src/pages/Overview/StyledWrapper.ts index 5a253ca..5099750 100644 --- a/packages/oc-docs/src/pages/Overview/StyledWrapper.ts +++ b/packages/oc-docs/src/pages/Overview/StyledWrapper.ts @@ -7,7 +7,6 @@ import styled from '@emotion/styled'; */ export const OverviewWrapper = styled.div` max-width: 1100px; - margin: 0 auto; color: var(--text-primary); padding-bottom: 2rem; border-bottom: 1px solid var(--border-color); diff --git a/packages/oc-docs/src/styles/index.css b/packages/oc-docs/src/styles/index.css index 6f77e8e..f653bfa 100644 --- a/packages/oc-docs/src/styles/index.css +++ b/packages/oc-docs/src/styles/index.css @@ -154,6 +154,7 @@ html, body { .playground-content { flex: 1; + min-width: 0; overflow-y: auto; scroll-behavior: smooth; padding: 0; @@ -213,6 +214,13 @@ html, body { flex-direction: column; } + .playground-content { + flex: 1; + width: 100%; + min-width: 0; + overflow-x: hidden; + } + .mobile-tabs { display: flex; padding: 0.5rem; From 4742909a473f71d1f9b95c27e9f7a6b4c3f0f32d Mon Sep 17 00:00:00 2001 From: bruno-sachin Date: Thu, 18 Jun 2026 15:38:57 +0530 Subject: [PATCH 07/51] BRU-3571 Removed the border bottom line --- packages/oc-docs/src/pages/Overview/StyledWrapper.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/oc-docs/src/pages/Overview/StyledWrapper.ts b/packages/oc-docs/src/pages/Overview/StyledWrapper.ts index 5099750..8d369b0 100644 --- a/packages/oc-docs/src/pages/Overview/StyledWrapper.ts +++ b/packages/oc-docs/src/pages/Overview/StyledWrapper.ts @@ -9,7 +9,6 @@ export const OverviewWrapper = styled.div` max-width: 1100px; color: var(--text-primary); padding-bottom: 2rem; - border-bottom: 1px solid var(--border-color); .overview-headline { display: flex; From d6cd48b929b0d1fba92b9cc235588c98a23585ff Mon Sep 17 00:00:00 2001 From: bruno-sachin Date: Thu, 18 Jun 2026 18:24:48 +0530 Subject: [PATCH 08/51] BRU-3571 Overview test-id revamp changes --- packages/oc-docs/e2e/utils/index.ts | 2 +- packages/oc-docs/e2e/utils/locators.ts | 57 +++++++------- packages/oc-docs/src/components/Code/Code.tsx | 9 ++- .../CollectionConfiguration.tsx | 74 ++++++++++++------- .../CollectionStats/CollectionStats.tsx | 10 ++- .../src/components/CopyButton/CopyButton.tsx | 4 + .../src/components/EmptyState/EmptyState.tsx | 8 +- .../EnvironmentSummary/EnvironmentSummary.tsx | 10 ++- .../EnvironmentSummaryItem.tsx | 10 ++- .../src/components/Heading/Heading.tsx | 6 +- .../components/SecretValue/SecretValue.tsx | 11 ++- .../src/components/Section/Section.tsx | 6 +- .../components/SectionLabel/SectionLabel.tsx | 6 +- packages/oc-docs/src/components/Stat/Stat.tsx | 11 ++- .../src/components/SubHeading/SubHeading.tsx | 6 +- .../oc-docs/src/pages/Overview/Overview.tsx | 25 +++++-- 16 files changed, 162 insertions(+), 93 deletions(-) diff --git a/packages/oc-docs/e2e/utils/index.ts b/packages/oc-docs/e2e/utils/index.ts index c609b99..fb540e0 100644 --- a/packages/oc-docs/e2e/utils/index.ts +++ b/packages/oc-docs/e2e/utils/index.ts @@ -13,6 +13,6 @@ export * from './locators'; export const gotoOverview = async (page: Page): Promise => { await test.step('Open the docs and wait for the Overview to render', async () => { await page.goto('/'); - await page.locator('.oc-overview').waitFor({ state: 'visible' }); + await page.getByTestId('overview').waitFor({ state: 'visible' }); }); }; diff --git a/packages/oc-docs/e2e/utils/locators.ts b/packages/oc-docs/e2e/utils/locators.ts index 7bb1758..cab8f23 100644 --- a/packages/oc-docs/e2e/utils/locators.ts +++ b/packages/oc-docs/e2e/utils/locators.ts @@ -3,16 +3,16 @@ import type { Page } from '@playwright/test'; /** * Locators for the collection Overview page, grouped by UI area. * - * Mirrors the `buildCommonLocators` pattern from the bruno tests: a single - * builder returns thunks organised by section, so specs read declaratively and - * every selector lives in one place. Prefers semantic role/text queries and - * falls back to the components' own stable class hooks. + * Selection is driven by `data-testid` hooks set from the Overview composition root + * (so specs are decoupled from styling/markup churn). The only exceptions are the + * rendered-markdown internals (table, headings, strong/code/blockquote), which are + * generated HTML with no place for a test id and so are matched by role/tag/text. */ export const buildOverviewLocators = (page: Page) => { - const root = () => page.locator('.oc-overview'); - const stat = (label: string) => root().locator('.collection-stats .stat').filter({ hasText: label }); - const environment = (name: string) => root().locator('.environment-summary-item').filter({ hasText: name }); - const configuration = () => root().locator('.collection-configuration'); + const root = () => page.getByTestId('overview'); + const stat = (label: string) => page.getByTestId('overview-stat').filter({ hasText: label }); + const environment = (name: string) => page.getByTestId('overview-environment').filter({ hasText: name }); + const configuration = () => page.getByTestId('overview-config'); return { /** The Overview page root. */ @@ -20,50 +20,51 @@ export const buildOverviewLocators = (page: Page) => { /** Headline: version label + collection name. */ header: { - version: () => root().locator('.overview-version'), - title: () => root().locator('.overview-headline').getByRole('heading', { level: 1 }) + version: () => page.getByTestId('overview-version'), + title: () => page.getByTestId('overview-title') }, /** Stat counters (Requests / Folders / Environments). */ stats: { - all: () => root().locator('.collection-stats .stat'), + all: () => page.getByTestId('overview-stat'), item: stat, - value: (label: string) => stat(label).locator('.stat-value') + value: (label: string) => stat(label).getByTestId('overview-stat-value') }, /** An uppercase section heading (e.g. "Environments", "Collection Configuration"). */ - sectionLabel: (name: string) => root().getByRole('heading', { level: 2, name }), + sectionLabel: (name: string) => page.getByTestId('overview-section-label').filter({ hasText: name }), /** Environments list. */ environments: { - list: () => root().locator('.environment-summary'), - items: () => root().locator('.environment-summary-item'), + list: () => page.getByTestId('overview-environments'), + items: () => page.getByTestId('overview-environment'), item: environment, - variableCount: (name: string) => environment(name).locator('.environment-summary-vars') + variableCount: (name: string) => environment(name).getByTestId('overview-environment-vars') }, - /** Rendered markdown documentation. */ + /** Rendered markdown documentation (body internals matched by role/tag — generated HTML). */ docs: { - content: () => root().locator('.overview-markdown'), - heading: (name: string) => root().locator('.overview-markdown').getByRole('heading', { name }), - table: () => root().locator('.overview-markdown table') + content: () => page.getByTestId('overview-docs'), + heading: (name: string) => page.getByTestId('overview-docs').getByRole('heading', { name }), + table: () => page.getByTestId('overview-docs').getByRole('table') }, /** Collection configuration: headers, auth, scripts and tests. */ configuration: { root: configuration, - subHeading: (name: string) => configuration().getByRole('heading', { level: 3, name, exact: true }), - row: (key: string) => configuration().locator('.config-row').filter({ hasText: key }), - rowValue: (key: string) => configuration().locator('.config-row').filter({ hasText: key }).locator('.config-value-cell'), - emptyMessages: () => configuration().locator('.config-empty-message'), - copyButtons: () => configuration().locator('.copy-button'), - secret: () => configuration().locator('.secret-value-text'), - revealSecretButton: () => configuration().locator('.secret-value-toggle') + subHeading: (name: string) => configuration().getByTestId('overview-config-subheading').filter({ hasText: name }), + row: (key: string) => configuration().getByTestId('overview-config-row').filter({ hasText: key }), + rowValue: (key: string) => + configuration().getByTestId('overview-config-row').filter({ hasText: key }).getByTestId('overview-config-row-value'), + emptyMessages: () => configuration().getByTestId('overview-config-empty'), + copyButtons: () => configuration().getByTestId('overview-config-copy'), + secret: () => configuration().getByTestId('overview-config-secret-text'), + revealSecretButton: () => configuration().getByTestId('overview-config-secret-toggle') }, /** Dashed empty-state placeholders (shown when a whole section has no data). */ emptyState: { - headings: () => root().locator('.empty-state-heading') + headings: () => root().getByTestId('overview-empty-heading') } }; }; diff --git a/packages/oc-docs/src/components/Code/Code.tsx b/packages/oc-docs/src/components/Code/Code.tsx index a5b85a4..093cb21 100644 --- a/packages/oc-docs/src/components/Code/Code.tsx +++ b/packages/oc-docs/src/components/Code/Code.tsx @@ -26,12 +26,14 @@ interface CodeProps { showLineNumbers?: boolean; /** Show the copy-to-clipboard button (read-only viewer). */ showCopy?: boolean; + /** Test hook (`data-testid`) applied to the copy button (read-only viewer). */ + copyTestId?: string; /** Editor height in editable mode. */ height?: string; className?: string; } -type CodeViewerProps = Pick; +type CodeViewerProps = Pick; /** * Read-only, Prism-highlighted code. Lightweight and SSR-safe (highlighting runs @@ -43,6 +45,7 @@ const CodeViewer: React.FC = ({ language = 'text', showLineNumbers = false, showCopy = true, + copyTestId, className }) => { const preRef = useRef(null); @@ -65,7 +68,7 @@ const CodeViewer: React.FC = ({ return (
    - {showCopy && } + {showCopy && } {showLineNumbers ? (
    @@ -97,6 +100,7 @@ export const Code: React.FC = ({ onChange, showLineNumbers = false, showCopy = true, + copyTestId, height = '200px', className }) => { @@ -114,6 +118,7 @@ export const Code: React.FC = ({ language={language} showLineNumbers={showLineNumbers} showCopy={showCopy} + copyTestId={copyTestId} className={className} /> ); diff --git a/packages/oc-docs/src/components/CollectionConfiguration/CollectionConfiguration.tsx b/packages/oc-docs/src/components/CollectionConfiguration/CollectionConfiguration.tsx index 9ae0c88..f80669b 100644 --- a/packages/oc-docs/src/components/CollectionConfiguration/CollectionConfiguration.tsx +++ b/packages/oc-docs/src/components/CollectionConfiguration/CollectionConfiguration.tsx @@ -19,6 +19,11 @@ interface CollectionConfigurationProps { scripts?: CollectionScripts; /** Maps an auth `type` to a display label (e.g. basic -> "Basic Auth"); supplied by the host. */ authModeLabels?: Record; + /** + * Test hook (`data-testid`) base, set by the composition root. Sub-elements derive + * stable ids from it (`-row`, `-row-value`, `-subheading`, `-empty`, `-secret`, `-copy`). + */ + testId?: string; } const containsVariable = (value: string): boolean => value.includes('{{'); @@ -26,10 +31,10 @@ const containsVariable = (value: string): boolean => value.includes('{{'); const resolveAuthMode = (auth: Auth, labels: Record): string => auth === 'inherit' ? 'Inherit' : labels[auth.type] || auth.type; -const ConfigRow: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => ( -
    +const ConfigRow: React.FC<{ label: string; children: React.ReactNode; testId?: string }> = ({ label, children, testId }) => ( +
    {label}
    -
    {children}
    +
    {children}
    ); @@ -38,14 +43,19 @@ const PlainValue: React.FC<{ value: string }> = ({ value }) => ( ); /** Italic placeholder shown for a configuration subsection that has no items yet. */ -const EmptyMessage: React.FC<{ children: React.ReactNode }> = ({ children }) => ( -

    {children}

    +const EmptyMessage: React.FC<{ children: React.ReactNode; testId?: string }> = ({ children, testId }) => ( +

    {children}

    ); /** Read-only rows for the collection-level auth, derived from the auth `type`. */ -const AuthRows: React.FC<{ auth: Auth; labels: Record }> = ({ auth, labels }) => { +const AuthRows: React.FC<{ auth: Auth; labels: Record; rowTestId?: string; secretTestId?: string }> = ({ + auth, + labels, + rowTestId, + secretTestId +}) => { const rows: React.ReactNode[] = [ - + ]; if (auth !== 'inherit') { @@ -53,16 +63,16 @@ const AuthRows: React.FC<{ auth: Auth; labels: Record }> = ({ au case 'basic': case 'digest': case 'ntlm': - if (auth.username) rows.push(); - if (auth.password) rows.push(); + if (auth.username) rows.push(); + if (auth.password) rows.push(); break; case 'bearer': - if (auth.token) rows.push(); + if (auth.token) rows.push(); break; case 'apikey': - if (auth.key) rows.push(); - if (auth.value) rows.push(); - if (auth.placement) rows.push(); + if (auth.key) rows.push(); + if (auth.value) rows.push(); + if (auth.placement) rows.push(); break; default: break; @@ -81,7 +91,8 @@ export const CollectionConfiguration: React.FC = ( headers = [], auth, scripts = {}, - authModeLabels = {} + authModeLabels = {}, + testId }) => { const visibleHeaders = headers.filter((header) => header && header.name && header.disabled !== true); const hasScripts = Boolean(scripts.preRequest || scripts.postResponse); @@ -91,60 +102,67 @@ export const CollectionConfiguration: React.FC = ( return null; } + // Stable test hooks derived from the base testId (omitted entirely when unset). + const rowTestId = testId ? `${testId}-row` : undefined; + const subTestId = testId ? `${testId}-subheading` : undefined; + const emptyTestId = testId ? `${testId}-empty` : undefined; + const secretTestId = testId ? `${testId}-secret` : undefined; + const copyTestId = testId ? `${testId}-copy` : undefined; + return ( - +
    - Headers + Headers {visibleHeaders.length > 0 ? (
    {visibleHeaders.map((header, index) => ( - + ))}
    ) : ( - Add headers to inherit in all requests in the collection + Add headers to inherit in all requests in the collection )}
    - Auth + Auth {auth ? ( - + ) : ( - Add authentication to inherit in all requests in the collection + Add authentication to inherit in all requests in the collection )}
    - Script + Script {hasScripts ? ( <> {scripts.preRequest && (

    Pre-Request

    - +
    )} {scripts.postResponse && (

    Post-Response

    - +
    )} ) : ( - Add scripts to run for all requests in the collection + Add scripts to run for all requests in the collection )}
    - Tests + Tests {scripts.tests ? ( - + ) : ( - Add tests to run for all requests in the collection + Add tests to run for all requests in the collection )}
    diff --git a/packages/oc-docs/src/components/CollectionStats/CollectionStats.tsx b/packages/oc-docs/src/components/CollectionStats/CollectionStats.tsx index 0a3f332..245f1ad 100644 --- a/packages/oc-docs/src/components/CollectionStats/CollectionStats.tsx +++ b/packages/oc-docs/src/components/CollectionStats/CollectionStats.tsx @@ -5,16 +5,20 @@ import { CollectionStatsWrapper } from './StyledWrapper'; interface CollectionStatsProps { stats: StatItem[]; + /** Test hook (`data-testid`) for the row container. */ + testId?: string; + /** Test hook applied to every `Stat` (its value cell gets `${itemTestId}-value`). */ + itemTestId?: string; } /** * A row of labelled stats. Fully prop-driven (labels + values supplied by the host) * and composed from the atomic `Stat` component. */ -export const CollectionStats: React.FC = ({ stats }) => ( - +export const CollectionStats: React.FC = ({ stats, testId, itemTestId }) => ( + {stats.map((stat, index) => ( - + ))} ); diff --git a/packages/oc-docs/src/components/CopyButton/CopyButton.tsx b/packages/oc-docs/src/components/CopyButton/CopyButton.tsx index c8ef61b..971bf75 100644 --- a/packages/oc-docs/src/components/CopyButton/CopyButton.tsx +++ b/packages/oc-docs/src/components/CopyButton/CopyButton.tsx @@ -10,6 +10,8 @@ interface CopyButtonProps { copiedLabel?: string; /** How long (ms) the confirmation state persists. */ resetAfterMs?: number; + /** Test hook (`data-testid`); useful here since the accessible name flips on copy. */ + testId?: string; className?: string; } @@ -36,6 +38,7 @@ export const CopyButton: React.FC = ({ label = 'Copy', copiedLabel = 'Copied', resetAfterMs = 2000, + testId, className }) => { const [copied, setCopied] = useState(false); @@ -67,6 +70,7 @@ export const CopyButton: React.FC = ({ className={['copy-button', className].filter(Boolean).join(' ')} onClick={handleCopy} aria-label={copied ? copiedLabel : label} + data-testid={testId} > {copied ? : } diff --git a/packages/oc-docs/src/components/EmptyState/EmptyState.tsx b/packages/oc-docs/src/components/EmptyState/EmptyState.tsx index 13cc0e1..2ae7e18 100644 --- a/packages/oc-docs/src/components/EmptyState/EmptyState.tsx +++ b/packages/oc-docs/src/components/EmptyState/EmptyState.tsx @@ -8,6 +8,8 @@ interface EmptyStateProps { heading: React.ReactNode; /** Supporting description shown beneath the heading. */ subheading: React.ReactNode; + /** Test hook (`data-testid`); the heading gets `${testId}-heading`. */ + testId?: string; className?: string; } @@ -16,12 +18,12 @@ interface EmptyStateProps { * circular icon badge, a heading and a subheading. Icon-agnostic and fully * prop-driven, so it can be reused for any empty section across pages. */ -export const EmptyState: React.FC = ({ icon, heading, subheading, className }) => ( - +export const EmptyState: React.FC = ({ icon, heading, subheading, testId, className }) => ( + -

    {heading}

    +

    {heading}

    {subheading}

    ); diff --git a/packages/oc-docs/src/components/EnvironmentSummary/EnvironmentSummary.tsx b/packages/oc-docs/src/components/EnvironmentSummary/EnvironmentSummary.tsx index 8cf3fbd..14f65d0 100644 --- a/packages/oc-docs/src/components/EnvironmentSummary/EnvironmentSummary.tsx +++ b/packages/oc-docs/src/components/EnvironmentSummary/EnvironmentSummary.tsx @@ -5,18 +5,22 @@ import { EnvironmentSummaryWrapper } from './StyledWrapper'; interface EnvironmentSummaryProps { environments: Environment[]; + /** Test hook (`data-testid`) for the list container. */ + testId?: string; + /** Test hook applied to every item (its var-count cell gets `${itemTestId}-vars`). */ + itemTestId?: string; } /** Read-only list of a collection's environments, composed from `EnvironmentSummaryItem`. */ -export const EnvironmentSummary: React.FC = ({ environments }) => { +export const EnvironmentSummary: React.FC = ({ environments, testId, itemTestId }) => { if (!environments.length) { return null; } return ( - + {environments.map((environment, index) => ( - + ))} ); diff --git a/packages/oc-docs/src/components/EnvironmentSummaryItem/EnvironmentSummaryItem.tsx b/packages/oc-docs/src/components/EnvironmentSummaryItem/EnvironmentSummaryItem.tsx index 30d80d7..52ab810 100644 --- a/packages/oc-docs/src/components/EnvironmentSummaryItem/EnvironmentSummaryItem.tsx +++ b/packages/oc-docs/src/components/EnvironmentSummaryItem/EnvironmentSummaryItem.tsx @@ -7,18 +7,22 @@ const formatVariableCount = (count: number): string => `${count} variable${count interface EnvironmentSummaryItemProps { environment: Environment; + /** Test hook (`data-testid`); the variable-count cell gets `${testId}-vars`. */ + testId?: string; } /** A single environment row: color dot, name, and variable count. Renders an `
  • `. */ -export const EnvironmentSummaryItem: React.FC = ({ environment }) => ( - +export const EnvironmentSummaryItem: React.FC = ({ environment, testId }) => ( + {environment.name} - {formatVariableCount(environment.variables?.length ?? 0)} + + {formatVariableCount(environment.variables?.length ?? 0)} + ); diff --git a/packages/oc-docs/src/components/Heading/Heading.tsx b/packages/oc-docs/src/components/Heading/Heading.tsx index 7043b55..7475fdd 100644 --- a/packages/oc-docs/src/components/Heading/Heading.tsx +++ b/packages/oc-docs/src/components/Heading/Heading.tsx @@ -7,6 +7,8 @@ interface HeadingProps { children: React.ReactNode; /** Semantic heading element to render (the visual style is the same). Defaults to "h1". */ as?: HeadingLevel; + /** Test hook (`data-testid`) for end-to-end tests; set by the composition root. */ + testId?: string; className?: string; } @@ -15,8 +17,8 @@ interface HeadingProps { * (Inter, Semi Bold 600, 20px, 100% line-height, -0.5px letter-spacing). * Use `as` to render the correct heading level for the document outline. */ -export const Heading: React.FC = ({ children, as = 'h1', className }) => ( - +export const Heading: React.FC = ({ children, as = 'h1', testId, className }) => ( + {children} ); diff --git a/packages/oc-docs/src/components/SecretValue/SecretValue.tsx b/packages/oc-docs/src/components/SecretValue/SecretValue.tsx index e8946ed..c818b48 100644 --- a/packages/oc-docs/src/components/SecretValue/SecretValue.tsx +++ b/packages/oc-docs/src/components/SecretValue/SecretValue.tsx @@ -6,6 +6,8 @@ export const SECRET_MASK = '•'.repeat(12); interface SecretValueProps { value: string; + /** Test hook (`data-testid`); the text gets `${testId}-text`, the toggle `${testId}-toggle`. */ + testId?: string; } const EyeIcon: React.FC<{ off?: boolean }> = ({ off }) => ( @@ -28,19 +30,22 @@ const EyeIcon: React.FC<{ off?: boolean }> = ({ off }) => ( * A masked, reveal-on-demand value (used for passwords/tokens in read-only config). * The masked form is a fixed length so the secret's true length is not exposed. */ -export const SecretValue: React.FC = ({ value }) => { +export const SecretValue: React.FC = ({ value, testId }) => { const [revealed, setRevealed] = useState(false); return ( - + {/* While masked, hide the placeholder dots from assistive tech so they aren't read out as "bullet" repeatedly; the toggle's label conveys it's hidden. */} - {revealed ? value : SECRET_MASK} + + {revealed ? value : SECRET_MASK} + - + ); }; diff --git a/packages/oc-docs/src/ui/SecretValue/StyledWrapper.ts b/packages/oc-docs/src/ui/SecretValue/StyledWrapper.ts index 66e6550..0a70496 100644 --- a/packages/oc-docs/src/ui/SecretValue/StyledWrapper.ts +++ b/packages/oc-docs/src/ui/SecretValue/StyledWrapper.ts @@ -1,6 +1,6 @@ import styled from '@emotion/styled'; -export const SecretValueWrapper = styled.span` +export const StyledWrapper = styled.span` display: flex; align-items: center; justify-content: space-between; diff --git a/packages/oc-docs/src/ui/Section/StyledWrapper.ts b/packages/oc-docs/src/ui/Section/StyledWrapper.ts deleted file mode 100644 index 37ab867..0000000 --- a/packages/oc-docs/src/ui/Section/StyledWrapper.ts +++ /dev/null @@ -1,7 +0,0 @@ -import styled from '@emotion/styled'; - -export const SectionWrapper = styled.section` - & + & { - margin-top: 2rem; - } -`; diff --git a/packages/oc-docs/src/ui/SectionLabel/SectionLabel.tsx b/packages/oc-docs/src/ui/SectionLabel/SectionLabel.tsx index 3b5de9f..1866174 100644 --- a/packages/oc-docs/src/ui/SectionLabel/SectionLabel.tsx +++ b/packages/oc-docs/src/ui/SectionLabel/SectionLabel.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { SectionLabelWrapper } from './StyledWrapper'; +import { StyledWrapper } from './StyledWrapper'; type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; @@ -11,9 +11,9 @@ interface SectionLabelProps { } export const SectionLabel: React.FC = ({ children, as = 'h2', testId, className }) => ( - + {children} - + ); export default SectionLabel; diff --git a/packages/oc-docs/src/ui/SectionLabel/StyledWrapper.ts b/packages/oc-docs/src/ui/SectionLabel/StyledWrapper.ts index b76db39..3ba00c1 100644 --- a/packages/oc-docs/src/ui/SectionLabel/StyledWrapper.ts +++ b/packages/oc-docs/src/ui/SectionLabel/StyledWrapper.ts @@ -1,6 +1,6 @@ import styled from '@emotion/styled'; -export const SectionLabelWrapper = styled.h2` +export const StyledWrapper = styled.h2` margin: 0 0 0.75rem 0; font-family: var(--font-sans); font-weight: 600; diff --git a/packages/oc-docs/src/components/SubHeading/StyledWrapper.ts b/packages/oc-docs/src/ui/SubHeading/StyledWrapper.ts similarity index 83% rename from packages/oc-docs/src/components/SubHeading/StyledWrapper.ts rename to packages/oc-docs/src/ui/SubHeading/StyledWrapper.ts index 87d2108..8857a9f 100644 --- a/packages/oc-docs/src/components/SubHeading/StyledWrapper.ts +++ b/packages/oc-docs/src/ui/SubHeading/StyledWrapper.ts @@ -1,6 +1,6 @@ import styled from '@emotion/styled'; -export const SubHeadingWrapper = styled.h3` +export const StyledWrapper = styled.h3` margin: 0 0 0.625rem 0; font-family: var(--font-sans); font-weight: 600; diff --git a/packages/oc-docs/src/components/SubHeading/SubHeading.spec.tsx b/packages/oc-docs/src/ui/SubHeading/SubHeading.spec.tsx similarity index 100% rename from packages/oc-docs/src/components/SubHeading/SubHeading.spec.tsx rename to packages/oc-docs/src/ui/SubHeading/SubHeading.spec.tsx diff --git a/packages/oc-docs/src/components/SubHeading/SubHeading.tsx b/packages/oc-docs/src/ui/SubHeading/SubHeading.tsx similarity index 70% rename from packages/oc-docs/src/components/SubHeading/SubHeading.tsx rename to packages/oc-docs/src/ui/SubHeading/SubHeading.tsx index a0069a3..7342be8 100644 --- a/packages/oc-docs/src/components/SubHeading/SubHeading.tsx +++ b/packages/oc-docs/src/ui/SubHeading/SubHeading.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { SubHeadingWrapper } from './StyledWrapper'; +import { StyledWrapper } from './StyledWrapper'; type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; @@ -11,9 +11,9 @@ interface SubHeadingProps { } export const SubHeading: React.FC = ({ children, as = 'h3', testId, className }) => ( - + {children} - + ); export default SubHeading; From 58a3dc72fcdb40119de4968537047b8e7d0e4cac Mon Sep 17 00:00:00 2001 From: bruno-sachin Date: Wed, 24 Jun 2026 18:57:47 +0530 Subject: [PATCH 21/51] Comments addressed --- ...=> collection-configuration.component..ts} | 0 ...onent.ts => collection-stats.component.ts} | 0 ...component.ts => environments.component.ts} | 0 ...ction.component.ts => header.component.ts} | 0 packages/oc-docs/e2e/pages/overview.page.ts | 8 +- packages/oc-docs/playwright.config.ts | 2 +- packages/oc-docs/src/components/Code/Code.tsx | 67 ++-------------- .../components/Code/CodeViewer/CodeViewer.tsx | 78 +++++++++++++++++++ .../Code/{ => CodeViewer}/StyledWrapper.ts | 9 +-- .../src/components/Code/CodeViewer/index.ts | 2 + .../CollectionConfiguration.tsx | 7 +- 11 files changed, 97 insertions(+), 76 deletions(-) rename packages/oc-docs/e2e/components/overview/{configuration-section.component.ts => collection-configuration.component..ts} (100%) rename packages/oc-docs/e2e/components/overview/{stats-section.component.ts => collection-stats.component.ts} (100%) rename packages/oc-docs/e2e/components/overview/{environments-section.component.ts => environments.component.ts} (100%) rename packages/oc-docs/e2e/components/overview/{header-section.component.ts => header.component.ts} (100%) create mode 100644 packages/oc-docs/src/components/Code/CodeViewer/CodeViewer.tsx rename packages/oc-docs/src/components/Code/{ => CodeViewer}/StyledWrapper.ts (88%) create mode 100644 packages/oc-docs/src/components/Code/CodeViewer/index.ts diff --git a/packages/oc-docs/e2e/components/overview/configuration-section.component.ts b/packages/oc-docs/e2e/components/overview/collection-configuration.component..ts similarity index 100% rename from packages/oc-docs/e2e/components/overview/configuration-section.component.ts rename to packages/oc-docs/e2e/components/overview/collection-configuration.component..ts diff --git a/packages/oc-docs/e2e/components/overview/stats-section.component.ts b/packages/oc-docs/e2e/components/overview/collection-stats.component.ts similarity index 100% rename from packages/oc-docs/e2e/components/overview/stats-section.component.ts rename to packages/oc-docs/e2e/components/overview/collection-stats.component.ts diff --git a/packages/oc-docs/e2e/components/overview/environments-section.component.ts b/packages/oc-docs/e2e/components/overview/environments.component.ts similarity index 100% rename from packages/oc-docs/e2e/components/overview/environments-section.component.ts rename to packages/oc-docs/e2e/components/overview/environments.component.ts diff --git a/packages/oc-docs/e2e/components/overview/header-section.component.ts b/packages/oc-docs/e2e/components/overview/header.component.ts similarity index 100% rename from packages/oc-docs/e2e/components/overview/header-section.component.ts rename to packages/oc-docs/e2e/components/overview/header.component.ts diff --git a/packages/oc-docs/e2e/pages/overview.page.ts b/packages/oc-docs/e2e/pages/overview.page.ts index a1e179f..5ee8001 100644 --- a/packages/oc-docs/e2e/pages/overview.page.ts +++ b/packages/oc-docs/e2e/pages/overview.page.ts @@ -1,10 +1,10 @@ import type { Locator } from '@playwright/test'; import { BasePage } from './base.page'; import { MarkdownComponent } from '../components/markdown.component'; -import { HeaderSection } from '../components/overview/header-section.component'; -import { StatsSection } from '../components/overview/stats-section.component'; -import { EnvironmentsSection } from '../components/overview/environments-section.component'; -import { ConfigurationSection } from '../components/overview/configuration-section.component'; +import { HeaderSection } from '../components/overview/header.component'; +import { StatsSection } from '../components/overview/collection-stats.component'; +import { EnvironmentsSection } from '../components/overview/environments.component'; +import { ConfigurationSection } from '../components/overview/collection-configuration.component.'; export class OverviewPage extends BasePage { readonly root = this.page.getByTestId('overview'); diff --git a/packages/oc-docs/playwright.config.ts b/packages/oc-docs/playwright.config.ts index 02942c6..0ddfd12 100644 --- a/packages/oc-docs/playwright.config.ts +++ b/packages/oc-docs/playwright.config.ts @@ -20,7 +20,7 @@ export default defineConfig({ projects: [ { name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + use: { ...devices['Desktop Chrome'], channel: 'chrome' }, }, ], webServer: { diff --git a/packages/oc-docs/src/components/Code/Code.tsx b/packages/oc-docs/src/components/Code/Code.tsx index 547cd86..aa564ab 100644 --- a/packages/oc-docs/src/components/Code/Code.tsx +++ b/packages/oc-docs/src/components/Code/Code.tsx @@ -1,13 +1,5 @@ -import React, { Suspense, lazy, useEffect, useMemo, useRef } from 'react'; -import { CopyButton } from '../../ui/CopyButton/CopyButton'; -import { StyledWrapper } from './StyledWrapper'; - -import Prism from 'prismjs'; -import 'prismjs/components/prism-javascript'; -import 'prismjs/components/prism-bash'; -import 'prismjs/components/prism-python'; -import 'prismjs/components/prism-json'; -import 'prismjs/components/prism-xml-doc'; +import React, { Suspense, lazy } from 'react'; +import { CodeViewer } from './CodeViewer/CodeViewer'; const LazyCodeEditor = lazy(() => import('../../ui/CodeEditor/CodeEditor')); @@ -18,60 +10,11 @@ interface CodeProps { onChange?: (value: string) => void; showLineNumbers?: boolean; showCopy?: boolean; - copyTestId?: string; + testId?: string; height?: string; className?: string; } -type CodeViewerProps = Pick; - -const CodeViewer: React.FC = ({ - code = '', - language = 'text', - showLineNumbers = false, - showCopy = true, - copyTestId, - 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 wrapperClassName = ['code-content-wrapper overflow-hidden', className].filter(Boolean).join(' '); - const codeEl = ( -
    -      {code}
    -    
    - ); - - return ( - -
    - {showCopy && } - - {showLineNumbers ? ( -
    - -
    {codeEl}
    -
    - ) : ( -
    {codeEl}
    - )} -
    -
    - ); -}; - export const Code: React.FC = ({ code = '', language = 'text', @@ -79,7 +22,7 @@ export const Code: React.FC = ({ onChange, showLineNumbers = false, showCopy = true, - copyTestId, + testId, height = '200px', className }) => { @@ -97,7 +40,7 @@ export const Code: React.FC = ({ language={language} showLineNumbers={showLineNumbers} showCopy={showCopy} - copyTestId={copyTestId} + testId={testId} className={className} /> ); diff --git a/packages/oc-docs/src/components/Code/CodeViewer/CodeViewer.tsx b/packages/oc-docs/src/components/Code/CodeViewer/CodeViewer.tsx new file mode 100644 index 0000000..54535bb --- /dev/null +++ b/packages/oc-docs/src/components/Code/CodeViewer/CodeViewer.tsx @@ -0,0 +1,78 @@ +import React, { useEffect, useMemo, useRef } from 'react'; +import { CopyButton } from '../../../ui/CopyButton/CopyButton'; +import { StyledWrapper } from './StyledWrapper'; + +import Prism from 'prismjs'; +import 'prismjs/components/prism-javascript'; +import 'prismjs/components/prism-bash'; +import 'prismjs/components/prism-python'; +import 'prismjs/components/prism-json'; +import 'prismjs/components/prism-xml-doc'; + +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/oc-docs/src/components/Code/StyledWrapper.ts b/packages/oc-docs/src/components/Code/CodeViewer/StyledWrapper.ts similarity index 88% rename from packages/oc-docs/src/components/Code/StyledWrapper.ts rename to packages/oc-docs/src/components/Code/CodeViewer/StyledWrapper.ts index 07f65ac..8ca2a8b 100644 --- a/packages/oc-docs/src/components/Code/StyledWrapper.ts +++ b/packages/oc-docs/src/components/Code/CodeViewer/StyledWrapper.ts @@ -31,15 +31,14 @@ export const StyledWrapper = styled.div` .code-content::-webkit-scrollbar-track { background: transparent; } + /* Reuse the app-wide scrollbar token (theme-aware); same hover treatment as the + global scrollbar in index.css. */ .code-content::-webkit-scrollbar-thumb { - background-color: rgba(0, 0, 0, 0.1); + background-color: var(--oc-scrollbar-color); border-radius: 4px; } - .code-content:hover::-webkit-scrollbar-thumb { - background-color: rgba(0, 0, 0, 0.2); - } .code-content::-webkit-scrollbar-thumb:hover { - background-color: rgba(0, 0, 0, 0.3); + background-color: color-mix(in srgb, var(--oc-text) 20%, transparent); } .code-content pre { diff --git a/packages/oc-docs/src/components/Code/CodeViewer/index.ts b/packages/oc-docs/src/components/Code/CodeViewer/index.ts new file mode 100644 index 0000000..f2fd81e --- /dev/null +++ b/packages/oc-docs/src/components/Code/CodeViewer/index.ts @@ -0,0 +1,2 @@ +export { CodeViewer, default } from './CodeViewer'; +export type { CodeViewerProps } from './CodeViewer'; diff --git a/packages/oc-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.tsx b/packages/oc-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.tsx index 62c1564..cb0387d 100644 --- a/packages/oc-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.tsx +++ b/packages/oc-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.tsx @@ -93,7 +93,6 @@ export const CollectionConfiguration: React.FC = ( const rowTestId = `${testId}-row`; const subTestId = `${testId}-subheading`; const secretTestId = `${testId}-secret`; - const copyTestId = `${testId}-copy`; return ( @@ -128,13 +127,13 @@ export const CollectionConfiguration: React.FC = ( {scripts.preRequest && (

    Pre-Request

    - +
    )} {scripts.postResponse && (

    Post-Response

    - +
    )} @@ -146,7 +145,7 @@ export const CollectionConfiguration: React.FC = (
    Tests {scripts.tests ? ( - + ) : ( Add tests to run for all requests in the collection )} From 4d614fb0994f872f2b1b9ea5d62d7bb2b0db55ff Mon Sep 17 00:00:00 2001 From: bruno-sachin Date: Wed, 24 Jun 2026 19:00:45 +0530 Subject: [PATCH 22/51] Comments addressed --- packages/oc-docs/playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/oc-docs/playwright.config.ts b/packages/oc-docs/playwright.config.ts index 0ddfd12..02942c6 100644 --- a/packages/oc-docs/playwright.config.ts +++ b/packages/oc-docs/playwright.config.ts @@ -20,7 +20,7 @@ export default defineConfig({ projects: [ { name: 'chromium', - use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + use: { ...devices['Desktop Chrome'] }, }, ], webServer: { From 6dc8525ae2c948dc245260f7e7cc46ea5147bd10 Mon Sep 17 00:00:00 2001 From: bruno-sachin Date: Wed, 24 Jun 2026 19:54:38 +0530 Subject: [PATCH 23/51] Comments addressed --- .../oc-docs/src/{ui => components}/Heading/Heading.spec.tsx | 0 packages/oc-docs/src/{ui => components}/Heading/Heading.tsx | 0 .../oc-docs/src/{ui => components}/Heading/StyledWrapper.ts | 0 .../CollectionConfiguration.tsx | 2 +- .../src/{ui => components}/PageWrapper/PageWrapper.spec.tsx | 0 .../src/{ui => components}/PageWrapper/PageWrapper.tsx | 0 .../src/{ui => components}/PageWrapper/StyledWrapper.ts | 0 packages/oc-docs/src/components/Section/Section.tsx | 2 +- .../src/{ui => components}/SectionLabel/SectionLabel.spec.tsx | 0 .../src/{ui => components}/SectionLabel/SectionLabel.tsx | 0 .../src/{ui => components}/SectionLabel/StyledWrapper.ts | 0 .../src/{ui => components}/SubHeading/StyledWrapper.ts | 0 .../src/{ui => components}/SubHeading/SubHeading.spec.tsx | 0 .../oc-docs/src/{ui => components}/SubHeading/SubHeading.tsx | 0 packages/oc-docs/src/pages/Overview/Overview.tsx | 4 ++-- 15 files changed, 4 insertions(+), 4 deletions(-) rename packages/oc-docs/src/{ui => components}/Heading/Heading.spec.tsx (100%) rename packages/oc-docs/src/{ui => components}/Heading/Heading.tsx (100%) rename packages/oc-docs/src/{ui => components}/Heading/StyledWrapper.ts (100%) rename packages/oc-docs/src/{ui => components}/PageWrapper/PageWrapper.spec.tsx (100%) rename packages/oc-docs/src/{ui => components}/PageWrapper/PageWrapper.tsx (100%) rename packages/oc-docs/src/{ui => components}/PageWrapper/StyledWrapper.ts (100%) rename packages/oc-docs/src/{ui => components}/SectionLabel/SectionLabel.spec.tsx (100%) rename packages/oc-docs/src/{ui => components}/SectionLabel/SectionLabel.tsx (100%) rename packages/oc-docs/src/{ui => components}/SectionLabel/StyledWrapper.ts (100%) rename packages/oc-docs/src/{ui => components}/SubHeading/StyledWrapper.ts (100%) rename packages/oc-docs/src/{ui => components}/SubHeading/SubHeading.spec.tsx (100%) rename packages/oc-docs/src/{ui => components}/SubHeading/SubHeading.tsx (100%) diff --git a/packages/oc-docs/src/ui/Heading/Heading.spec.tsx b/packages/oc-docs/src/components/Heading/Heading.spec.tsx similarity index 100% rename from packages/oc-docs/src/ui/Heading/Heading.spec.tsx rename to packages/oc-docs/src/components/Heading/Heading.spec.tsx diff --git a/packages/oc-docs/src/ui/Heading/Heading.tsx b/packages/oc-docs/src/components/Heading/Heading.tsx similarity index 100% rename from packages/oc-docs/src/ui/Heading/Heading.tsx rename to packages/oc-docs/src/components/Heading/Heading.tsx diff --git a/packages/oc-docs/src/ui/Heading/StyledWrapper.ts b/packages/oc-docs/src/components/Heading/StyledWrapper.ts similarity index 100% rename from packages/oc-docs/src/ui/Heading/StyledWrapper.ts rename to packages/oc-docs/src/components/Heading/StyledWrapper.ts diff --git a/packages/oc-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.tsx b/packages/oc-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.tsx index cb0387d..d294b05 100644 --- a/packages/oc-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.tsx +++ b/packages/oc-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.tsx @@ -3,7 +3,7 @@ import type { HttpRequestHeader } from '@opencollection/types/requests/http'; import type { Auth } from '@opencollection/types/common/auth'; import { Code } from '../Code/Code'; import { SecretValue } from '../../ui/SecretValue/SecretValue'; -import { SubHeading } from '../../ui/SubHeading/SubHeading'; +import { SubHeading } from '../SubHeading/SubHeading'; import { StyledWrapper } from './StyledWrapper'; interface CollectionScripts { diff --git a/packages/oc-docs/src/ui/PageWrapper/PageWrapper.spec.tsx b/packages/oc-docs/src/components/PageWrapper/PageWrapper.spec.tsx similarity index 100% rename from packages/oc-docs/src/ui/PageWrapper/PageWrapper.spec.tsx rename to packages/oc-docs/src/components/PageWrapper/PageWrapper.spec.tsx diff --git a/packages/oc-docs/src/ui/PageWrapper/PageWrapper.tsx b/packages/oc-docs/src/components/PageWrapper/PageWrapper.tsx similarity index 100% rename from packages/oc-docs/src/ui/PageWrapper/PageWrapper.tsx rename to packages/oc-docs/src/components/PageWrapper/PageWrapper.tsx diff --git a/packages/oc-docs/src/ui/PageWrapper/StyledWrapper.ts b/packages/oc-docs/src/components/PageWrapper/StyledWrapper.ts similarity index 100% rename from packages/oc-docs/src/ui/PageWrapper/StyledWrapper.ts rename to packages/oc-docs/src/components/PageWrapper/StyledWrapper.ts diff --git a/packages/oc-docs/src/components/Section/Section.tsx b/packages/oc-docs/src/components/Section/Section.tsx index ef6703a..db35770 100644 --- a/packages/oc-docs/src/components/Section/Section.tsx +++ b/packages/oc-docs/src/components/Section/Section.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { SectionLabel } from '../../ui/SectionLabel/SectionLabel' +import { SectionLabel } from '../SectionLabel/SectionLabel' import { StyledWrapper } from './StyledWrapper'; interface SectionProps { diff --git a/packages/oc-docs/src/ui/SectionLabel/SectionLabel.spec.tsx b/packages/oc-docs/src/components/SectionLabel/SectionLabel.spec.tsx similarity index 100% rename from packages/oc-docs/src/ui/SectionLabel/SectionLabel.spec.tsx rename to packages/oc-docs/src/components/SectionLabel/SectionLabel.spec.tsx diff --git a/packages/oc-docs/src/ui/SectionLabel/SectionLabel.tsx b/packages/oc-docs/src/components/SectionLabel/SectionLabel.tsx similarity index 100% rename from packages/oc-docs/src/ui/SectionLabel/SectionLabel.tsx rename to packages/oc-docs/src/components/SectionLabel/SectionLabel.tsx diff --git a/packages/oc-docs/src/ui/SectionLabel/StyledWrapper.ts b/packages/oc-docs/src/components/SectionLabel/StyledWrapper.ts similarity index 100% rename from packages/oc-docs/src/ui/SectionLabel/StyledWrapper.ts rename to packages/oc-docs/src/components/SectionLabel/StyledWrapper.ts diff --git a/packages/oc-docs/src/ui/SubHeading/StyledWrapper.ts b/packages/oc-docs/src/components/SubHeading/StyledWrapper.ts similarity index 100% rename from packages/oc-docs/src/ui/SubHeading/StyledWrapper.ts rename to packages/oc-docs/src/components/SubHeading/StyledWrapper.ts diff --git a/packages/oc-docs/src/ui/SubHeading/SubHeading.spec.tsx b/packages/oc-docs/src/components/SubHeading/SubHeading.spec.tsx similarity index 100% rename from packages/oc-docs/src/ui/SubHeading/SubHeading.spec.tsx rename to packages/oc-docs/src/components/SubHeading/SubHeading.spec.tsx diff --git a/packages/oc-docs/src/ui/SubHeading/SubHeading.tsx b/packages/oc-docs/src/components/SubHeading/SubHeading.tsx similarity index 100% rename from packages/oc-docs/src/ui/SubHeading/SubHeading.tsx rename to packages/oc-docs/src/components/SubHeading/SubHeading.tsx diff --git a/packages/oc-docs/src/pages/Overview/Overview.tsx b/packages/oc-docs/src/pages/Overview/Overview.tsx index 9f9ad4d..95d272b 100644 --- a/packages/oc-docs/src/pages/Overview/Overview.tsx +++ b/packages/oc-docs/src/pages/Overview/Overview.tsx @@ -11,8 +11,8 @@ import { CollectionStats } from '../../components/CollectionStats/CollectionStat import { EnvironmentSummary } from '../../components/OverviewEnvironments/EnvironmentSummary/EnvironmentSummary'; import { CollectionConfiguration } from '../../components/OverviewCollectionConfiguration/CollectionConfiguration'; import { EmptyState } from '../../ui/EmptyState/EmptyState'; -import { PageWrapper } from '../../ui/PageWrapper/PageWrapper'; -import { Heading } from '../../ui/Heading/Heading'; +import { PageWrapper } from '../../components/PageWrapper/PageWrapper'; +import { Heading } from '../../components/Heading/Heading'; import { Section } from '../../components/Section/Section'; import { GlobeIcon, BookIcon } from '../../assets/icons'; import { StyledWrapper } from './StyledWrapper'; From d8ee95087cb05e3d681970fe9a4a2592f86ee829 Mon Sep 17 00:00:00 2001 From: bruno-sachin Date: Mon, 22 Jun 2026 15:03:41 +0530 Subject: [PATCH 24/51] Request page redesign changes --- .../AuthDetails/AuthDetails.spec.tsx | 42 + .../components/AuthDetails/AuthDetails.tsx | 119 + .../src/components/AuthDetails/index.ts | 2 + .../components/Breadcrumb/Breadcrumb.spec.tsx | 19 + .../src/components/Breadcrumb/Breadcrumb.tsx | 45 + .../components/Breadcrumb/StyledWrapper.ts | 46 + .../src/components/Breadcrumb/index.ts | 3 + .../src/components/Chevron/Chevron.tsx | 34 + .../src/components/Chevron/StyledWrapper.ts | 18 + .../oc-docs/src/components/Chevron/index.ts | 2 + .../Code/CodeViewer/StyledWrapper.ts | 25 +- .../CodeSnippetTabs/CodeSnippetTabs.spec.tsx | 21 + .../CodeSnippetTabs/CodeSnippetTabs.tsx | 119 + .../CodeSnippetTabs/StyledWrapper.ts | 105 + .../src/components/CodeSnippetTabs/index.ts | 2 + .../ContentTypeBadge.spec.tsx | 10 + .../ContentTypeBadge/ContentTypeBadge.tsx | 16 + .../ContentTypeBadge/StyledWrapper.ts | 20 + .../src/components/ContentTypeBadge/index.ts | 2 + .../Docs/CodeSnippets/CodeSnippets.tsx | 83 - .../Docs/CodeSnippets/StyledWrapper.ts | 105 - .../Docs/CodeSnippets/generateCodeSnippets.ts | 64 - packages/oc-docs/src/components/Docs/Docs.tsx | 91 +- .../Examples/ExamplesView/ExamplesView.tsx | 387 -- .../Examples/ExamplesView/StyledWrapper.tsx | 387 -- .../oc-docs/src/components/Docs/Item/Item.tsx | 453 -- .../components/Docs/Item/Scripts/Scripts.tsx | 95 - .../Docs/Item/Scripts/StyledWrapper.ts | 118 - .../src/components/Docs/Item/StyledWrapper.ts | 190 - .../Examples/ExampleCard/ExampleCard.spec.tsx | 137 + .../Examples/ExampleCard/ExampleCard.tsx | 437 ++ .../Examples/ExampleCard/StyledWrapper.ts | 326 + .../components/Examples/ExampleCard/index.ts | 2 + .../src/components/Examples/Examples.spec.tsx | 25 + .../src/components/Examples/Examples.tsx | 34 + .../src/components/Examples/StyledWrapper.ts | 5 + .../oc-docs/src/components/Examples/index.ts | 3 + .../ExecutionContext/AssertList.tsx | 37 + .../ExecutionContext.spec.tsx | 147 + .../ExecutionContext/ExecutionContext.tsx | 101 + .../ExecutionContext/FlowToggle.tsx | 35 + .../components/ExecutionContext/ScopeTag.tsx | 14 + .../ExecutionContext/ScriptChain.tsx | 63 + .../ExecutionContext/ScriptStep.tsx | 94 + .../ExecutionContext/StyledWrapper.ts | 315 + .../components/ExecutionContext/TestList.tsx | 64 + .../ExecutionContext/VariablesPanel.tsx | 35 + .../src/components/ExecutionContext/index.ts | 9 + .../src/components/Heading/Heading.spec.tsx | 14 + .../src/components/Heading/Heading.tsx | 14 +- .../src/components/Heading/StyledWrapper.ts | 15 +- .../HiddenSections/HiddenSections.tsx | 75 + .../HiddenSections/StyledWrapper.ts | 66 + .../src/components/HiddenSections/index.ts | 1 + .../components/LevelBadge/LevelBadge.spec.tsx | 11 + .../src/components/LevelBadge/LevelBadge.tsx | 20 + .../components/LevelBadge/StyledWrapper.ts | 16 + .../src/components/LevelBadge/index.ts | 3 + .../MethodBadge/MethodBadge.spec.tsx | 14 + .../components/MethodBadge/MethodBadge.tsx | 20 + .../components/MethodBadge/StyledWrapper.ts | 11 + .../src/components/MethodBadge/index.ts | 2 + .../src/components/Modal/Modal.spec.tsx | 47 + .../oc-docs/src/components/Modal/Modal.tsx | 74 + .../src/components/Modal/StyledWrapper.ts | 87 + .../oc-docs/src/components/Modal/index.ts | 1 + .../CollectionConfiguration.spec.tsx | 3 +- .../CollectionConfiguration.tsx | 170 +- .../StyledWrapper.ts | 56 +- .../src/components/Portal/Portal.spec.tsx | 21 + .../oc-docs/src/components/Portal/Portal.tsx | 27 + .../oc-docs/src/components/Portal/index.ts | 1 + .../PropertyTable/PropertyTable.spec.tsx | 34 + .../PropertyTable/PropertyTable.tsx | 59 + .../components/PropertyTable/StyledWrapper.ts | 73 + .../src/components/PropertyTable/index.ts | 3 + .../RequestBody/RequestBody.spec.tsx | 26 + .../components/RequestBody/RequestBody.tsx | 39 + .../components/RequestBody/StyledWrapper.ts | 14 + .../src/components/RequestBody/index.ts | 2 + .../RequestDescription.spec.tsx | 15 + .../RequestDescription/RequestDescription.tsx | 67 + .../RequestDescription/StyledWrapper.ts | 44 + .../components/RequestDescription/index.ts | 2 + .../RequestParams/RequestParams.spec.tsx | 23 + .../RequestParams/RequestParams.tsx | 38 + .../components/RequestParams/StyledWrapper.ts | 7 + .../src/components/RequestParams/index.ts | 2 + .../RequestUrlBar/RequestUrlBar.spec.tsx | 18 + .../RequestUrlBar/RequestUrlBar.tsx | 57 + .../components/RequestUrlBar/StyledWrapper.ts | 70 + .../src/components/RequestUrlBar/index.ts | 2 + .../RequestVars/RequestVars.spec.tsx | 24 + .../components/RequestVars/RequestVars.tsx | 35 + .../components/RequestVars/StyledWrapper.ts | 7 + .../src/components/RequestVars/index.ts | 2 + .../src/components/Section/Section.tsx | 75 +- .../src/components/Section/StyledWrapper.ts | 56 +- .../components/SectionLabel/StyledWrapper.ts | 3 +- .../components/SubHeading/StyledWrapper.ts | 12 +- .../components/VariableText/StyledWrapper.ts | 14 + .../VariableText/VariableText.spec.tsx | 18 + .../components/VariableText/VariableText.tsx | 36 + .../src/components/VariableText/index.ts | 2 + packages/oc-docs/src/dev.spec.ts | 2 +- .../oc-docs/src/hooks/useMarkdownRenderer.ts | 6 +- .../src/pages/Overview/StyledWrapper.ts | 2 +- .../src/pages/Request/Request.spec.tsx | 122 + .../oc-docs/src/pages/Request/Request.tsx | 224 + .../src/pages/Request/StyledWrapper.ts | 62 + packages/oc-docs/src/pages/Request/index.ts | 2 + packages/oc-docs/src/sampleCollection.ts | 5420 +++++++++++++++-- packages/oc-docs/src/styles/index.css | 8 + .../oc-docs/src/ui/CopyButton/CopyButton.tsx | 7 +- .../src/ui/CopyButton/StyledWrapper.ts | 3 +- packages/oc-docs/src/utils/assertions.spec.ts | 29 + packages/oc-docs/src/utils/assertions.ts | 69 + packages/oc-docs/src/utils/codegen.spec.ts | 51 + packages/oc-docs/src/utils/codegen.ts | 187 + .../oc-docs/src/utils/exampleResponse.spec.ts | 24 + packages/oc-docs/src/utils/exampleResponse.ts | 32 + .../oc-docs/src/utils/extractTests.spec.ts | 29 + packages/oc-docs/src/utils/extractTests.ts | 54 + packages/oc-docs/src/utils/itemTree.spec.ts | 34 + packages/oc-docs/src/utils/itemTree.ts | 42 + .../oc-docs/src/utils/requestAuth.spec.ts | 32 + packages/oc-docs/src/utils/requestAuth.ts | 51 + .../oc-docs/src/utils/requestBody.spec.ts | 48 + packages/oc-docs/src/utils/requestBody.ts | 112 + .../oc-docs/src/utils/requestScripts.spec.ts | 42 + packages/oc-docs/src/utils/requestScripts.ts | 104 + .../oc-docs/src/utils/requestVars.spec.ts | 32 + packages/oc-docs/src/utils/requestVars.ts | 48 + packages/oc-docs/src/utils/schemaHelpers.ts | 27 +- packages/oc-types/src/requests/http.ts | 1 + 135 files changed, 10248 insertions(+), 2812 deletions(-) create mode 100644 packages/oc-docs/src/components/AuthDetails/AuthDetails.spec.tsx create mode 100644 packages/oc-docs/src/components/AuthDetails/AuthDetails.tsx create mode 100644 packages/oc-docs/src/components/AuthDetails/index.ts create mode 100644 packages/oc-docs/src/components/Breadcrumb/Breadcrumb.spec.tsx create mode 100644 packages/oc-docs/src/components/Breadcrumb/Breadcrumb.tsx create mode 100644 packages/oc-docs/src/components/Breadcrumb/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/Breadcrumb/index.ts create mode 100644 packages/oc-docs/src/components/Chevron/Chevron.tsx create mode 100644 packages/oc-docs/src/components/Chevron/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/Chevron/index.ts create mode 100644 packages/oc-docs/src/components/CodeSnippetTabs/CodeSnippetTabs.spec.tsx create mode 100644 packages/oc-docs/src/components/CodeSnippetTabs/CodeSnippetTabs.tsx create mode 100644 packages/oc-docs/src/components/CodeSnippetTabs/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/CodeSnippetTabs/index.ts create mode 100644 packages/oc-docs/src/components/ContentTypeBadge/ContentTypeBadge.spec.tsx create mode 100644 packages/oc-docs/src/components/ContentTypeBadge/ContentTypeBadge.tsx create mode 100644 packages/oc-docs/src/components/ContentTypeBadge/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/ContentTypeBadge/index.ts delete mode 100644 packages/oc-docs/src/components/Docs/CodeSnippets/CodeSnippets.tsx delete mode 100644 packages/oc-docs/src/components/Docs/CodeSnippets/StyledWrapper.ts delete mode 100644 packages/oc-docs/src/components/Docs/CodeSnippets/generateCodeSnippets.ts delete mode 100644 packages/oc-docs/src/components/Docs/Item/Examples/ExamplesView/ExamplesView.tsx delete mode 100644 packages/oc-docs/src/components/Docs/Item/Examples/ExamplesView/StyledWrapper.tsx delete mode 100644 packages/oc-docs/src/components/Docs/Item/Item.tsx delete mode 100644 packages/oc-docs/src/components/Docs/Item/Scripts/Scripts.tsx delete mode 100644 packages/oc-docs/src/components/Docs/Item/Scripts/StyledWrapper.ts delete mode 100644 packages/oc-docs/src/components/Docs/Item/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/Examples/ExampleCard/ExampleCard.spec.tsx create mode 100644 packages/oc-docs/src/components/Examples/ExampleCard/ExampleCard.tsx create mode 100644 packages/oc-docs/src/components/Examples/ExampleCard/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/Examples/ExampleCard/index.ts create mode 100644 packages/oc-docs/src/components/Examples/Examples.spec.tsx create mode 100644 packages/oc-docs/src/components/Examples/Examples.tsx create mode 100644 packages/oc-docs/src/components/Examples/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/Examples/index.ts create mode 100644 packages/oc-docs/src/components/ExecutionContext/AssertList.tsx create mode 100644 packages/oc-docs/src/components/ExecutionContext/ExecutionContext.spec.tsx create mode 100644 packages/oc-docs/src/components/ExecutionContext/ExecutionContext.tsx create mode 100644 packages/oc-docs/src/components/ExecutionContext/FlowToggle.tsx create mode 100644 packages/oc-docs/src/components/ExecutionContext/ScopeTag.tsx create mode 100644 packages/oc-docs/src/components/ExecutionContext/ScriptChain.tsx create mode 100644 packages/oc-docs/src/components/ExecutionContext/ScriptStep.tsx create mode 100644 packages/oc-docs/src/components/ExecutionContext/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/ExecutionContext/TestList.tsx create mode 100644 packages/oc-docs/src/components/ExecutionContext/VariablesPanel.tsx create mode 100644 packages/oc-docs/src/components/ExecutionContext/index.ts create mode 100644 packages/oc-docs/src/components/HiddenSections/HiddenSections.tsx create mode 100644 packages/oc-docs/src/components/HiddenSections/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/HiddenSections/index.ts create mode 100644 packages/oc-docs/src/components/LevelBadge/LevelBadge.spec.tsx create mode 100644 packages/oc-docs/src/components/LevelBadge/LevelBadge.tsx create mode 100644 packages/oc-docs/src/components/LevelBadge/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/LevelBadge/index.ts create mode 100644 packages/oc-docs/src/components/MethodBadge/MethodBadge.spec.tsx create mode 100644 packages/oc-docs/src/components/MethodBadge/MethodBadge.tsx create mode 100644 packages/oc-docs/src/components/MethodBadge/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/MethodBadge/index.ts create mode 100644 packages/oc-docs/src/components/Modal/Modal.spec.tsx create mode 100644 packages/oc-docs/src/components/Modal/Modal.tsx create mode 100644 packages/oc-docs/src/components/Modal/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/Modal/index.ts create mode 100644 packages/oc-docs/src/components/Portal/Portal.spec.tsx create mode 100644 packages/oc-docs/src/components/Portal/Portal.tsx create mode 100644 packages/oc-docs/src/components/Portal/index.ts create mode 100644 packages/oc-docs/src/components/PropertyTable/PropertyTable.spec.tsx create mode 100644 packages/oc-docs/src/components/PropertyTable/PropertyTable.tsx create mode 100644 packages/oc-docs/src/components/PropertyTable/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/PropertyTable/index.ts create mode 100644 packages/oc-docs/src/components/RequestBody/RequestBody.spec.tsx create mode 100644 packages/oc-docs/src/components/RequestBody/RequestBody.tsx create mode 100644 packages/oc-docs/src/components/RequestBody/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/RequestBody/index.ts create mode 100644 packages/oc-docs/src/components/RequestDescription/RequestDescription.spec.tsx create mode 100644 packages/oc-docs/src/components/RequestDescription/RequestDescription.tsx create mode 100644 packages/oc-docs/src/components/RequestDescription/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/RequestDescription/index.ts create mode 100644 packages/oc-docs/src/components/RequestParams/RequestParams.spec.tsx create mode 100644 packages/oc-docs/src/components/RequestParams/RequestParams.tsx create mode 100644 packages/oc-docs/src/components/RequestParams/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/RequestParams/index.ts create mode 100644 packages/oc-docs/src/components/RequestUrlBar/RequestUrlBar.spec.tsx create mode 100644 packages/oc-docs/src/components/RequestUrlBar/RequestUrlBar.tsx create mode 100644 packages/oc-docs/src/components/RequestUrlBar/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/RequestUrlBar/index.ts create mode 100644 packages/oc-docs/src/components/RequestVars/RequestVars.spec.tsx create mode 100644 packages/oc-docs/src/components/RequestVars/RequestVars.tsx create mode 100644 packages/oc-docs/src/components/RequestVars/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/RequestVars/index.ts create mode 100644 packages/oc-docs/src/components/VariableText/StyledWrapper.ts create mode 100644 packages/oc-docs/src/components/VariableText/VariableText.spec.tsx create mode 100644 packages/oc-docs/src/components/VariableText/VariableText.tsx create mode 100644 packages/oc-docs/src/components/VariableText/index.ts create mode 100644 packages/oc-docs/src/pages/Request/Request.spec.tsx create mode 100644 packages/oc-docs/src/pages/Request/Request.tsx create mode 100644 packages/oc-docs/src/pages/Request/StyledWrapper.ts create mode 100644 packages/oc-docs/src/pages/Request/index.ts create mode 100644 packages/oc-docs/src/utils/assertions.spec.ts create mode 100644 packages/oc-docs/src/utils/assertions.ts create mode 100644 packages/oc-docs/src/utils/codegen.spec.ts create mode 100644 packages/oc-docs/src/utils/codegen.ts create mode 100644 packages/oc-docs/src/utils/exampleResponse.spec.ts create mode 100644 packages/oc-docs/src/utils/exampleResponse.ts create mode 100644 packages/oc-docs/src/utils/extractTests.spec.ts create mode 100644 packages/oc-docs/src/utils/extractTests.ts create mode 100644 packages/oc-docs/src/utils/itemTree.spec.ts create mode 100644 packages/oc-docs/src/utils/itemTree.ts create mode 100644 packages/oc-docs/src/utils/requestAuth.spec.ts create mode 100644 packages/oc-docs/src/utils/requestAuth.ts create mode 100644 packages/oc-docs/src/utils/requestBody.spec.ts create mode 100644 packages/oc-docs/src/utils/requestBody.ts create mode 100644 packages/oc-docs/src/utils/requestScripts.spec.ts create mode 100644 packages/oc-docs/src/utils/requestScripts.ts create mode 100644 packages/oc-docs/src/utils/requestVars.spec.ts create mode 100644 packages/oc-docs/src/utils/requestVars.ts diff --git a/packages/oc-docs/src/components/AuthDetails/AuthDetails.spec.tsx b/packages/oc-docs/src/components/AuthDetails/AuthDetails.spec.tsx new file mode 100644 index 0000000..1f6cd0e --- /dev/null +++ b/packages/oc-docs/src/components/AuthDetails/AuthDetails.spec.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import { AuthDetails } from './AuthDetails'; +import { AUTH_MODE_LABELS } from '../../constants'; + +describe('AuthDetails', () => { + it('renders basic auth: mode + username, masks the password', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('Basic Auth'); + expect(html).toContain('user@example.com'); + expect(html).not.toContain('s3cr3t'); + }); + + it('masks the bearer token and falls back to the raw type without a label', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('bearer'); + expect(html).not.toContain('abc123'); + }); + + it('renders apikey fields including placement', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('API Key'); + expect(html).toContain('X-Api-Key'); + expect(html).toContain('header'); + }); + + it('shows the empty message when there is no auth', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('No authentication configured'); + }); +}); diff --git a/packages/oc-docs/src/components/AuthDetails/AuthDetails.tsx b/packages/oc-docs/src/components/AuthDetails/AuthDetails.tsx new file mode 100644 index 0000000..e3a86d7 --- /dev/null +++ b/packages/oc-docs/src/components/AuthDetails/AuthDetails.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import type { Auth } from '@opencollection/types/common/auth'; +import { PropertyTable, type PropertyRow } from '../PropertyTable'; + +interface AuthDetailsProps { + auth?: Auth; + /** Maps an auth `type` to a display label (e.g. basic -> "Basic Auth"). */ + authModeLabels?: Record; + /** For `inherit`: where the effective auth was resolved from (e.g. 'Collection'). */ + inheritedFrom?: string; + /** Italic placeholder when there is no auth at all. */ + emptyMessage?: string; +} + +const modeLabel = (auth: Auth, labels: Record): string => + auth === 'inherit' ? 'Inherit' : labels[auth.type] || auth.type; + +const pushRow = (rows: PropertyRow[], label: string, value: unknown, secret = false): void => { + if (typeof value === 'string' && value.length > 0) rows.push({ label, value, secret }); +}; + +/** Display rows for a concrete (non-inherit) auth object; secrets are flagged. */ +const buildAuthRows = (auth: Exclude): PropertyRow[] => { + const rows: PropertyRow[] = []; + + switch (auth.type) { + case 'basic': + case 'digest': + case 'wsse': + pushRow(rows, 'Username', auth.username); + pushRow(rows, 'Password', auth.password, true); + break; + case 'ntlm': + pushRow(rows, 'Username', auth.username); + pushRow(rows, 'Password', auth.password, true); + pushRow(rows, 'Domain', auth.domain); + break; + case 'bearer': + pushRow(rows, 'Token', auth.token, true); + break; + case 'apikey': + pushRow(rows, 'Key', auth.key); + pushRow(rows, 'Value', auth.value, true); + pushRow(rows, 'Add To', auth.placement); + break; + case 'awsv4': + pushRow(rows, 'Access Key Id', auth.accessKeyId); + pushRow(rows, 'Secret Access Key', auth.secretAccessKey, true); + pushRow(rows, 'Session Token', auth.sessionToken, true); + pushRow(rows, 'Service', auth.service); + pushRow(rows, 'Region', auth.region); + pushRow(rows, 'Profile Name', auth.profileName); + break; + case 'oauth1': + pushRow(rows, 'Consumer Key', auth.consumerKey); + pushRow(rows, 'Consumer Secret', auth.consumerSecret, true); + pushRow(rows, 'Access Token', auth.accessToken, true); + pushRow(rows, 'Access Token Secret', auth.accessTokenSecret, true); + pushRow(rows, 'Signature Method', auth.signatureMethod); + pushRow(rows, 'Callback URL', auth.callbackUrl); + pushRow(rows, 'Placement', auth.placement); + break; + case 'oauth2': { + // oauth2 is a union of grant flows with differing optional fields; read them + // structurally so each is surfaced when present. + const o = auth as { + flow?: string; + scope?: string; + accessTokenUrl?: string; + authorizationUrl?: string; + callbackUrl?: string; + credentials?: { clientId?: string; clientSecret?: string; placement?: string }; + resourceOwner?: { username?: string; password?: string }; + }; + pushRow(rows, 'Flow', o.flow); + pushRow(rows, 'Client Id', o.credentials?.clientId); + pushRow(rows, 'Client Secret', o.credentials?.clientSecret, true); + pushRow(rows, 'Add Credentials To', o.credentials?.placement); + pushRow(rows, 'Access Token URL', o.accessTokenUrl); + pushRow(rows, 'Authorization URL', o.authorizationUrl); + pushRow(rows, 'Callback URL', o.callbackUrl); + pushRow(rows, 'Scope', o.scope); + pushRow(rows, 'Username', o.resourceOwner?.username); + pushRow(rows, 'Password', o.resourceOwner?.password, true); + break; + } + default: + break; + } + return rows; +}; + +/** + * Read-only auth display: the auth mode plus its fields (secrets masked) as a + * PropertyTable. Reused by Collection Configuration (Overview) and the request page. + * For `inherit`, the host resolves the effective auth and passes `inheritedFrom`. + */ +export const AuthDetails: React.FC = ({ + auth, + authModeLabels = {}, + inheritedFrom, + emptyMessage +}) => { + if (!auth) { + return emptyMessage ? : null; + } + + const rows: PropertyRow[] = [{ label: 'Mode', value: modeLabel(auth, authModeLabels) }]; + + if (auth === 'inherit') { + if (inheritedFrom) rows.push({ label: 'Inherited From', value: inheritedFrom }); + } else { + rows.push(...buildAuthRows(auth)); + } + + return ; +}; + +export default AuthDetails; diff --git a/packages/oc-docs/src/components/AuthDetails/index.ts b/packages/oc-docs/src/components/AuthDetails/index.ts new file mode 100644 index 0000000..c24da1f --- /dev/null +++ b/packages/oc-docs/src/components/AuthDetails/index.ts @@ -0,0 +1,2 @@ +export { AuthDetails } from './AuthDetails'; +export { default } from './AuthDetails'; diff --git a/packages/oc-docs/src/components/Breadcrumb/Breadcrumb.spec.tsx b/packages/oc-docs/src/components/Breadcrumb/Breadcrumb.spec.tsx new file mode 100644 index 0000000..2f353ba --- /dev/null +++ b/packages/oc-docs/src/components/Breadcrumb/Breadcrumb.spec.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import { Breadcrumb } from './Breadcrumb'; + +describe('Breadcrumb', () => { + it('renders ancestor folders and the current page', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('Authentication'); + expect(html).toContain('Login'); + expect(html).toContain('aria-current="page"'); + }); + + it('renders nothing when there are no segments and no current', () => { + expect(renderToStaticMarkup()).toBe(''); + }); +}); diff --git a/packages/oc-docs/src/components/Breadcrumb/Breadcrumb.tsx b/packages/oc-docs/src/components/Breadcrumb/Breadcrumb.tsx new file mode 100644 index 0000000..627055c --- /dev/null +++ b/packages/oc-docs/src/components/Breadcrumb/Breadcrumb.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { BreadcrumbWrapper } from './StyledWrapper'; + +export interface BreadcrumbSegment { + name: string; + uuid: string; +} + +interface BreadcrumbProps { + /** Ancestor folders (clickable), ordered root → parent. */ + segments: BreadcrumbSegment[]; + /** Current page name, shown as the non-clickable trailing crumb. */ + current?: string; + onSegmentClick?: (uuid: string) => void; + className?: string; +} + +/** Accessible breadcrumb trail (folders as buttons, current page as `aria-current`). */ +export const Breadcrumb: React.FC = ({ segments, current, onSegmentClick, className }) => { + const hasSegments = segments && segments.length > 0; + if (!hasSegments && !current) return null; + + return ( + +
      + {segments.map((segment, index) => ( +
    1. + {index > 0 && } + +
    2. + ))} + {current && ( +
    3. + {hasSegments && } + {current} +
    4. + )} +
    +
    + ); +}; + +export default Breadcrumb; diff --git a/packages/oc-docs/src/components/Breadcrumb/StyledWrapper.ts b/packages/oc-docs/src/components/Breadcrumb/StyledWrapper.ts new file mode 100644 index 0000000..d424ec8 --- /dev/null +++ b/packages/oc-docs/src/components/Breadcrumb/StyledWrapper.ts @@ -0,0 +1,46 @@ +import styled from '@emotion/styled'; + +export const BreadcrumbWrapper = styled.nav` + ol { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem; + font-family: var(--font-sans); + font-weight: 400; + font-size: 0.75rem; + line-height: 1; + letter-spacing: 0; + } + li { + display: inline-flex; + align-items: center; + gap: 0.25rem; + } + .oc-breadcrumb-sep { + color: var(--oc-breadcrumb-color-segments, var(--text-secondary)); + } + .oc-breadcrumb-link { + border: none; + background: none; + padding: 0; + margin: 0; + cursor: pointer; + font: inherit; + color: var(--oc-breadcrumb-color-segments, var(--text-secondary)); + } + .oc-breadcrumb-link:hover { + color: var(--oc-breadcrumb-color-current, var(--text-primary)); + } + .oc-breadcrumb-link:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + border-radius: 2px; + } + .oc-breadcrumb-current { + color: var(--oc-breadcrumb-color-current, var(--text-primary)); + } +`; diff --git a/packages/oc-docs/src/components/Breadcrumb/index.ts b/packages/oc-docs/src/components/Breadcrumb/index.ts new file mode 100644 index 0000000..740d7be --- /dev/null +++ b/packages/oc-docs/src/components/Breadcrumb/index.ts @@ -0,0 +1,3 @@ +export { Breadcrumb } from './Breadcrumb'; +export type { BreadcrumbSegment } from './Breadcrumb'; +export { default } from './Breadcrumb'; diff --git a/packages/oc-docs/src/components/Chevron/Chevron.tsx b/packages/oc-docs/src/components/Chevron/Chevron.tsx new file mode 100644 index 0000000..d809a30 --- /dev/null +++ b/packages/oc-docs/src/components/Chevron/Chevron.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { ChevronIcon } from './StyledWrapper'; + +interface ChevronProps { + /** When true the chevron rotates 90° to point down (expanded state). */ + open?: boolean; + /** Icon size in pixels (width = height). Defaults to 13. */ + size?: number; + className?: string; +} + +/** + * Small disclosure chevron shared by collapsible sections and rows. Purely + * decorative (aria-hidden) — the control that owns it carries the accessible + * state via `aria-expanded`. + */ +export const Chevron: React.FC = ({ open = false, size = 13, className }) => ( + +); + +export default Chevron; diff --git a/packages/oc-docs/src/components/Chevron/StyledWrapper.ts b/packages/oc-docs/src/components/Chevron/StyledWrapper.ts new file mode 100644 index 0000000..3fad1b8 --- /dev/null +++ b/packages/oc-docs/src/components/Chevron/StyledWrapper.ts @@ -0,0 +1,18 @@ +import styled from '@emotion/styled'; + +/** + * A chevron icon that points right and rotates to point down when `.is-open`. + * Inherits its colour from the surrounding text (stroke: currentColor). + */ +export const ChevronIcon = styled.svg` + flex-shrink: 0; + transition: transform 0.2s ease; + + &.is-open { + transform: rotate(90deg); + } + + @media (prefers-reduced-motion: reduce) { + transition: none; + } +`; diff --git a/packages/oc-docs/src/components/Chevron/index.ts b/packages/oc-docs/src/components/Chevron/index.ts new file mode 100644 index 0000000..8f4b222 --- /dev/null +++ b/packages/oc-docs/src/components/Chevron/index.ts @@ -0,0 +1,2 @@ +export { Chevron } from './Chevron'; +export { default } from './Chevron'; diff --git a/packages/oc-docs/src/components/Code/CodeViewer/StyledWrapper.ts b/packages/oc-docs/src/components/Code/CodeViewer/StyledWrapper.ts index 8ca2a8b..3399ad6 100644 --- a/packages/oc-docs/src/components/Code/CodeViewer/StyledWrapper.ts +++ b/packages/oc-docs/src/components/Code/CodeViewer/StyledWrapper.ts @@ -1,10 +1,9 @@ import styled from '@emotion/styled'; export const StyledWrapper = styled.div` - max-width: 100%; - background-color: var(--code-bg); - border: 1px solid var(--oc-table-border); - border-radius: 8px; + background-color: var(--oc-background-base); + border: 1px solid var(--border-color); + border-radius: 6px; .code-copy-floating { position: absolute; @@ -20,7 +19,7 @@ export const StyledWrapper = styled.div` } .code-content { - background-color: var(--code-bg); + background-color: var(--oc-background-base); color: var(--text-primary); } @@ -42,29 +41,33 @@ export const StyledWrapper = styled.div` } .code-content pre { - font-size: 13px; + font-size: 0.75rem; color: var(--text-primary); line-height: 1.65; } .code-content code { color: var(--text-primary); - font-size: 13px; + font-size: 0.75rem; } .code-content-numbered { display: flex; align-items: stretch; - background-color: var(--oc-bg); + background-color: var(--oc-background-base); } + /* Design: 8px vertical padding; each number padded 12px left / 10px right so the + gutter lines up with the code rows. Dim, 70% opacity. */ .code-line-numbers { flex-shrink: 0; - padding: 1rem 0 1rem 1rem; + padding: 0.5rem 0; text-align: right; user-select: none; color: var(--text-tertiary); + opacity: 0.7; } .code-line-numbers span { display: block; + padding: 0 0.625rem 0 0.75rem; font-family: 'Fira Code', var(--font-mono); font-weight: 400; font-size: 0.75rem; @@ -74,8 +77,8 @@ export const StyledWrapper = styled.div` .code-content--numbered { flex: 1; min-width: 0; - padding: 1rem 1rem 1rem 0.75rem; - background-color: var(--background-light); + padding: 0.5rem 0.875rem 0.5rem 0; + background-color: var(--oc-background-base); } .code-content--numbered pre, .code-content--numbered code { diff --git a/packages/oc-docs/src/components/CodeSnippetTabs/CodeSnippetTabs.spec.tsx b/packages/oc-docs/src/components/CodeSnippetTabs/CodeSnippetTabs.spec.tsx new file mode 100644 index 0000000..4c802c1 --- /dev/null +++ b/packages/oc-docs/src/components/CodeSnippetTabs/CodeSnippetTabs.spec.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import { CodeSnippetTabs } from './CodeSnippetTabs'; + +describe('CodeSnippetTabs', () => { + it('renders language tabs and the default cURL snippet', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('cURL'); + expect(html).toContain('Javascript'); + expect(html).toContain('Python'); + expect(html).toContain('curl -X POST'); + }); +}); diff --git a/packages/oc-docs/src/components/CodeSnippetTabs/CodeSnippetTabs.tsx b/packages/oc-docs/src/components/CodeSnippetTabs/CodeSnippetTabs.tsx new file mode 100644 index 0000000..475973c --- /dev/null +++ b/packages/oc-docs/src/components/CodeSnippetTabs/CodeSnippetTabs.tsx @@ -0,0 +1,119 @@ +import React, { useMemo, useState } from 'react'; +import type { HttpRequestBody, HttpRequestBodyVariant, HttpRequestHeader } from '@opencollection/types/requests/http'; +import type { Auth } from '@opencollection/types/common/auth'; +import { Code } from '../Code/Code'; +import { CopyButton} from '../../ui/CopyButton/CopyButton'; +import { SectionLabel } from '../SectionLabel/SectionLabel'; +import { Modal } from '../Modal'; +import { + generateCurlCommand, + generateJavaScriptCode, + generatePythonCode, + type SnippetHeader, + type SnippetInput +} from '../../utils/codegen'; +import { CodeSnippetTabsWrapper } from './StyledWrapper'; + +interface CodeSnippetTabsProps { + method: string; + url: string; + headers?: HttpRequestHeader[]; + body?: HttpRequestBody | HttpRequestBodyVariant[]; + auth?: Auth; + className?: string; +} + +const LANGUAGES = [ + { id: 'curl', label: 'cURL', language: 'bash', generate: generateCurlCommand }, + { id: 'javascript', label: 'Javascript', language: 'javascript', generate: generateJavaScriptCode }, + { id: 'python', label: 'Python', language: 'python', generate: generatePythonCode } +] as const; + +/** Maximize glyph (diagonal arrows out) for the expand-to-fullscreen control. */ +const ExpandGlyph: React.FC = () => ( + +); + +/** Generated request snippets (cURL / Javascript / Python) with a copy button and expand-to-fullscreen. */ +export const CodeSnippetTabs: React.FC = ({ method, url, headers, body, auth, className }) => { + const [active, setActive] = useState(LANGUAGES[0].id); + const [expanded, setExpanded] = useState(false); + + const snippetHeaders: SnippetHeader[] = useMemo( + () => + (headers ?? []) + .filter((header) => header && header.name && header.disabled !== true) + .map((header) => ({ name: header.name, value: header.value })), + [headers] + ); + + const snippets = useMemo(() => { + const input: SnippetInput = { method, url, headers: snippetHeaders, body, auth }; + return LANGUAGES.reduce>((acc, lang) => { + acc[lang.id] = lang.generate(input); + return acc; + }, {}); + }, [method, url, snippetHeaders, body, auth]); + + const activeLang = LANGUAGES.find((lang) => lang.id === active) ?? LANGUAGES[0]; + + /** + * The unified, bordered panel — identical in both contexts — whose header bar + * holds the language tabs and a right-side control, with the active snippet below + * a divider. Inline: the header shows the expand control and the code its floating + * copy. Modal: the header shows a copy button (the dialog supplies title + close), + * and the code's floating copy is suppressed. + */ + const renderSnippetBox = (variant: 'inline' | 'modal') => ( +
    +
    +
    + {LANGUAGES.map((lang) => ( + + ))} +
    + + {variant === 'inline' ? ( + + ) : ( + + )} +
    + +
    + ); + + return ( + + {renderSnippetBox('inline')} + setExpanded(false)} title={Code snippet} ariaLabel="Code snippet"> + {expanded && ( + {renderSnippetBox('modal')} + )} + + + ); +}; + +export default CodeSnippetTabs; diff --git a/packages/oc-docs/src/components/CodeSnippetTabs/StyledWrapper.ts b/packages/oc-docs/src/components/CodeSnippetTabs/StyledWrapper.ts new file mode 100644 index 0000000..0814508 --- /dev/null +++ b/packages/oc-docs/src/components/CodeSnippetTabs/StyledWrapper.ts @@ -0,0 +1,105 @@ +import styled from '@emotion/styled'; + +/** + * Code snippet panel. A single bordered box whose header bar holds the language + * tabs (cURL / Javascript / Python) and the expand control, separated from the + * code by a divider; the active tab carries a brand underline. The inner `Code` + * panel renders flush (its own border/radius are dropped). Sizes are in rem so the + * panel scales with the root font size. + */ +export const CodeSnippetTabsWrapper = styled.div` + .oc-snippet-box { + border: 1px solid var(--border-color); + border-radius: 0.5rem; + overflow: hidden; + background: var(--oc-background-base); + } + + /* Header bar: tabs on the left, expand on the right, divider below. */ + .oc-snippet-head { + display: flex; + align-items: stretch; + min-height: 2.375rem; + padding: 0 0.375rem; + border-bottom: 1px solid var(--border-color); + } + + .oc-snippet-tabs { + display: flex; + align-items: stretch; + } + + .oc-snippet-tab { + display: inline-flex; + align-items: center; + padding: 0 0.625rem; + background: transparent; + border: none; + /* Active underline overlaps the header divider (−1px) so it reads as one line. */ + border-bottom: 2px solid transparent; + margin-bottom: -1px; + font-family: var(--font-sans); + font-size: 0.75rem; + font-weight: 500; + line-height: 1; + color: var(--text-muted); + cursor: pointer; + transition: color 0.12s ease; + } + .oc-snippet-tab:hover { + color: var(--text-primary); + } + .oc-snippet-tab.is-active { + color: var(--text-primary); + font-weight: 600; + border-bottom-color: var(--primary-color); + } + .oc-snippet-tab:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: -2px; + } + + .oc-snippet-head-spacer { + flex: 1; + } + + /* The code panel sits flush inside the box. */ + .oc-snippet-box .code-content-wrapper { + border: none; + border-radius: 0; + } + + /* Keep the code's copy button always visible in the snippet (matches design). */ + && .code-copy-floating { + opacity: 1; + } + + /* Maximize / minimize icon button in the header bar. */ + .oc-code-snippet-expand { + align-self: center; + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.25rem; + border: none; + background: none; + color: var(--text-tertiary); + cursor: pointer; + border-radius: var(--oc-radius); + transition: color 0.12s ease; + } + .oc-code-snippet-expand:hover { + color: var(--text-primary); + } + .oc-code-snippet-expand:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } + + /* Copy control shown in the header when the snippet is in the expanded modal. */ + .oc-snippet-copy { + align-self: center; + flex: 0 0 auto; + } +`; diff --git a/packages/oc-docs/src/components/CodeSnippetTabs/index.ts b/packages/oc-docs/src/components/CodeSnippetTabs/index.ts new file mode 100644 index 0000000..9eb6fe1 --- /dev/null +++ b/packages/oc-docs/src/components/CodeSnippetTabs/index.ts @@ -0,0 +1,2 @@ +export { CodeSnippetTabs } from './CodeSnippetTabs'; +export { default } from './CodeSnippetTabs'; diff --git a/packages/oc-docs/src/components/ContentTypeBadge/ContentTypeBadge.spec.tsx b/packages/oc-docs/src/components/ContentTypeBadge/ContentTypeBadge.spec.tsx new file mode 100644 index 0000000..ef6b0f5 --- /dev/null +++ b/packages/oc-docs/src/components/ContentTypeBadge/ContentTypeBadge.spec.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import { ContentTypeBadge } from './ContentTypeBadge'; + +describe('ContentTypeBadge', () => { + it('renders the label', () => { + expect(renderToStaticMarkup()).toContain('application/json'); + }); +}); diff --git a/packages/oc-docs/src/components/ContentTypeBadge/ContentTypeBadge.tsx b/packages/oc-docs/src/components/ContentTypeBadge/ContentTypeBadge.tsx new file mode 100644 index 0000000..3f8e0e4 --- /dev/null +++ b/packages/oc-docs/src/components/ContentTypeBadge/ContentTypeBadge.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { ContentTypeBadgeWrapper } from './StyledWrapper'; + +interface ContentTypeBadgeProps { + label: string; + className?: string; +} + +/** Small muted pill for a full content type (e.g. "application/json") or an inherited-from badge. */ +export const ContentTypeBadge: React.FC = ({ label, className }) => ( + + {label} + +); + +export default ContentTypeBadge; diff --git a/packages/oc-docs/src/components/ContentTypeBadge/StyledWrapper.ts b/packages/oc-docs/src/components/ContentTypeBadge/StyledWrapper.ts new file mode 100644 index 0000000..9b30e97 --- /dev/null +++ b/packages/oc-docs/src/components/ContentTypeBadge/StyledWrapper.ts @@ -0,0 +1,20 @@ +import styled from '@emotion/styled'; + +/** + * Muted pill used for section-heading badges ("Inherited from collection", + * content types like "application/json", etc.). + * Figma: Inter · Medium 500 · 11px (0.6875rem) · 100% line-height · 4px radius · + * #838383 text on a #F1F1F1 fill — both via dark-safe theme tokens. + */ +export const ContentTypeBadgeWrapper = styled.span` + display: inline-flex; + align-items: center; + padding: 0.2rem 0.45rem; + border-radius: 0.25rem; + font-family: var(--font-sans); + font-weight: 500; + font-size: 0.6875rem; + line-height: 1; + color: var(--oc-colors-text-muted); + background-color: var(--badge-bg); +`; diff --git a/packages/oc-docs/src/components/ContentTypeBadge/index.ts b/packages/oc-docs/src/components/ContentTypeBadge/index.ts new file mode 100644 index 0000000..22466bd --- /dev/null +++ b/packages/oc-docs/src/components/ContentTypeBadge/index.ts @@ -0,0 +1,2 @@ +export { ContentTypeBadge } from './ContentTypeBadge'; +export { default } from './ContentTypeBadge'; diff --git a/packages/oc-docs/src/components/Docs/CodeSnippets/CodeSnippets.tsx b/packages/oc-docs/src/components/Docs/CodeSnippets/CodeSnippets.tsx deleted file mode 100644 index 514a854..0000000 --- a/packages/oc-docs/src/components/Docs/CodeSnippets/CodeSnippets.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import React from 'react'; -import { TabGroup } from '../../../ui/MinimalComponents'; -import { Code } from '../../Code/Code'; -import { StyledWrapper } from './StyledWrapper'; -import { generateCurlCommand, generateJavaScriptCode, generatePythonCode } from './generateCodeSnippets'; - -interface CodeSnippetsProps { - method: string; - url: string; - headers?: Array<{ name: string; value: string; disabled?: boolean }>; - body?: { type?: string; data?: string }; -} - -export const CodeSnippets: React.FC = ({ - method, - url, - headers = [], - body -}) => { - const snippetInput = { method, url, headers, body }; - - const tabDefinitions = [ - { - id: 'curl', - label: 'cURL', - code: generateCurlCommand(snippetInput), - language: 'bash', - }, - { - id: 'javascript', - label: 'JavaScript', - code: generateJavaScriptCode(snippetInput), - language: 'javascript', - }, - { - id: 'python', - label: 'Python', - code: generatePythonCode(snippetInput), - language: 'python', - }, - ]; - - return ( - -
    -

    Code Snippet

    -
    - ({ id, label }))} - defaultTab="curl" - renderContent={(activeTab: string) => { - const tab = - tabDefinitions.find(({ id }) => id === activeTab) ?? - tabDefinitions[0]; - - return ( - - ); - }} - /> -
    -
    -
    - ); -}; - -const ExampleCodeContent: React.FC<{ code: string; language: string }> = ({ - code, - language -}) => { - return ( -
    - -
    - ); -}; - diff --git a/packages/oc-docs/src/components/Docs/CodeSnippets/StyledWrapper.ts b/packages/oc-docs/src/components/Docs/CodeSnippets/StyledWrapper.ts deleted file mode 100644 index 923dc7a..0000000 --- a/packages/oc-docs/src/components/Docs/CodeSnippets/StyledWrapper.ts +++ /dev/null @@ -1,105 +0,0 @@ -import styled from '@emotion/styled'; - -export const StyledWrapper = styled.div` - .code-example-section { - display: flex; - flex-direction: column; - gap: 0.375rem; - } - - .code-example-card { - border-radius: 8px; - overflow: hidden; - background-color: var(--code-bg); - } - - .code-example-card .compact-code-view { - border: none; - border-radius: 0; - } - - .code-samples { - width: 100%; - min-width: 30%; - } - - .code-samples-frame { - width: 100%; - min-width: 30%; - height: fit-content; - } - - .code-samples-container { - width: 100%; - overflow-x: auto; - } - - .tab-header { - padding-inline: 16px; - padding-top: 8px; - background-color: var(--oc-background-surface0); - - .tab-button { - padding: 6px 0px; - border: none; - border-bottom: solid 2px transparent; - margin-right: 1.25rem; - margin-left: 0; - color: var(--oc-colors-text-muted); - cursor: pointer; - background: none; - font-size: 0.75rem; - font-weight: 500; - transition: color 0.15s ease, border-color 0.15s ease; - - &:focus, - &:active, - &:focus-within, - &:focus-visible, - &:target { - outline: none !important; - box-shadow: none !important; - } - - &:hover { - color: var(--oc-text); - } - - &.active { - color: var(--oc-text) !important; - border-bottom: solid 2px var(--primary-color) !important; - } - } - } - - .code-example-code-wrapper { - position: relative; - } - - .code-example-code-wrapper .compact-code-view { - border: none; - border-radius: 0; - background-color: transparent; - } - - .code-example-code-wrapper .compact-code-view .code-content { - padding: 32px 16px 16px; - background-color: var(--code-bg); - border-top: 1px solid var(--oc-border-border1); - } - - .code-example-code-wrapper .compact-code-view pre { - margin: 0; - } - - @media (max-width: 1100px) { - .code-samples-container { - position: static; - } - } - - .code-content-wrapper { - border-top-left-radius: 0; - border-top-right-radius: 0; - } -`; diff --git a/packages/oc-docs/src/components/Docs/CodeSnippets/generateCodeSnippets.ts b/packages/oc-docs/src/components/Docs/CodeSnippets/generateCodeSnippets.ts deleted file mode 100644 index 8c63428..0000000 --- a/packages/oc-docs/src/components/Docs/CodeSnippets/generateCodeSnippets.ts +++ /dev/null @@ -1,64 +0,0 @@ -interface Header { - name: string; - value: string; - disabled?: boolean; -} - -export interface CodeSnippetInput { - method: string; - url: string; - headers?: Header[]; - // Generators only emit a body when type is 'json' - body?: { type?: string; data?: unknown } | null; -} - -export const generateCurlCommand = ({ method, url, headers = [], body }: CodeSnippetInput): string => { - const headersString = headers - .filter((h) => h.disabled !== true) - .map((h) => `-H "${h.name}: ${h.value}"`) - .join(' \\\n '); - - let bodyData = ''; - if (body?.type === 'json' && body.data) { - const data = typeof body.data === 'string' ? body.data.trim() : JSON.stringify(body.data); - bodyData = ` \\\n -d '${data}'`; - } - - return `curl -X ${method} "${url}"${headersString ? ` \\\n ${headersString}` : ''}${bodyData}`; -}; - -export const generateJavaScriptCode = ({ method, url, headers = [], body }: CodeSnippetInput): string => { - const enabledHeaders = headers.filter((h) => h.disabled !== true); - const headersString = enabledHeaders.map((h) => ` "${h.name}": "${h.value}"`).join(',\n'); - const bodyString = body?.type === 'json' && body.data - ? `,\n body: JSON.stringify(${typeof body.data === 'string' ? body.data.trim() : JSON.stringify(body.data)})` - : ''; - - return `const response = await fetch("${url}", { - method: "${method}", - headers: { -${headersString} - }${bodyString} -}); - -const data = await response.json();`; -}; - -export const generatePythonCode = ({ method, url, headers = [], body }: CodeSnippetInput): string => { - const enabledHeaders = headers.filter((h) => h.disabled !== true); - const headersString = enabledHeaders.map((h) => ` "${h.name}": "${h.value}"`).join(',\n'); - const bodyString = body?.type === 'json' && body.data - ? `,\n json=${typeof body.data === 'string' ? body.data.trim() : JSON.stringify(body.data)}` - : ''; - - return `import requests - -response = requests.${method.toLowerCase()}( - "${url}", - headers={ -${headersString} - }${bodyString} -) - -data = response.json()`; -}; diff --git a/packages/oc-docs/src/components/Docs/Docs.tsx b/packages/oc-docs/src/components/Docs/Docs.tsx index 7981d44..21ad66f 100644 --- a/packages/oc-docs/src/components/Docs/Docs.tsx +++ b/packages/oc-docs/src/components/Docs/Docs.tsx @@ -1,66 +1,38 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useMemo } from 'react'; import type { OpenCollection as OpenCollectionCollection } from '@opencollection/types'; import Sidebar from './Sidebar/Sidebar'; import Overview from '../../pages/Overview/Overview'; -import { getItemId, generateSafeId } from '../../utils/itemUtils'; -import { isFolder } from '../../utils/schemaHelpers'; -import { useAppSelector } from '../../store/hooks'; -import { selectSelectedItemId } from '../../store/slices/docs'; +import Request from '../../pages/Request/Request'; +import { findItemByUuid, getAncestorsByUuid } from '../../utils/itemTree'; +import { isHttpRequest } from '../../utils/schemaHelpers'; +import { useAppSelector, useAppDispatch } from '../../store/hooks'; +import { selectSelectedItemId, selectItem } from '../../store/slices/docs'; interface DocsProps { docsCollection: OpenCollectionCollection | null; - filteredCollectionItems: any[]; + /** Retained for API compatibility; the sidebar reads collection items from the store. */ + filteredCollectionItems?: unknown[]; onOpenPlayground?: () => void; } -const Docs: React.FC = ({ - docsCollection, - filteredCollectionItems, -}) => { +/** + * Docs content shell: a sidebar plus the active page. When an HTTP request is + * selected we show its detail page; otherwise (a folder, or nothing selected) we + * show the collection Overview. Selection flows through the Redux `docs` slice. + */ +const Docs: React.FC = ({ docsCollection, onOpenPlayground }) => { + const dispatch = useAppDispatch(); const selectedItemId = useAppSelector(selectSelectedItemId); - const isInitialMount = useRef(true); - // Scroll to selected item when it changes (but not on initial load) - useEffect(() => { - if (isInitialMount.current) { - isInitialMount.current = false; - return; - } - - if (selectedItemId && filteredCollectionItems.length > 0) { - // Find the item by UUID to get its safe ID for scrolling - const findItemForScroll = (items: any[]): any => { - for (const item of items) { - const itemUuid = (item as any).uuid; - const itemId = getItemId(item); - const safeId = generateSafeId(itemId); - - // Check if this is the selected item - if (itemUuid === selectedItemId || safeId === selectedItemId || itemId === selectedItemId) { - return { item, safeId }; - } - - // If it's a folder, search recursively - if (isFolder(item) && item.items) { - const found = findItemForScroll(item.items); - if (found) return found; - } - } - return null; - }; + const selected = useMemo( + () => (docsCollection && selectedItemId ? findItemByUuid(docsCollection.items, selectedItemId) : null), + [docsCollection, selectedItemId] + ); - const result = findItemForScroll(filteredCollectionItems); - if (result) { - // Scroll to the item after a short delay to ensure DOM is updated - setTimeout(() => { - const element = document.getElementById(`section-${result.safeId}`); - if (element) { - element.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - }, 100); - } - } - }, [selectedItemId, filteredCollectionItems]); + const ancestry = useMemo( + () => (docsCollection && selectedItemId ? getAncestorsByUuid(docsCollection, selectedItemId) : []), + [docsCollection, selectedItemId] + ); return ( <> @@ -76,16 +48,21 @@ const Docs: React.FC = ({
    -
    - {docsCollection && ( +
    + {docsCollection && isHttpRequest(selected) ? ( + onOpenPlayground?.()} + onBreadcrumbClick={(uuid) => dispatch(selectItem(uuid))} + /> + ) : docsCollection ? ( - )} + ) : null}
    ); }; export default Docs; - diff --git a/packages/oc-docs/src/components/Docs/Item/Examples/ExamplesView/ExamplesView.tsx b/packages/oc-docs/src/components/Docs/Item/Examples/ExamplesView/ExamplesView.tsx deleted file mode 100644 index 517866f..0000000 --- a/packages/oc-docs/src/components/Docs/Item/Examples/ExamplesView/ExamplesView.tsx +++ /dev/null @@ -1,387 +0,0 @@ -import React, { useState, useMemo, useEffect, useRef } from 'react'; -import type { HttpRequestExample } from '@opencollection/types/requests/http'; -import StyledWrapper from './StyledWrapper'; -import { generateCurlCommand, generateJavaScriptCode, generatePythonCode } from '../../../CodeSnippets/generateCodeSnippets'; - -interface ExamplesProps { - examples: HttpRequestExample[]; - method?: string; - url?: string; -} - -const getStatusClass = (status: number): string => { - if (status >= 200 && status < 300) return 'success'; - if (status >= 300 && status < 400) return 'redirect'; - if (status >= 400 && status < 500) return 'client-error'; - if (status >= 500) return 'server-error'; - return ''; -}; - -export const Examples: React.FC = ({ examples, method = 'GET', url = '' }) => { - const [activeExampleIndex, setActiveExampleIndex] = useState(0); - const [requestTab, setRequestTab] = useState<'body' | 'headers' | 'params'>('body'); - const [responseTab, setResponseTab] = useState<'body' | 'headers'>('body'); - const [copied, setCopied] = useState(null); - const [bodyCopied, setBodyCopied] = useState<'request' | 'response' | null>(null); - const [showCopyMenu, setShowCopyMenu] = useState(false); - const dropdownRef = useRef(null); - - // Close dropdown when clicking outside - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setShowCopyMenu(false); - } - }; - - if (showCopyMenu) { - document.addEventListener('mousedown', handleClickOutside); - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [showCopyMenu]); - - // Filter to examples that have either request or response data - const validExamples = useMemo(() => - examples.filter(ex => ex.request?.body || ex.request?.headers?.length || ex.response), - [examples] - ); - - if (validExamples.length === 0) return null; - - const activeExample = validExamples[activeExampleIndex]; - const hasRequestBody = !!activeExample?.request?.body; - const hasRequestHeaders = activeExample?.request?.headers && activeExample.request.headers.length > 0; - const hasRequestParams = activeExample?.request?.params && activeExample.request.params.length > 0; - const hasRequest = hasRequestBody || hasRequestHeaders || hasRequestParams; - const hasResponse = !!activeExample?.response; - const hasResponseHeaders = activeExample?.response?.headers && activeExample.response.headers.length > 0; - const hasResponseBody = !!activeExample?.response?.body?.data; - - const formatJson = (data: any): string => { - if (!data) return ''; - if (typeof data === 'string') { - try { - const parsed = JSON.parse(data); - return JSON.stringify(parsed, null, 2); - } catch { - return data; - } - } - return JSON.stringify(data, null, 2); - }; - - const getRequestData = () => { - const exampleMethod = activeExample?.request?.method || method; - const exampleUrl = activeExample?.request?.url || url; - const headers = activeExample?.request?.headers || []; - const body = activeExample?.request?.body; - return { method: exampleMethod, url: exampleUrl, headers, body }; - }; - - const handleCopy = async (type: 'curl' | 'javascript' | 'python') => { - try { - const snippetInput = getRequestData(); - const generators = { - curl: generateCurlCommand, - javascript: generateJavaScriptCode, - python: generatePythonCode, - }; - const code = generators[type](snippetInput); - await navigator.clipboard.writeText(code); - setCopied(type); - setShowCopyMenu(false); - setTimeout(() => setCopied(null), 2000); - } catch (error) { - console.error('Failed to copy code', error); - } - }; - - const handleCopyBody = async (type: 'request' | 'response', content: string) => { - try { - await navigator.clipboard.writeText(content); - setBodyCopied(type); - setTimeout(() => setBodyCopied(null), 2000); - } catch (error) { - console.error('Failed to copy body', error); - } - }; - - return ( - -

    Examples

    -
    -
    -
    - {validExamples.map((example, index) => ( - - ))} -
    -
    - -
    -
    - - {activeExample?.request?.method || method} - - {activeExample?.request?.url || url} -
    -
    - - {showCopyMenu && ( -
    - - - -
    - )} -
    -
    - -
    - {/* Request Section - Always show */} -
    -
    - Request -
    - - - -
    -
    - - {hasRequest ? ( - <> - {requestTab === 'body' && ( - hasRequestBody ? ( -
    - -
    {formatJson(activeExample.request?.body?.data)}
    -
    - ) : ( -
    No request body
    - ) - )} - - {requestTab === 'headers' && ( - hasRequestHeaders ? ( - - - - - - - - - {activeExample.request?.headers?.map((header, idx) => ( - - - - - ))} - -
    NameValue
    {header.name}{header.value}
    - ) : ( -
    No request headers
    - ) - )} - - {requestTab === 'params' && ( - hasRequestParams ? ( - - - - - - - - - {activeExample.request?.params?.map((param: any, idx: number) => ( - - - - - ))} - -
    NameValue
    {param.name}{param.value}
    - ) : ( -
    No request params
    - ) - )} - - ) : ( -
    No request data
    - )} -
    - - {/* Response Section - Always show */} -
    -
    -
    - Response - {activeExample.response?.status && ( - - {activeExample.response.status} - - )} -
    -
    - - -
    -
    - - {hasResponse ? ( - <> - {responseTab === 'body' && ( - hasResponseBody ? ( -
    - -
    {formatJson(activeExample.response?.body?.data)}
    -
    - ) : ( -
    No response body
    - ) - )} - - {responseTab === 'headers' && ( - hasResponseHeaders ? ( - - - - - - - - - {activeExample.response?.headers?.map((header, idx) => ( - - - - - ))} - -
    NameValue
    {header.name}{header.value}
    - ) : ( -
    No response headers
    - ) - )} - - ) : ( -
    No response data
    - )} -
    -
    -
    -
    - ); -}; - -export default Examples; diff --git a/packages/oc-docs/src/components/Docs/Item/Examples/ExamplesView/StyledWrapper.tsx b/packages/oc-docs/src/components/Docs/Item/Examples/ExamplesView/StyledWrapper.tsx deleted file mode 100644 index a39758b..0000000 --- a/packages/oc-docs/src/components/Docs/Item/Examples/ExamplesView/StyledWrapper.tsx +++ /dev/null @@ -1,387 +0,0 @@ -import styled from '@emotion/styled'; - -const StyledWrapper = styled.div` - margin-top: 1rem; - - .section-title { - font-size: 0.75rem; - font-weight: 600; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.05em; - margin-bottom: 0.5rem; - } - - .examples-container { - border: 1px solid var(--border-color); - border-radius: 0.5rem; - overflow: hidden; - background-color: var(--oc-background-base); - } - - .example-tabs { - display: flex; - align-items: center; - justify-content: space-between; - background-color: var(--oc-background-mantle); - border-bottom: 1px solid var(--border-color); - } - - .example-tabs-left { - display: flex; - gap: 0; - overflow-x: auto; - } - - .example-tabs-right { - padding-right: 0.75rem; - } - - .example-url-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; - padding: 0.5rem 1rem; - background-color: var(--oc-background-base); - border-bottom: 1px solid var(--border-color); - font-family: var(--font-mono, 'SF Mono', 'Consolas', monospace); - font-size: 0.75rem; - } - - .example-url-left { - display: flex; - align-items: center; - gap: 0.5rem; - flex: 1; - min-width: 0; - } - - .example-method { - padding: 0.125rem 0.375rem; - border-radius: 0.25rem; - font-size: 0.65rem; - font-weight: 600; - text-transform: uppercase; - color: var(--oc-colors-text-white); - } - - .example-method.get { background-color: var(--oc-request-methods-get); } - .example-method.post { background-color: var(--oc-request-methods-post); } - .example-method.put { background-color: var(--oc-request-methods-put); } - .example-method.patch { background-color: var(--oc-request-methods-patch); } - .example-method.delete { background-color: var(--oc-request-methods-delete); } - - .example-url { - color: var(--text-primary); - word-break: break-all; - } - - .example-tab { - padding: 0.5rem 1rem; - font-size: 0.75rem; - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; - border: none; - border-bottom: 2px solid transparent; - background-color: transparent; - color: var(--text-secondary); - white-space: nowrap; - } - - .example-tab:hover { - color: var(--text-primary); - background-color: var(--oc-background-base); - } - - .example-tab.active { - color: var(--primary-color); - border-bottom-color: var(--primary-color); - background-color: var(--oc-background-base); - } - - .example-content { - display: grid; - grid-template-columns: 1fr 1fr; - align-items: stretch; - } - - @media (max-width: 768px) { - .example-content { - grid-template-columns: 1fr; - } - } - - .content-section { - display: flex; - flex-direction: column; - border-right: 1px solid var(--border-color); - min-height: 150px; - } - - .content-section:last-child { - border-right: none; - } - - @media (max-width: 768px) { - .content-section { - border-right: none; - border-bottom: 1px solid var(--border-color); - } - - .content-section:last-child { - border-bottom: none; - } - } - - .section-body { - flex: 1; - display: flex; - flex-direction: column; - } - - .content-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.5rem 1rem; - border-bottom: 1px solid var(--border-color); - min-height: 40px; - } - - .content-label { - font-size: 0.7rem; - font-weight: 600; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.05em; - } - - .status-badge { - display: inline-flex; - align-items: center; - padding: 0.125rem 0.5rem; - border-radius: 0.25rem; - font-size: 0.7rem; - font-weight: 600; - } - - .status-badge.success { - background-color: var(--oc-status-success-background); - color: var(--oc-status-success-text); - } - - .status-badge.redirect { - background-color: var(--oc-status-warning-background); - color: var(--oc-status-warning-text); - } - - .status-badge.client-error { - background-color: var(--oc-status-danger-background); - color: var(--oc-status-danger-text); - } - - .status-badge.server-error { - background-color: var(--oc-status-danger-background); - color: var(--oc-status-danger-text); - } - - .content-toggle { - display: flex; - align-items: center; - background-color: var(--oc-background-base); - border-radius: 0.25rem; - padding: 0.125rem; - border: 1px solid var(--border-color); - } - - .toggle-btn { - padding: 0.25rem 0.5rem; - font-size: 0.65rem; - font-weight: 500; - color: var(--text-secondary); - background: none; - border: none; - cursor: pointer; - border-radius: 0.125rem; - transition: all 0.15s ease; - } - - .toggle-btn:hover { - color: var(--text-primary); - } - - .toggle-btn.active { - color: var(--text-primary); - background-color: var(--oc-background-surface0); - } - - .toggle-btn.disabled { - opacity: 0.4; - cursor: not-allowed; - } - - .copy-curl-btn { - display: inline-flex; - align-items: center; - gap: 0.25rem; - padding: 0.25rem 0.5rem; - font-size: 0.65rem; - font-weight: 500; - color: var(--text-secondary); - background: var(--oc-background-base); - border: 1px solid var(--border-color); - border-radius: 0.25rem; - cursor: pointer; - transition: all 0.15s ease; - } - - .copy-curl-btn:hover { - color: var(--text-primary); - border-color: var(--text-secondary); - } - - .copy-curl-btn.copied { - color: var(--oc-colors-text-green); - border-color: var(--oc-colors-text-green); - } - - .copy-curl-btn:disabled { - opacity: 0.4; - cursor: not-allowed; - } - - .copy-dropdown { - position: relative; - } - - .copy-menu { - position: absolute; - top: 100%; - right: 0; - margin-top: 0.25rem; - background: var(--oc-background-base); - border: 1px solid var(--border-color); - border-radius: 0.375rem; - box-shadow: var(--oc-shadow-md); - z-index: 10; - min-width: 120px; - overflow: hidden; - } - - .copy-menu-item { - display: flex; - align-items: center; - gap: 0.5rem; - width: 100%; - padding: 0.5rem 0.75rem; - font-size: 0.75rem; - font-weight: 500; - color: var(--text-primary); - background: none; - border: none; - cursor: pointer; - text-align: left; - transition: background-color 0.15s ease; - } - - .copy-menu-item:hover { - background-color: var(--oc-background-mantle); - } - - .copy-menu-item.copied { - color: var(--oc-colors-text-green); - } - - .body-content { - padding: 0.75rem 1rem; - overflow-x: auto; - background-color: var(--code-bg); - flex: 1; - position: relative; - } - - .body-copy-btn { - position: absolute; - top: 0.5rem; - right: 0.5rem; - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - background: var(--oc-background-base); - border: 1px solid var(--border-color); - border-radius: 0.25rem; - cursor: pointer; - color: var(--text-secondary); - transition: all 0.15s ease; - opacity: 0; - } - - .body-content:hover .body-copy-btn { - opacity: 1; - } - - .body-copy-btn:hover { - color: var(--text-primary); - border-color: var(--text-secondary); - } - - .body-copy-btn.copied { - color: var(--oc-colors-text-green); - border-color: var(--oc-colors-text-green); - opacity: 1; - } - - .body-content pre { - margin: 0; - padding: 0; - font-size: 0.75rem; - font-family: var(--font-mono, 'SF Mono', 'Consolas', monospace); - line-height: 1.5; - white-space: pre-wrap; - word-break: break-word; - color: var(--code-text); - } - - .headers-table { - width: 100%; - font-size: 0.75rem; - border-collapse: collapse; - } - - .headers-table th { - padding: 0.5rem 1rem; - text-align: left; - font-weight: 500; - color: var(--text-secondary); - background-color: var(--oc-background-mantle); - border-bottom: 1px solid var(--border-color); - } - - .headers-table td { - padding: 0.5rem 1rem; - border-bottom: 1px solid var(--border-color); - color: var(--text-primary); - font-family: var(--font-mono, monospace); - } - - .headers-table tr:last-child td { - border-bottom: none; - } - - .no-content { - padding: 1rem; - text-align: center; - color: var(--text-secondary); - font-size: 0.75rem; - flex: 1; - display: flex; - align-items: center; - justify-content: center; - } -`; - -export default StyledWrapper; diff --git a/packages/oc-docs/src/components/Docs/Item/Item.tsx b/packages/oc-docs/src/components/Docs/Item/Item.tsx deleted file mode 100644 index f2b417d..0000000 --- a/packages/oc-docs/src/components/Docs/Item/Item.tsx +++ /dev/null @@ -1,453 +0,0 @@ -import React, { memo, useState } from 'react'; -import 'prismjs/components/prism-bash'; -import 'prismjs/components/prism-http'; -import 'prismjs/components/prism-graphql'; -import 'prismjs/components/prism-json'; -import 'prismjs/components/prism-xml-doc'; -import 'prismjs/components/prism-python'; -import type { HttpRequest, HttpRequestParam } from '@opencollection/types/requests/http'; -import type { Variable } from '@opencollection/types/common/variables'; -import { generateSectionId, getItemId } from '../../../utils/itemUtils'; -import { - getItemType, - getItemName, - getItemDocs, - getHttpMethod, - getRequestUrl, - getHttpHeaders, - getHttpBody, - getHttpParams, - getRequestAuth, - getRequestVariables, - getRequestAssertions, - getRequestScripts, - getRequestExamples, - scriptsArrayToObject, - isFolder, -} from '../../../utils/schemaHelpers'; -import { - MinimalDataTable, - StatusBadge -} from '../../../ui/MinimalComponents'; -import { Code } from '../../Code/Code'; -import { CodeSnippets } from '../CodeSnippets/CodeSnippets'; -import { StyledWrapper } from './StyledWrapper'; -import { Scripts } from './Scripts/Scripts'; -import { Examples } from './Examples/ExamplesView/ExamplesView'; -import { useMarkdownRenderer } from '../../../hooks'; -import { getMethodColorVar } from '../../../theme/methodColors'; - -const Item = memo(({ - item, - parentPath = '', - breadcrumb = [], - collection, - toggleRunnerMode, - onTryClick, - onBreadcrumbClick -}: { - item: any; - parentPath?: string; - breadcrumb?: Array<{ name: string; uuid: string }>; - collection?: any; - toggleRunnerMode?: () => void; - onTryClick?: () => void; - onBreadcrumbClick?: (uuid: string) => void; -}) => { - const md = useMarkdownRenderer(); - const [bodyScriptsView, setBodyScriptsView] = useState<'body' | 'scripts'>('body'); - const itemId = getItemId(item); - const sectionId = generateSectionId(item, parentPath); - - const renderBreadcrumb = () => { - if (breadcrumb.length === 0) return null; - return ( -
    - {breadcrumb.map((segment, i) => ( - - {i > 0 && /} - onBreadcrumbClick?.(segment.uuid)} - > - - - - {segment.name} - - - ))} -
    - ); - }; - - if (isFolder(item)) { - const folderItem = item as any; - const folderName = getItemName(folderItem) || 'Untitled Folder'; - const folderDocs = getItemDocs(folderItem); - const folderHeaders = folderItem.request?.headers || []; - const folderVariables = getRequestVariables(folderItem); - const folderScripts = scriptsArrayToObject(getRequestScripts(folderItem)); - - return ( - -
    - {renderBreadcrumb()} -
    -

    - - - - {folderName} -

    -
    -
    - - {folderDocs && ( -
    -
    -
    - )} - -
    - {folderHeaders && folderHeaders.length > 0 && ( - val === false ? : null } - ]} - /> - )} - - {folderVariables && folderVariables.length > 0 && ( - ({ - name: v.name, - value: v.value || '', - enabled: !v.disabled - }))} - title="Variables" - columns={[ - { key: 'name', label: 'Name', width: '40%' }, - { key: 'value', label: 'Value', width: '40%' }, - { key: 'enabled', label: '', width: '20%', render: (val) => } - ]} - /> - )} - - -
    - - ); - } - - const itemType = getItemType(item); - - if (itemType === 'script') { - const scriptItem = item as any; - const scriptName = getItemName(scriptItem) || 'Untitled Script'; - - return ( - -
    - {renderBreadcrumb()} -
    -
    - - - - - Script -
    -

    {scriptName}

    -
    -
    - - {scriptItem.script && ( - - )} -
    - ); - } - - if (itemType === 'http') { - const httpItem = item as HttpRequest; - const scripts = scriptsArrayToObject(getRequestScripts(httpItem)); - - const examples = getRequestExamples(httpItem); - - const endpoint = { - id: itemId, - name: getItemName(httpItem) || 'Untitled', - method: getHttpMethod(httpItem), - url: getRequestUrl(httpItem), - description: getItemDocs(httpItem) || '', - headers: getHttpHeaders(httpItem), - body: getHttpBody(httpItem) || { mode: 'none' }, - params: getHttpParams(httpItem), - auth: getRequestAuth(httpItem) || { mode: 'none' }, - vars: getRequestVariables(httpItem), - assertions: getRequestAssertions(httpItem), - tests: '', - script: scripts, - examples - }; - - // Query and path params share one `params` array (distinguished by `type`); - // split in a single pass so each renders in its own labelled table. - const queryParams: HttpRequestParam[] = []; - const pathParams: HttpRequestParam[] = []; - for (const param of endpoint.params || []) { - (param?.type === 'path' ? pathParams : queryParams).push(param); - } - - return ( - -
    - {renderBreadcrumb()} -
    -

    {endpoint.name}

    -
    - - {endpoint.method} - -
    - {endpoint.url} - {(onTryClick || toggleRunnerMode) && ( - - )} -
    -
    -
    -
    - - {endpoint.description && ( -
    -
    -
    - )} - -
    -
    - {queryParams.length > 0 && ( - val === false ? : null } - ]} - /> - )} - - {pathParams.length > 0 && ( - - )} - - {endpoint.headers && endpoint.headers.length > 0 && ( - val === false ? : null } - ]} - /> - )} - - {(() => { - const hasBody = endpoint.body && typeof endpoint.body === 'object' && 'data' in endpoint.body; - const hasScripts = !!(endpoint.script?.preRequest || endpoint.script?.postResponse); - - if (!hasBody && !hasScripts) return null; - if (!hasBody && hasScripts) { - return ( - - ); - } - - if (hasBody && !hasScripts) { - return ( -
    -

    Body

    - { - const bodyData = (endpoint.body as any).data; - const bodyType = (endpoint.body as any).type; - if (bodyType === 'form-urlencoded' && Array.isArray(bodyData)) { - return bodyData - .filter((entry: any) => entry.disabled !== true) - .map((entry: any) => `${encodeURIComponent(entry.name)}=${encodeURIComponent(entry.value)}`) - .join('&'); - } else if (bodyType === 'multipart-form' && Array.isArray(bodyData)) { - return bodyData - .filter((entry: any) => entry.disabled !== true) - .map((entry: any) => `${entry.name}: ${entry.value}`) - .join('\n'); - } else if (typeof bodyData === 'string') { - return bodyData; - } else { - return JSON.stringify(bodyData, null, 2); - } - })()} - language={(() => { - const bodyType = (endpoint.body as any).type; - if (bodyType === 'form-urlencoded') return 'text'; - if (bodyType === 'multipart-form') return 'text'; - return bodyType || 'json'; - })()} - /> -
    - ); - } - - return ( -
    -
    -

    setBodyScriptsView('body')} - > - Body -

    -

    setBodyScriptsView('scripts')} - > - Scripts -

    -
    - {bodyScriptsView === 'body' ? ( - { - const bodyData = (endpoint.body as any).data; - const bodyType = (endpoint.body as any).type; - if (bodyType === 'form-urlencoded' && Array.isArray(bodyData)) { - return bodyData - .filter((entry: any) => entry.disabled !== true) - .map((entry: any) => `${encodeURIComponent(entry.name)}=${encodeURIComponent(entry.value)}`) - .join('&'); - } else if (bodyType === 'multipart-form' && Array.isArray(bodyData)) { - return bodyData - .filter((entry: any) => entry.disabled !== true) - .map((entry: any) => `${entry.name}: ${entry.value}`) - .join('\n'); - } else if (typeof bodyData === 'string') { - return bodyData; - } else { - return JSON.stringify(bodyData, null, 2); - } - })()} - language={(() => { - const bodyType = (endpoint.body as any).type; - if (bodyType === 'form-urlencoded') return 'text'; - if (bodyType === 'multipart-form') return 'text'; - return bodyType || 'json'; - })()} - /> - ) : ( - - )} -
    - ); - })()} -
    - -
    - -
    -
    - - {endpoint.examples && endpoint.examples.length > 0 && ( - - )} - - ); - } - - return ( - -
    -

    {getItemName(item) || 'Untitled Item'}

    -

    Unsupported item type: {itemType}

    -
    -
    - ); -}, (prevProps, nextProps) => { - if (getItemType(prevProps.item) !== getItemType(nextProps.item)) { - return false; - } - - const prevItemId = getItemId(prevProps.item); - const nextItemId = getItemId(nextProps.item); - if (prevItemId !== nextItemId) { - return false; - } - - return ( - prevProps.parentPath === nextProps.parentPath - ); -}); - -export default Item; - diff --git a/packages/oc-docs/src/components/Docs/Item/Scripts/Scripts.tsx b/packages/oc-docs/src/components/Docs/Item/Scripts/Scripts.tsx deleted file mode 100644 index 76a453d..0000000 --- a/packages/oc-docs/src/components/Docs/Item/Scripts/Scripts.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import React, { useState } from 'react'; -import { TabGroup } from '../../../../ui/MinimalComponents'; -import { Code } from '../../../Code/Code'; -import { StyledWrapper } from './StyledWrapper'; - -interface ScriptsProps { - preRequest?: string | null; - postResponse?: string | null; - hideTitle?: boolean; -} - -export const Scripts: React.FC = ({ - preRequest, - postResponse, - hideTitle = false -}) => { - const tabs = [ - ...(preRequest ? [{ id: 'pre', label: 'Pre-request', code: preRequest }] : []), - ...(postResponse ? [{ id: 'post', label: 'Post-response', code: postResponse }] : []) - ]; - - if (tabs.length === 0) { - return null; - } - - const defaultTab = tabs[0]?.id ?? 'pre'; - - return ( - -
    - {!hideTitle &&

    Scripts

    } -
    - ({ id, label }))} - defaultTab={defaultTab} - renderContent={(activeTab: string) => { - const tab = tabs.find(({ id }) => id === activeTab) ?? tabs[0]; - const code = tab?.code ?? ''; - - return ( - - ); - }} - /> -
    -
    -
    - ); -}; - -const ScriptsCodeContent: React.FC<{ code: string }> = ({ code }) => { - const [copied, setCopied] = useState(false); - - const handleCopy = async () => { - if (typeof navigator !== 'undefined' && navigator?.clipboard?.writeText) { - try { - await navigator.clipboard.writeText(code || ''); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (error) { - console.error('Failed to copy script content', error); - } - } - }; - - return ( -
    - - -
    - ); -}; - - diff --git a/packages/oc-docs/src/components/Docs/Item/Scripts/StyledWrapper.ts b/packages/oc-docs/src/components/Docs/Item/Scripts/StyledWrapper.ts deleted file mode 100644 index d292019..0000000 --- a/packages/oc-docs/src/components/Docs/Item/Scripts/StyledWrapper.ts +++ /dev/null @@ -1,118 +0,0 @@ -import styled from '@emotion/styled'; - -export const StyledWrapper = styled.div` - .scripts-section { - display: flex; - flex-direction: column; - gap: 0.375rem; - } - - .scripts-card { - border-radius: 8px; - overflow: hidden; - background-color: var(--code-bg); - border: 1px solid var(--oc-border-border1); - } - - .scripts-card .tab-header { - padding-inline: 16px; - padding-top: 8px; - background-color: var(--oc-background-surface0); - } - - .scripts-card .tab-header .tab-button { - padding: 6px 0; - border: none; - border-bottom: 2px solid transparent; - margin-right: 1.25rem; - background: none; - color: var(--oc-colors-text-muted); - cursor: pointer; - font-size: 0.75rem; - font-weight: 500; - transition: color 0.15s ease, border-color 0.15s ease; - } - - .scripts-card .tab-header .tab-button:focus, - .scripts-card .tab-header .tab-button:active, - .scripts-card .tab-header .tab-button:focus-within, - .scripts-card .tab-header .tab-button:focus-visible, - .scripts-card .tab-header .tab-button:target { - outline: none; - box-shadow: none; - } - - .scripts-card .tab-header .tab-button:hover { - color: var(--oc-text); - } - - .scripts-card .tab-header .tab-button.active { - color: var(--oc-text) !important; - border-bottom-color: var(--primary-color) !important; - } - - .scripts-card .tab-content { - border-top: none; - background-color: var(--code-bg); - } - - .scripts-code-wrapper { - position: relative; - } - - .scripts-code-wrapper .compact-code-view { - border: none; - border-radius: 0; - background-color: transparent; - } - - .scripts-code-wrapper .compact-code-view .code-content { - padding: 32px 16px 16px; - background-color: var(--code-bg); - border-top: 1px solid var(--oc-border-border1); - } - - .scripts-copy-button { - position: absolute; - top: 12px; - right: 16px; - display: inline-flex; - align-items: center; - gap: 0.25rem; - padding: 0.35rem 0.5rem; - border-radius: 4px; - font-size: 0.7rem; - font-weight: 500; - color: var(--oc-colors-text-muted); - background-color: transparent; - border: none; - cursor: pointer; - opacity: 0; - pointer-events: none; - transform: translateY(-4px); - transition: all 0.15s ease; - } - - .scripts-code-wrapper:hover .scripts-copy-button, - .scripts-copy-button:focus, - .scripts-copy-button:focus-visible, - .scripts-copy-button.copied { - opacity: 1; - pointer-events: auto; - transform: translateY(0); - } - - .scripts-copy-button:hover { - background-color: color-mix(in srgb, var(--oc-text) 4%, transparent); - color: var(--oc-text); - } - - .scripts-copy-button.copied { - color: var(--oc-colors-text-green); - background-color: color-mix(in srgb, var(--oc-colors-text-green) 8%, transparent); - } - - .scripts-card .compact-code-view pre { - margin: 0; - } -`; diff --git a/packages/oc-docs/src/components/Docs/Item/StyledWrapper.ts b/packages/oc-docs/src/components/Docs/Item/StyledWrapper.ts deleted file mode 100644 index 81c18a3..0000000 --- a/packages/oc-docs/src/components/Docs/Item/StyledWrapper.ts +++ /dev/null @@ -1,190 +0,0 @@ -import styled from '@emotion/styled'; - -export const StyledWrapper = styled.div` - width: 100%; - max-width: 80rem; - - .item-header-minimal { - margin-bottom: 1.25rem; - } - - .item-breadcrumb { - font-size: 0.75rem; - color: var(--text-tertiary); - margin-bottom: 0.25rem; - display: flex; - align-items: center; - flex-wrap: wrap; - } - - .breadcrumb-sep { - margin: 0 0.3rem; - opacity: 0.5; - } - - .breadcrumb-link { - display: inline-flex; - align-items: center; - gap: 0.2rem; - cursor: pointer; - transition: color 0.15s ease; - border-radius: 3px; - padding: 0.05rem 0.2rem; - } - - .breadcrumb-link:hover { - color: var(--text-secondary); - background-color: color-mix(in srgb, var(--oc-text) 4%, transparent); - } - - .breadcrumb-icon { - flex-shrink: 0; - opacity: 0.6; - } - - .item-title-section { - display: flex; - flex-direction: column; - gap: 0.5rem; - align-items: flex-start; - } - - .item-type-badge { - display: inline-flex; - align-items: center; - gap: 0.375rem; - padding: 0.2rem 0.5rem; - border-radius: 4px; - font-size: 0.6875rem; - font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; - font-family: var(--font-mono); - } - - .item-type-badge.folder { - background-color: color-mix(in srgb, var(--oc-colors-text-purple) 10%, transparent); - color: var(--oc-colors-text-purple); - } - - .item-type-badge.script { - background-color: color-mix(in srgb, var(--oc-colors-text-green) 10%, transparent); - color: var(--oc-colors-text-green); - } - - .item-title { - margin: 0; - font-size: 1.25rem; - font-weight: 600; - color: var(--text-primary); - letter-spacing: -0.02em; - line-height: 1.3; - display: flex; - align-items: center; - gap: 0.375rem; - } - - .item-title-icon { - flex-shrink: 0; - color: var(--text-tertiary); - } - - .item-subtitle { - margin: 0; - font-size: 0.8125rem; - color: var(--text-tertiary); - } - - /* ============================================================ - DOCS / PROSE - ============================================================ */ - - .item-docs { - max-width: none; - margin-bottom: 1.5rem; - font-size: 0.9375rem; - color: var(--text-secondary); - line-height: 1.7; - } - - .item-docs h1 { margin: 0 0 0.75rem; font-size: 1.375rem; font-weight: 600; color: var(--text-primary); letter-spacing: -0.02em; } - .item-docs h2 { margin: 1rem 0 0.5rem; font-size: 1.125rem; font-weight: 600; color: var(--text-primary); } - .item-docs h3 { margin: 0.75rem 0 0.375rem; font-size: 1rem; font-weight: 600; color: var(--text-primary); } - .item-docs p { margin: 0 0 0.75rem; } - .item-docs a { color: var(--prose-links); text-decoration: none; transition: color 0.15s ease; } - .item-docs a:hover { color: var(--prose-links-hover); text-decoration: underline; text-underline-offset: 3px; } - .item-docs code { display: inline-block; padding: 0.1rem 0.35rem; border-radius: 4px; font-size: 0.85em; font-family: var(--font-mono); background-color: var(--prose-code-bg); color: var(--prose-code-text); } - .item-docs pre { margin: 0 0 0.75rem; padding: 1rem 1.25rem; border-radius: 8px; overflow-x: auto; background-color: var(--code-bg); color: var(--code-text); } - .item-docs ul, .item-docs ol { margin: 0 0 0.75rem; padding-left: 1.25rem; } - .item-docs ul { list-style: disc inside; } - .item-docs ol { list-style: decimal inside; } - .item-docs li { margin: 0.2rem 0; } - .item-docs blockquote { margin: 0.75rem 0; padding: 0.625rem 1rem; border-left: 3px solid var(--prose-blockquote-border); color: var(--text-secondary); background-color: color-mix(in srgb, var(--oc-colors-accent) 4%, transparent); border-radius: 0 6px 6px 0; } - - /* ============================================================ - CONTENT LAYOUT - ============================================================ */ - - .item-content-grid { display: flex; flex-direction: column; gap: 1rem; } - .item-content-main { display: flex; flex-direction: column; gap: 1.5rem; } - .request-details { display: flex; flex-direction: column; gap: 1rem; } - .request-body-section { display: flex; flex-direction: column; gap: 0.375rem; } - - @media (min-width: 1024px) { - .item-content-main { flex-direction: row; } - .request-details { flex: 4; min-width: 0; } - .code-snippets-wrapper { flex: 3; min-width: 0; } - } - - /* ============================================================ - ENDPOINT BADGES - ============================================================ */ - - .endpoint-badges { display: flex; align-items: stretch; flex-wrap: wrap; } - - .badge-method { font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; font-family: var(--font-sans); padding: 0.25rem 0.625rem; display: inline-flex; align-items: center; color: white !important; border-radius: 6px 0 0 6px; } - - .endpoint-url-container { - display: inline-flex; - align-items: stretch; - border-radius: 0 6px 6px 0; - border: 1px solid var(--border-color); - border-left: none; - background-color: var(--oc-background-base); - overflow: hidden; - } - - .badge-url { font-size: 0.8125rem; font-family: var(--font-mono); color: var(--text-secondary); font-weight: 400; padding: 0.25rem 0.625rem; display: inline-flex; align-items: center; } - .badge-try { display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.25rem 0.625rem; margin-left: auto; font-size: 0.75rem; font-weight: 500; color: var(--primary-color); background-color: color-mix(in srgb, var(--oc-colors-accent) 6%, transparent); border: none; border-left: 1px solid var(--border-color); cursor: pointer; transition: all 0.15s ease; } - .badge-try:hover { background-color: color-mix(in srgb, var(--oc-colors-accent) 12%, transparent); } - - /* ============================================================ - SECTION TITLE TABS (Body / Scripts switcher) - ============================================================ */ - - .section-title-tabs { - display: flex; - align-items: center; - gap: 0.75rem; - } - - .section-title-tab { - font-size: 0.8125rem; - font-weight: 500; - cursor: pointer; - color: var(--text-tertiary); - transition: color 0.15s ease; - margin: 0; - padding-bottom: 2px; - border-bottom: 1.5px dashed transparent; - } - - .section-title-tab:hover { - color: var(--text-secondary); - } - - .section-title-tab.active { - color: var(--text-primary); - border-bottom-color: var(--text-tertiary); - } -`; diff --git a/packages/oc-docs/src/components/Examples/ExampleCard/ExampleCard.spec.tsx b/packages/oc-docs/src/components/Examples/ExampleCard/ExampleCard.spec.tsx new file mode 100644 index 0000000..b26f0bd --- /dev/null +++ b/packages/oc-docs/src/components/Examples/ExampleCard/ExampleCard.spec.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import { ExampleCard } from './ExampleCard'; +import type { HttpRequestExample } from '@opencollection/types/requests/http'; + +const example: HttpRequestExample = { + name: 'Successful login', + description: 'Returns an auth token', + request: { + url: '{{baseUrl}}/auth/login', + headers: [{ name: 'Content-Type', value: 'application/json' }], + params: [{ name: 'verbose', value: 'true', type: 'query' }], + body: { type: 'json', data: '{"email":"a@b.com"}' } + }, + response: { + status: 200, + statusText: 'OK', + headers: [{ name: 'Content-Type', value: 'application/json' }], + body: { type: 'json', data: '{"token":"abc"}' } + } +}; + +describe('ExampleCard', () => { + it('shows the name, status and a Try button when collapsed', () => { + const html = renderToStaticMarkup( {}} />); + expect(html).toContain('Successful login'); + expect(html).toContain('200'); + expect(html).toContain('>Try<'); + expect(html).toContain('--oc-status-success-text'); + // collapsed: detail panes are not rendered + expect(html).not.toContain('REQUEST'); + }); + + it('renders request and response panes when expanded', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('REQUEST'); + expect(html).toContain('RESPONSE'); + expect(html).toContain('Params'); + expect(html).toContain('Body'); + expect(html).toContain('Headers'); + // computed response size is shown + expect(html).toContain('B'); + }); + + it('falls back to a default name and omits Try without a handler', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('Example'); + expect(html).toContain('--oc-status-danger-text'); + // the Try control is not rendered without a handler + expect(html).not.toContain('>Try<'); + }); + + it('colours the status badge by class: 4xx error (red), 3xx info (blue)', () => { + const clientError = renderToStaticMarkup(); + expect(clientError).toContain('--oc-status-danger-text'); + const redirect = renderToStaticMarkup(); + expect(redirect).toContain('--oc-status-info-text'); + }); + + it('shows a single empty state for a side with no data', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('No request data.'); + expect(html).toContain('RESPONSE'); + }); + + it('always renders the canonical request tabs even when the request is sparse', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('Params'); + expect(html).toContain('Body'); + expect(html).toContain('Headers'); + // the response has neither body nor headers → its empty state shows + expect(html).toContain('No response data.'); + }); + + it('renders the request auth in the Auth tab (mode label + masked secret)', () => { + const html = renderToStaticMarkup( + + ); + // Auth tab is the default-selected pane (it is the only request tab with data). + expect(html).toContain('Auth'); + expect(html).toContain('Bearer Token'); // AUTH_MODE_LABELS[bearer] + expect(html).toContain('Token'); // field label + expect(html).not.toContain('super-secret-token'); // token is masked + }); + + it('renders a description object and accessible tab semantics', () => { + const html = renderToStaticMarkup( + {}} + defaultExpanded + /> + ); + expect(html).toContain('A described example'); + // Toggle and Try are separate, sibling buttons (never nested). + expect(html).toContain('class="oc-example-toggle"'); + expect(html).toContain('class="oc-example-try"'); + expect(html).toContain('aria-expanded="true"'); + // WAI-ARIA tabs. + expect(html).toContain('role="tablist"'); + expect(html).toContain('role="tab"'); + expect(html).toContain('role="tabpanel"'); + }); +}); diff --git a/packages/oc-docs/src/components/Examples/ExampleCard/ExampleCard.tsx b/packages/oc-docs/src/components/Examples/ExampleCard/ExampleCard.tsx new file mode 100644 index 0000000..6b1583a --- /dev/null +++ b/packages/oc-docs/src/components/Examples/ExampleCard/ExampleCard.tsx @@ -0,0 +1,437 @@ +import React, { useEffect, useId, useMemo, useRef, useState } from 'react'; +import type { + HttpRequestExample, + HttpRequestParam, + HttpRequestHeader, + HttpResponseHeader, + HttpRequestBody +} from '@opencollection/types/requests/http'; +import type { Auth } from '@opencollection/types/common/auth'; +import { MethodBadge } from '../../MethodBadge'; +import { CopyButton } from '../../../ui/CopyButton/CopyButton'; +import { PropertyTable, type PropertyRow } from '../../PropertyTable'; +import { AuthDetails } from '../../AuthDetails'; +import { Code } from '../../Code/Code'; +import { AUTH_MODE_LABELS } from '../../../constants'; +import { computeBodySize, formatBytes, responseBodyLanguage } from '../../../utils/exampleResponse'; +import { ExampleCardWrapper, statusToneColor } from './StyledWrapper'; + +interface ExampleCardProps { + example: HttpRequestExample; + method: string; + url: string; + onTry?: () => void; + defaultExpanded?: boolean; +} + +/** A single pane tab: a label, whether it carries data (brand dot), its content-type label and body. */ +interface PaneTab { + id: string; + label: string; + hasData: boolean; + ctype: string; + content: React.ReactNode; +} + +const ChevronIcon: React.FC<{ open: boolean }> = ({ open }) => ( + +); + +const PlayIcon: React.FC = () => ( + +); + +const paramRows = (params: HttpRequestParam[]): PropertyRow[] => + params.map((p) => ({ label: p.name, value: p.value, disabled: p.disabled })); + +const headerRows = (headers: (HttpRequestHeader | HttpResponseHeader)[]): PropertyRow[] => + headers.map((h) => ({ label: h.name, value: h.value, disabled: 'disabled' in h ? h.disabled : undefined })); + +const headerCtype = (count: number): string => `${count} header${count === 1 ? '' : 's'}`; + +/** The auth mode shown as the pane content type (e.g. "bearer", "oauth2", "inherit"). */ +const authTypeLabel = (auth: Auth): string => (typeof auth === 'string' ? auth : auth.type); + +/** The full MIME content type for a request body mode (shown in the pane header). */ +const requestBodyCtype = (body: HttpRequestBody): string => { + switch (body.type) { + case 'json': + return 'application/json'; + case 'xml': + return 'application/xml'; + case 'text': + return 'text/plain'; + case 'sparql': + return 'application/sparql-query'; + case 'form-urlencoded': + return 'application/x-www-form-urlencoded'; + case 'multipart-form': + return 'multipart/form-data'; + case 'file': + return 'application/octet-stream'; + default: + return 'text/plain'; + } +}; + +/** The full MIME content type for a response body type. */ +const responseBodyCtype = (type: string | undefined): string => { + switch (type) { + case 'json': + return 'application/json'; + case 'xml': + return 'application/xml'; + case 'html': + return 'text/html'; + case 'binary': + return 'application/octet-stream'; + default: + return 'text/plain'; + } +}; + +const emptyPane = (label: string): React.ReactNode =>

    No {label}.

    ; + +/** Renders a request body: raw bodies as highlighted code, structured ones as a key/value table. */ +const RequestBodyContent: React.FC<{ body: HttpRequestBody }> = ({ body }) => { + switch (body.type) { + case 'json': + case 'xml': + case 'text': + return ; + case 'sparql': + return ; + case 'file': + return ({ label: e.filePath, value: e.contentType }))} />; + case 'multipart-form': + return ( + ({ label: e.name, value: Array.isArray(e.value) ? e.value.join(', ') : e.value }))} + /> + ); + case 'form-urlencoded': + return ({ label: e.name, value: e.value }))} />; + default: + return emptyPane('body'); + } +}; + +/** + * One pane (REQUEST or RESPONSE): a header bar (title, tab pills, content type, optional + * meta) over the active tab's body. Tabs follow the WAI-ARIA tabs pattern (roving + * tabindex + arrow/Home/End navigation, a single labelled tabpanel). When the side has + * no data at all, the tab strip is replaced by a single empty message. + */ +const Pane: React.FC<{ + title: string; + emptyMessage: string; + tabs: PaneTab[]; + meta?: React.ReactNode; + /** Render the tab strip on the right of the header (RESPONSE layout). */ + tabsRight?: boolean; + paneClassName: string; +}> = ({ title, emptyMessage, tabs, meta, tabsRight, paneClassName }) => { + const baseId = useId(); + const panelId = `${baseId}-panel`; + const tabId = (id: string) => `${baseId}-tab-${id}`; + const tabRefs = useRef>({}); + + const hasAnyData = tabs.some((t) => t.hasData); + // Default to the Body tab when it has data, else the first tab with data, else the first tab. + const defaultTab = tabs.find((t) => t.hasData && t.id === 'body') ?? tabs.find((t) => t.hasData) ?? tabs[0]; + const [activeId, setActiveId] = useState(defaultTab?.id); + const active = tabs.find((t) => t.id === activeId) ?? defaultTab; + + // Keep the selected tab visible by scrolling only the tab strip (never an + // ancestor / the page) when the strip overflows on narrow panes. + useEffect(() => { + const el = tabRefs.current[active?.id ?? '']; + const strip = el?.parentElement; + if (!el || !strip) return; + const tab = el.getBoundingClientRect(); + const box = strip.getBoundingClientRect(); + if (tab.left < box.left) strip.scrollLeft -= box.left - tab.left; + else if (tab.right > box.right) strip.scrollLeft += tab.right - box.right; + }, [active?.id]); + + const onTabKeyDown = (event: React.KeyboardEvent) => { + const index = tabs.findIndex((t) => t.id === active?.id); + if (index < 0) return; + let next = index; + switch (event.key) { + case 'ArrowRight': + case 'ArrowDown': + next = (index + 1) % tabs.length; + break; + case 'ArrowLeft': + case 'ArrowUp': + next = (index - 1 + tabs.length) % tabs.length; + break; + case 'Home': + next = 0; + break; + case 'End': + next = tabs.length - 1; + break; + default: + return; + } + event.preventDefault(); + const nextTab = tabs[next]; + setActiveId(nextTab.id); + tabRefs.current[nextTab.id]?.focus(); + }; + + const tabStrip = hasAnyData ? ( +
    + {tabs.map((tab) => { + const isActive = tab.id === active?.id; + return ( + + ); + })} +
    + ) : null; + + const ctype = hasAnyData ? active?.ctype : ''; + + return ( +
    +
    + {title} + {tabsRight ? ( + <> + {meta} + + {ctype && {ctype}} + {tabStrip} + + ) : ( + <> + {tabStrip} + + {ctype && {ctype}} + + )} +
    + {hasAnyData && active ? ( +
    + {active.content} +
    + ) : ( +
    +

    {emptyMessage}

    +
    + )} +
    + ); +}; + +/** A single saved example: a collapsible summary over the request/response detail. */ +export const ExampleCard: React.FC = ({ example, method, url, onTry, defaultExpanded }) => { + const [expanded, setExpanded] = useState(Boolean(defaultExpanded)); + // Mount the (heavy, Prism-highlighted) detail lazily on first open, then keep it + // mounted so opening/closing animates without re-running highlighting. + const [mounted, setMounted] = useState(Boolean(defaultExpanded)); + const detailId = useId(); + const detailRef = useRef(null); + + // While collapsed, take the (animating-but-hidden) detail out of the tab order and + // the accessibility tree via `inert` — robust across browsers/React versions. + useEffect(() => { + const el = detailRef.current; + if (!el) return; + if (expanded) el.removeAttribute('inert'); + else el.setAttribute('inert', ''); + }, [expanded, mounted]); + + const toggle = () => { + if (!expanded) setMounted(true); + setExpanded((v) => !v); + }; + + const request = example.request ?? {}; + const response = example.response ?? {}; + const status = response.status; + const responseBody = response.body; + const name = example.name ?? 'Example'; + const description = + typeof example.description === 'string' ? example.description : example.description?.content || undefined; + + const size = useMemo(() => computeBodySize(responseBody?.data), [responseBody]); + const toneColor = statusToneColor(status); + const displayUrl = request.url || url; + + // REQUEST: canonical Params / Body / Auth / Headers tabs, each with a data dot when populated. + const requestTabs: PaneTab[] = useMemo(() => { + const params = request.params ?? []; + const headers = request.headers ?? []; + const body = request.body; + const auth = request.auth; + return [ + { + id: 'params', + label: 'Params', + hasData: params.length > 0, + ctype: params.length ? 'query' : '', + content: params.length ? : emptyPane('params') + }, + { + id: 'body', + label: 'Body', + hasData: Boolean(body), + ctype: body ? requestBodyCtype(body) : '', + content: body ? : emptyPane('body') + }, + { + id: 'auth', + label: 'Auth', + hasData: Boolean(auth), + ctype: auth ? authTypeLabel(auth) : '', + content: auth ? : emptyPane('auth') + }, + { + id: 'headers', + label: 'Headers', + hasData: headers.length > 0, + ctype: headers.length ? headerCtype(headers.length) : '', + content: headers.length ? : emptyPane('headers') + } + ]; + }, [request.params, request.body, request.auth, request.headers]); + + // RESPONSE: canonical Body / Headers tabs. + const responseTabs: PaneTab[] = useMemo(() => { + const headers = response.headers ?? []; + const hasBody = Boolean(responseBody?.data); + return [ + { + id: 'body', + label: 'Body', + hasData: hasBody, + ctype: hasBody ? responseBodyCtype(responseBody?.type) : '', + content: hasBody ? ( + + ) : ( + emptyPane('body') + ) + }, + { + id: 'headers', + label: 'Headers', + hasData: headers.length > 0, + ctype: headers.length ? headerCtype(headers.length) : '', + content: headers.length ? : emptyPane('headers') + } + ]; + }, [responseBody, response.headers]); + + const responseMeta = + status !== undefined ? ( + + + {status} + + {responseBody?.data ? ` · ${formatBytes(size)}` : ''} + + ) : undefined; + + const handleTry = () => { + setMounted(true); + setExpanded(true); + onTry?.(); + }; + + return ( + +
    + + {onTry && ( + + )} +
    + + {/* Grid-rows 0fr→1fr animates the height open/close with no fixed max-height. */} +
    +
    + {mounted && ( +
    + {description &&

    {description}

    } + +
    + + {displayUrl} + +
    + +
    + + +
    +
    + )} +
    +
    +
    + ); +}; + +export default ExampleCard; diff --git a/packages/oc-docs/src/components/Examples/ExampleCard/StyledWrapper.ts b/packages/oc-docs/src/components/Examples/ExampleCard/StyledWrapper.ts new file mode 100644 index 0000000..f3a88f3 --- /dev/null +++ b/packages/oc-docs/src/components/Examples/ExampleCard/StyledWrapper.ts @@ -0,0 +1,326 @@ +import styled from '@emotion/styled'; + +/** + * Tone color for a status badge: 2xx success (green), 3xx/1xx info (blue), + * 4xx & 5xx error (red) — matching the design (success vs error) while keeping + * redirects/informational visually distinct. + */ +export const statusToneColor = (status?: number): string => { + if (status === undefined) return 'var(--text-muted)'; + if (status >= 200 && status < 300) return 'var(--oc-status-success-text)'; + if (status >= 400) return 'var(--oc-status-danger-text)'; + return 'var(--oc-status-info-text)'; +}; + +export const ExampleCardWrapper = styled.div` + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + background: var(--oc-background-base); + + & + & { + margin-top: 12px; + } + + /* ── Summary row ───────────────────────────────────────────── */ + /* A flex row holding the toggle button and the (sibling) Try button — never + nested, so both are real, keyboard-operable controls. */ + .oc-example-summary { + display: flex; + align-items: center; + gap: 12px; + padding: 8px; + } + + .oc-example-toggle { + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: center; + gap: 12px; + padding: 0; + margin: 0; + background: none; + border: none; + cursor: pointer; + text-align: left; + color: inherit; + font: inherit; + } + .oc-example-toggle:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + border-radius: 4px; + } + + .oc-example-chevron { + flex: 0 0 auto; + color: var(--text-muted); + transition: transform 0.15s ease; + } + .oc-example-chevron.is-open { + transform: rotate(90deg); + } + + .oc-example-status { + flex: 0 0 auto; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 700; + line-height: 1; + border-radius: 4px; + padding: 2px 6px; + } + + .oc-example-name { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .oc-example-try { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 5px; + height: 24px; + padding: 0 9px; + box-sizing: border-box; + background: var(--brand-soft); + color: var(--primary-text); + border: 1px solid var(--primary-color); + border-radius: 6px; + font-family: var(--font-sans); + font-size: 11.5px; + font-weight: 600; + cursor: pointer; + } + .oc-example-try:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } + + /* ── Detail (animated open/close) ──────────────────────────── */ + /* grid-template-rows 0fr → 1fr animates height without a fixed max-height; the + clip wrapper hides the overflow (and the body's top border) while collapsed. */ + .oc-example-detail { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.22s ease; + } + .oc-example-detail.is-open { + grid-template-rows: 1fr; + } + .oc-example-detail-clip { + overflow: hidden; + min-height: 0; + } + /* No divider between the summary row and the description below it. */ + .oc-example-description { + margin: 0; + padding: 0.5rem; + font-family: var(--font-sans); + font-weight: 400; + font-size: 0.75rem; + line-height: 1; + letter-spacing: 0; + color: #555555; + } + + .oc-example-url-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px; + border-top: 1px solid var(--border-color); + border-bottom: 1px solid var(--border-color); + } + .oc-example-url-text { + flex: 1; + font-family: var(--font-sans); + font-weight: 400; + font-size: 0.75rem; + line-height: 1; + letter-spacing: 0; + color: #555555; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + /* ── Two-pane grid ─────────────────────────────────────────── */ + .oc-example-grid { + display: grid; + grid-template-columns: 1fr 1fr; + } + .oc-example-pane-left, + .oc-example-pane-right { + display: flex; + flex-direction: column; + min-width: 0; + } + .oc-example-pane-left { + border-right: 1px solid var(--border-color); + } + + @media (max-width: 900px) { + .oc-example-grid { + grid-template-columns: 1fr; + } + .oc-example-pane-left { + border-right: none; + border-bottom: 1px solid var(--border-color); + } + } + + /* ── Pane ──────────────────────────────────────────────────── */ + .oc-pane-head { + display: flex; + align-items: center; + gap: 8px; + min-height: 38px; + padding: 0 10px; + background: var(--oc-background-mantle); + border-bottom: 1px solid var(--border-color); + } + + .oc-pane-title { + flex: 0 0 auto; + font-family: var(--font-sans); + font-weight: 600; + font-size: 0.6875rem; + line-height: 1; + letter-spacing: 0; + color: #343434; + } + + .oc-pane-spacer { + flex: 1; + } + + .oc-pane-ctype { + flex: 0 0 auto; + font-size: 11px; + color: var(--text-muted); + font-family: var(--font-mono); + white-space: nowrap; + } + + .oc-pane-meta { + flex: 0 0 auto; + font-size: 11px; + color: var(--text-muted); + font-family: var(--font-mono); + white-space: nowrap; + } + .oc-pane-meta-status { + font-weight: 700; + } + + /* Tabs can shrink and scroll horizontally so they never overflow the header + (e.g. all four request tabs on a narrow / mobile pane). The scrollbar is + hidden — tabs scroll by swipe/drag/keyboard. */ + .oc-pane-tabs { + display: flex; + align-items: center; + gap: 4px; + flex: 0 1 auto; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; + } + .oc-pane-tabs::-webkit-scrollbar { + display: none; + } + + .oc-pane-tab { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 5px; + cursor: pointer; + font-size: 11.5px; + padding: 3px 9px; + border-radius: 4px; + background: transparent; + font: inherit; + font-size: 11.5px; + line-height: 1; + font-weight: 500; + color: var(--text-muted); + border: 1px solid transparent; + } + .oc-pane-tab.is-active { + font-weight: 600; + color: var(--text-primary); + border: 1px solid var(--border-strong); + background: var(--oc-background-base); + } + .oc-pane-tab-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--primary-color); + display: inline-block; + } + + /* Long bodies / header lists scroll within the pane (capped height) with a thin + scrollbar, instead of growing the card unboundedly. */ + .oc-pane-body { + flex: 1; + min-height: 96px; + max-height: 20rem; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: thin; + scrollbar-color: var(--oc-scrollbar-color) transparent; + } + .oc-pane-body::-webkit-scrollbar { + width: 6px; + height: 6px; + } + .oc-pane-body::-webkit-scrollbar-thumb { + background-color: var(--oc-scrollbar-color); + border-radius: 3px; + } + .oc-pane-body::-webkit-scrollbar-track { + background: transparent; + } + /* Content sits flush inside the pane — the pane frames it, so the inner code + panel and property table drop their own borders. */ + .oc-pane-body .code-content-wrapper { + border: none; + border-radius: 0; + } + .oc-pane-body .property-box { + box-shadow: none; + border-radius: 0; + } + + .oc-pane-empty { + margin: 0; + padding: 12px; + font-style: italic; + font-size: 12px; + color: var(--text-tertiary); + } + + /* On phones, drop the content-type label so the tabs get the full header width. */ + @media (max-width: 600px) { + .oc-pane-ctype { + display: none; + } + } + + @media (prefers-reduced-motion: reduce) { + .oc-example-detail { + transition: none; + } + .oc-example-chevron { + transition: none; + } + } +`; diff --git a/packages/oc-docs/src/components/Examples/ExampleCard/index.ts b/packages/oc-docs/src/components/Examples/ExampleCard/index.ts new file mode 100644 index 0000000..8b2c91d --- /dev/null +++ b/packages/oc-docs/src/components/Examples/ExampleCard/index.ts @@ -0,0 +1,2 @@ +export { ExampleCard } from './ExampleCard'; +export { default } from './ExampleCard'; diff --git a/packages/oc-docs/src/components/Examples/Examples.spec.tsx b/packages/oc-docs/src/components/Examples/Examples.spec.tsx new file mode 100644 index 0000000..fcd7a09 --- /dev/null +++ b/packages/oc-docs/src/components/Examples/Examples.spec.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import { Examples } from './Examples'; +import type { HttpRequestExample } from '@opencollection/types/requests/http'; + +const examples: HttpRequestExample[] = [ + { name: 'Happy path', response: { status: 200, body: { type: 'json', data: '{}' } } }, + { name: 'Unauthorized', response: { status: 401 } } +]; + +describe('Examples', () => { + it('renders one card per example with the first expanded', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('Happy path'); + expect(html).toContain('Unauthorized'); + // first example defaults to expanded + expect(html).toContain('REQUEST'); + }); + + it('renders nothing when there are no examples', () => { + expect(renderToStaticMarkup()).toBe(''); + expect(renderToStaticMarkup()).toBe(''); + }); +}); diff --git a/packages/oc-docs/src/components/Examples/Examples.tsx b/packages/oc-docs/src/components/Examples/Examples.tsx new file mode 100644 index 0000000..47b4dd6 --- /dev/null +++ b/packages/oc-docs/src/components/Examples/Examples.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import type { HttpRequestExample } from '@opencollection/types/requests/http'; +import { ExampleCard } from './ExampleCard'; +import { ExamplesWrapper } from './StyledWrapper'; + +interface ExamplesProps { + examples?: HttpRequestExample[]; + method: string; + url: string; + onTry?: () => void; + className?: string; +} + +/** A list of saved request examples, each an expandable request/response card. */ +export const Examples: React.FC = ({ examples, method, url, onTry, className }) => { + if (!examples || examples.length === 0) return null; + + return ( + + {examples.map((example, index) => ( + + ))} + + ); +}; + +export default Examples; diff --git a/packages/oc-docs/src/components/Examples/StyledWrapper.ts b/packages/oc-docs/src/components/Examples/StyledWrapper.ts new file mode 100644 index 0000000..958a054 --- /dev/null +++ b/packages/oc-docs/src/components/Examples/StyledWrapper.ts @@ -0,0 +1,5 @@ +import styled from '@emotion/styled'; + +export const ExamplesWrapper = styled.div` + display: block; +`; diff --git a/packages/oc-docs/src/components/Examples/index.ts b/packages/oc-docs/src/components/Examples/index.ts new file mode 100644 index 0000000..454dc16 --- /dev/null +++ b/packages/oc-docs/src/components/Examples/index.ts @@ -0,0 +1,3 @@ +export { Examples } from './Examples'; +export { default } from './Examples'; +export { ExampleCard } from './ExampleCard'; diff --git a/packages/oc-docs/src/components/ExecutionContext/AssertList.tsx b/packages/oc-docs/src/components/ExecutionContext/AssertList.tsx new file mode 100644 index 0000000..dac79f6 --- /dev/null +++ b/packages/oc-docs/src/components/ExecutionContext/AssertList.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { ScopeTag } from './ScopeTag'; +import { VariableText } from '../VariableText'; +import type { AssertionRow } from '../../utils/assertions'; + +interface AssertListProps { + assertions: AssertionRow[]; +} + +/** Compose an assertion into a single readable expression, e.g. `res.status equals 200`. */ +const assertionText = (assert: AssertionRow): string => + [assert.expression, assert.operatorLabel, assert.isUnary ? undefined : assert.value] + .filter((part): part is string => part !== undefined && part !== '') + .join(' '); + +/** + * Static list of defined assertions (no pass/fail — this is documentation). + * Each row shows a scope tag and the composed assertion expression in mono. + */ +export const AssertList: React.FC = ({ assertions }) => { + if (assertions.length === 0) return null; + + return ( + <> + {assertions.map((assert, index) => ( +
    + + + + +
    + ))} + + ); +}; + +export default AssertList; diff --git a/packages/oc-docs/src/components/ExecutionContext/ExecutionContext.spec.tsx b/packages/oc-docs/src/components/ExecutionContext/ExecutionContext.spec.tsx new file mode 100644 index 0000000..f4d48e5 --- /dev/null +++ b/packages/oc-docs/src/components/ExecutionContext/ExecutionContext.spec.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import { ExecutionContext } from './ExecutionContext'; +import { ScriptChain } from './ScriptChain'; +import { FlowToggle } from './FlowToggle'; +import type { ScriptChainStep } from '../../utils/requestScripts'; +import type { AssertionRow } from '../../utils/assertions'; +import type { TestRow } from '../../utils/extractTests'; + +const scriptChain: ScriptChainStep[] = [ + { level: 'collection', phase: 'before-request', label: 'Collection Pre-Request', sourceName: 'API', code: 'bru.setVar("x", 1)', inherited: true, order: 0 }, + { level: 'request', phase: 'before-request', label: 'Request Pre-Request', code: 'console.log("go")', inherited: false, order: 1 }, + { level: 'request', phase: 'after-response', label: 'Request Post-Response', code: 'console.log("done")', inherited: false, order: 1 } +]; + +// Collection + folder + request post-response steps, used to assert flow ordering. +const postChain: ScriptChainStep[] = [ + { level: 'collection', phase: 'after-response', label: 'Collection Post-Response', code: 'a', inherited: true, order: 0 }, + { level: 'folder', phase: 'after-response', label: 'Folder Post-Response', code: 'b', inherited: true, order: 1 }, + { level: 'request', phase: 'after-response', label: 'Request Post-Response', code: 'c', inherited: false, order: 2 } +]; + +const assertions: AssertionRow[] = [ + { level: 'request', expression: 'res.status', operatorLabel: 'equals', value: '200', isUnary: false }, + { level: 'request', expression: 'res.body.token', operatorLabel: 'is defined', isUnary: true } +]; + +const tests: TestRow[] = [ + { level: 'collection', name: 'is authenticated', sourceName: 'API' }, + { level: 'request', name: 'returns a token' } +]; + +const order = (html: string, ...labels: string[]) => labels.map((l) => html.indexOf(l)); +const isAscending = (xs: number[]) => xs.every((x, i) => i === 0 || (x > xs[i - 1] && x >= 0)); + +describe('ExecutionContext', () => { + it('renders the script chain with an HTTP marker, vars, asserts and tests', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('Scripts'); + expect(html).toContain('Collection Pre-Request'); + expect(html).toContain('HTTP'); + expect(html).toContain('Variables'); + expect(html).toContain('Pre-Request'); + expect(html).toContain('Post-Response'); + expect(html).toContain('sessionId'); + expect(html).toContain('res.body.id'); + // neutral counts + expect(html).toContain('2 asserts'); + expect(html).toContain('equals'); + expect(html).toContain('is defined'); + expect(html).toContain('2 tests'); + expect(html).toContain('returns a token'); + }); + + it('uses singular nouns for a single assert/test', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('1 assert'); + expect(html).not.toContain('1 asserts'); + expect(html).toContain('1 test'); + expect(html).not.toContain('1 tests'); + }); + + it('renders nothing when everything is empty', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toBe(''); + }); + + it('defaults to the sandwich flow (pill shows the sandwich label)', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('Sandwich execution flow'); + expect(html).not.toContain('Sequential execution flow'); + }); + + it('honours an explicit defaultFlow of sequential', () => { + const html = renderToStaticMarkup( + + ); + // Sequential post order: collection → folder → request. + expect(isAscending(order(html, 'Collection Post-Response', 'Folder Post-Response', 'Request Post-Response'))).toBe(true); + }); +}); + +describe('ScriptChain flow ordering', () => { + it('sandwich: post-response runs request → folder → collection (reverse of pre)', () => { + const html = renderToStaticMarkup(); + expect(isAscending(order(html, 'Request Post-Response', 'Folder Post-Response', 'Collection Post-Response'))).toBe(true); + }); + + it('sequential: post-response runs collection → folder → request', () => { + const html = renderToStaticMarkup(); + expect(isAscending(order(html, 'Collection Post-Response', 'Folder Post-Response', 'Request Post-Response'))).toBe(true); + }); + + it('pre-request order is identical across flows (collection → folder → request)', () => { + const pre: ScriptChainStep[] = [ + { level: 'collection', phase: 'before-request', label: 'Collection Pre-Request', code: 'a', inherited: true, order: 0 }, + { level: 'folder', phase: 'before-request', label: 'Folder Pre-Request', code: 'b', inherited: true, order: 1 }, + { level: 'request', phase: 'before-request', label: 'Request Pre-Request', code: 'c', inherited: false, order: 2 } + ]; + for (const flow of ['sandwich', 'sequential'] as const) { + const html = renderToStaticMarkup(); + expect(isAscending(order(html, 'Collection Pre-Request', 'Folder Pre-Request', 'Request Pre-Request'))).toBe(true); + } + }); + + it('numbers rows 1..N in display order, request execution included', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('HTTP'); + // 3 post steps + 1 marker → positions 1..4 present. + ['>1<', '>2<', '>3<', '>4<'].forEach((n) => expect(html).toContain(n)); + }); +}); + +describe('FlowToggle', () => { + it('shows the active flow as a labelled pill button', () => { + const html = renderToStaticMarkup( {}} />); + expect(html).toContain('Sequential execution flow'); + expect(html).toContain('oc-flow-toggle'); + expect(html).toContain('aria-label'); + }); + + it('labels the sandwich flow likewise', () => { + const html = renderToStaticMarkup( {}} />); + expect(html).toContain('Sandwich execution flow'); + }); +}); diff --git a/packages/oc-docs/src/components/ExecutionContext/ExecutionContext.tsx b/packages/oc-docs/src/components/ExecutionContext/ExecutionContext.tsx new file mode 100644 index 0000000..aaf5aff --- /dev/null +++ b/packages/oc-docs/src/components/ExecutionContext/ExecutionContext.tsx @@ -0,0 +1,101 @@ +import React, { useState } from 'react'; +import { ScriptChain } from './ScriptChain'; +import { FlowToggle } from './FlowToggle'; +import { VariablesPanel } from './VariablesPanel'; +import { AssertList } from './AssertList'; +import { TestList } from './TestList'; +import { ExecutionContextWrapper } from './StyledWrapper'; +import type { ScriptChainStep, ScriptFlow } from '../../utils/requestScripts'; +import type { PreRequestVarRow, PostResponseVarRow } from '../../utils/requestVars'; +import type { AssertionRow } from '../../utils/assertions'; +import type { TestRow } from '../../utils/extractTests'; + +interface ExecutionContextProps { + scriptChain: ScriptChainStep[]; + preVars: PreRequestVarRow[]; + postVars: PostResponseVarRow[]; + assertions: AssertionRow[]; + tests: TestRow[]; + /** Method/url shown on the synthetic "Request Execution" row in the script chain. */ + method?: string; + url?: string; + /** Initial script execution flow. Defaults to `sandwich`. */ + defaultFlow?: ScriptFlow; + className?: string; +} + +const countLabel = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? '' : 's'}`; + +/** + * A titled section card inside the Execution Context. These are not individually + * collapsible — the whole Execution Context collapses as one (see the page's + * collapsible `Section`); each card just groups a title + optional meta + content. + */ +const Card: React.FC<{ title: string; meta?: React.ReactNode; children: React.ReactNode }> = ({ + title, + meta, + children +}) => ( +
    +
    + {title} + {meta !== undefined && {meta}} +
    + {children} +
    +); + +/** + * Execution context for a request: the ordered script chain, pre/post variables, + * defined asserts and tests — each in its own bordered card. Purely presentational + * (static documentation, not a run). Renders nothing when every group is empty. + */ +export const ExecutionContext: React.FC = ({ + scriptChain, + preVars, + postVars, + assertions, + tests, + method, + url, + defaultFlow = 'sandwich', + className +}) => { + const [flow, setFlow] = useState(defaultFlow); + + const hasScripts = scriptChain.length > 0; + const hasVars = preVars.length > 0 || postVars.length > 0; + const hasAsserts = assertions.length > 0; + const hasTests = tests.length > 0; + + if (!hasScripts && !hasVars && !hasAsserts && !hasTests) return null; + + const varCount = preVars.length + postVars.length; + + return ( + + {hasScripts && ( + }> + + + )} + {hasVars && ( + + + + )} + {hasAsserts && ( + + + + )} + {hasTests && ( + + + + )} + + ); +}; + +export default ExecutionContext; diff --git a/packages/oc-docs/src/components/ExecutionContext/FlowToggle.tsx b/packages/oc-docs/src/components/ExecutionContext/FlowToggle.tsx new file mode 100644 index 0000000..86f87f3 --- /dev/null +++ b/packages/oc-docs/src/components/ExecutionContext/FlowToggle.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import type { ScriptFlow } from '../../utils/requestScripts'; + +interface FlowToggleProps { + value: ScriptFlow; + onChange: (flow: ScriptFlow) => void; +} + +const FLOW_LABEL: Record = { + sandwich: 'Sandwich', + sequential: 'Sequential' +}; + +/** + * Pill in the Scripts header showing the active execution flow. Activating it + * toggles between the two flows (the ordering logic itself is unchanged); it is a + * single button so the keyboard interaction is the standard activate-to-switch. + */ +export const FlowToggle: React.FC = ({ value, onChange }) => { + const next: ScriptFlow = value === 'sandwich' ? 'sequential' : 'sandwich'; + + return ( + + ); +}; + +export default FlowToggle; diff --git a/packages/oc-docs/src/components/ExecutionContext/ScopeTag.tsx b/packages/oc-docs/src/components/ExecutionContext/ScopeTag.tsx new file mode 100644 index 0000000..d7b1b43 --- /dev/null +++ b/packages/oc-docs/src/components/ExecutionContext/ScopeTag.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +export type Scope = 'request' | 'folder' | 'collection'; + +interface ScopeTagProps { + scope: Scope; +} + +/** Small mono pill marking the scope (request / folder / collection) of an assert or test. */ +export const ScopeTag: React.FC = ({ scope }) => ( + {scope} +); + +export default ScopeTag; diff --git a/packages/oc-docs/src/components/ExecutionContext/ScriptChain.tsx b/packages/oc-docs/src/components/ExecutionContext/ScriptChain.tsx new file mode 100644 index 0000000..c3d41ba --- /dev/null +++ b/packages/oc-docs/src/components/ExecutionContext/ScriptChain.tsx @@ -0,0 +1,63 @@ +import React, { useMemo } from 'react'; +import { VariableText } from '../VariableText'; +import { ScriptStep } from './ScriptStep'; +import type { ScriptChainStep, ScriptFlow } from '../../utils/requestScripts'; + +interface ScriptChainProps { + steps: ScriptChainStep[]; + /** Active execution flow; controls post-response ordering. */ + flow: ScriptFlow; + /** Method/url for the synthetic "HTTP" execution marker, if known. */ + method?: string; + url?: string; +} + +/** The synthetic row marking where the HTTP request is actually sent. */ +const HttpMarker: React.FC<{ position: number; url?: string }> = ({ position, url }) => ( +
    +
    + {position} +
    +
    +); + +/** + * The ordered script-execution chain rendered as rows: pre-request scripts run + * first (collection → folders → request), then the request is sent (the "HTTP" + * marker), then post-response scripts run in an order that depends on `flow` — + * innermost→outermost for `sandwich`, outermost→innermost for `sequential`. Rows + * are numbered 1..N across both kinds in display order. + */ +export const ScriptChain: React.FC = ({ steps, flow, url }) => { + // Pre-request order is the same for both flows; only post-response ordering + // differs, so we sort by hierarchy index and reverse for the sandwich flow. + const { pre, post } = useMemo(() => { + const byOrderAsc = (a: ScriptChainStep, b: ScriptChainStep) => a.order - b.order; + const preSteps = steps.filter((s) => s.phase === 'before-request').sort(byOrderAsc); + const postSteps = steps.filter((s) => s.phase === 'after-response').sort(byOrderAsc); + return { pre: preSteps, post: flow === 'sandwich' ? postSteps.reverse() : postSteps }; + }, [steps, flow]); + + if (steps.length === 0) return null; + + let position = 0; + const next = (): number => (position += 1); + + return ( + <> + {pre.map((step, index) => ( + + ))} + + {post.map((step, index) => ( + + ))} + + ); +}; + +export default ScriptChain; diff --git a/packages/oc-docs/src/components/ExecutionContext/ScriptStep.tsx b/packages/oc-docs/src/components/ExecutionContext/ScriptStep.tsx new file mode 100644 index 0000000..3861450 --- /dev/null +++ b/packages/oc-docs/src/components/ExecutionContext/ScriptStep.tsx @@ -0,0 +1,94 @@ +import React, { useMemo, useState } from 'react'; +import { Code } from '../Code/Code'; +import { Chevron } from '../Chevron'; +import type { ScriptChainStep } from '../../utils/requestScripts'; + +interface ScriptStepProps { + step: ScriptChainStep; + /** 1-based position of this row in the display order. */ + position: number; +} + +/** + * Split a script into a human-readable description (its leading `//` comment block) + * and the runnable body shown when the row is expanded. Scripts with no leading + * comment simply have no description and show their full code. + */ +const splitScript = (code: string): { description?: string; body: string } => { + const lines = code.replace(/\r\n/g, '\n').split('\n'); + const comment: string[] = []; + let i = 0; + while (i < lines.length && lines[i].trim() === '') i += 1; + while (i < lines.length && lines[i].trim().startsWith('//')) { + comment.push(lines[i].trim().replace(/^\/\/\s?/, '')); + i += 1; + } + const body = lines.slice(i).join('\n').trim(); + return { description: comment.join(' ').trim() || undefined, body: body || code }; +}; + +/** + * One script node in the chain: number + chevron + label (+ inherited tag) + a short + * description, with the source code revealed in a height-animated panel on demand. + */ +export const ScriptStep: React.FC = ({ step, position }) => { + const [open, setOpen] = useState(false); + // Mount the (Prism-highlighted) code lazily on first open, then keep it mounted so + // the open/close height animation runs without re-highlighting each time. + const [mounted, setMounted] = useState(false); + const { description, body } = useMemo(() => splitScript(step.code), [step.code]); + + const toggle = () => { + if (!open) setMounted(true); + setOpen((v) => !v); + }; + + return ( +
    +
    { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggle(); + } + }} + > + {position} + + + {step.label} + {step.inherited && inherited} + + {description} + +
    + +
    +
    + {mounted && ( +
    + +
    + )} +
    +
    +
    + ); +}; + +export default ScriptStep; diff --git a/packages/oc-docs/src/components/ExecutionContext/StyledWrapper.ts b/packages/oc-docs/src/components/ExecutionContext/StyledWrapper.ts new file mode 100644 index 0000000..f1215d6 --- /dev/null +++ b/packages/oc-docs/src/components/ExecutionContext/StyledWrapper.ts @@ -0,0 +1,315 @@ +import styled from '@emotion/styled'; + +export const ExecutionContextWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; + + /* ----- Section card (static; the whole Execution Context collapses as one) ----- */ + .oc-exec-card { + border: 1px solid var(--border-color); + border-radius: 0.625rem; + overflow: hidden; + background: var(--oc-background-base); + } + + .oc-exec-card-head { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.5rem 0.75rem; + padding: 0.5rem 0.5rem; + border-bottom: 1px solid var(--border-color); + } + .oc-exec-card-title { + font-size: 0.8125rem; + font-weight: 600; + color: var(--text-primary); + } + .oc-exec-card-meta { + font-size: 0.75rem; + color: var(--text-muted); + } + + /* ----- Execution-flow pill (toggles sandwich ⇄ sequential) ----- */ + .oc-flow-toggle { + appearance: none; + cursor: pointer; + font-family: var(--font-sans); + font-size: 0.6875rem; + font-weight: 500; + line-height: 1; + letter-spacing: 0; + color: #838383; + background: var(--oc-background-surface0); + border: 1px solid var(--border-color); + border-radius: 0.25rem; + padding: 0.125rem 0.25rem; + white-space: nowrap; + transition: color 0.15s ease, border-color 0.15s ease; + } + .oc-flow-toggle:hover { + color: var(--text-primary); + border-color: var(--border-strong); + } + .oc-flow-toggle:focus-visible { + outline: 2px solid var(--oc-status-info-text); + outline-offset: 0.0625rem; + } + + /* ----- Script chain ----- */ + /* The chain has a single divider, sitting above the HTTP execution marker. */ + .oc-script-row--marker { + border-top: 1px solid var(--oc-border-border0); + } + + /* Shared row grid so every row aligns: num | chevron | label | description | action. */ + .oc-script-line { + display: grid; + grid-template-columns: 1.25rem 0.875rem minmax(5rem, 15rem) minmax(0, 1fr) auto; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + } + + .oc-step-num { + justify-self: end; + font-family: var(--font-mono); + font-size: 0.71875rem; + color: var(--text-muted); + } + + .oc-script-step-head { + cursor: pointer; + } + .oc-script-step-head:focus-visible { + outline: 2px solid var(--oc-status-info-text); + outline-offset: -2px; + border-radius: 0.375rem; + } + + /* Rotation/transition are owned by the shared Chevron; we only set its colour. */ + .oc-script-chevron { + color: var(--text-muted); + } + + .oc-script-label-cell { + display: inline-flex; + align-items: baseline; + gap: 0.5rem; + min-width: 0; + } + .oc-script-step-label { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-primary); + text-transform: uppercase; + letter-spacing: 0.02em; + white-space: nowrap; + } + .oc-script-inherited-tag { + font-size: 0.65625rem; + font-weight: 400; + color: var(--text-muted); + white-space: nowrap; + } + + .oc-script-desc { + font-family: var(--font-sans); + font-size: 0.75rem; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + /* HTTP execution marker — same grid; its chevron + action cells stay empty. */ + .oc-script-http-label { + font-size: 0.75rem; + font-weight: 600; + color: var(--primary-text); + } + .oc-script-http-url { + font-family: var(--font-mono); + font-size: 0.75rem; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + /* "view code" / "hide code" text button */ + .oc-code-toggle { + justify-self: end; + background: none; + border: none; + padding: 0; + cursor: pointer; + font-family: var(--font-sans); + font-size: 0.75rem; + font-weight: 500; + color: var(--primary-text); + } + .oc-code-toggle:hover { + text-decoration: underline; + } + .oc-code-toggle:focus-visible { + outline: 2px solid var(--oc-status-info-text); + outline-offset: 0.125rem; + border-radius: 0.25rem; + } + + /* Expanded code (height animation; indented past the num + chevron columns). */ + .oc-script-code { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.22s ease; + } + .oc-script-code.is-open { + grid-template-rows: 1fr; + } + .oc-script-code-clip { + overflow: hidden; + min-height: 0; + } + .oc-script-code-inner { + padding: 0 1rem 0.875rem 3.5rem; + } + + @media (max-width: 600px) { + /* Stack each row so nothing overlaps: number + chevron + label (+ action) on + the first line, the description/url wrapping full-width beneath. */ + .oc-script-line { + grid-template-columns: 1.25rem 0.875rem minmax(0, 1fr) auto; + grid-template-areas: + 'num chevron label action' + 'num chevron desc desc'; + row-gap: 0.25rem; + } + .oc-script-line > :nth-child(1) { + grid-area: num; + } + .oc-script-line > :nth-child(2) { + grid-area: chevron; + } + .oc-script-line > :nth-child(3) { + grid-area: label; + } + .oc-script-line > :nth-child(4) { + grid-area: desc; + } + .oc-script-line > :nth-child(5) { + grid-area: action; + } + .oc-script-label-cell { + flex-wrap: wrap; + } + .oc-script-step-label, + .oc-script-desc, + .oc-script-http-url { + white-space: normal; + overflow-wrap: anywhere; + } + .oc-script-code-inner { + padding: 0 1rem 0.875rem 1rem; + } + } + + @media (prefers-reduced-motion: reduce) { + .oc-script-code { + transition: none; + } + } + + /* ----- Variables card body ----- */ + .oc-vars-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + padding: 14px 16px; + } + .oc-vars-field-label { + font-size: 12px; + color: var(--text-muted); + margin-bottom: 6px; + } + .oc-vars-none { + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 10px 14px; + font-size: 12px; + color: var(--text-tertiary); + font-style: italic; + } + + @media (max-width: 900px) { + .oc-vars-grid { + grid-template-columns: 1fr; + } + } + + /* ----- Asserts card body ----- */ + .oc-assert-row { + display: flex; + align-items: center; + gap: 12px; + padding: 11px 16px; + } + .oc-assert-row + .oc-assert-row { + border-top: 1px solid var(--oc-border-border0); + } + .oc-assert-row.is-disabled { + opacity: 0.55; + } + .oc-assert-expr { + font-family: var(--font-mono); + font-size: 12.5px; + color: var(--text-primary); + } + + /* ----- Tests card body ----- */ + .oc-test-row + .oc-test-row { + border-top: 1px solid var(--oc-border-border0); + } + .oc-test-head { + display: flex; + align-items: center; + gap: 12px; + padding: 11px 16px; + cursor: pointer; + } + .oc-test-name { + font-family: var(--font-mono); + font-size: 12.5px; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .oc-test-spacer { + flex: 1; + } + + /* ----- ScopeTag pill ----- */ + .oc-scope-tag { + font-family: var(--font-mono); + font-size: 10.5px; + font-weight: 500; + padding: 2px 8px; + border-radius: 4px; + flex-shrink: 0; + } + .oc-scope-tag--request { + color: var(--oc-status-info-text); + background: color-mix(in srgb, var(--oc-status-info-text) 12%, transparent); + } + .oc-scope-tag--folder { + color: var(--oc-request-methods-head); + background: color-mix(in srgb, var(--oc-request-methods-head) 12%, transparent); + } + .oc-scope-tag--collection { + color: var(--oc-status-warning-text); + background: color-mix(in srgb, var(--oc-status-warning-text) 12%, transparent); + } +`; diff --git a/packages/oc-docs/src/components/ExecutionContext/TestList.tsx b/packages/oc-docs/src/components/ExecutionContext/TestList.tsx new file mode 100644 index 0000000..c1e9b52 --- /dev/null +++ b/packages/oc-docs/src/components/ExecutionContext/TestList.tsx @@ -0,0 +1,64 @@ +import React, { useState } from 'react'; +import { ScopeTag } from './ScopeTag'; +import { Code } from '../Code/Code'; +import type { TestRow } from '../../utils/extractTests'; + +interface TestListProps { + tests: TestRow[]; +} + +/** + * Body of a test: `TestRow` carries only the parsed title (no source code), so we + * render a representative `test(...)` snippet built from the title. + */ +const testCode = (test: TestRow): string => + `test("${test.name}", function() {\n expect(res.status).to.equal(200);\n});`; + +/** One test row: scope tag + mono name, expandable to reveal the test code. */ +const TestItem: React.FC<{ test: TestRow }> = ({ test }) => { + const [open, setOpen] = useState(false); + + return ( +
    +
    setOpen((v) => !v)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setOpen((v) => !v); + } + }} + > + + {test.name} + + +
    + {open && ( +
    + +
    + )} +
    + ); +}; + +/** Static list of test titles parsed from `test()`/`it()` blocks, with scope tags. */ +export const TestList: React.FC = ({ tests }) => { + if (tests.length === 0) return null; + + return ( + <> + {tests.map((test, index) => ( + + ))} + + ); +}; + +export default TestList; diff --git a/packages/oc-docs/src/components/ExecutionContext/VariablesPanel.tsx b/packages/oc-docs/src/components/ExecutionContext/VariablesPanel.tsx new file mode 100644 index 0000000..142ac27 --- /dev/null +++ b/packages/oc-docs/src/components/ExecutionContext/VariablesPanel.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { PropertyTable, type PropertyRow } from '../PropertyTable'; +import type { PreRequestVarRow, PostResponseVarRow } from '../../utils/requestVars'; + +interface VariablesPanelProps { + preVars: PreRequestVarRow[]; + postVars: PostResponseVarRow[]; +} + +const preRows = (vars: PreRequestVarRow[]): PropertyRow[] => + vars.map((v) => ({ label: v.name, value: v.value, disabled: v.disabled })); + +const postRows = (vars: PostResponseVarRow[]): PropertyRow[] => + vars.map((v) => ({ label: v.name, value: v.expression, disabled: v.disabled })); + +const Field: React.FC<{ label: string; rows: PropertyRow[] }> = ({ label, rows }) => ( +
    +
    {label}
    + {rows.length > 0 ? :
    None.
    } +
    +); + +/** Pre-request variables and post-response captures, side by side as key/value tables. */ +export const VariablesPanel: React.FC = ({ preVars, postVars }) => { + if (preVars.length === 0 && postVars.length === 0) return null; + + return ( +
    + + +
    + ); +}; + +export default VariablesPanel; diff --git a/packages/oc-docs/src/components/ExecutionContext/index.ts b/packages/oc-docs/src/components/ExecutionContext/index.ts new file mode 100644 index 0000000..d8ba71e --- /dev/null +++ b/packages/oc-docs/src/components/ExecutionContext/index.ts @@ -0,0 +1,9 @@ +export { ExecutionContext } from './ExecutionContext'; +export { default } from './ExecutionContext'; +export { ScriptChain } from './ScriptChain'; +export { ScriptStep } from './ScriptStep'; +export { FlowToggle } from './FlowToggle'; +export { VariablesPanel } from './VariablesPanel'; +export { AssertList } from './AssertList'; +export { TestList } from './TestList'; +export { ScopeTag } from './ScopeTag'; diff --git a/packages/oc-docs/src/components/Heading/Heading.spec.tsx b/packages/oc-docs/src/components/Heading/Heading.spec.tsx index 393e2d7..4f43575 100644 --- a/packages/oc-docs/src/components/Heading/Heading.spec.tsx +++ b/packages/oc-docs/src/components/Heading/Heading.spec.tsx @@ -15,4 +15,18 @@ describe('Heading', () => { expect(html).toContain(' tag contains both + // variant selectors regardless of which one is applied to the element). + it('defaults to the lg size variant', () => { + const html = renderToStaticMarkup(Title); + expect(html).toMatch(/class="[^"]*oc-heading--lg[^"]*"/); + expect(html).not.toMatch(/class="[^"]*oc-heading--md[^"]*"/); + }); + + it('applies the md size variant when requested', () => { + const html = renderToStaticMarkup(Login); + expect(html).toMatch(/class="[^"]*oc-heading--md[^"]*"/); + expect(html).not.toMatch(/class="[^"]*oc-heading--lg[^"]*"/); + }); }); diff --git a/packages/oc-docs/src/components/Heading/Heading.tsx b/packages/oc-docs/src/components/Heading/Heading.tsx index 5acea68..7de66d8 100644 --- a/packages/oc-docs/src/components/Heading/Heading.tsx +++ b/packages/oc-docs/src/components/Heading/Heading.tsx @@ -2,16 +2,24 @@ import React from 'react'; import { StyledWrapper } from './StyledWrapper'; type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; +type HeadingSize = 'lg' | 'md'; interface HeadingProps { children: React.ReactNode; as?: HeadingLevel; - testId?: string; + size?: HeadingSize; + style?: React.CSSProperties; className?: string; + testId?: string; } -export const Heading: React.FC = ({ children, as = 'h1', testId, className }) => ( - +export const Heading: React.FC = ({ children, as = 'h1', size = 'lg', style, className, testId }) => ( + {children} ); diff --git a/packages/oc-docs/src/components/Heading/StyledWrapper.ts b/packages/oc-docs/src/components/Heading/StyledWrapper.ts index f962138..daa839c 100644 --- a/packages/oc-docs/src/components/Heading/StyledWrapper.ts +++ b/packages/oc-docs/src/components/Heading/StyledWrapper.ts @@ -4,8 +4,17 @@ export const StyledWrapper = styled.h1` margin: 0; font-family: var(--font-sans); font-weight: 600; - font-size: 1.25rem; - line-height: 1; - letter-spacing: -0.5px; color: var(--text-primary); + + &.oc-heading--lg { + font-size: 1.25rem; + line-height: 1; + letter-spacing: -0.5px; + } + + &.oc-heading--md { + font-size: 1rem; + line-height: 1.5rem; + letter-spacing: 0; + } `; diff --git a/packages/oc-docs/src/components/HiddenSections/HiddenSections.tsx b/packages/oc-docs/src/components/HiddenSections/HiddenSections.tsx new file mode 100644 index 0000000..d0f5c05 --- /dev/null +++ b/packages/oc-docs/src/components/HiddenSections/HiddenSections.tsx @@ -0,0 +1,75 @@ +import React, { useEffect, useState } from 'react'; +import { HiddenSectionsWrapper } from './StyledWrapper'; + +interface HiddenSectionsProps { + /** + * Titles of the sections that are empty for the current item, in display order + * (e.g. ["Body", "Auth"]). Each becomes an "(empty)" card when revealed. + */ + titles: string[]; + className?: string; +} + +/** Eye / eye-off glyph (Tabler-style), matching the finalized design. */ +const EyeIcon: React.FC<{ crossed: boolean }> = ({ crossed }) => ( + +); + +/** + * Surfaces the sections that have no content for the current item behind a single + * toggle, so the page stays clean while still signalling the sections exist. Renders + * nothing when no section is hidden. Reusable across the request and Overview pages. + */ +export const HiddenSections: React.FC = ({ titles, className }) => { + const [open, setOpen] = useState(false); + + // Collapse again whenever the hidden set changes (e.g. navigating to another item). + const signature = titles.join('|'); + useEffect(() => { + setOpen(false); + }, [signature]); + + if (titles.length === 0) return null; + const count = titles.length; + + return ( + + + {open && + titles.map((title) => ( +
    +
    {title}
    +
    (empty)
    +
    + ))} +
    + ); +}; + +export default HiddenSections; diff --git a/packages/oc-docs/src/components/HiddenSections/StyledWrapper.ts b/packages/oc-docs/src/components/HiddenSections/StyledWrapper.ts new file mode 100644 index 0000000..0809cdb --- /dev/null +++ b/packages/oc-docs/src/components/HiddenSections/StyledWrapper.ts @@ -0,0 +1,66 @@ +import styled from '@emotion/styled'; + +/** + * Collapsible group for sections that are empty for the current item. An eye chip + * ("N hidden sections" → "These fields were hidden") reveals one labelled "(empty)" + * field per hidden section. Matches the finalized design and is reused on both the + * request page (Body/Auth/…) and the Overview (collection configuration). All sizes + * are in rem so the group scales with the root font size. + */ +export const HiddenSectionsWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 0.875rem; + + .oc-hidden-toggle { + display: inline-flex; + align-items: center; + gap: 0.375rem; + width: fit-content; + margin: 0; + padding: 0.25rem 0.4rem; + background: var(--oc-background-crust); + border: none; + border-radius: 0.25rem; + cursor: pointer; + font-family: var(--font-sans); + font-size: 0.78125rem; + line-height: 1; + color: var(--text-muted); + transition: color 0.12s ease; + } + .oc-hidden-toggle svg { + flex-shrink: 0; + } + .oc-hidden-toggle:hover { + color: var(--text-secondary); + } + .oc-hidden-toggle:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } + + /* Each hidden section: an uppercase label above a bordered "(empty)" field, + mirroring how populated, labelled fields read elsewhere on the page. */ + .oc-hidden-item-title { + font-family: var(--font-sans); + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.06em; + line-height: 1; + text-transform: uppercase; + color: var(--text-primary); + margin-bottom: 0.375rem; + } + .oc-hidden-item-box { + border: 1px solid var(--border-color); + border-radius: 0.5rem; + padding: 0.5rem 0.875rem; + font-family: var(--font-sans); + font-weight: 400; + font-size: 0.75rem; + line-height: 1.2; + letter-spacing: normal; + color: var(--text-tertiary); + } +`; diff --git a/packages/oc-docs/src/components/HiddenSections/index.ts b/packages/oc-docs/src/components/HiddenSections/index.ts new file mode 100644 index 0000000..61634f1 --- /dev/null +++ b/packages/oc-docs/src/components/HiddenSections/index.ts @@ -0,0 +1 @@ +export { HiddenSections, default } from './HiddenSections'; diff --git a/packages/oc-docs/src/components/LevelBadge/LevelBadge.spec.tsx b/packages/oc-docs/src/components/LevelBadge/LevelBadge.spec.tsx new file mode 100644 index 0000000..a65ef9b --- /dev/null +++ b/packages/oc-docs/src/components/LevelBadge/LevelBadge.spec.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import { LevelBadge } from './LevelBadge'; + +describe('LevelBadge', () => { + it('renders the level label', () => { + expect(renderToStaticMarkup()).toContain('folder'); + expect(renderToStaticMarkup()).toContain('request'); + }); +}); diff --git a/packages/oc-docs/src/components/LevelBadge/LevelBadge.tsx b/packages/oc-docs/src/components/LevelBadge/LevelBadge.tsx new file mode 100644 index 0000000..a47e806 --- /dev/null +++ b/packages/oc-docs/src/components/LevelBadge/LevelBadge.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { LevelBadgeWrapper } from './StyledWrapper'; + +export type ScriptLevel = 'request' | 'folder' | 'collection' | 'inherited'; + +interface LevelBadgeProps { + level: ScriptLevel; + className?: string; +} + +/** Small pill marking which scope an assert / test / script step comes from. */ +export const LevelBadge: React.FC = ({ level, className }) => ( + + {level} + +); + +export default LevelBadge; diff --git a/packages/oc-docs/src/components/LevelBadge/StyledWrapper.ts b/packages/oc-docs/src/components/LevelBadge/StyledWrapper.ts new file mode 100644 index 0000000..ae52d3f --- /dev/null +++ b/packages/oc-docs/src/components/LevelBadge/StyledWrapper.ts @@ -0,0 +1,16 @@ +import styled from '@emotion/styled'; + +export const LevelBadgeWrapper = styled.span` + display: inline-flex; + align-items: center; + padding: 0.05rem 0.4rem; + border-radius: 999px; + font-family: var(--font-sans); + font-weight: 500; + font-size: 0.625rem; + line-height: 1.4; + letter-spacing: 0.02em; + text-transform: lowercase; + color: var(--text-secondary); + background-color: var(--badge-bg); +`; diff --git a/packages/oc-docs/src/components/LevelBadge/index.ts b/packages/oc-docs/src/components/LevelBadge/index.ts new file mode 100644 index 0000000..ae17a9f --- /dev/null +++ b/packages/oc-docs/src/components/LevelBadge/index.ts @@ -0,0 +1,3 @@ +export { LevelBadge } from './LevelBadge'; +export type { ScriptLevel } from './LevelBadge'; +export { default } from './LevelBadge'; diff --git a/packages/oc-docs/src/components/MethodBadge/MethodBadge.spec.tsx b/packages/oc-docs/src/components/MethodBadge/MethodBadge.spec.tsx new file mode 100644 index 0000000..1fed42b --- /dev/null +++ b/packages/oc-docs/src/components/MethodBadge/MethodBadge.spec.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect } from 'vitest'; +import { MethodBadge } from './MethodBadge'; + +describe('MethodBadge', () => { + it('renders the method uppercased', () => { + expect(renderToStaticMarkup()).toContain('POST'); + }); + + it('defaults to GET when no method is given', () => { + expect(renderToStaticMarkup()).toContain('GET'); + }); +}); diff --git a/packages/oc-docs/src/components/MethodBadge/MethodBadge.tsx b/packages/oc-docs/src/components/MethodBadge/MethodBadge.tsx new file mode 100644 index 0000000..4658b8f --- /dev/null +++ b/packages/oc-docs/src/components/MethodBadge/MethodBadge.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { getMethodColorVar } from '../../theme/methodColors'; +import { MethodBadgeWrapper } from './StyledWrapper'; + +interface MethodBadgeProps { + method: string; + className?: string; +} + +/** Coloured HTTP method label (GET/POST/…), tinted via the theme method-color tokens. */ +export const MethodBadge: React.FC = ({ method, className }) => ( + + {(method || 'GET').toUpperCase()} + +); + +export default MethodBadge; diff --git a/packages/oc-docs/src/components/MethodBadge/StyledWrapper.ts b/packages/oc-docs/src/components/MethodBadge/StyledWrapper.ts new file mode 100644 index 0000000..89e24ee --- /dev/null +++ b/packages/oc-docs/src/components/MethodBadge/StyledWrapper.ts @@ -0,0 +1,11 @@ +import styled from '@emotion/styled'; + +export const MethodBadgeWrapper = styled.span` + display: inline-flex; + align-items: center; + font-family: var(--font-mono); + font-weight: 700; + font-size: 0.75rem; + letter-spacing: 0.02em; + text-transform: uppercase; +`; diff --git a/packages/oc-docs/src/components/MethodBadge/index.ts b/packages/oc-docs/src/components/MethodBadge/index.ts new file mode 100644 index 0000000..110ac08 --- /dev/null +++ b/packages/oc-docs/src/components/MethodBadge/index.ts @@ -0,0 +1,2 @@ +export { MethodBadge } from './MethodBadge'; +export { default } from './MethodBadge'; diff --git a/packages/oc-docs/src/components/Modal/Modal.spec.tsx b/packages/oc-docs/src/components/Modal/Modal.spec.tsx new file mode 100644 index 0000000..ec891e5 --- /dev/null +++ b/packages/oc-docs/src/components/Modal/Modal.spec.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, it, expect, vi } from 'vitest'; +import { Modal } from './Modal'; + +// Render the portal inline so the dialog markup is assertable in the node test +// environment (the real Portal defers to a DOM that doesn't exist during SSR). +vi.mock('../Portal', () => ({ + Portal: ({ children }: { children: React.ReactNode }) => children +})); + +describe('Modal', () => { + it('renders nothing while closed', () => { + const html = renderToStaticMarkup( + {}}> +

    body

    +
    + ); + expect(html).toBe(''); + }); + + it('renders an accessible dialog with the title, close button and children when open', () => { + const html = renderToStaticMarkup( + {}} title={Code snippet}> +

    snippet body

    +
    + ); + expect(html).toContain('role="dialog"'); + expect(html).toContain('aria-modal="true"'); + expect(html).toContain('Code snippet'); + expect(html).toContain('snippet body'); + // close button is always present and labelled + expect(html).toContain('aria-label="Close"'); + }); + + it('omits the title slot when no title is provided but keeps the close button', () => { + const html = renderToStaticMarkup( + {}} ariaLabel="Plain dialog"> +

    body

    +
    + ); + // The title element is not rendered (the class still appears in the injected