Merge branch 'main' into feat/url-encoding-settings-refactor

This commit is contained in:
lohit
2025-07-15 14:47:58 +05:30
committed by GitHub
42 changed files with 1126 additions and 98 deletions

5
package-lock.json generated
View File

@@ -32959,9 +32959,14 @@
"axios": "^1.9.0"
},
"devDependencies": {
"@babel/preset-env": "^7.22.0",
"@babel/preset-typescript": "^7.22.0",
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-typescript": "^9.0.2",
"@types/jest": "^29.5.11",
"babel-jest": "^29.7.0",
"jest": "^29.2.0",
"rollup": "3.29.5",
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-peer-deps-external": "^2.2.4",

View File

@@ -187,17 +187,17 @@ export default class CodeEditor extends React.Component {
editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
editor.on('change', this._onEdit);
this.addOverlay();
const getAllVariablesHandler = () => getAllVariables(this.props.collection, this.props.item);
// Setup AutoComplete Helper for all modes
const autoCompleteOptions = {
showHintsFor: this.props.showHintsFor
showHintsFor: this.props.showHintsFor,
getAllVariables: getAllVariablesHandler
};
const getVariables = () => getAllVariables(this.props.collection, this.props.item);
this.brunoAutoCompleteCleanup = setupAutoComplete(
editor,
getVariables,
autoCompleteOptions
);
}

View File

@@ -74,18 +74,20 @@ class MultiLineEditor extends Component {
'Shift-Tab': false
}
});
const getAllVariablesHandler = () => getAllVariables(this.props.collection, this.props.item);
const getAnywordAutocompleteHints = () => this.props.autocomplete || [];
// Setup AutoComplete Helper
const autoCompleteOptions = {
showHintsFor: ['variables'],
anywordAutocompleteHints: this.props.autocomplete
getAllVariables: getAllVariablesHandler,
getAnywordAutocompleteHints
};
const getVariables = () => getAllVariables(this.props.collection, this.props.item);
this.brunoAutoCompleteCleanup = setupAutoComplete(
this.editor,
getVariables,
autoCompleteOptions
);

View File

@@ -19,6 +19,7 @@ import StyledWrapper from './StyledWrapper';
import Documentation from 'components/Documentation/index';
import GraphQLSchemaActions from '../GraphQLSchemaActions/index';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import Settings from 'components/RequestPane/Settings';
const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handleGqlClickReference }) => {
const dispatch = useDispatch();
@@ -101,6 +102,9 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
case 'docs': {
return <Documentation item={item} collection={collection} />;
}
case 'settings': {
return <Settings item={item} collection={collection} />;
}
default: {
return <div className="mt-4">404 | Not found</div>;
}
@@ -152,6 +156,9 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
<div className={getTabClassname('docs')} role="tab" onClick={() => selectTab('docs')}>
Docs
</div>
<div className={getTabClassname('settings')} role="tab" onClick={() => selectTab('settings')}>
Settings
</div>
<GraphQLSchemaActions item={item} collection={collection} onSchemaLoad={setSchema} toggleDocs={toggleDocs} />
</div>
<section className="flex w-full mt-5 flex-1 relative">

View File

@@ -101,6 +101,7 @@ const HttpRequestPane = ({ item, collection }) => {
const requestVars = getPropertyFromDraftOrRequest('request.vars.req');
const responseVars = getPropertyFromDraftOrRequest('request.vars.res');
const auth = getPropertyFromDraftOrRequest('request.auth');
const tags = getPropertyFromDraftOrRequest('tags');
const activeParamsLength = params.filter((param) => param.enabled).length;
const activeHeadersLength = headers.filter((header) => header.enabled).length;
@@ -164,6 +165,7 @@ const HttpRequestPane = ({ item, collection }) => {
</div>
<div className={getTabClassname('settings')} role="tab" onClick={() => selectTab('settings')}>
Settings
{tags && tags.length > 0 && <StatusDot />}
</div>
{focusedTab.requestPaneTab === 'body' ? (
<div className="flex flex-grow justify-end items-center">

View File

@@ -0,0 +1,63 @@
import React, { useCallback, useEffect } from 'react';
import get from 'lodash/get';
import { useDispatch } from 'react-redux';
import { addRequestTag, deleteRequestTag, updateCollectionTagsList } from 'providers/ReduxStore/slices/collections';
import TagList from 'components/TagList/index';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
const Tags = ({ item, collection }) => {
const dispatch = useDispatch();
// all tags in the collection
const collectionTags = collection.allTags || [];
// tags for the current request
const tags = item.draft ? get(item, 'draft.tags', []) : get(item, 'tags', []);
// Filter out tags that are already associated with the current request
const collectionTagsWithoutCurrentRequestTags = collectionTags?.filter(tag => !tags.includes(tag)) || [];
const handleAdd = useCallback((tag) => {
const trimmedTag = tag.trim();
if (trimmedTag && !tags.includes(trimmedTag)) {
dispatch(
addRequestTag({
tag: trimmedTag,
itemUid: item.uid,
collectionUid: collection.uid
})
);
}
}, [dispatch, tags, item.uid, collection.uid]);
const handleRemove = useCallback((tag) => {
dispatch(
deleteRequestTag({
tag,
itemUid: item.uid,
collectionUid: collection.uid
})
);
}, [dispatch, item.uid, collection.uid]);
const handleRequestSave = () => {
dispatch(saveRequest(item.uid, collection.uid));
}
useEffect(() => {
dispatch(updateCollectionTagsList({ collectionUid: collection.uid }));
}, [collection.uid, dispatch]);
return (
<div className="flex flex-col">
<TagList
tagsHintList={collectionTagsWithoutCurrentRequestTags}
handleAddTag={handleAdd}
handleRemoveTag={handleRemove}
tags={tags}
onSave={handleRequestSave}
/>
</div>
);
};
export default Tags;

View File

@@ -1,8 +1,10 @@
import React, { useState, useCallback } from 'react';
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import get from 'lodash/get';
import { IconTag } from '@tabler/icons';
import ToggleSelector from 'components/RequestPane/Settings/ToggleSelector';
import { updateItemSettings } from 'providers/ReduxStore/slices/collections';
import Tags from './Tags/index';
const Settings = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -22,7 +24,16 @@ const Settings = ({ item, collection }) => {
}, [encodeUrl, dispatch, collection.uid, item.uid]);
return (
<div className="h-full flex flex-col gap-2">
<div className="w-full h-full flex flex-col gap-10">
<div className='flex flex-col gap-2 max-w-[400px]'>
<h3 className="text-xs font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1">
<IconTag size={16} />
Tags
</h3>
<div label="Tags">
<Tags item={item} collection={collection} />
</div>
</div>
<div className='flex flex-col gap-4'>
<ToggleSelector
checked={encodeUrl}

View File

@@ -0,0 +1,128 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { get, cloneDeep, find } from 'lodash';
import { updateCollectionTagsList, updateRunnerTagsDetails } from 'providers/ReduxStore/slices/collections';
import TagList from 'components/TagList';
const RunnerTags = ({ collectionUid }) => {
const dispatch = useDispatch();
const collections = useSelector((state) => state.collections.collections);
const collection = cloneDeep(find(collections, (c) => c.uid === collectionUid));
// tags for the collection run
const tags = get(collection, 'runnerTags', { include: [], exclude: [] });
// have tags been enabled for the collection run
const tagsEnabled = get(collection, 'runnerTagsEnabled', false);
// all available tags in the collection that can be used for filtering
const availableTags = get(collection, 'allTags', []);
const tagsHintList = availableTags.filter(t => !tags.exclude.includes(t) && !tags.include.includes(t));
useEffect(() => {
dispatch(updateCollectionTagsList({ collectionUid }));
}, [collection.uid, dispatch]);
const handleValidation = (tag) => {
const trimmedTag = tag.trim();
if (!availableTags.includes(trimmedTag)) {
return 'tag does not exist!';
}
if (tags.include.includes(trimmedTag)) {
return 'tag already present in the include list!';
}
if (tags.exclude.includes(trimmedTag)) {
return 'tag is present in the exclude list!';
}
}
const handleAddTag = ({ tag, to }) => {
const trimmedTag = tag.trim();
if (!trimmedTag) return;
// add tag to the `include` list
if (to === 'include') {
if (tags.include.includes(trimmedTag) || tags.exclude.includes(trimmedTag)) return;
if (!availableTags.includes(trimmedTag)) {
return;
}
const newTags = { ...tags, include: [...tags.include, trimmedTag].sort() };
setTags(newTags);
return;
}
// add tag to the `exclude` list
if (to === 'exclude') {
if (tags.include.includes(trimmedTag) || tags.exclude.includes(trimmedTag)) return;
if (!availableTags.includes(trimmedTag)) {
return;
}
const newTags = { ...tags, exclude: [...tags.exclude, trimmedTag].sort() };
setTags(newTags);
}
};
const handleRemoveTag = ({ tag, from }) => {
const trimmedTag = tag.trim();
if (!trimmedTag) return;
// remove tag from the `include` list
if (from === 'include') {
if (!tags.include.includes(trimmedTag)) return;
const newTags = { ...tags, include: tags.include.filter((t) => t !== trimmedTag) };
setTags(newTags);
return;
}
// remove tag from the `exclude` list
if (from === 'exclude') {
if (!tags.exclude.includes(trimmedTag)) return;
const newTags = { ...tags, exclude: tags.exclude.filter((t) => t !== trimmedTag) };
setTags(newTags);
}
};
const setTags = (tags) => {
dispatch(updateRunnerTagsDetails({ collectionUid: collection.uid, tags }));
};
const setTagsEnabled = (tagsEnabled) => {
dispatch(updateRunnerTagsDetails({ collectionUid: collection.uid, tagsEnabled }));
};
return (
<div className="mt-6 flex flex-col">
<div className="flex gap-2">
<label className="block font-medium">Filter requests with tags</label>
<input
className="cursor-pointer"
type="checkbox"
checked={tagsEnabled}
onChange={() => setTagsEnabled(!tagsEnabled)}
/>
</div>
{tagsEnabled && (
<div className="flex flex-row mt-4 gap-4 w-full">
<div className="w-1/2 flex flex-col gap-2 max-w-[400px]">
<span>Included tags:</span>
<TagList
tags={tags.include}
handleAddTag={tag => handleAddTag({ tag, to: 'include' })}
handleRemoveTag={tag => handleRemoveTag({ tag, from: 'include' })}
tagsHintList={tagsHintList}
handleValidation={handleValidation}
/>
</div>
<div className="w-1/2 flex flex-col gap-2 max-w-[400px]">
<span>Excluded tags:</span>
<TagList
tags={tags.exclude}
handleAddTag={tag => handleAddTag({ tag, to: 'exclude' })}
handleRemoveTag={tag => handleRemoveTag({ tag, from: 'exclude' })}
tagsHintList={tagsHintList}
handleValidation={handleValidation}
/>
</div>
</div>
)}
</div>
)
}
export default RunnerTags;

View File

@@ -9,6 +9,7 @@ import { IconRefresh, IconCircleCheck, IconCircleX, IconCircleOff, IconCheck, Ic
import ResponsePane from './ResponsePane';
import StyledWrapper from './StyledWrapper';
import { areItemsLoading } from 'utils/collections';
import RunnerTags from './RunnerTags/index';
const getDisplayName = (fullPath, pathname, name = '') => {
let relativePath = path.relative(fullPath, pathname);
@@ -63,6 +64,15 @@ export default function RunnerResults({ collection }) {
const collectionCopy = cloneDeep(collection);
const runnerInfo = get(collection, 'runnerResult.info', {});
// tags for the collection run
const tags = get(collection, 'runnerTags', { include: [], exclude: [] });
// have tags been enabled for the collection run
const tagsEnabled = get(collection, 'runnerTagsEnabled', false);
// have tags been added for the collection run
const areTagsAdded = tags.include.length > 0 || tags.exclude.length > 0;
const items = cloneDeep(get(collection, 'runnerResult.items', []))
.map((item) => {
const info = findItemInCollection(collectionCopy, item.uid);
@@ -75,7 +85,8 @@ export default function RunnerResults({ collection }) {
type: info.type,
filename: info.filename,
pathname: info.pathname,
displayName: getDisplayName(collection.pathname, info.pathname, info.name)
displayName: getDisplayName(collection.pathname, info.pathname, info.name),
tags: [...(info.request?.tags || [])].sort(),
};
if (newItem.status !== 'error' && newItem.status !== 'skipped') {
newItem.testStatus = getTestStatus(newItem.testResults);
@@ -88,11 +99,19 @@ export default function RunnerResults({ collection }) {
.filter(Boolean);
const runCollection = () => {
dispatch(runCollectionFolder(collection.uid, null, true, Number(delay)));
dispatch(runCollectionFolder(collection.uid, null, true, Number(delay), tagsEnabled && tags));
};
const runAgain = () => {
dispatch(runCollectionFolder(collection.uid, runnerInfo.folderUid, runnerInfo.isRecursive, Number(delay)));
dispatch(
runCollectionFolder(
collection.uid,
runnerInfo.folderUid,
runnerInfo.isRecursive,
Number(delay),
tagsEnabled && tags
)
);
};
const resetRunner = () => {
@@ -141,6 +160,9 @@ export default function RunnerResults({ collection }) {
/>
</div>
{/* Tags for the collection run */}
<RunnerTags collectionUid={collection.uid} />
<button type="submit" className="submit btn btn-sm btn-secondary mt-6" onClick={runCollection}>
Run Collection
</button>
@@ -174,11 +196,25 @@ export default function RunnerResults({ collection }) {
Total Requests: {items.length}, Passed: {passedRequests.length}, Failed: {failedRequests.length}, Skipped:{' '}
{skippedRequests.length}
</div>
{tagsEnabled && areTagsAdded && (
<div className="pb-2 text-xs flex flex-row gap-1">
Tags:
<div className='flex flex-row items-center gap-x-2'>
<div className="text-green-500">
{tags.include.join(', ')}
</div>
<div className="text-gray-500">
{tags.exclude.join(', ')}
</div>
</div>
</div>
)}
{runnerInfo?.statusText ?
<div className="pb-2 font-medium danger">
{runnerInfo?.statusText}
</div>
: null}
{items.map((item) => {
return (
<div key={item.uid}>
@@ -214,6 +250,11 @@ export default function RunnerResults({ collection }) {
</span>
)}
</div>
{tagsEnabled && areTagsAdded && item?.tags?.length > 0 && (
<div className="pl-7 text-xs text-gray-500">
Tags: {item.tags.filter(t => tags.include.includes(t)).join(', ')}
</div>
)}
{item.status == 'error' ? <div className="error-message pl-8 pt-2 text-xs">{item.error}</div> : null}
<ul className="pl-8">

View File

@@ -8,6 +8,7 @@ import { runCollectionFolder } from 'providers/ReduxStore/slices/collections/act
import { flattenItems } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import { areItemsLoading } from 'utils/collections';
import RunnerTags from 'components/RunnerResults/RunnerTags/index';
const RunCollectionItem = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch();
@@ -15,6 +16,12 @@ const RunCollectionItem = ({ collectionUid, item, onClose }) => {
const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
const isCollectionRunInProgress = collection?.runnerResult?.info?.status && (collection?.runnerResult?.info?.status !== 'ended');
// tags for the collection run
const tags = get(collection, 'runnerTags', { include: [], exclude: [] });
// have tags been enabled for the collection run
const tagsEnabled = get(collection, 'runnerTagsEnabled', false);
const onSubmit = (recursive) => {
dispatch(
addTab({
@@ -24,7 +31,7 @@ const RunCollectionItem = ({ collectionUid, item, onClose }) => {
})
);
if (!isCollectionRunInProgress) {
dispatch(runCollectionFolder(collection.uid, item ? item.uid : null, recursive));
dispatch(runCollectionFolder(collection.uid, item ? item.uid : null, recursive, 0, tagsEnabled && tags));
}
onClose();
};
@@ -71,6 +78,10 @@ const RunCollectionItem = ({ collectionUid, item, onClose }) => {
<div className={isFolderLoading ? "mb-2" : "mb-8"}>This will run all the requests in this folder and all its subfolders.</div>
{isFolderLoading ? <div className='mb-8 warning'>Requests in this folder are still loading.</div> : null}
{isCollectionRunInProgress ? <div className='mb-6 warning'>A Collection Run is already in progress.</div> : null}
{/* Tags for the collection run */}
<RunnerTags collectionUid={collection.uid} />
<div className="flex justify-end bruno-modal-footer">
<span className="mr-3">
<button type="button" onClick={onClose} className="btn btn-md btn-close">

View File

@@ -74,17 +74,19 @@ class SingleLineEditor extends Component {
}
});
const getAllVariablesHandler = () => getAllVariables(this.props.collection, this.props.item);
const getAnywordAutocompleteHints = () => this.props.autocomplete || [];
// Setup AutoComplete Helper
const autoCompleteOptions = {
showHintsFor: ['variables'],
anywordAutocompleteHints: this.props.autocomplete
getAllVariables: getAllVariablesHandler,
getAnywordAutocompleteHints,
showHintsFor: this.props.showHintsFor || ['variables'],
showHintsOnClick: this.props.showHintsOnClick
};
const getVariables = () => getAllVariables(this.props.collection, this.props.item);
this.brunoAutoCompleteCleanup = setupAutoComplete(
this.editor,
getVariables,
autoCompleteOptions
);
@@ -189,7 +191,7 @@ class SingleLineEditor extends Component {
render() {
return (
<div className="flex flex-row justify-between w-full overflow-x-auto">
<div className={`flex flex-row justify-between w-full overflow-x-auto ${this.props.className}`}>
<StyledWrapper ref={this.editorRef} className="single-line-editor grow" />
{this.secretEye(this.props.isSecret)}
</div>

View File

@@ -0,0 +1,132 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-height: 40px;
padding: 8px 0;
}
.tag-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 7px;
background-color: ${(props) => props.theme.sidebar.bg};
border: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
border-radius: 3px;
font-size: 12px;
font-weight: 500;
color: ${(props) => props.theme.text};
max-width: 200px;
transition: all 0.2s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
&:hover {
background-color: ${(props) => props.theme.requestTabs.active.bg};
border-color: ${(props) => props.theme.requestTabs.active.border || props.theme.requestTabs.bottomBorder};
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
}
.tag-icon {
color: ${(props) => props.theme.textSecondary || props.theme.text};
opacity: 0.7;
flex-shrink: 0;
}
.tag-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.tag-remove {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
padding: 2px;
border-radius: 3px;
color: ${(props) => props.theme.textSecondary || props.theme.text};
transition: all 0.2s ease;
flex-shrink: 0;
opacity: 0.7;
&:hover {
background-color: ${(props) => props.theme.danger};
color: white;
opacity: 1;
transform: scale(1.1);
}
&:focus-visible {
outline: 2px solid ${(props) => props.theme.danger};
outline-offset: 1px;
}
}
.empty-state {
display: flex;
align-items: center;
gap: 12px;
padding: 24px 16px;
background-color: ${(props) => props.theme.sidebar.bg};
border: 2px dashed ${(props) => props.theme.requestTabs.bottomBorder};
border-radius: 3px;
color: ${(props) => props.theme.textSecondary || props.theme.text};
text-align: left;
}
.empty-icon {
opacity: 0.5;
flex-shrink: 0;
}
.empty-text {
flex: 1;
min-width: 0;
}
.empty-title {
font-weight: 600;
margin: 0 0 4px 0;
font-size: 14px;
color: ${(props) => props.theme.text};
}
.empty-subtitle {
margin: 0;
font-size: 12px;
opacity: 0.8;
line-height: 1.5;
color: ${(props) => props.theme.textSecondary || props.theme.text};
}
/* Responsive design */
@media (max-width: 480px) {
.tags-container {
gap: 6px;
}
.tag-item {
padding: 4px 8px;
font-size: 11px;
}
.empty-state {
padding: 16px 12px;
flex-direction: column;
text-align: center;
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,77 @@
import { useState } from 'react';
import { IconX, IconTag } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import SingleLineEditor from 'components/SingleLineEditor/index';
import { useTheme } from 'providers/Theme/index';
const TagList = ({ tagsHintList = [], handleAddTag, tags, handleRemoveTag, onSave, handleValidation }) => {
const { displayedTheme } = useTheme();
const tagNameRegex = /^[\w-]+$/;
const [text, setText] = useState('');
const [error, setError] = useState('');
const handleInputChange = (value) => {
setError('');
setText(value);
};
const handleKeyDown = (e) => {
if (!tagNameRegex.test(text)) {
setError('Tags must only contain alpha-numeric characters, "-", "_"');
return;
}
if (tags.includes(text)) {
setError(`Tag "${text}" already exists`);
return;
}
if (handleValidation) {
const error = handleValidation(text);
if (error) {
setError(error);
setText('');
return;
}
}
handleAddTag(text);
setText('');
};
return (
<StyledWrapper className="flex flex-wrap flex-col gap-2">
<SingleLineEditor
className="border border-gray-500/50 px-2"
value={text}
placeholder="Enter tag name (e.g., smoke, regression etc)"
autocomplete={tagsHintList}
showHintsOnClick={true}
showHintsFor={[]}
theme={displayedTheme}
onChange={handleInputChange}
onRun={handleKeyDown}
onSave={onSave}
/>
{error && <span className='text-xs text-red-500'>{error}</span>}
<ul className="flex flex-wrap gap-1">
{tags && tags.length
? tags.map((_tag) => (
<li key={_tag}>
<button
className="tag-item"
onClick={() => handleRemoveTag(_tag)}
type="button"
>
<IconTag size={12} className="tag-icon" aria-hidden="true" />
<span className="tag-text" title={_tag}>
{_tag}
</span>
<IconX size={12} strokeWidth={2} aria-hidden="true" />
</button>
</li>
))
: null}
</ul>
</StyledWrapper>
);
};
export default TagList;

View File

@@ -316,7 +316,7 @@ export const cancelRunnerExecution = (cancelTokenUid) => (dispatch) => {
cancelNetworkRequest(cancelTokenUid).catch((err) => console.log(err));
};
export const runCollectionFolder = (collectionUid, folderUid, recursive, delay) => (dispatch, getState) => {
export const runCollectionFolder = (collectionUid, folderUid, recursive, delay, tags) => (dispatch, getState) => {
const state = getState();
const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments;
const collection = findCollectionByUid(state.collections.collections, collectionUid);
@@ -355,7 +355,8 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive, delay)
environment,
collectionCopy.runtimeVariables,
recursive,
delay
delay,
tags
)
.then(resolve)
.catch((err) => {

View File

@@ -21,6 +21,7 @@ import { getSubdirectoriesFromRoot } from 'utils/common/platform';
import toast from 'react-hot-toast';
import mime from 'mime-types';
import path from 'utils/common/path';
import { getUniqueTagsFromItems } from 'utils/collections/index';
const initialState = {
collections: [],
@@ -37,6 +38,7 @@ export const collectionsSlice = createSlice({
collection.settingsSelectedTab = 'overview';
collection.folderLevelSettingsSelectedTab = {};
collection.allTags = []; // Initialize collection-level tags
// Collection mount status is used to track the mount status of the collection
// values can be 'unmounted', 'mounting', 'mounted'
@@ -1861,6 +1863,7 @@ export const collectionsSlice = createSlice({
currentItem.name = file.data.name;
currentItem.type = file.data.type;
currentItem.seq = file.data.seq;
currentItem.tags = file.data.tags;
currentItem.request = file.data.request;
currentItem.filename = file.meta.name;
currentItem.pathname = file.meta.pathname;
@@ -1876,6 +1879,7 @@ export const collectionsSlice = createSlice({
name: file.data.name,
type: file.data.type,
seq: file.data.seq,
tags: file.data.tags,
request: file.data.request,
settings: file.data.settings,
filename: file.meta.name,
@@ -1966,6 +1970,7 @@ export const collectionsSlice = createSlice({
item.name = file.data.name;
item.type = file.data.type;
item.seq = file.data.seq;
item.tags = file.data.tags;
item.request = file.data.request;
item.settings = file.data.settings;
item.filename = file.meta.name;
@@ -2225,6 +2230,20 @@ export const collectionsSlice = createSlice({
if (collection) {
collection.runnerResult = null;
collection.runnerTags = { include: [], exclude: [] }
collection.runnerTagsEnabled = false;
}
},
updateRunnerTagsDetails: (state, action) => {
const { collectionUid, tags, tagsEnabled } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
if (tags) {
collection.runnerTags = tags;
}
if (typeof tagsEnabled === 'boolean') {
collection.runnerTagsEnabled = tagsEnabled;
}
}
},
updateRequestDocs: (state, action) => {
@@ -2340,9 +2359,55 @@ export const collectionsSlice = createSlice({
set(folder, 'root.request.auth', {});
set(folder, 'root.request.auth.mode', action.payload.mode);
}
}
},
},
addRequestTag: (state, action) => {
const { tag, collectionUid, itemUid } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
const item = findItemInCollection(collection, itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
item.draft = cloneDeep(item);
}
item.draft.tags = item.draft.tags || [];
if (!item.draft.tags.includes(tag.trim())) {
item.draft.tags.push(tag.trim());
}
collection.allTags = getUniqueTagsFromItems(collection.items);
}
}
},
deleteRequestTag: (state, action) => {
const { tag, collectionUid, itemUid } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
const item = findItemInCollection(collection, itemUid);
if (item && isItemARequest(item)) {
if (!item.draft) {
item.draft = cloneDeep(item);
}
item.draft.tags = item.draft.tags || [];
item.draft.tags = item.draft.tags.filter((t) => t !== tag.trim());
collection.allTags = getUniqueTagsFromItems(collection.items);
}
}
},
updateCollectionTagsList: (state, action) => {
const { collectionUid } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
collection.allTags = getUniqueTagsFromItems(collection.items);
}
}
}
});
export const {
@@ -2450,6 +2515,7 @@ export const {
runRequestEvent,
runFolderEvent,
resetCollectionRunner,
updateRunnerTagsDetails,
updateRequestDocs,
updateFolderDocs,
moveCollection,
@@ -2458,6 +2524,9 @@ export const {
collectionGetOauth2CredentialsByUrl,
updateFolderAuth,
updateFolderAuthMode,
addRequestTag,
deleteRequestTag,
updateCollectionTagsList
} = collectionsSlice.actions;
export default collectionsSlice.reducer;

View File

@@ -178,11 +178,11 @@ const addVariableHintsToSet = (variableHints, allVariables) => {
/**
* Add custom hints to categorized hints
* @param {Set} anywordHints - Set to add custom hints to
* @param {Object} options - Options containing custom hints
* @param {string[]} customHints - Array of custom hints
*/
const addCustomHintsToSet = (anywordHints, options) => {
if (options.anywordAutocompleteHints && Array.isArray(options.anywordAutocompleteHints)) {
options.anywordAutocompleteHints.forEach(hint => {
const addCustomHintsToSet = (anywordHints, customHints) => {
if (customHints && Array.isArray(customHints)) {
customHints.forEach(hint => {
generateProgressiveHints(hint).forEach(h => anywordHints.add(h));
});
}
@@ -191,10 +191,11 @@ const addCustomHintsToSet = (anywordHints, options) => {
/**
* Build categorized hints list from all sources
* @param {Object} allVariables - All available variables
* @param {string[]} anywordAutocompleteHints - Custom autocomplete hints
* @param {Object} options - Configuration options
* @returns {Object} Categorized hints object
*/
const buildCategorizedHintsList = (allVariables = {}, options = {}) => {
const buildCategorizedHintsList = (allVariables = {}, anywordAutocompleteHints = [], options = {}) => {
const categorizedHints = {
api: new Set(),
variables: new Set(),
@@ -206,7 +207,7 @@ const buildCategorizedHintsList = (allVariables = {}, options = {}) => {
// Add different types of hints
addApiHintsToSet(categorizedHints.api, showHintsFor);
addVariableHintsToSet(categorizedHints.variables, allVariables);
addCustomHintsToSet(categorizedHints.anyword, options);
addCustomHintsToSet(categorizedHints.anyword, anywordAutocompleteHints);
return {
api: Array.from(categorizedHints.api).sort(),
@@ -499,10 +500,11 @@ const createStandardHintList = (filteredHints, from, to) => {
* Bruno AutoComplete Helper - Main function with context awareness
* @param {Object} cm - CodeMirror instance
* @param {Object} allVariables - All available variables
* @param {string[]} anywordAutocompleteHints - Custom autocomplete hints
* @param {Object} options - Configuration options
* @returns {Object|null} Hint object or null
*/
export const getAutoCompleteHints = (cm, allVariables = {}, options = {}) => {
export const getAutoCompleteHints = (cm, allVariables = {}, anywordAutocompleteHints = [], options = {}) => {
if (!allVariables) {
return null;
}
@@ -513,14 +515,14 @@ export const getAutoCompleteHints = (cm, allVariables = {}, options = {}) => {
}
const { word, from, to, context, requiresBraces } = wordInfo;
const showHintsFor = options.showHintsFor || [];
const showHintsFor = options.showHintsFor || [];
// Check if this context requires braces but we're not in a brace context
if (context === 'variables' && !requiresBraces) {
return null;
}
const categorizedHints = buildCategorizedHintsList(allVariables, options);
const categorizedHints = buildCategorizedHintsList(allVariables, anywordAutocompleteHints, options);
const filteredHints = filterHintsByContext(categorizedHints, word, context, showHintsFor);
if (filteredHints.length === 0) {
@@ -534,21 +536,75 @@ export const getAutoCompleteHints = (cm, allVariables = {}, options = {}) => {
return createStandardHintList(filteredHints, from, to);
};
/**
* Handle click events for autocomplete
* @param {Object} cm - CodeMirror instance
* @param {Object} options - Configuration options
*/
const handleClickForAutocomplete = (cm, options) => {
const allVariables = options.getAllVariables?.() || {};
const anywordAutocompleteHints = options.getAnywordAutocompleteHints?.() || [];
const showHintsFor = options.showHintsFor || [];
// Build all available hints
const categorizedHints = buildCategorizedHintsList(allVariables, anywordAutocompleteHints, options);
// Combine all hints based on showHintsFor configuration
let allHints = [];
// Add API hints if enabled
const hasApiHints = showHintsFor.some(hint => ['req', 'res', 'bru'].includes(hint));
if (hasApiHints) {
allHints = [...allHints, ...categorizedHints.api];
}
// Add variable hints if enabled
if (showHintsFor.includes('variables')) {
allHints = [...allHints, ...categorizedHints.variables];
}
// Add anyword hints (always included)
allHints = [...allHints, ...categorizedHints.anyword];
// Remove duplicates and sort
allHints = [...new Set(allHints)].sort();
if (allHints.length === 0) {
return;
}
const cursor = cm.getCursor();
if (cursor.ch > 0) return;
// Defer showHint to ensure editor is focused
setTimeout(() => {
cm.showHint({
hint: () => ({
list: allHints,
from: cursor,
to: cursor
}),
completeSingle: false
});
}, 0);
};
/**
* Handle keyup events for autocomplete
* @param {Object} cm - CodeMirror instance
* @param {Event} event - The keyup event
* @param {Function} getAllVariablesFunc - Function to get all variables
* @param {Object} options - Configuration options
*/
const handleKeyupForAutocomplete = (cm, event, getAllVariablesFunc, options) => {
const handleKeyupForAutocomplete = (cm, event, options) => {
// Skip non-character keys
if (!NON_CHARACTER_KEYS.test(event?.key)) {
return;
}
const allVariables = getAllVariablesFunc();
const hints = getAutoCompleteHints(cm, allVariables, options);
const allVariables = options.getAllVariables?.() || {};
const anywordAutocompleteHints = options.getAnywordAutocompleteHints?.() || [];
const hints = getAutoCompleteHints(cm, allVariables, anywordAutocompleteHints, options);
if (!hints) {
if (cm.state.completionActive) {
@@ -566,23 +622,37 @@ const handleKeyupForAutocomplete = (cm, event, getAllVariablesFunc, options) =>
/**
* Setup Bruno AutoComplete Helper on a CodeMirror editor
* @param {Object} editor - CodeMirror editor instance
* @param {Function} getAllVariablesFunc - Function to get all variables
* @param {Object} options - Configuration options
* @returns {Function} Cleanup function
*/
export const setupAutoComplete = (editor, getAllVariablesFunc, options = {}) => {
export const setupAutoComplete = (editor, options = {}) => {
if (!editor) {
return;
}
const keyupHandler = (cm, event) => {
handleKeyupForAutocomplete(cm, event, getAllVariablesFunc, options);
handleKeyupForAutocomplete(cm, event, options);
};
editor.on('keyup', keyupHandler);
const clickHandler = (cm) => {
// Only show hints on click if the option is enabled and there's no active completion
if (options.showHintsOnClick) {
handleClickForAutocomplete(cm, options);
}
};
// Add click handler if showHintsOnClick is enabled
if (options.showHintsOnClick) {
editor.on('mousedown', clickHandler);
}
return () => {
editor.off('keyup', keyupHandler);
if (options.showHintsOnClick) {
editor.off('mousedown', clickHandler);
}
};
};

View File

@@ -43,7 +43,7 @@ describe('Bruno Autocomplete', () => {
envVar2: 'value2',
};
const result = getAutoCompleteHints(mockedCodemirror, allVariables, {
const result = getAutoCompleteHints(mockedCodemirror, allVariables, [], {
showHintsFor: ['variables']
});
@@ -60,7 +60,7 @@ describe('Bruno Autocomplete', () => {
mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 9 });
mockedCodemirror.getRange.mockReturnValue('{{$randomI');
const result = getAutoCompleteHints(mockedCodemirror, {}, {
const result = getAutoCompleteHints(mockedCodemirror, {}, [], {
showHintsFor: ['variables']
});
@@ -84,7 +84,7 @@ describe('Bruno Autocomplete', () => {
mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 14 });
mockedCodemirror.getRange.mockReturnValue('{{process.env.N');
const result = getAutoCompleteHints(mockedCodemirror, allVariables, {
const result = getAutoCompleteHints(mockedCodemirror, allVariables, [], {
showHintsFor: ['variables']
});
@@ -106,7 +106,7 @@ describe('Bruno Autocomplete', () => {
path: 'value'
};
const result = getAutoCompleteHints(mockedCodemirror, allVariables, {
const result = getAutoCompleteHints(mockedCodemirror, allVariables, [], {
showHintsFor: ['variables']
});
@@ -134,7 +134,7 @@ describe('Bruno Autocomplete', () => {
'config.app.name': 'bruno'
};
const result = getAutoCompleteHints(mockedCodemirror, allVariables, {
const result = getAutoCompleteHints(mockedCodemirror, allVariables, [], {
showHintsFor: ['variables']
});
@@ -174,7 +174,7 @@ describe('Bruno Autocomplete', () => {
mockedCodemirror.getLine.mockReturnValue(input);
mockedCodemirror.getRange.mockReturnValue(input);
const result = getAutoCompleteHints(mockedCodemirror, {}, {
const result = getAutoCompleteHints(mockedCodemirror, {}, [], {
showHintsFor: ['req', 'res', 'bru']
});
@@ -188,7 +188,7 @@ describe('Bruno Autocomplete', () => {
mockedCodemirror.getLine.mockReturnValue('req.get');
mockedCodemirror.getRange.mockReturnValue('req.get');
const result = getAutoCompleteHints(mockedCodemirror, {}, {
const result = getAutoCompleteHints(mockedCodemirror, {}, [], {
showHintsFor: ['req']
});
@@ -213,7 +213,7 @@ describe('Bruno Autocomplete', () => {
mockedCodemirror.getLine.mockReturnValue('bru.runner.');
mockedCodemirror.getRange.mockReturnValue('bru.runner.');
const result = getAutoCompleteHints(mockedCodemirror, {}, {
const result = getAutoCompleteHints(mockedCodemirror, {}, [], {
showHintsFor: ['bru']
});
@@ -234,11 +234,9 @@ describe('Bruno Autocomplete', () => {
mockedCodemirror.getLine.mockReturnValue('Content-');
mockedCodemirror.getRange.mockReturnValue('Content-');
const options = {
anywordAutocompleteHints: ['Content-Type', 'Content-Encoding', 'Content-Length']
};
const customHints = ['Content-Type', 'Content-Encoding', 'Content-Length'];
const result = getAutoCompleteHints(mockedCodemirror, {}, options, {
const result = getAutoCompleteHints(mockedCodemirror, {}, customHints, {
showHintsFor: ['variables']
});
@@ -253,11 +251,9 @@ describe('Bruno Autocomplete', () => {
mockedCodemirror.getLine.mockReturnValue('utils.');
mockedCodemirror.getRange.mockReturnValue('utils.');
const options = {
anywordAutocompleteHints: ['utils.string.trim', 'utils.string.capitalize', 'utils.array.map']
};
const customHints = ['utils.string.trim', 'utils.string.capitalize', 'utils.array.map'];
const result = getAutoCompleteHints(mockedCodemirror, {}, options, {
const result = getAutoCompleteHints(mockedCodemirror, {}, customHints, {
showHintsFor: ['variables']
});
@@ -277,18 +273,14 @@ describe('Bruno Autocomplete', () => {
it('should respect showHintsFor option for excluding hints', () => {
const options = { showHintsFor: ['res', 'bru'] };
const result = getAutoCompleteHints(mockedCodemirror, {}, options, {
showHintsFor: ['req']
});
const result = getAutoCompleteHints(mockedCodemirror, {}, [], options);
expect(result).toBeNull();
});
it('should show hints when included in showHintsFor', () => {
const options = { showHintsFor: ['req'] };
const result = getAutoCompleteHints(mockedCodemirror, {}, options, {
showHintsFor: ['req']
});
const result = getAutoCompleteHints(mockedCodemirror, {}, [], options);
expect(result).toBeTruthy();
expect(result.list).toEqual(
@@ -303,7 +295,7 @@ describe('Bruno Autocomplete', () => {
const allVariables = { envVar1: 'value1' };
const options = { showHintsFor: ['req', 'res', 'bru'] };
const result = getAutoCompleteHints(mockedCodemirror, allVariables, options);
const result = getAutoCompleteHints(mockedCodemirror, allVariables, [], options);
expect(result).toBeNull();
});
@@ -318,7 +310,7 @@ describe('Bruno Autocomplete', () => {
allVariables[`var${i}`] = `value${i}`;
}
const result = getAutoCompleteHints(mockedCodemirror, allVariables, {
const result = getAutoCompleteHints(mockedCodemirror, allVariables, [], {
showHintsFor: ['variables']
});
@@ -337,7 +329,7 @@ describe('Bruno Autocomplete', () => {
'v.banana': 'value3'
};
const result = getAutoCompleteHints(mockedCodemirror, allVariables, {
const result = getAutoCompleteHints(mockedCodemirror, allVariables, [], {
showHintsFor: ['variables']
});
@@ -357,7 +349,7 @@ describe('Bruno Autocomplete', () => {
mockedCodemirror.getLine.mockReturnValue(' ');
mockedCodemirror.getRange.mockReturnValue('');
const result = getAutoCompleteHints(mockedCodemirror, {});
const result = getAutoCompleteHints(mockedCodemirror, {}, []);
expect(result).toBeNull();
});
@@ -367,8 +359,8 @@ describe('Bruno Autocomplete', () => {
mockedCodemirror.getLine.mockReturnValue('{{varName}}');
mockedCodemirror.getRange.mockReturnValue('{{varName');
const emptyResult = getAutoCompleteHints(mockedCodemirror, {});
const nullResult = getAutoCompleteHints(mockedCodemirror, null);
const emptyResult = getAutoCompleteHints(mockedCodemirror, {}, []);
const nullResult = getAutoCompleteHints(mockedCodemirror, null, []);
expect(emptyResult).toBeNull();
expect(nullResult).toBeNull();
@@ -380,7 +372,7 @@ describe('Bruno Autocomplete', () => {
mockedCodemirror.getLine.mockReturnValue(line);
mockedCodemirror.getRange.mockReturnValue(line);
const result = getAutoCompleteHints(mockedCodemirror, {}, {
const result = getAutoCompleteHints(mockedCodemirror, {}, [], {
showHintsFor: ['req']
});
@@ -401,7 +393,7 @@ describe('Bruno Autocomplete', () => {
VARIABLE3: 'value3'
};
const result = getAutoCompleteHints(mockedCodemirror, allVariables, {
const result = getAutoCompleteHints(mockedCodemirror, allVariables, [], {
showHintsFor: ['variables']
});
@@ -428,7 +420,8 @@ describe('Bruno Autocomplete', () => {
describe('Setup and cleanup', () => {
it('should setup keyup event listener and return cleanup function', () => {
cleanupFn = setupAutoComplete(mockedCodemirror, mockGetAllVariables);
const options = { getAllVariables: mockGetAllVariables };
cleanupFn = setupAutoComplete(mockedCodemirror, options);
expect(mockedCodemirror.on).toHaveBeenCalledWith('keyup', expect.any(Function));
expect(cleanupFn).toBeInstanceOf(Function);
@@ -438,7 +431,7 @@ describe('Bruno Autocomplete', () => {
});
it('should not setup if editor is null', () => {
const result = setupAutoComplete(null, mockGetAllVariables);
const result = setupAutoComplete(null, { getAllVariables: mockGetAllVariables });
expect(result).toBeUndefined();
expect(mockedCodemirror.on).not.toHaveBeenCalled();
@@ -447,9 +440,11 @@ describe('Bruno Autocomplete', () => {
describe('Event handling', () => {
it('should trigger hints on character key press', () => {
cleanupFn = setupAutoComplete(mockedCodemirror, mockGetAllVariables, {
showHintsFor: ['req']
});
const options = {
getAllVariables: mockGetAllVariables,
showHintsFor: ['req']
};
cleanupFn = setupAutoComplete(mockedCodemirror, options);
const keyupHandler = mockedCodemirror.on.mock.calls[0][1];
mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 4 });
@@ -464,7 +459,8 @@ describe('Bruno Autocomplete', () => {
});
it('should not trigger hints on non-character keys', () => {
cleanupFn = setupAutoComplete(mockedCodemirror, mockGetAllVariables);
const options = { getAllVariables: mockGetAllVariables };
cleanupFn = setupAutoComplete(mockedCodemirror, options);
const keyupHandler = mockedCodemirror.on.mock.calls[0][1];
const nonCharacterKeys = ['Shift', 'Tab', 'Enter', 'Escape', 'ArrowUp', 'ArrowDown', 'Meta'];
@@ -478,7 +474,8 @@ describe('Bruno Autocomplete', () => {
});
it('should close existing completion when no hints available', () => {
cleanupFn = setupAutoComplete(mockedCodemirror, mockGetAllVariables);
const options = { getAllVariables: mockGetAllVariables };
cleanupFn = setupAutoComplete(mockedCodemirror, options);
const keyupHandler = mockedCodemirror.on.mock.calls[0][1];
const mockCompletion = { close: jest.fn() };
@@ -495,8 +492,11 @@ describe('Bruno Autocomplete', () => {
});
it('should pass options to getAutoCompleteHints', () => {
const options = { showHintsFor: ['req'] };
cleanupFn = setupAutoComplete(mockedCodemirror, mockGetAllVariables, options);
const options = {
getAllVariables: mockGetAllVariables,
showHintsFor: ['req']
};
cleanupFn = setupAutoComplete(mockedCodemirror, options);
const keyupHandler = mockedCodemirror.on.mock.calls[0][1];
mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 4 });
@@ -512,6 +512,173 @@ describe('Bruno Autocomplete', () => {
});
});
});
describe('Click event handling (showHintsOnClick)', () => {
it('should setup mousedown event listener when showHintsOnClick is enabled', () => {
const options = {
getAllVariables: mockGetAllVariables,
showHintsOnClick: true
};
cleanupFn = setupAutoComplete(mockedCodemirror, options);
expect(mockedCodemirror.on).toHaveBeenCalledWith('keyup', expect.any(Function));
expect(mockedCodemirror.on).toHaveBeenCalledWith('mousedown', expect.any(Function));
expect(mockedCodemirror.on).toHaveBeenCalledTimes(2);
});
it('should not setup mousedown event listener when showHintsOnClick is disabled', () => {
const options = {
getAllVariables: mockGetAllVariables,
showHintsOnClick: false
};
cleanupFn = setupAutoComplete(mockedCodemirror, options);
expect(mockedCodemirror.on).toHaveBeenCalledWith('keyup', expect.any(Function));
expect(mockedCodemirror.on).toHaveBeenCalledTimes(1);
});
it('should not setup mousedown event listener when showHintsOnClick is undefined', () => {
const options = {
getAllVariables: mockGetAllVariables
};
cleanupFn = setupAutoComplete(mockedCodemirror, options);
expect(mockedCodemirror.on).toHaveBeenCalledWith('keyup', expect.any(Function));
expect(mockedCodemirror.on).toHaveBeenCalledTimes(1);
});
it('should show hints on click when showHintsOnClick is enabled', () => {
jest.useFakeTimers();
const mockGetAnywordAutocompleteHints = jest.fn(() => ['Content-Type', 'Accept']);
const options = {
getAllVariables: mockGetAllVariables,
getAnywordAutocompleteHints: mockGetAnywordAutocompleteHints,
showHintsOnClick: true,
showHintsFor: ['req', 'variables']
};
cleanupFn = setupAutoComplete(mockedCodemirror, options);
// Find the click handler (mousedown event)
const clickHandler = mockedCodemirror.on.mock.calls.find(call => call[0] === 'mousedown')[1];
mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 0 });
clickHandler(mockedCodemirror);
// Run all timers to execute the setTimeout
jest.runAllTimers();
expect(mockGetAllVariables).toHaveBeenCalled();
expect(mockGetAnywordAutocompleteHints).toHaveBeenCalled();
expect(mockedCodemirror.showHint).toHaveBeenCalled();
jest.useRealTimers();
});
it('should not show hints on click when showHintsOnClick is disabled', () => {
const options = {
getAllVariables: mockGetAllVariables,
showHintsOnClick: false
};
cleanupFn = setupAutoComplete(mockedCodemirror, options);
// There should be no mousedown handler
const mousedownCalls = mockedCodemirror.on.mock.calls.filter(call => call[0] === 'mousedown');
expect(mousedownCalls).toHaveLength(0);
});
it('should cleanup mousedown event listener when showHintsOnClick was enabled', () => {
const options = {
getAllVariables: mockGetAllVariables,
showHintsOnClick: true
};
cleanupFn = setupAutoComplete(mockedCodemirror, options);
cleanupFn();
expect(mockedCodemirror.off).toHaveBeenCalledWith('keyup', expect.any(Function));
expect(mockedCodemirror.off).toHaveBeenCalledWith('mousedown', expect.any(Function));
expect(mockedCodemirror.off).toHaveBeenCalledTimes(2);
});
it('should only cleanup keyup event listener when showHintsOnClick was disabled', () => {
const options = {
getAllVariables: mockGetAllVariables,
showHintsOnClick: false
};
cleanupFn = setupAutoComplete(mockedCodemirror, options);
cleanupFn();
expect(mockedCodemirror.off).toHaveBeenCalledWith('keyup', expect.any(Function));
expect(mockedCodemirror.off).toHaveBeenCalledTimes(1);
});
it('should show all available hints on click based on showHintsFor configuration', () => {
jest.useFakeTimers();
const mockGetAnywordAutocompleteHints = jest.fn(() => ['Content-Type', 'Accept']);
const options = {
getAllVariables: mockGetAllVariables.mockReturnValue({
envVar1: 'value1',
envVar2: 'value2'
}),
getAnywordAutocompleteHints: mockGetAnywordAutocompleteHints,
showHintsOnClick: true,
showHintsFor: ['req', 'variables']
};
cleanupFn = setupAutoComplete(mockedCodemirror, options);
// Find the click handler (mousedown event)
const clickHandler = mockedCodemirror.on.mock.calls.find(call => call[0] === 'mousedown')[1];
const mockCursor = { line: 0, ch: 0 };
mockedCodemirror.getCursor.mockReturnValue(mockCursor);
clickHandler(mockedCodemirror);
// Run all timers to execute the setTimeout
jest.runAllTimers();
expect(mockedCodemirror.showHint).toHaveBeenCalledWith({
hint: expect.any(Function),
completeSingle: false
});
// Verify the hint function returns the expected structure
const hintCall = mockedCodemirror.showHint.mock.calls[0][0];
const hintResult = hintCall.hint();
expect(hintResult).toEqual({
list: expect.any(Array),
from: mockCursor,
to: mockCursor
});
expect(hintResult.list.length).toBeGreaterThan(0);
jest.useRealTimers();
});
it('should not show hints on click when no hints are available', () => {
const options = {
getAllVariables: mockGetAllVariables.mockReturnValue({}),
getAnywordAutocompleteHints: jest.fn(() => []),
showHintsOnClick: true,
showHintsFor: []
};
cleanupFn = setupAutoComplete(mockedCodemirror, options);
// Find the click handler (mousedown event)
const clickHandler = mockedCodemirror.on.mock.calls.find(call => call[0] === 'mousedown')[1];
mockedCodemirror.getCursor.mockReturnValue({ line: 0, ch: 0 });
clickHandler(mockedCodemirror);
expect(mockedCodemirror.showHint).not.toHaveBeenCalled();
});
});
});
describe('CodeMirror integration', () => {

View File

@@ -233,7 +233,8 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
name: si.name,
filename: si.filename,
seq: si.seq,
settings: si.settings
settings: si.settings,
tags: si.tags
};
if (si.request) {
@@ -554,6 +555,7 @@ export const transformRequestToSaveToFilesystem = (item) => {
name: _item.name,
seq: _item.seq,
settings: _item.settings,
tags: _item.tags,
request: {
method: _item.request.method,
url: _item.request.url,
@@ -1106,3 +1108,20 @@ export const calculateDraggedItemNewPathname = ({ draggedItem, targetItem, dropT
};
// item sequence utils - END
export const getUniqueTagsFromItems = (items = []) => {
const allTags = new Set();
const getTags = (items) => {
items.forEach(item => {
if (isItemARequest(item)) {
const tags = item.draft ? get(item, 'draft.tags', []) : get(item, 'tags', []);
tags.forEach(tag => allTags.add(tag));
}
if (item.items) {
getTags(item.items);
}
});
};
getTags(items);
return Array.from(allTags).sort();
};

View File

@@ -6,6 +6,7 @@ const { getRunnerSummary } = require('@usebruno/common/runner');
const { exists, isFile, isDirectory } = require('../utils/filesystem');
const { runSingleRequest } = require('../runner/run-single-request');
const { bruToEnvJson, getEnvVars } = require('../utils/bru');
const { isRequestTagsIncluded } = require("@usebruno/common")
const makeJUnitOutput = require('../reporters/junit');
const makeHtmlOutput = require('../reporters/html');
const { rpad } = require('../utils/common');
@@ -199,6 +200,14 @@ const builder = async (yargs) => {
type:"number",
description: "Delay between each requests (in miliseconds)"
})
.option('tags', {
type: 'string',
description: 'Tags to include in the run'
})
.option('exclude-tags', {
type: 'string',
description: 'Tags to exclude from the run'
})
.example('$0 run request.bru', 'Run a request')
.example('$0 run request.bru --env local', 'Run a request with the environment set to local')
.example('$0 run request.bru --env-file env.bru', 'Run a request with the environment from env.bru file')
@@ -241,7 +250,11 @@ const builder = async (yargs) => {
)
.example('$0 run --client-cert-config client-cert-config.json', 'Run a request with Client certificate configurations')
.example('$0 run folder --delay delayInMs', 'Run a folder with given miliseconds delay between each requests.')
.example('$0 run --noproxy', 'Run requests with system proxy disabled');
.example('$0 run --noproxy', 'Run requests with system proxy disabled')
.example(
'$0 run folder --tags=hello,world --exclude-tags=skip',
'Run only requests with tags "hello" or "world" and exclude any request with tag "skip".'
);
};
const handler = async function (argv) {
@@ -268,7 +281,9 @@ const handler = async function (argv) {
reporterSkipHeaders,
clientCertConfig,
noproxy,
delay
delay,
tags: includeTags,
excludeTags
} = argv;
const collectionPath = process.cwd();
@@ -353,7 +368,7 @@ const handler = async function (argv) {
if (!match) {
console.error(
chalk.red(`Overridable environment variable not correct: use name=value - presented: `) +
chalk.dim(`${value}`)
chalk.dim(`${value}`)
);
process.exit(constants.EXIT_STATUS.ERROR_INCORRECT_ENV_OVERRIDE);
}
@@ -389,6 +404,9 @@ const handler = async function (argv) {
}
options['ignoreTruststore'] = ignoreTruststore;
includeTags = includeTags ? includeTags.split(',') : [];
excludeTags = excludeTags ? excludeTags.split(',') : [];
if (['json', 'junit', 'html'].indexOf(format) === -1) {
console.error(chalk.red(`Format must be one of "json", "junit or "html"`));
process.exit(constants.EXIT_STATUS.ERROR_INCORRECT_OUTPUT_FORMAT);
@@ -456,6 +474,10 @@ const handler = async function (argv) {
});
}
requestItems = requestItems.filter((item) => {
return isRequestTagsIncluded(item.tags, includeTags, excludeTags);
});
const runtime = getJsSandboxRuntime(sandbox);
const runSingleRequestByPathname = async (relativeItemPathname) => {

View File

@@ -64,6 +64,7 @@ const bruToJson = (bru) => {
name: _.get(json, 'meta.name'),
seq: !_.isNaN(sequence) ? Number(sequence) : 1,
settings: _.get(json, 'settings', {}),
tags: _.get(json, 'meta.tags', []),
request: {
method: _.upperCase(_.get(json, 'http.method')),
url: _.get(json, 'http.url'),

View File

@@ -1,4 +1,5 @@
export { mockDataFunctions } from './utils/faker-functions';
export { default as interpolate } from './interpolate';
export { default as isRequestTagsIncluded } from './tags';
export * as utils from './utils';
export * as utils from './utils';

View File

@@ -0,0 +1,43 @@
import isRequestTagsIncluded from './index';
describe('isRequestTagsIncluded', () => {
it('should include request when it has an included tag', () => {
const requestTags = ['tag1', 'tag2'];
const includeTags = ['tag1'];
const excludeTags: string[] = [];
const result = isRequestTagsIncluded(requestTags, includeTags, excludeTags);
expect(result).toBe(true);
});
it('should include request when included tags is empty', () => {
const requestTags = ['tag1', 'tag2'];
const includeTags: string[] = [];
const excludeTags: string[] = [];
const result = isRequestTagsIncluded(requestTags, includeTags, excludeTags);
expect(result).toBe(true);
});
it('should exclude request when it does not have an included tag', () => {
const requestTags = ['tag1'];
const includeTags = ['tag2'];
const excludeTags: string[] = [];
const result = isRequestTagsIncluded(requestTags, includeTags, excludeTags);
expect(result).toBe(false);
});
it('should exclude request when it has an excluded tag', () => {
const requestTags = ['tag1'];
const includeTags: string[] = [];
const excludeTags = ['tag1'];
const result = isRequestTagsIncluded(requestTags, includeTags, excludeTags);
expect(result).toBe(false);
});
it('should exclude request when it has both included and excluded tag', () => {
const requestTags = ['tag1', 'tag2'];
const includeTags: string[] = ['tag2'];
const excludeTags = ['tag1'];
const result = isRequestTagsIncluded(requestTags, includeTags, excludeTags);
expect(result).toBe(false);
});
});

View File

@@ -0,0 +1,13 @@
/**
* A request should be included if it has at least one tag that is included and no tags that are excluded
* @param requestTags Tags of the request
* @param includeTags Tags to include
* @param excludeTags Tags to exclude
*/
export const isRequestTagsIncluded = (requestTags: string[], includeTags: string[], excludeTags: string[]) => {
const shouldInclude = includeTags.length === 0 || requestTags.some((tag) => includeTags.includes(tag));
const shouldExclude = excludeTags.length > 0 && requestTags.some((tag) => excludeTags.includes(tag));
return shouldInclude && !shouldExclude;
};
export default isRequestTagsIncluded;

View File

@@ -157,6 +157,12 @@ const transformInsomniaRequestItem = (request, index, allRequests) => {
brunoRequestItem.request.body.graphql = parseGraphQL(request.body.text);
}
const settings = {
encodeUrl: request.settings?.encodeUrl !== false && request.settingEncodeUrl !== false, // handles v4 and v5 import
}
brunoRequestItem.settings = settings;
return brunoRequestItem;
};
@@ -200,7 +206,8 @@ const parseInsomniaV5Collection = (data) => {
parameters: item.parameters || [],
pathParameters: item.pathParameters || [],
authentication: item.authentication || {},
body: item.body || {}
body: item.body || {},
settings: item.settings || {}
};
return transformInsomniaRequestItem(request, index, allItems);
} else if (item.children && Array.isArray(item.children)) {

View File

@@ -380,6 +380,12 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, { useWorke
}
};
const settings = {
encodeUrl: i.protocolProfileBehavior?.disableUrlEncoding !== true
}
brunoRequestItem.settings = settings;
brunoParent.items.push(brunoRequestItem);
if (i.event) {

View File

@@ -4,7 +4,7 @@ import insomniaToBruno from '../../src/insomnia/insomnia-to-bruno';
describe('insomnia-collection', () => {
it('should correctly import a valid Insomnia v5 collection file', async () => {
const brunoCollection = insomniaToBruno(insomniaCollection);
expect(brunoCollection).toMatchObject(expectedOutput)
});
});
@@ -59,7 +59,7 @@ collection:
method: GET
settings:
renderRequestBody: true
encodeUrl: true
encodeUrl: false
followRedirects: global
cookies:
send: true
@@ -113,6 +113,9 @@ const expectedOutput = {
"seq": 1,
"type": "http-request",
"uid": "mockeduuidvalue123456",
"settings": {
"encodeUrl": true,
},
},
],
"name": "Folder1",
@@ -146,6 +149,9 @@ const expectedOutput = {
"seq": 1,
"type": "http-request",
"uid": "mockeduuidvalue123456",
"settings": {
"encodeUrl": false,
},
},
],
"name": "Folder2",

View File

@@ -22,6 +22,7 @@ const insomniaCollection = {
"name": "Request1",
"method": "GET",
"url": "https://httpbin.org/get",
"settingEncodeUrl": false,
"parameters": []
},
{
@@ -31,6 +32,7 @@ const insomniaCollection = {
"name": "Request2",
"method": "GET",
"url": "https://httpbin.org/get",
"settingEncodeUrl": true,
"parameters": []
},
{
@@ -92,6 +94,9 @@ const expectedOutput = {
"seq": 1,
"type": "http-request",
"uid": "mockeduuidvalue123456",
"settings": {
"encodeUrl": false,
},
},
{
"name": "Request1",
@@ -118,6 +123,9 @@ const expectedOutput = {
"seq": 2,
"type": "http-request",
"uid": "mockeduuidvalue123456",
"settings": {
"encodeUrl": false,
},
},
],
"name": "Folder1",
@@ -151,6 +159,9 @@ const expectedOutput = {
"seq": 1,
"type": "http-request",
"uid": "mockeduuidvalue123456",
"settings": {
"encodeUrl": true,
},
},
{
"name": "Request2",
@@ -177,6 +188,9 @@ const expectedOutput = {
"seq": 2,
"type": "http-request",
"uid": "mockeduuidvalue123456",
"settings": {
"encodeUrl": true,
},
},
],
"name": "Folder2",

View File

@@ -138,6 +138,7 @@ const bruToJson = (data, parsed = false) => {
name: _.get(json, 'meta.name'),
seq: !_.isNaN(sequence) ? Number(sequence) : 1,
settings: _.get(json, 'settings', {}),
tags: _.get(json, 'meta.tags', []),
request: {
method: _.upperCase(_.get(json, 'http.method')),
url: _.get(json, 'http.url'),
@@ -155,7 +156,6 @@ const bruToJson = (data, parsed = false) => {
transformedJson.request.auth.mode = _.get(json, 'http.auth', 'none');
transformedJson.request.body.mode = _.get(json, 'http.body', 'none');
return transformedJson;
} catch (e) {
return Promise.reject(e);
@@ -195,7 +195,8 @@ const jsonToBru = async (json) => {
meta: {
name: _.get(json, 'name'),
type: type,
seq: !_.isNaN(sequence) ? Number(sequence) : 1
seq: !_.isNaN(sequence) ? Number(sequence) : 1,
tags: _.get(json, 'tags', []),
},
http: {
method: _.lowerCase(_.get(json, 'request.method')),
@@ -237,7 +238,8 @@ const jsonToBruViaWorker = async (json) => {
meta: {
name: _.get(json, 'name'),
type: type,
seq: !_.isNaN(sequence) ? Number(sequence) : 1
seq: !_.isNaN(sequence) ? Number(sequence) : 1,
tags: _.get(json, 'tags', [])
},
http: {
method: _.lowerCase(_.get(json, 'request.method')),

View File

@@ -31,6 +31,7 @@ const { preferencesUtil } = require('../../store/preferences');
const { getProcessEnvVars } = require('../../store/process-env');
const { getBrunoConfig } = require('../../store/bruno-config');
const Oauth2Store = require('../../store/oauth2');
const { isRequestTagsIncluded } = require('@usebruno/common');
const saveCookies = (url, headers) => {
if (preferencesUtil.shouldStoreCookies()) {
@@ -952,7 +953,7 @@ const registerNetworkIpc = (mainWindow) => {
ipcMain.handle(
'renderer:run-collection-folder',
async (event, folder, collection, environment, runtimeVariables, recursive, delay) => {
async (event, folder, collection, environment, runtimeVariables, recursive, delay, tags) => {
const collectionUid = collection.uid;
const collectionPath = collection.pathname;
const folderUid = folder ? folder.uid : null;
@@ -1012,6 +1013,15 @@ const registerNetworkIpc = (mainWindow) => {
folderRequests = sortByNameThenSequence(folderRequests)
}
// Filter requests based on tags
if (tags && tags.include && tags.exclude) {
const includeTags = tags.include ? tags.include : [];
const excludeTags = tags.exclude ? tags.exclude : [];
folderRequests = folderRequests.filter(({ tags }) => {
return isRequestTagsIncluded(tags, includeTags, excludeTags)
});
}
let currentRequestIndex = 0;
let nJumps = 0; // count the number of jumps to avoid infinite loops
while (currentRequestIndex < folderRequests.length) {

View File

@@ -61,6 +61,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
break;
case 'apikey':
const apiKeyAuth = get(collectionAuth, 'apikey');
if (apiKeyAuth.key.length === 0) break;
if (apiKeyAuth.placement === 'header') {
axiosRequest.headers[apiKeyAuth.key] = apiKeyAuth.value;
} else if (apiKeyAuth.placement === 'queryparams') {
@@ -277,6 +278,7 @@ const setAuthHeaders = (axiosRequest, request, collectionRoot) => {
break;
case 'apikey':
const apiKeyAuth = get(request, 'auth.apikey');
if (apiKeyAuth.key.length === 0) break;
if (apiKeyAuth.placement === 'header') {
axiosRequest.headers[apiKeyAuth.key] = apiKeyAuth.value;
} else if (apiKeyAuth.placement === 'queryparams') {
@@ -426,7 +428,7 @@ const prepareRequest = async (item, collection = {}, abortController) => {
}
// if the mode is 'none' then set the content-type header to false. #1693
if (request.body.mode === 'none') {
if (request.body.mode === 'none' && request.auth.mode !== 'awsv4') {
if(!contentTypeDefined) {
axiosRequest.headers['content-type'] = false;
}

View File

@@ -62,7 +62,7 @@ describe('prepare-request: prepareRequest', () => {
describe.each(['POST', 'PUT', 'PATCH'])('POST request with no body', (method) => {
it('Should set content-type header to false if method is ' + method + ' and there is no data in the body', async () => {
const request = { method: method, url: 'test-domain', body: { mode: 'none' } };
const request = { method: method, url: 'test-domain', body: { mode: 'none' }, auth: { mode: 'none' } };
const result = await prepareRequest({ request, collection: { pathname: '' } });
expect(result.headers['content-type']).toEqual(false);
});
@@ -71,7 +71,8 @@ describe('prepare-request: prepareRequest', () => {
method: method,
url: 'test-domain',
body: { mode: 'none' },
headers: [{ name: 'content-type', value: 'application/json', enabled: true }]
headers: [{ name: 'content-type', value: 'application/json', enabled: true }],
auth: { mode: 'none' }
};
const result = await prepareRequest({ request, collection: { pathname: '' } });
expect(result.headers['content-type']).toEqual('application/json');

View File

@@ -4,7 +4,7 @@ const { safeParseJson, outdentString } = require('./utils');
/**
* A Bru file is made up of blocks.
* There are two types of blocks
* There are three types of blocks
*
* 1. Dictionary Blocks - These are blocks that have key value pairs
* ex:
@@ -19,6 +19,13 @@ const { safeParseJson, outdentString } = require('./utils');
* "username": "John Nash",
* "password": "governingdynamics
* }
* 3. List Blocks - These are blocks that have a list of items
* ex:
* tags [
* regression
* smoke-test
* ]
*
*/
const grammar = ohm.grammar(`Bru {
@@ -45,7 +52,7 @@ const grammar = ohm.grammar(`Bru {
pairlist = optionalnl* pair (~tagend stnl* pair)* (~tagend space)*
pair = st* key st* ":" st* value st*
key = keychar*
value = multilinetextblock | valuechar*
value = list | multilinetextblock | valuechar*
// Dictionary for Assert Block
assertdictionary = st* "{" assertpairlist? tagend
@@ -59,6 +66,12 @@ const grammar = ohm.grammar(`Bru {
textline = textchar*
textchar = ~nl any
// List
listend = stnl* "]"
list = st* "[" listitems? listend
listitems = (~listend stnl)* listitem (~listend stnl* listitem)* (~listend space)*
listitem = st* textchar+ st*
meta = "meta" dictionary
settings = "settings" dictionary
@@ -262,6 +275,10 @@ const sem = grammar.createSemantics().addAttribute('ast', {
},
pair(_1, key, _2, _3, _4, value, _5) {
let res = {};
if (Array.isArray(value.ast)) {
res[key.ast] = value.ast;
return res;
}
res[key.ast] = value.ast ? value.ast.trim() : '';
return res;
},
@@ -269,6 +286,9 @@ const sem = grammar.createSemantics().addAttribute('ast', {
return chars.sourceString ? chars.sourceString.trim() : '';
},
value(chars) {
if (chars.ctorName === 'list') {
return chars.ast;
}
try {
let isMultiline = chars.sourceString?.startsWith(`'''`) && chars.sourceString?.endsWith(`'''`);
if (isMultiline) {
@@ -298,6 +318,15 @@ const sem = grammar.createSemantics().addAttribute('ast', {
assertkey(chars) {
return chars.sourceString ? chars.sourceString.trim() : '';
},
list(_1, _2, listitems, _3) {
return listitems.ast.flat()
},
listitems(_1, listitem, _2, rest, _3) {
return [listitem.ast, ...rest.ast]
},
listitem(_1, textchar, _2) {
return textchar.sourceString;
},
textblock(line, _1, rest) {
return [line.ast, ...rest.ast].join('\n');
},

View File

@@ -36,9 +36,22 @@ const jsonToBru = (json) => {
if (meta) {
bru += 'meta {\n';
const tags = meta.tags;
delete meta.tags;
for (const key in meta) {
bru += ` ${key}: ${meta[key]}\n`;
}
if (tags && tags.length) {
bru += ` tags: [\n`;
for (const tag of tags) {
bru += ` ${tag}\n`;
}
bru += ` ]\n`;
}
bru += '}\n\n';
}

View File

@@ -2,6 +2,10 @@ meta {
name: Send Bulk SMS
type: http
seq: 1
tags: [
foo
bar
]
}
get {

View File

@@ -2,7 +2,8 @@
"meta": {
"name": "Send Bulk SMS",
"type": "http",
"seq": "1"
"seq": "1",
"tags": ["foo", "bar"]
},
"http": {
"method": "get",

View File

@@ -0,0 +1,33 @@
/**
* This test file is used to test the text parser.
*/
const parser = require('../src/bruToJson');
describe('tags parser', () => {
it('should parse request tags', () => {
const input = `
meta {
name: request
type: http
seq: 1
tags: [
tag_1
tag_2
tag_3
tag_4
]
}
`;
const output = parser(input);
const expected = {
meta: {
name: 'request',
type: 'http',
tags: ['tag_1', 'tag_2', 'tag_3', 'tag_4'],
seq: '1'
}
};
expect(output).toEqual(expected);
});
});

View File

@@ -355,6 +355,7 @@ const itemSchema = Yup.object({
type: Yup.string().oneOf(['http-request', 'graphql-request', 'folder', 'js']).required('type is required'),
seq: Yup.number().min(1),
name: Yup.string().min(1, 'name must be at least 1 character').required('name is required'),
tags: Yup.array().of(Yup.string().matches(/^[\w-]+$/, 'tag must be alphanumeric')),
request: requestSchema.when('type', {
is: (type) => ['http-request', 'graphql-request'].includes(type),
then: (schema) => schema.required('request is required when item-type is request')

View File

@@ -7,7 +7,8 @@ describe('Item Schema Validation', () => {
const item = {
uid: uuid(),
name: 'A Folder',
type: 'folder'
type: 'folder',
tags: ['smoke-test']
};
const isValid = await itemSchema.validate(item);

View File

@@ -47,6 +47,10 @@ const jsonToToml = (json) => {
}
};
if (json.tags && json.tags.length) {
formattedJson.tags = get(json, 'tags', []);
}
if (json.headers && json.headers.length) {
const hasDuplicateHeaders = keyValPairHasDuplicateKeys(json.headers);
const hasReservedHeaders = keyValPairHasReservedKeys(json.headers);

View File

@@ -24,6 +24,10 @@ const tomlToJson = (toml) => {
}
};
if (json.tags && json.tags.length) {
formattedJson.tags = get(json, 'tags', []);
}
if (json.headers) {
formattedJson.headers = [];

View File

@@ -4,6 +4,7 @@
"type": "http",
"seq": 1
},
"tags": ["foo", "bar"],
"http": {
"method": "GET",
"url": "https://reqres.in/api/users"

View File

@@ -1,3 +1,5 @@
tags = [ 'foo', 'bar' ]
[meta]
name = 'Get users'
type = 'http'