From 0e28c97f8fa7f4c75daef69a3092404c0ca73fc5 Mon Sep 17 00:00:00 2001 From: lohit-bruno Date: Tue, 15 Jul 2025 14:33:18 +0530 Subject: [PATCH] collection runner tag updates --- package-lock.json | 5 + .../src/components/CodeEditor/index.js | 8 +- .../src/components/MultiLineEditor/index.js | 10 +- .../RequestPane/GraphQLRequestPane/index.js | 10 +- .../RequestPane/HttpRequestPane/index.js | 9 +- .../RequestPane/Settings/Tags/index.js | 63 +++++ .../components/RequestPane/Settings/index.js | 15 +- .../RequestPane/Tags/TagList/StyledWrapper.js | 25 -- .../RequestPane/Tags/TagList/TagList.js | 69 ----- .../src/components/RequestPane/Tags/index.js | 43 --- .../RunnerResults/RunnerTags/index.jsx | 128 +++++++++ .../src/components/RunnerResults/index.jsx | 69 +++-- .../CollectionItem/RunCollectionItem/index.js | 45 +--- .../src/components/SingleLineEditor/index.js | 14 +- .../src/components/TagList/StyledWrapper.js | 132 ++++++++++ .../bruno-app/src/components/TagList/index.js | 77 ++++++ .../ReduxStore/slices/collections/index.js | 45 +++- .../src/utils/codemirror/autocomplete.js | 102 ++++++-- .../src/utils/codemirror/autocomplete.spec.js | 245 +++++++++++++++--- .../bruno-app/src/utils/collections/index.js | 27 +- packages/bruno-cli/src/commands/run.js | 8 +- packages/bruno-cli/src/utils/bru.js | 2 +- packages/bruno-electron/src/bru/index.js | 17 +- .../bruno-electron/src/ipc/network/index.js | 4 +- packages/bruno-lang/v2/src/bruToJson.js | 17 +- packages/bruno-lang/v2/src/jsonToBru.js | 19 +- .../bruno-lang/v2/tests/fixtures/request.bru | 9 +- .../bruno-lang/v2/tests/fixtures/request.json | 4 +- packages/bruno-lang/v2/tests/tags.spec.js | 33 +++ .../bruno-schema/src/collections/index.js | 4 +- .../src/collections/itemSchema.spec.js | 3 +- .../src/collections/requestSchema.spec.js | 1 - 32 files changed, 920 insertions(+), 342 deletions(-) create mode 100644 packages/bruno-app/src/components/RequestPane/Settings/Tags/index.js delete mode 100644 packages/bruno-app/src/components/RequestPane/Tags/TagList/StyledWrapper.js delete mode 100644 packages/bruno-app/src/components/RequestPane/Tags/TagList/TagList.js delete mode 100644 packages/bruno-app/src/components/RequestPane/Tags/index.js create mode 100644 packages/bruno-app/src/components/RunnerResults/RunnerTags/index.jsx create mode 100644 packages/bruno-app/src/components/TagList/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/TagList/index.js create mode 100644 packages/bruno-lang/v2/tests/tags.spec.js diff --git a/package-lock.json b/package-lock.json index e34e6f213..577b1d843 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/bruno-app/src/components/CodeEditor/index.js b/packages/bruno-app/src/components/CodeEditor/index.js index 1f91c86e5..a83535300 100644 --- a/packages/bruno-app/src/components/CodeEditor/index.js +++ b/packages/bruno-app/src/components/CodeEditor/index.js @@ -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 ); } diff --git a/packages/bruno-app/src/components/MultiLineEditor/index.js b/packages/bruno-app/src/components/MultiLineEditor/index.js index e73e506e1..bd4fc60fe 100644 --- a/packages/bruno-app/src/components/MultiLineEditor/index.js +++ b/packages/bruno-app/src/components/MultiLineEditor/index.js @@ -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 ); diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js index dc42c883f..da48bb34a 100644 --- a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js +++ b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js @@ -19,7 +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'; +import Settings from 'components/RequestPane/Settings'; const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handleGqlClickReference }) => { const dispatch = useDispatch(); @@ -102,8 +102,8 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle case 'docs': { return ; } - case 'tags': { - return ; + case 'settings': { + return ; } default: { return
404 | Not found
; @@ -156,8 +156,8 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
selectTab('docs')}> Docs
-
selectTab('tags')}> - Tags +
selectTab('settings')}> + Settings
diff --git a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js index 72c222f57..1ca7d39c6 100644 --- a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js +++ b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js @@ -18,7 +18,6 @@ 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(); @@ -66,9 +65,6 @@ const HttpRequestPane = ({ item, collection }) => { case 'settings': { return ; } - case 'tags': { - return ; - } default: { return
404 | Not found
; } @@ -105,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; @@ -168,9 +165,7 @@ const HttpRequestPane = ({ item, collection }) => {
selectTab('settings')}> Settings -
-
selectTab('tags')}> - Tags + {tags && tags.length > 0 && }
{focusedTab.requestPaneTab === 'body' ? (
diff --git a/packages/bruno-app/src/components/RequestPane/Settings/Tags/index.js b/packages/bruno-app/src/components/RequestPane/Settings/Tags/index.js new file mode 100644 index 000000000..006a9894f --- /dev/null +++ b/packages/bruno-app/src/components/RequestPane/Settings/Tags/index.js @@ -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 ( +
+ +
+ ); +}; + +export default Tags; \ No newline at end of file diff --git a/packages/bruno-app/src/components/RequestPane/Settings/index.js b/packages/bruno-app/src/components/RequestPane/Settings/index.js index 97caaf1af..df570085d 100644 --- a/packages/bruno-app/src/components/RequestPane/Settings/index.js +++ b/packages/bruno-app/src/components/RequestPane/Settings/index.js @@ -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 ( -
+
+
+

+ + Tags +

+
+ +
+
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 deleted file mode 100644 index 7248d54da..000000000 --- a/packages/bruno-app/src/components/RequestPane/Tags/TagList/TagList.js +++ /dev/null @@ -1,69 +0,0 @@ -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 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 */} +
+ {tagsEnabled && areTagsAdded && ( +
+ Tags: +
+
+ {tags.include.join(', ')} +
+
+ {tags.exclude.join(', ')} +
+
+
+ )} {runnerInfo?.statusText ?
{runnerInfo?.statusText}
: null} + {items.map((item) => { return (
@@ -256,6 +250,11 @@ export default function RunnerResults({ collection }) { )}
+ {tagsEnabled && areTagsAdded && item?.tags?.length > 0 && ( +
+ Tags: {item.tags.filter(t => tags.include.includes(t)).join(', ')} +
+ )} {item.status == 'error' ?
{item.error}
: null}
    diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js index ed650bdba..1bdb2fcf5 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React from 'react'; import get from 'lodash/get'; import { uuid } from 'utils/common'; import Modal from 'components/Modal'; @@ -8,15 +8,19 @@ import { runCollectionFolder } from 'providers/ReduxStore/slices/collections/act import { flattenItems } from 'utils/collections'; import StyledWrapper from './StyledWrapper'; import { areItemsLoading } from 'utils/collections'; -import TagList from 'components/RequestPane/Tags/TagList/TagList'; +import RunnerTags from 'components/RunnerResults/RunnerTags/index'; const RunCollectionItem = ({ collectionUid, item, onClose }) => { const dispatch = useDispatch(); const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid)); const isCollectionRunInProgress = collection?.runnerResult?.info?.status && (collection?.runnerResult?.info?.status !== 'ended'); - const [tags, setTags] = useState({ include: [], exclude: [] }); - const [tagsEnabled, setTagsEnabled] = useState(false); + + // 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( @@ -75,37 +79,8 @@ const RunCollectionItem = ({ collectionUid, item, onClose }) => { {isFolderLoading ?
    Requests in this folder are still loading.
    : null} {isCollectionRunInProgress ?
    A Collection Run is already in progress.
    : null} -
    -
    - - 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 */} +
    diff --git a/packages/bruno-app/src/components/SingleLineEditor/index.js b/packages/bruno-app/src/components/SingleLineEditor/index.js index 4ced64f1e..c29558df5 100644 --- a/packages/bruno-app/src/components/SingleLineEditor/index.js +++ b/packages/bruno-app/src/components/SingleLineEditor/index.js @@ -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 ( -
    +
    {this.secretEye(this.props.isSecret)}
    diff --git a/packages/bruno-app/src/components/TagList/StyledWrapper.js b/packages/bruno-app/src/components/TagList/StyledWrapper.js new file mode 100644 index 000000000..c81897a27 --- /dev/null +++ b/packages/bruno-app/src/components/TagList/StyledWrapper.js @@ -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; \ No newline at end of file diff --git a/packages/bruno-app/src/components/TagList/index.js b/packages/bruno-app/src/components/TagList/index.js new file mode 100644 index 000000000..3e13637f3 --- /dev/null +++ b/packages/bruno-app/src/components/TagList/index.js @@ -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 ( + + + {error && {error}} +
      + {tags && tags.length + ? tags.map((_tag) => ( +
    • + +
    • + )) + : 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' }