Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: collection search #3823

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import styled from 'styled-components';

const StyledWrapper = styled.div`
`;

export default StyledWrapper;
200 changes: 200 additions & 0 deletions packages/bruno-app/src/components/CollectionSearch/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import React, { useState, useMemo, useRef, useEffect } from 'react';
import Modal from 'components/Modal';

const searchCollection = (collection, term) => {
const results = [];
const search = (items, path = []) => {
items.forEach(item => {
const itemPath = [...path, item.name];

if (item.type === 'http-request') {
const matches = [];

// Search in name
if (item.name.toLowerCase().includes(term.toLowerCase())) {
matches.push({ type: 'name', value: item.name });
}

// Search in URL
if (item.request?.url?.toLowerCase().includes(term.toLowerCase())) {
matches.push({ type: 'url', value: item.request.url });
}

// Search in headers
item.request?.headers?.forEach(header => {
if (header.name.toLowerCase().includes(term.toLowerCase()) ||
header.value.toLowerCase().includes(term.toLowerCase())) {
matches.push({ type: 'header', value: `${header.name}: ${header.value}` });
}
});

// Search in body
if (item.request?.body?.mode === 'json' && item.request.body.json) {
const bodyJson = JSON.stringify(item.request.body.json);
if (bodyJson.toLowerCase().includes(term.toLowerCase())) {
matches.push({ type: 'body', value: bodyJson });
}
} else if (item.request?.body?.mode === 'text' && item.request.body.text) {
if (item.request.body.text.toLowerCase().includes(term.toLowerCase())) {
matches.push({ type: 'body', value: item.request.body.text });
}
}

// Search in script
if (item.request?.script?.req?.toLowerCase().includes(term.toLowerCase())) {
matches.push({ type: 'script', value: item.request.script.req });
}

// Search in assertions
item.request?.assertions?.forEach(assertion => {
if (assertion.name.toLowerCase().includes(term.toLowerCase()) ||
assertion.value.toLowerCase().includes(term.toLowerCase())) {
matches.push({ type: 'assertion', value: `${assertion.name}: ${assertion.value}` });
}
});

// Search in tests
if (item.request?.tests?.toLowerCase().includes(term.toLowerCase())) {
matches.push({ type: 'test', value: item.request.tests });
}

if (matches.length > 0) {
results.push({ item, path: itemPath, matches });
}
}

if (item.items) {
search(item.items, itemPath);
}
});
};
search(collection.items);
return results;
};

const HighlightedText = ({ text, highlight }) => {
const parts = text.split(new RegExp(`(${highlight})`, 'gi'));
return (
<span>
{parts.map((part, i) =>
part.toLowerCase() === highlight.toLowerCase() ?
<mark key={i} className="bg-yellow-200 text-gray-900">{part}</mark> :
part
)}
</span>
);
};

const CollectionSearch = ({ collection, onClose }) => {
const [searchTerm, setSearchTerm] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const resultsRef = useRef(null);
const inputRef = useRef(null);

const searchResults = useMemo(() => {
return searchTerm ? searchCollection(collection, searchTerm) : [];
}, [collection, searchTerm]);

useEffect(() => {
setSelectedIndex(searchResults.length > 0 ? 0 : -1);
}, [searchResults]);

const handleKeyDown = (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(prevIndex =>
prevIndex < searchResults.length - 1 ? prevIndex + 1 : prevIndex
);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(prevIndex => prevIndex > 0 ? prevIndex - 1 : 0);
}
};

useEffect(() => {
if (selectedIndex >= 0 && resultsRef.current) {
const selectedElement = resultsRef.current.children[selectedIndex];
if (selectedElement) {
selectedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
}, [selectedIndex]);

useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);

return (
<Modal size="md" title={'Search'} handleCancel={onClose} hideFooter={true}>
<div className="w-full max-w-md mx-auto">
<div className="relative">
<input
ref={inputRef}
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search HTTP requests..."
className="w-full px-4 py-2 text-gray-900 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none"
/>
<svg
className="absolute right-3 top-2.5 h-5 w-5 text-gray-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
clipRule="evenodd"
/>
</svg>
</div>
{searchResults.length > 0 && (
<div ref={resultsRef} className="mt-4 bg-white border border-gray-300 rounded-md shadow-sm max-h-96 overflow-y-auto">
{searchResults.map((result, index) => (
<div
key={index}
className={`px-4 py-3 cursor-pointer border-b border-gray-200 last:border-b-0 ${
index === selectedIndex ? 'bg-blue-100' : 'hover:bg-gray-100'
}`}
>
<div className="font-medium text-gray-900">
<HighlightedText text={result.item.name} highlight={searchTerm} />
{result.matches.some(m => m.type === 'name') && (
<span className="ml-2 text-xs font-normal text-gray-500">(name match)</span>
)}
</div>
<div className="text-sm text-gray-500 mb-2">
{result.path.slice(0, -1).join(' > ')}
</div>
{result.matches.filter(match => match.type !== 'name').map((match, matchIndex) => (
<div key={matchIndex} className="mt-1 text-sm flex items-center">
<span className={`px-2 py-1 rounded text-xs font-medium mr-2 ${
match.type === 'url' ? 'bg-green-100 text-green-800' :
match.type === 'header' ? 'bg-purple-100 text-purple-800' :
match.type === 'body' ? 'bg-yellow-100 text-yellow-800' :
match.type === 'script' ? 'bg-blue-100 text-blue-800' :
match.type === 'assertion' ? 'bg-indigo-100 text-indigo-800' :
match.type === 'test' ? 'bg-indigo-100 text-indigo-800' :
'bg-gray-100 text-gray-800'
}`}>
{match.type.charAt(0).toUpperCase() + match.type.slice(1)}
</span>
<span className="text-gray-600">
<HighlightedText text={match.value} highlight={searchTerm} />
</span>
</div>
))}
</div>
))}
</div>
)}
</div>
</Modal>
);
};

export default CollectionSearch;
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { uuid } from 'utils/common';
import { IconFiles, IconRun, IconEye, IconSettings } from '@tabler/icons';
import { IconFiles, IconRun, IconEye, IconSettings, IconSearch } from '@tabler/icons';
import EnvironmentSelector from 'components/Environments/EnvironmentSelector';
import GlobalEnvironmentSelector from 'components/GlobalEnvironments/EnvironmentSelector';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { useDispatch } from 'react-redux';
import ToolHint from 'components/ToolHint';
import StyledWrapper from './StyledWrapper';
import JsSandboxMode from 'components/SecuritySettings/JsSandboxMode';
import CollectionSearch from 'components/CollectionSearch';
import Mousetrap from 'mousetrap';

const CollectionToolBar = ({ collection }) => {
const dispatch = useDispatch();
const [searchModalOpen, setSearchModalOpen] = useState(false);

useEffect(() => {
Mousetrap.bind(['command+k', 'ctrl+k'], (e) => {
e.preventDefault();
handleSearch();
});

return () => {
Mousetrap.unbind(['command+k', 'ctrl+k']);
};
}, []);

const handleRun = () => {
dispatch(
Expand Down Expand Up @@ -42,17 +56,26 @@ const CollectionToolBar = ({ collection }) => {
);
};

const handleSearch = () => setSearchModalOpen(true);
const handleCloseSearch = () => setSearchModalOpen(false);

return (
<StyledWrapper>
{searchModalOpen && <CollectionSearch onClose={handleCloseSearch} collection={collection} />}
<div className="flex items-center p-2">
<div className="flex flex-1 items-center cursor-pointer hover:underline" onClick={viewCollectionSettings}>
<IconFiles size={18} strokeWidth={1.5} />
<span className="ml-2 mr-4 font-semibold">{collection?.name}</span>
</div>
<div className="flex flex-3 items-center justify-end">
<span className="mr-2">
<span className="mr-3">
<JsSandboxMode collection={collection} />
</span>
<span className="mr-3">
<ToolHint text="Search" toolhintId="SearchToolhintId" place='bottom'>
<IconSearch className="cursor-pointer" size={20} strokeWidth={1.5} onClick={handleSearch} />
</ToolHint>
</span>
<span className="mr-3">
<ToolHint text="Runner" toolhintId="RunnnerToolhintId" place='bottom'>
<IconRun className="cursor-pointer" size={18} strokeWidth={1.5} onClick={handleRun} />
Expand Down
Loading