Merge pull request #6583 from naman-bruno/add/collection-docs

add: collection-docs
This commit is contained in:
naman-bruno
2026-01-01 17:01:31 +05:30
committed by GitHub
parent 8e74fa6233
commit 2c973bbd35
5 changed files with 270 additions and 2 deletions

View File

@@ -38,6 +38,15 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.textLink};
}
}
&.generate-docs {
background-color: ${(props) => rgba(props.theme.accents.primary, 0.08)};
border: 1px solid ${(props) => rgba(props.theme.accents.primary, 0.09)};
svg {
color: ${(props) => props.theme.accents.primary};
}
}
}
`;

View File

@@ -1,10 +1,11 @@
import React from 'react';
import { getTotalRequestCountInCollection } from 'utils/collections/';
import { IconFolder, IconWorld, IconApi, IconShare } from '@tabler/icons';
import { IconFolder, IconWorld, IconApi, IconShare, IconBook } from '@tabler/icons';
import { areItemsLoading, getItemsLoadStats } from 'utils/collections/index';
import { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import ShareCollection from 'components/ShareCollection/index';
import GenerateDocumentation from 'components/Sidebar/Collections/Collection/GenerateDocumentation';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import StyledWrapper from './StyledWrapper';
@@ -15,6 +16,7 @@ const Info = ({ collection }) => {
const isCollectionLoading = areItemsLoading(collection);
const { loading: itemsLoadingCount, total: totalItems } = getItemsLoadStats(collection);
const [showShareCollectionModal, toggleShowShareCollectionModal] = useState(false);
const [showGenerateDocumentationModal, setShowGenerateDocumentationModal] = useState(false);
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
@@ -111,6 +113,19 @@ const Info = ({ collection }) => {
</div>
</div>
{showShareCollectionModal && <ShareCollection collectionUid={collection.uid} onClose={handleToggleShowShareCollectionModal(false)} />}
<div className="flex items-start group cursor-pointer" onClick={() => setShowGenerateDocumentationModal(true)}>
<div className="icon-box generate-docs flex-shrink-0 p-3 rounded-lg">
<IconBook className="w-5 h-5" stroke={1.5} />
</div>
<div className="ml-4 h-full flex flex-col justify-start">
<div className="font-medium h-fit my-auto">Documentation</div>
<div className="group-hover:underline text-link">
Generate Docs
</div>
</div>
</div>
{showGenerateDocumentationModal && <GenerateDocumentation collectionUid={collection.uid} onClose={() => setShowGenerateDocumentationModal(false)} />}
</div>
</div>
</StyledWrapper>

View File

@@ -0,0 +1,48 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
.info-card {
border-radius: ${(props) => props.theme.border.radius.base};
background-color: ${(props) => rgba(props.theme.accents.primary, 0.06)};
border: 1px solid ${(props) => rgba(props.theme.accents.primary, 0.2)};
.info-icon {
color: ${(props) => props.theme.accents.primary};
}
.info-title {
font-weight: 500;
color: ${(props) => props.theme.text};
}
.info-description {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
line-height: 1.5;
}
}
.feature-item {
border-radius: ${(props) => props.theme.border.radius.base};
background-color: ${(props) => props.theme.background.base};
border: 1px solid ${(props) => props.theme.border.border0};
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.text};
.feature-icon {
color: ${(props) => props.theme.colors.text.green};
}
}
.note-section {
border-radius: ${(props) => props.theme.border.radius.base};
background-color: ${(props) => rgba(props.theme.colors.text.warning, 0.06)};
border: 1px solid ${(props) => rgba(props.theme.colors.text.warning, 0.2)};
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.warning};
line-height: 1.5;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,181 @@
import React from 'react';
import Modal from 'components/Modal';
import { IconCheck, IconInfoCircle, IconAlertTriangle, IconLoader2 } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import { cloneDeep } from 'lodash';
import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index';
import { useSelector } from 'react-redux';
import { findCollectionByUid, areItemsLoading } from 'utils/collections/index';
import { brunoToOpenCollection } from '@usebruno/converters';
import { sanitizeName } from 'utils/common/regex';
import * as FileSaver from 'file-saver';
import jsyaml from 'js-yaml';
import toast from 'react-hot-toast';
import { useApp } from 'providers/App';
import { escapeHtml } from 'utils/response';
const GenerateDocumentation = ({ onClose, collectionUid }) => {
const { version } = useApp();
const collection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid));
const isCollectionLoading = collection ? areItemsLoading(collection) : false;
if (!collection) {
return (
<Modal
size="md"
title="Generate Documentation"
confirmText="Close"
handleConfirm={onClose}
hideCancel={true}
>
<StyledWrapper className="w-[500px]">
<div className="flex flex-col gap-4">
<div className="note-section flex items-start gap-2 p-3">
<IconAlertTriangle size={16} className="shrink-0 mt-px" />
<span>Collection not found. It may have been deleted or is no longer available.</span>
</div>
</div>
</StyledWrapper>
</Modal>
);
}
const generateHtmlDocumentation = () => {
try {
const collectionCopy = cloneDeep(collection);
const transformedCollection = transformCollectionToSaveToExportAsFile(collectionCopy);
const openCollection = brunoToOpenCollection(transformedCollection);
if (!openCollection.extensions) {
openCollection.extensions = {};
}
if (!openCollection.extensions.bruno) {
openCollection.extensions.bruno = {};
}
openCollection.extensions.bruno.exportedAt = new Date().toISOString();
openCollection.extensions.bruno.exportedUsing = version ? `Bruno/${version}` : 'Bruno';
const yamlContent = jsyaml.dump(openCollection, {
indent: 2,
lineWidth: -1,
noRefs: true,
sortKeys: false
});
const escapedYaml = yamlContent
.replace(/\\/g, '\\\\')
.replace(/`/g, '\\`')
.replace(/\$/g, '\\$');
const escapedCollectionName = escapeHtml(collection.name);
const htmlContent = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapedCollectionName} - API Documentation</title>
<style>
body {
margin: 0;
padding: 0;
}
#opencollection-container {
width: 100vw;
height: 100vh;
}
</style>
<link rel="stylesheet" href="https://cdn.opencollection.com/core.css">
<script src="https://cdn.opencollection.com/playground.umd.js"></script>
</head>
<body>
<div id="opencollection-container"></div>
<script>
const collectionData = \`${escapedYaml}\`;
const container = document.getElementById('opencollection-container');
new window.OpenCollection({
target: container,
opencollection: collectionData,
theme: 'light'
});
</script>
</body>
</html>`;
const sanitizedName = sanitizeName(collection.name);
const fileName = `${sanitizedName}-documentation.html`;
const fileBlob = new Blob([htmlContent], { type: 'text/html' });
FileSaver.saveAs(fileBlob, fileName);
toast.success('Documentation generated successfully');
onClose();
} catch (error) {
console.error('Error generating documentation:', error);
toast.error('Failed to generate documentation');
}
};
return (
<Modal
size="md"
title="Generate Documentation"
confirmText={isCollectionLoading ? 'Loading...' : 'Generate'}
cancelText="Cancel"
handleConfirm={isCollectionLoading ? undefined : generateHtmlDocumentation}
handleCancel={onClose}
confirmDisabled={isCollectionLoading}
>
<StyledWrapper className="w-[500px]">
<div className="flex flex-col gap-4">
<div className="info-card flex items-start p-4 gap-3">
<div className="info-icon shrink-0">
{isCollectionLoading ? (
<IconLoader2 size={20} className="animate-spin" />
) : (
<IconInfoCircle size={20} />
)}
</div>
<div className="flex-1">
<div className="info-title mb-1">
{isCollectionLoading ? 'Loading collection...' : 'Interactive API Documentation'}
</div>
<div className="info-description">
{isCollectionLoading
? 'Please wait while the collection is being loaded.'
: 'Generate a standalone HTML file containing interactive documentation for your API collection. This file can be hosted anywhere or shared with your team.'}
</div>
</div>
</div>
{!isCollectionLoading && (
<>
<div className="flex flex-col gap-2 mt-2">
<div className="feature-item flex items-center gap-2 py-2 px-3">
<IconCheck size={16} className="feature-icon shrink-0" />
<span>Standalone HTML file - no server required</span>
</div>
<div className="feature-item flex items-center gap-2 py-2 px-3">
<IconCheck size={16} className="feature-icon shrink-0" />
<span>Interactive API playground</span>
</div>
<div className="feature-item flex items-center gap-2 py-2 px-3">
<IconCheck size={16} className="feature-icon shrink-0" />
<span>Host on any static file server</span>
</div>
</div>
<div className="note-section flex items-start gap-2 p-3">
<IconAlertTriangle size={16} className="shrink-0 mt-px" />
<span>
The generated file uses OpenCollection CDN for rendering. An internet connection is required when viewing the documentation.
</span>
</div>
</>
)}
</div>
</StyledWrapper>
</Modal>
);
};
export default GenerateDocumentation;

View File

@@ -19,7 +19,8 @@ import {
IconX,
IconSettings,
IconTerminal2,
IconFolder
IconFolder,
IconBook
} from '@tabler/icons';
import { toggleCollection, collapseFullCollection } from 'providers/ReduxStore/slices/collections';
import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop, pasteItem, showInFolder, saveCollectionSecurityConfig } from 'providers/ReduxStore/slices/collections/actions';
@@ -40,6 +41,7 @@ import CloneCollection from './CloneCollection';
import { areItemsLoading } from 'utils/collections';
import { scrollToTheActiveTab } from 'utils/tabs';
import ShareCollection from 'components/ShareCollection/index';
import GenerateDocumentation from './GenerateDocumentation';
import { CollectionItemDragPreview } from './CollectionItem/CollectionItemDragPreview/index';
import { sortByNameThenSequence } from 'utils/common/index';
import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal';
@@ -54,6 +56,7 @@ const Collection = ({ collection, searchText }) => {
const [showRenameCollectionModal, setShowRenameCollectionModal] = useState(false);
const [showCloneCollectionModalOpen, setShowCloneCollectionModalOpen] = useState(false);
const [showShareCollectionModal, setShowShareCollectionModal] = useState(false);
const [showGenerateDocumentationModal, setShowGenerateDocumentationModal] = useState(false);
const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false);
const [dropType, setDropType] = useState(null);
const [isKeyboardFocused, setIsKeyboardFocused] = useState(false);
@@ -339,6 +342,15 @@ const Collection = ({ collection, searchText }) => {
setShowShareCollectionModal(true);
}
},
{
id: 'generate-docs',
leftSection: IconBook,
label: 'Generate Docs',
onClick: () => {
ensureCollectionIsMounted();
setShowGenerateDocumentationModal(true);
}
},
{
id: 'collapse',
leftSection: IconFoldDown,
@@ -393,6 +405,9 @@ const Collection = ({ collection, searchText }) => {
{showShareCollectionModal && (
<ShareCollection collectionUid={collection.uid} onClose={() => setShowShareCollectionModal(false)} />
)}
{showGenerateDocumentationModal && (
<GenerateDocumentation collectionUid={collection.uid} onClose={() => setShowGenerateDocumentationModal(false)} />
)}
{showCloneCollectionModalOpen && (
<CloneCollection collectionUid={collection.uid} onClose={() => setShowCloneCollectionModalOpen(false)} />
)}