mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-29 15:44:13 +00:00
Merge pull request #6583 from naman-bruno/add/collection-docs
add: collection-docs
This commit is contained in:
@@ -38,6 +38,15 @@ const StyledWrapper = styled.div`
|
|||||||
color: ${(props) => props.theme.textLink};
|
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};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { getTotalRequestCountInCollection } from 'utils/collections/';
|
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 { areItemsLoading, getItemsLoadStats } from 'utils/collections/index';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useSelector, useDispatch } from 'react-redux';
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
import ShareCollection from 'components/ShareCollection/index';
|
import ShareCollection from 'components/ShareCollection/index';
|
||||||
|
import GenerateDocumentation from 'components/Sidebar/Collections/Collection/GenerateDocumentation';
|
||||||
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||||
import StyledWrapper from './StyledWrapper';
|
import StyledWrapper from './StyledWrapper';
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ const Info = ({ collection }) => {
|
|||||||
const isCollectionLoading = areItemsLoading(collection);
|
const isCollectionLoading = areItemsLoading(collection);
|
||||||
const { loading: itemsLoadingCount, total: totalItems } = getItemsLoadStats(collection);
|
const { loading: itemsLoadingCount, total: totalItems } = getItemsLoadStats(collection);
|
||||||
const [showShareCollectionModal, toggleShowShareCollectionModal] = useState(false);
|
const [showShareCollectionModal, toggleShowShareCollectionModal] = useState(false);
|
||||||
|
const [showGenerateDocumentationModal, setShowGenerateDocumentationModal] = useState(false);
|
||||||
|
|
||||||
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
|
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
|
||||||
|
|
||||||
@@ -111,6 +113,19 @@ const Info = ({ collection }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showShareCollectionModal && <ShareCollection collectionUid={collection.uid} onClose={handleToggleShowShareCollectionModal(false)} />}
|
{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>
|
||||||
</div>
|
</div>
|
||||||
</StyledWrapper>
|
</StyledWrapper>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -19,7 +19,8 @@ import {
|
|||||||
IconX,
|
IconX,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconTerminal2,
|
IconTerminal2,
|
||||||
IconFolder
|
IconFolder,
|
||||||
|
IconBook
|
||||||
} from '@tabler/icons';
|
} from '@tabler/icons';
|
||||||
import { toggleCollection, collapseFullCollection } from 'providers/ReduxStore/slices/collections';
|
import { toggleCollection, collapseFullCollection } from 'providers/ReduxStore/slices/collections';
|
||||||
import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop, pasteItem, showInFolder, saveCollectionSecurityConfig } from 'providers/ReduxStore/slices/collections/actions';
|
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 { areItemsLoading } from 'utils/collections';
|
||||||
import { scrollToTheActiveTab } from 'utils/tabs';
|
import { scrollToTheActiveTab } from 'utils/tabs';
|
||||||
import ShareCollection from 'components/ShareCollection/index';
|
import ShareCollection from 'components/ShareCollection/index';
|
||||||
|
import GenerateDocumentation from './GenerateDocumentation';
|
||||||
import { CollectionItemDragPreview } from './CollectionItem/CollectionItemDragPreview/index';
|
import { CollectionItemDragPreview } from './CollectionItem/CollectionItemDragPreview/index';
|
||||||
import { sortByNameThenSequence } from 'utils/common/index';
|
import { sortByNameThenSequence } from 'utils/common/index';
|
||||||
import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal';
|
import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal';
|
||||||
@@ -54,6 +56,7 @@ const Collection = ({ collection, searchText }) => {
|
|||||||
const [showRenameCollectionModal, setShowRenameCollectionModal] = useState(false);
|
const [showRenameCollectionModal, setShowRenameCollectionModal] = useState(false);
|
||||||
const [showCloneCollectionModalOpen, setShowCloneCollectionModalOpen] = useState(false);
|
const [showCloneCollectionModalOpen, setShowCloneCollectionModalOpen] = useState(false);
|
||||||
const [showShareCollectionModal, setShowShareCollectionModal] = useState(false);
|
const [showShareCollectionModal, setShowShareCollectionModal] = useState(false);
|
||||||
|
const [showGenerateDocumentationModal, setShowGenerateDocumentationModal] = useState(false);
|
||||||
const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false);
|
const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false);
|
||||||
const [dropType, setDropType] = useState(null);
|
const [dropType, setDropType] = useState(null);
|
||||||
const [isKeyboardFocused, setIsKeyboardFocused] = useState(false);
|
const [isKeyboardFocused, setIsKeyboardFocused] = useState(false);
|
||||||
@@ -339,6 +342,15 @@ const Collection = ({ collection, searchText }) => {
|
|||||||
setShowShareCollectionModal(true);
|
setShowShareCollectionModal(true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'generate-docs',
|
||||||
|
leftSection: IconBook,
|
||||||
|
label: 'Generate Docs',
|
||||||
|
onClick: () => {
|
||||||
|
ensureCollectionIsMounted();
|
||||||
|
setShowGenerateDocumentationModal(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'collapse',
|
id: 'collapse',
|
||||||
leftSection: IconFoldDown,
|
leftSection: IconFoldDown,
|
||||||
@@ -393,6 +405,9 @@ const Collection = ({ collection, searchText }) => {
|
|||||||
{showShareCollectionModal && (
|
{showShareCollectionModal && (
|
||||||
<ShareCollection collectionUid={collection.uid} onClose={() => setShowShareCollectionModal(false)} />
|
<ShareCollection collectionUid={collection.uid} onClose={() => setShowShareCollectionModal(false)} />
|
||||||
)}
|
)}
|
||||||
|
{showGenerateDocumentationModal && (
|
||||||
|
<GenerateDocumentation collectionUid={collection.uid} onClose={() => setShowGenerateDocumentationModal(false)} />
|
||||||
|
)}
|
||||||
{showCloneCollectionModalOpen && (
|
{showCloneCollectionModalOpen && (
|
||||||
<CloneCollection collectionUid={collection.uid} onClose={() => setShowCloneCollectionModalOpen(false)} />
|
<CloneCollection collectionUid={collection.uid} onClose={() => setShowCloneCollectionModalOpen(false)} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user