From 3803576aa416e6e72219ccb62e52b03a055d3619 Mon Sep 17 00:00:00 2001 From: Antti Sonkeri Date: Sat, 20 Jul 2024 11:41:56 +0300 Subject: [PATCH 1/5] feat: Tagging requests and filtering collection runs using tags --- .../RequestPane/GraphQLRequestPane/index.js | 7 ++ .../RequestPane/HttpRequestPane/index.js | 7 ++ .../RequestPane/Tags/TagList/StyledWrapper.js | 25 +++++++ .../RequestPane/Tags/TagList/TagList.js | 69 +++++++++++++++++++ .../src/components/RequestPane/Tags/index.js | 43 ++++++++++++ .../src/components/RunnerResults/index.jsx | 46 ++++++++++++- .../CollectionItem/RunCollectionItem/index.js | 40 ++++++++++- .../ReduxStore/slices/collections/actions.js | 5 +- .../ReduxStore/slices/collections/index.js | 40 ++++++++++- .../bruno-app/src/utils/collections/index.js | 6 +- packages/bruno-cli/src/commands/run.js | 28 +++++++- packages/bruno-cli/src/utils/bru.js | 1 + packages/bruno-common/src/index.ts | 3 +- packages/bruno-common/src/tags/index.spec.ts | 43 ++++++++++++ packages/bruno-common/src/tags/index.ts | 13 ++++ packages/bruno-electron/src/bru/index.js | 9 ++- .../bruno-electron/src/ipc/network/index.js | 12 +++- packages/bruno-lang/v2/src/bruToJson.js | 28 +++++++- packages/bruno-lang/v2/src/jsonToBru.js | 8 +++ .../bruno-lang/v2/tests/fixtures/request.bru | 5 ++ .../bruno-lang/v2/tests/fixtures/request.json | 1 + .../bruno-schema/src/collections/index.js | 3 +- .../src/collections/requestSchema.spec.js | 1 + packages/bruno-toml/src/jsonToToml.js | 4 ++ packages/bruno-toml/src/tomlToJson.js | 4 ++ .../bruno-toml/tests/methods/get/request.json | 1 + .../bruno-toml/tests/methods/get/request.toml | 2 + 27 files changed, 434 insertions(+), 20 deletions(-) create mode 100644 packages/bruno-app/src/components/RequestPane/Tags/TagList/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/RequestPane/Tags/TagList/TagList.js create mode 100644 packages/bruno-app/src/components/RequestPane/Tags/index.js create mode 100644 packages/bruno-common/src/tags/index.spec.ts create mode 100644 packages/bruno-common/src/tags/index.ts diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js index 34558d928..dc42c883f 100644 --- a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js +++ b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js @@ -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 Tags from 'components/RequestPane/Tags/index'; const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handleGqlClickReference }) => { const dispatch = useDispatch(); @@ -101,6 +102,9 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle case 'docs': { return ; } + case 'tags': { + return ; + } default: { return
404 | Not found
; } @@ -152,6 +156,9 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
selectTab('docs')}> Docs
+
selectTab('tags')}> + Tags +
diff --git a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js index 2a2acbb21..72c222f57 100644 --- a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js +++ b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js @@ -18,6 +18,7 @@ import HeightBoundContainer from 'ui/HeightBoundContainer'; import { useEffect } from 'react'; import StatusDot from 'components/StatusDot'; import Settings from 'components/RequestPane/Settings'; +import Tags from 'components/RequestPane/Tags/index'; const HttpRequestPane = ({ item, collection }) => { const dispatch = useDispatch(); @@ -65,6 +66,9 @@ const HttpRequestPane = ({ item, collection }) => { case 'settings': { return ; } + case 'tags': { + return ; + } default: { return
404 | Not found
; } @@ -165,6 +169,9 @@ const HttpRequestPane = ({ item, collection }) => {
selectTab('settings')}> Settings
+
selectTab('tags')}> + Tags +
{focusedTab.requestPaneTab === 'body' ? (
diff --git a/packages/bruno-app/src/components/RequestPane/Tags/TagList/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/Tags/TagList/StyledWrapper.js new file mode 100644 index 000000000..651fc761b --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/Tags/TagList/StyledWrapper.js @@ -0,0 +1,25 @@ +import styled from 'styled-components'; + +const Wrapper = styled.div` + input[type='text'] { + border: solid 1px transparent; + outline: none !important; + background-color: inherit; + + &:focus { + outline: none !important; + border: solid 1px transparent; + } + } + + li { + display: flex; + align-items: center; + border: 1px solid ${(props) => props.theme.text}; + border-radius: 5px; + padding-inline: 5px; + background: ${(props) => props.theme.sidebar.bg}; + } +`; + +export default Wrapper; diff --git a/packages/bruno-app/src/components/RequestPane/Tags/TagList/TagList.js b/packages/bruno-app/src/components/RequestPane/Tags/TagList/TagList.js new file mode 100644 index 000000000..7248d54da --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/Tags/TagList/TagList.js @@ -0,0 +1,69 @@ +import { IconX } from '@tabler/icons'; +import { useState } from 'react'; +import toast from 'react-hot-toast'; +import StyledWrapper from './StyledWrapper'; + +const TagList = ({ tags, onTagRemove, onTagAdd }) => { + const tagNameRegex = /^[\w-]+$/; + const [isEditing, setIsEditing] = useState(false); + const [text, setText] = useState(''); + + const handleChange = (e) => { + setText(e.target.value); + }; + + const handleKeyDown = (e) => { + if (e.code == 'Escape') { + setText(''); + setIsEditing(false); + return; + } + if (e.code !== 'Enter' && e.code !== 'Space') { + return; + } + if (!tagNameRegex.test(text)) { + toast.error('Tags must only contain alpha-numeric characters, "-", "_"'); + return; + } + if (tags.includes(text)) { + toast.error(`Tag "${text}" already exists`); + return; + } + onTagAdd(text); + setText(''); + setIsEditing(false); + }; + + return ( + +
    + {tags && tags.length + ? tags.map((_tag) => ( +
  • + {_tag} + +
  • + )) + : null} +
+ {isEditing ? ( + + ) : ( + + )} +
+ ); +}; + +export default TagList; diff --git a/packages/bruno-app/src/components/RequestPane/Tags/index.js b/packages/bruno-app/src/components/RequestPane/Tags/index.js new file mode 100644 index 000000000..5f035fa88 --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/Tags/index.js @@ -0,0 +1,43 @@ +import 'github-markdown-css/github-markdown.css'; +import get from 'lodash/get'; +import { addRequestTag, deleteRequestTag } from 'providers/ReduxStore/slices/collections'; +import { useDispatch } from 'react-redux'; +import TagList from './TagList/TagList'; + +const Tags = ({ item, collection }) => { + const tags = item.draft ? get(item, 'draft.request.tags') : get(item, 'request.tags'); + + const dispatch = useDispatch(); + + const handleAdd = (_tag) => { + dispatch( + addRequestTag({ + tag: _tag, + itemUid: item.uid, + collectionUid: collection.uid + }) + ); + }; + + const handleRemove = (_tag) => { + dispatch( + deleteRequestTag({ + tag: _tag, + itemUid: item.uid, + collectionUid: collection.uid + }) + ); + }; + + if (!item) { + return null; + } + + return ( +
+ +
+ ); +}; + +export default Tags; diff --git a/packages/bruno-app/src/components/RunnerResults/index.jsx b/packages/bruno-app/src/components/RunnerResults/index.jsx index c4945c7aa..f09e305d3 100644 --- a/packages/bruno-app/src/components/RunnerResults/index.jsx +++ b/packages/bruno-app/src/components/RunnerResults/index.jsx @@ -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 TagList from 'components/RequestPane/Tags/TagList/TagList'; const getDisplayName = (fullPath, pathname, name = '') => { let relativePath = path.relative(fullPath, pathname); @@ -42,6 +43,8 @@ export default function RunnerResults({ collection }) { const dispatch = useDispatch(); const [selectedItem, setSelectedItem] = useState(null); const [delay, setDelay] = useState(null); + const [tags, setTags] = useState({ include: [], exclude: [] }); + const [tagsEnabled, setTagsEnabled] = useState(false); // ref for the runner output body const runnerBodyRef = useRef(); @@ -88,11 +91,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 = () => { @@ -140,6 +151,37 @@ export default function RunnerResults({ collection }) { onChange={(e) => setDelay(e.target.value)} />
+
+
+ + setTagsEnabled(!tagsEnabled)} + /> +
+ {tagsEnabled && ( +
+
+ Included tags: + setTags({ ...tags, include: [...tags.include, tag] })} + onTagRemove={(tag) => setTags({ ...tags, include: tags.include.filter((t) => t !== tag) })} + /> +
+
+ Excluded tags: + setTags({ ...tags, exclude: [...tags.exclude, tag] })} + onTagRemove={(tag) => setTags({ ...tags, exclude: tags.exclude.filter((t) => t !== tag) })} + /> +
+
+ )} +
- - )) - : null} - - {isEditing ? ( - - ) : ( - - )} - - ); -}; - -export default TagList; diff --git a/packages/bruno-app/src/components/RequestPane/Tags/index.js b/packages/bruno-app/src/components/RequestPane/Tags/index.js deleted file mode 100644 index 5f035fa88..000000000 --- a/packages/bruno-app/src/components/RequestPane/Tags/index.js +++ /dev/null @@ -1,43 +0,0 @@ -import 'github-markdown-css/github-markdown.css'; -import get from 'lodash/get'; -import { addRequestTag, deleteRequestTag } from 'providers/ReduxStore/slices/collections'; -import { useDispatch } from 'react-redux'; -import TagList from './TagList/TagList'; - -const Tags = ({ item, collection }) => { - const tags = item.draft ? get(item, 'draft.request.tags') : get(item, 'request.tags'); - - const dispatch = useDispatch(); - - const handleAdd = (_tag) => { - dispatch( - addRequestTag({ - tag: _tag, - itemUid: item.uid, - collectionUid: collection.uid - }) - ); - }; - - const handleRemove = (_tag) => { - dispatch( - deleteRequestTag({ - tag: _tag, - itemUid: item.uid, - collectionUid: collection.uid - }) - ); - }; - - if (!item) { - return null; - } - - return ( -
- -
- ); -}; - -export default Tags; diff --git a/packages/bruno-app/src/components/RunnerResults/RunnerTags/index.jsx b/packages/bruno-app/src/components/RunnerResults/RunnerTags/index.jsx new file mode 100644 index 000000000..c24b5a233 --- /dev/null +++ b/packages/bruno-app/src/components/RunnerResults/RunnerTags/index.jsx @@ -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 ( +
+
+ + setTagsEnabled(!tagsEnabled)} + /> +
+ {tagsEnabled && ( +
+
+ Included tags: + handleAddTag({ tag, to: 'include' })} + handleRemoveTag={tag => handleRemoveTag({ tag, from: 'include' })} + tagsHintList={tagsHintList} + handleValidation={handleValidation} + /> +
+
+ Excluded tags: + handleAddTag({ tag, to: 'exclude' })} + handleRemoveTag={tag => handleRemoveTag({ tag, from: 'exclude' })} + tagsHintList={tagsHintList} + handleValidation={handleValidation} + /> +
+
+ )} +
+ ) +} + +export default RunnerTags; \ No newline at end of file diff --git a/packages/bruno-app/src/components/RunnerResults/index.jsx b/packages/bruno-app/src/components/RunnerResults/index.jsx index f09e305d3..b4fd9274a 100644 --- a/packages/bruno-app/src/components/RunnerResults/index.jsx +++ b/packages/bruno-app/src/components/RunnerResults/index.jsx @@ -9,7 +9,7 @@ import { IconRefresh, IconCircleCheck, IconCircleX, IconCircleOff, IconCheck, Ic import ResponsePane from './ResponsePane'; import StyledWrapper from './StyledWrapper'; import { areItemsLoading } from 'utils/collections'; -import TagList from 'components/RequestPane/Tags/TagList/TagList'; +import RunnerTags from './RunnerTags/index'; const getDisplayName = (fullPath, pathname, name = '') => { let relativePath = path.relative(fullPath, pathname); @@ -43,8 +43,6 @@ export default function RunnerResults({ collection }) { const dispatch = useDispatch(); const [selectedItem, setSelectedItem] = useState(null); const [delay, setDelay] = useState(null); - const [tags, setTags] = useState({ include: [], exclude: [] }); - const [tagsEnabled, setTagsEnabled] = useState(false); // ref for the runner output body const runnerBodyRef = useRef(); @@ -66,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); @@ -78,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); @@ -151,37 +159,9 @@ export default function RunnerResults({ collection }) { onChange={(e) => setDelay(e.target.value)} /> -
-
- - setTagsEnabled(!tagsEnabled)} - /> -
- {tagsEnabled && ( -
-
- Included tags: - setTags({ ...tags, include: [...tags.include, tag] })} - onTagRemove={(tag) => setTags({ ...tags, include: tags.include.filter((t) => t !== tag) })} - /> -
-
- Excluded tags: - setTags({ ...tags, exclude: [...tags.exclude, tag] })} - onTagRemove={(tag) => setTags({ ...tags, exclude: tags.exclude.filter((t) => t !== tag) })} - /> -
-
- )} -
+ + {/* Tags for the collection run */} + + + )) + : null} + + + ); +}; + +export default TagList; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index 1a6de82f1..6056c20f7 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -20,6 +20,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: [], @@ -36,6 +37,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' @@ -1860,6 +1862,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; @@ -1875,6 +1878,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, @@ -1965,6 +1969,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; @@ -2224,6 +2229,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) => { @@ -2352,10 +2371,12 @@ export const collectionsSlice = createSlice({ if (!item.draft) { item.draft = cloneDeep(item); } - item.draft.request.tags = item.draft.request.tags || []; - if (!item.draft.request.tags.includes(tag.trim())) { - item.draft.request.tags.push(tag.trim()); + item.draft.tags = item.draft.tags || []; + if (!item.draft.tags.includes(tag.trim())) { + item.draft.tags.push(tag.trim()); } + + collection.allTags = getUniqueTagsFromItems(collection.items); } } }, @@ -2370,10 +2391,20 @@ export const collectionsSlice = createSlice({ if (!item.draft) { item.draft = cloneDeep(item); } - item.draft.request.tags = item.draft.request.tags || []; - item.draft.request.tags = item.draft.request.tags.filter((t) => t !== tag.trim()); + 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); + } } } }); @@ -2483,6 +2514,7 @@ export const { runRequestEvent, runFolderEvent, resetCollectionRunner, + updateRunnerTagsDetails, updateRequestDocs, updateFolderDocs, moveCollection, @@ -2492,7 +2524,8 @@ export const { updateFolderAuth, updateFolderAuthMode, addRequestTag, - deleteRequestTag + deleteRequestTag, + updateCollectionTagsList } = collectionsSlice.actions; export default collectionsSlice.reducer; diff --git a/packages/bruno-app/src/utils/codemirror/autocomplete.js b/packages/bruno-app/src/utils/codemirror/autocomplete.js index 93ad76493..7b65f4234 100644 --- a/packages/bruno-app/src/utils/codemirror/autocomplete.js +++ b/packages/bruno-app/src/utils/codemirror/autocomplete.js @@ -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); + } }; }; diff --git a/packages/bruno-app/src/utils/codemirror/autocomplete.spec.js b/packages/bruno-app/src/utils/codemirror/autocomplete.spec.js index a14f05917..5a8d984c1 100644 --- a/packages/bruno-app/src/utils/codemirror/autocomplete.spec.js +++ b/packages/bruno-app/src/utils/codemirror/autocomplete.spec.js @@ -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', () => { diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index 45e03beff..69918c218 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -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) { @@ -257,8 +258,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} vars: si.request.vars, assertions: si.request.assertions, tests: si.request.tests, - docs: si.request.docs, - tags: si.request.tags + docs: si.request.docs }; // Handle auth object dynamically @@ -555,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, @@ -566,8 +567,7 @@ export const transformRequestToSaveToFilesystem = (item) => { vars: _item.request.vars, assertions: _item.request.assertions, tests: _item.request.tests, - docs: _item.request.docs, - tags: _item.request.tags + docs: _item.request.docs } }; @@ -1108,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(); +}; diff --git a/packages/bruno-cli/src/commands/run.js b/packages/bruno-cli/src/commands/run.js index 1adde3e1e..811314d41 100644 --- a/packages/bruno-cli/src/commands/run.js +++ b/packages/bruno-cli/src/commands/run.js @@ -462,10 +462,6 @@ const handler = async function (argv) { console.error(chalk.red(`Path not found: ${resolvedPath}`)); process.exit(constants.EXIT_STATUS.ERROR_FILE_NOT_FOUND); } - - requestItems = requestItems.filter((item) => { - return isRequestTagsIncluded(item.tags, includeTags, excludeTags); - }); } requestItems = getCallStack(resolvedPaths, collection, { recursive }); @@ -478,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) => { diff --git a/packages/bruno-cli/src/utils/bru.js b/packages/bruno-cli/src/utils/bru.js index ef3ca9137..b709f76f9 100644 --- a/packages/bruno-cli/src/utils/bru.js +++ b/packages/bruno-cli/src/utils/bru.js @@ -64,7 +64,7 @@ const bruToJson = (bru) => { name: _.get(json, 'meta.name'), seq: !_.isNaN(sequence) ? Number(sequence) : 1, settings: _.get(json, 'settings', {}), - tags: _.get(json, 'tags', []), + tags: _.get(json, 'meta.tags', []), request: { method: _.upperCase(_.get(json, 'http.method')), url: _.get(json, 'http.url'), diff --git a/packages/bruno-electron/src/bru/index.js b/packages/bruno-electron/src/bru/index.js index 319575ab9..9dd920d8d 100644 --- a/packages/bruno-electron/src/bru/index.js +++ b/packages/bruno-electron/src/bru/index.js @@ -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'), @@ -149,14 +150,12 @@ const bruToJson = (data, parsed = false) => { vars: _.get(json, 'vars', {}), assertions: _.get(json, 'assertions', []), tests: _.get(json, 'tests', ''), - docs: _.get(json, 'docs', ''), - tags: _.get(json, 'tags', []) + docs: _.get(json, 'docs', '') } }; 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); @@ -196,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')), @@ -216,8 +216,7 @@ const jsonToBru = async (json) => { assertions: _.get(json, 'request.assertions', []), tests: _.get(json, 'request.tests', ''), settings: _.get(json, 'settings', {}), - docs: _.get(json, 'request.docs', ''), - tags: _.get(json, 'request.tags', []) + docs: _.get(json, 'request.docs', '') }; const bru = jsonToBruV2(bruJson); @@ -239,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')), @@ -259,8 +259,7 @@ const jsonToBruViaWorker = async (json) => { assertions: _.get(json, 'request.assertions', []), tests: _.get(json, 'request.tests', ''), settings: _.get(json, 'settings', {}), - docs: _.get(json, 'request.docs', ''), - tags: _.get(json, 'request.tags', []) + docs: _.get(json, 'request.docs', '') }; const bru = await bruParserWorker?.jsonToBru(bruJson) diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index ef0750e7a..a61ba239c 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -1017,8 +1017,8 @@ const registerNetworkIpc = (mainWindow) => { if (tags && tags.include && tags.exclude) { const includeTags = tags.include ? tags.include : []; const excludeTags = tags.exclude ? tags.exclude : []; - folderRequests = folderRequests.filter(({ request }) => { - return isRequestTagsIncluded(request.tags, includeTags, excludeTags) + folderRequests = folderRequests.filter(({ tags }) => { + return isRequestTagsIncluded(tags, includeTags, excludeTags) }); } diff --git a/packages/bruno-lang/v2/src/bruToJson.js b/packages/bruno-lang/v2/src/bruToJson.js index b12ac491a..3ce411841 100644 --- a/packages/bruno-lang/v2/src/bruToJson.js +++ b/packages/bruno-lang/v2/src/bruToJson.js @@ -52,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 @@ -67,12 +67,11 @@ const grammar = ohm.grammar(`Bru { textchar = ~nl any // List - listend = nl "]" + listend = stnl* "]" list = st* "[" listitems? listend - listitems = (~listend nl)* listitem (~listend stnl* listitem)* (~listend space)* + listitems = (~listend stnl)* listitem (~listend stnl* listitem)* (~listend space)* listitem = st* textchar+ st* - tags = "tags" list meta = "meta" dictionary settings = "settings" dictionary @@ -276,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; }, @@ -283,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) { @@ -342,9 +348,6 @@ const sem = grammar.createSemantics().addAttribute('ast', { _iter(...elements) { return elements.map((e) => e.ast); }, - tags(_1, list) { - return { tags: list.ast }; - }, meta(_1, dictionary) { let meta = mapPairListToKeyValPair(dictionary.ast); diff --git a/packages/bruno-lang/v2/src/jsonToBru.js b/packages/bruno-lang/v2/src/jsonToBru.js index b4adfad92..3a072a254 100644 --- a/packages/bruno-lang/v2/src/jsonToBru.js +++ b/packages/bruno-lang/v2/src/jsonToBru.js @@ -36,18 +36,23 @@ 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`; } - bru += '}\n\n'; - } - if (tags) { - bru += 'tags [\n'; - for (const tag of tags) { - bru += ` ${tag}\n`; + if (tags && tags.length) { + bru += ` tags: [\n`; + for (const tag of tags) { + bru += ` ${tag}\n`; + } + bru += ` ]\n`; } - bru += ']\n\n'; + + bru += '}\n\n'; } if (http && http.method) { diff --git a/packages/bruno-lang/v2/tests/fixtures/request.bru b/packages/bruno-lang/v2/tests/fixtures/request.bru index 5230a8132..dc70da54b 100644 --- a/packages/bruno-lang/v2/tests/fixtures/request.bru +++ b/packages/bruno-lang/v2/tests/fixtures/request.bru @@ -2,13 +2,12 @@ meta { name: Send Bulk SMS type: http seq: 1 + tags: [ + foo + bar + ] } -tags [ - foo - bar -] - get { url: https://api.textlocal.in/send/:id body: json diff --git a/packages/bruno-lang/v2/tests/fixtures/request.json b/packages/bruno-lang/v2/tests/fixtures/request.json index b97419241..539eae116 100644 --- a/packages/bruno-lang/v2/tests/fixtures/request.json +++ b/packages/bruno-lang/v2/tests/fixtures/request.json @@ -2,9 +2,9 @@ "meta": { "name": "Send Bulk SMS", "type": "http", - "seq": "1" + "seq": "1", + "tags": ["foo", "bar"] }, - "tags": ["foo", "bar"], "http": { "method": "get", "url": "https://api.textlocal.in/send/:id", diff --git a/packages/bruno-lang/v2/tests/tags.spec.js b/packages/bruno-lang/v2/tests/tags.spec.js new file mode 100644 index 000000000..58613bca8 --- /dev/null +++ b/packages/bruno-lang/v2/tests/tags.spec.js @@ -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); + }); +}); diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js index 185734bf6..2f143c0e1 100644 --- a/packages/bruno-schema/src/collections/index.js +++ b/packages/bruno-schema/src/collections/index.js @@ -310,8 +310,7 @@ const requestSchema = Yup.object({ .nullable(), assertions: Yup.array().of(keyValueSchema).nullable(), tests: Yup.string().nullable(), - docs: Yup.string().nullable(), - tags: Yup.array().of(Yup.string().matches(/^[\w-]+$/, 'tag must be alphanumeric')) + docs: Yup.string().nullable() }) .noUnknown(true) .strict(); @@ -356,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') diff --git a/packages/bruno-schema/src/collections/itemSchema.spec.js b/packages/bruno-schema/src/collections/itemSchema.spec.js index 8c46bed2c..9d52132da 100644 --- a/packages/bruno-schema/src/collections/itemSchema.spec.js +++ b/packages/bruno-schema/src/collections/itemSchema.spec.js @@ -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); diff --git a/packages/bruno-schema/src/collections/requestSchema.spec.js b/packages/bruno-schema/src/collections/requestSchema.spec.js index 3e17e9190..9fd223cb2 100644 --- a/packages/bruno-schema/src/collections/requestSchema.spec.js +++ b/packages/bruno-schema/src/collections/requestSchema.spec.js @@ -9,7 +9,6 @@ describe('Request Schema Validation', () => { method: 'GET', headers: [], params: [], - tags: ['smoke-test'], body: { mode: 'none' } From 16736958c192500ee47e4cd40d05c4062bfd7091 Mon Sep 17 00:00:00 2001 From: maintainer-bruno Date: Tue, 15 Jul 2025 14:40:46 +0530 Subject: [PATCH 3/5] feat(url): import url encode settings from postman and insomnia (#5102) --- .../src/insomnia/insomnia-to-bruno.js | 9 ++++++++- .../src/postman/postman-to-bruno.js | 6 ++++++ .../tests/insomnia/insomnia-collection-v5.spec.js | 10 ++++++++-- .../tests/insomnia/insomnia-collection.spec.js | 14 ++++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/bruno-converters/src/insomnia/insomnia-to-bruno.js b/packages/bruno-converters/src/insomnia/insomnia-to-bruno.js index d4d829e7b..4968870b0 100644 --- a/packages/bruno-converters/src/insomnia/insomnia-to-bruno.js +++ b/packages/bruno-converters/src/insomnia/insomnia-to-bruno.js @@ -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)) { diff --git a/packages/bruno-converters/src/postman/postman-to-bruno.js b/packages/bruno-converters/src/postman/postman-to-bruno.js index 62317ee0e..9fa85c625 100644 --- a/packages/bruno-converters/src/postman/postman-to-bruno.js +++ b/packages/bruno-converters/src/postman/postman-to-bruno.js @@ -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) { diff --git a/packages/bruno-converters/tests/insomnia/insomnia-collection-v5.spec.js b/packages/bruno-converters/tests/insomnia/insomnia-collection-v5.spec.js index c09065fa6..3d620d1db 100644 --- a/packages/bruno-converters/tests/insomnia/insomnia-collection-v5.spec.js +++ b/packages/bruno-converters/tests/insomnia/insomnia-collection-v5.spec.js @@ -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", diff --git a/packages/bruno-converters/tests/insomnia/insomnia-collection.spec.js b/packages/bruno-converters/tests/insomnia/insomnia-collection.spec.js index e6cb5a2c8..03df7e44f 100644 --- a/packages/bruno-converters/tests/insomnia/insomnia-collection.spec.js +++ b/packages/bruno-converters/tests/insomnia/insomnia-collection.spec.js @@ -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", From 85c4871701ccbed466243c49e1b1e62c0183abeb Mon Sep 17 00:00:00 2001 From: Pooja Date: Tue, 15 Jul 2025 14:41:56 +0530 Subject: [PATCH 4/5] fix: awsv4 signature error bug (#5099) --- packages/bruno-electron/src/ipc/network/prepare-request.js | 2 +- .../bruno-electron/tests/network/prepare-request.spec.js | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/bruno-electron/src/ipc/network/prepare-request.js b/packages/bruno-electron/src/ipc/network/prepare-request.js index b2f11b9f9..777f0446d 100644 --- a/packages/bruno-electron/src/ipc/network/prepare-request.js +++ b/packages/bruno-electron/src/ipc/network/prepare-request.js @@ -426,7 +426,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; } diff --git a/packages/bruno-electron/tests/network/prepare-request.spec.js b/packages/bruno-electron/tests/network/prepare-request.spec.js index 6fbd745a7..f8c014869 100644 --- a/packages/bruno-electron/tests/network/prepare-request.spec.js +++ b/packages/bruno-electron/tests/network/prepare-request.spec.js @@ -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'); From 903c5b4363224d4f7d3d1516907256dcdf1b149b Mon Sep 17 00:00:00 2001 From: Wibaek Park <34394229+wibaek@users.noreply.github.com> Date: Tue, 15 Jul 2025 18:12:46 +0900 Subject: [PATCH 5/5] fix: Ignore empty header on Auth API Key(Header) to prevent sending request error (#5007) --- packages/bruno-electron/src/ipc/network/prepare-request.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/bruno-electron/src/ipc/network/prepare-request.js b/packages/bruno-electron/src/ipc/network/prepare-request.js index 777f0446d..0a95657c7 100644 --- a/packages/bruno-electron/src/ipc/network/prepare-request.js +++ b/packages/bruno-electron/src/ipc/network/prepare-request.js @@ -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') {