From 574324e7848cc763058fe0934b591d44fa4929b4 Mon Sep 17 00:00:00 2001 From: Chirag Chandrashekhar Date: Tue, 3 Mar 2026 19:24:20 +0530 Subject: [PATCH] feat: add collection creation flow in SaveTransientRequest modal (#7328) --- .../CollectionHeader/StyledWrapper.js | 8 +- .../RequestTabs/CollectionHeader/index.js | 3 +- .../CollectionListItem/index.js | 10 +- .../SaveTransientRequest/StyledWrapper.js | 94 ++++++++- .../components/SaveTransientRequest/index.js | 188 ++++++++++++++++-- 5 files changed, 279 insertions(+), 24 deletions(-) diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/StyledWrapper.js index 31eb50f34..54d4efceb 100644 --- a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/StyledWrapper.js @@ -17,8 +17,7 @@ const StyledWrapper = styled.div` background: transparent; color: ${(props) => props.theme.text}; cursor: pointer; - font-size: 15px; - font-weight: 600; + font-weight: 500; transition: background-color 0.15s ease; &:hover { @@ -30,6 +29,11 @@ const StyledWrapper = styled.div` overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + + &.scratch-collection { + font-weight: 600; + font-size: 15px; + } } .tab-count { diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js index a377e2d97..241f5a24f 100644 --- a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js +++ b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js @@ -325,8 +325,7 @@ const CollectionHeader = ({ collection, isScratchCollection }) => { icon={( )} diff --git a/packages/bruno-app/src/components/SaveTransientRequest/CollectionListItem/index.js b/packages/bruno-app/src/components/SaveTransientRequest/CollectionListItem/index.js index cb9826013..8d33eec43 100644 --- a/packages/bruno-app/src/components/SaveTransientRequest/CollectionListItem/index.js +++ b/packages/bruno-app/src/components/SaveTransientRequest/CollectionListItem/index.js @@ -1,6 +1,6 @@ import React, { useMemo, useCallback, memo } from 'react'; import { useSelector } from 'react-redux'; -import { IconDatabase, IconCheck, IconLoader2 } from '@tabler/icons'; +import { IconDatabase, IconLoader2 } from '@tabler/icons'; import { areItemsLoading } from 'utils/collections'; const CollectionListItem = memo(({ collectionUid, collectionPath, collectionName, isSelected, onSelect }) => { @@ -8,11 +8,10 @@ const CollectionListItem = memo(({ collectionUid, collectionPath, collectionName state.collections.collections.find((c) => c.uid === collectionUid || c.pathname === collectionPath) ); - const { isFullyLoaded, isLoading } = useMemo(() => { + const isLoading = useMemo(() => { const isMounted = collection?.mountStatus === 'mounted'; const fullyLoaded = isMounted && !areItemsLoading(collection); - const loading = isSelected && !fullyLoaded; - return { isFullyLoaded: fullyLoaded, isLoading: loading }; + return isSelected && !fullyLoaded; }, [collection, isSelected]); const handleClick = useCallback(() => { @@ -33,9 +32,6 @@ const CollectionListItem = memo(({ collectionUid, collectionPath, collectionName {isLoading && ( )} - {isFullyLoaded && ( - - )} ); }); diff --git a/packages/bruno-app/src/components/SaveTransientRequest/StyledWrapper.js b/packages/bruno-app/src/components/SaveTransientRequest/StyledWrapper.js index 90794cfb0..6a6fcf49e 100644 --- a/packages/bruno-app/src/components/SaveTransientRequest/StyledWrapper.js +++ b/packages/bruno-app/src/components/SaveTransientRequest/StyledWrapper.js @@ -204,7 +204,7 @@ const StyledWrapper = styled.div` display: flex; justify-content: space-between; align-items: center; - padding: 16px 0px; + padding: 16px 0px 0px 0px; background-color: ${(props) => props.theme.modal.body.bg}; border-top: 1px solid ${(props) => props.theme.border.border0}; border-bottom-left-radius: ${(props) => props.theme.border.radius.base}; @@ -370,6 +370,98 @@ const StyledWrapper = styled.div` font-size: 12px; margin-top: 4px; } + + /* New Collection Input Styles */ + .new-collection-item { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px; + border-top: 1px solid ${(props) => props.theme.border.border1}; + margin-top: 4px; + + &:first-child { + border-top: none; + margin-top: 0; + } + } + + .new-collection-field { + display: flex; + flex-direction: column; + gap: 6px; + } + + .new-collection-label { + font-size: 13px; + font-weight: 500; + color: ${(props) => props.theme.text}; + } + + .new-collection-input { + width: 100%; + padding: 8px 10px; + border-radius: ${(props) => props.theme.border.radius.sm}; + background-color: ${(props) => props.theme.input.bg}; + border: 1px solid ${(props) => props.theme.input.border}; + color: ${(props) => props.theme.text}; + font-size: 14px; + transition: border-color ease-in-out 0.1s; + + &:focus { + border: solid 1px ${(props) => props.theme.input.focusBorder} !important; + outline: none !important; + } + + &::placeholder { + color: ${(props) => props.theme.colors.text.muted}; + } + + &.cursor-pointer { + cursor: pointer; + } + } + + .new-collection-location-row { + display: flex; + align-items: center; + gap: 8px; + } + + .new-collection-select { + width: 100%; + padding: 8px 10px; + padding-right: 28px; + border-radius: ${(props) => props.theme.border.radius.sm}; + background-color: ${(props) => props.theme.input.bg}; + border: 1px solid ${(props) => props.theme.input.border}; + color: ${(props) => props.theme.text}; + font-size: 14px; + cursor: pointer; + transition: border-color ease-in-out 0.1s; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 10px center; + + &:focus { + border: solid 1px ${(props) => props.theme.input.focusBorder} !important; + outline: none !important; + } + } + + .new-collection-actions-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; + } + + .collection-empty-state-subtitle { + font-size: 12px; + margin-top: 4px; + opacity: 0.8; + } `; export default StyledWrapper; diff --git a/packages/bruno-app/src/components/SaveTransientRequest/index.js b/packages/bruno-app/src/components/SaveTransientRequest/index.js index 994672ca9..7f49fd701 100644 --- a/packages/bruno-app/src/components/SaveTransientRequest/index.js +++ b/packages/bruno-app/src/components/SaveTransientRequest/index.js @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react'; +import React, { useState, useMemo, useEffect, useCallback } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import Modal from 'components/Modal'; import SearchInput from 'components/SearchInput'; @@ -14,7 +14,7 @@ import FolderBreadcrumbs from './FolderBreadcrumbs'; import useCollectionFolderTree from 'hooks/useCollectionFolderTree'; import { removeSaveTransientRequestModal } from 'providers/ReduxStore/slices/collections'; import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app'; -import { newFolder, closeTabs, mountCollection } from 'providers/ReduxStore/slices/collections/actions'; +import { newFolder, closeTabs, mountCollection, createCollection, browseDirectory } from 'providers/ReduxStore/slices/collections/actions'; import { sanitizeName, validateName, validateNameError } from 'utils/common/regex'; import { resolveRequestFilename } from 'utils/common/platform'; import path from 'utils/common/path'; @@ -23,6 +23,7 @@ import { DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants'; import { itemSchema } from '@usebruno/schema'; import { uuid } from 'utils/common'; import { formatIpcError } from 'utils/common/error'; +import get from 'lodash/get'; const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOpen = false, onClose }) => { const dispatch = useDispatch(); @@ -39,6 +40,11 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid); const allCollections = useSelector((state) => state.collections.collections); const isScratchCollection = activeWorkspace?.scratchCollectionUid === collection?.uid; + const preferences = useSelector((state) => state.app.preferences); + const isDefaultWorkspace = activeWorkspace?.type === 'default'; + const defaultCollectionLocation = isDefaultWorkspace + ? get(preferences, 'general.defaultLocation', '') + : (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : ''); const availableCollections = useMemo(() => { if (!isScratchCollection || !activeWorkspace) return []; @@ -66,7 +72,9 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp const [showFilesystemName, setShowFilesystemName] = useState(false); const [isEditingFolderFilename, setIsEditingFolderFilename] = useState(false); const [pendingFolderNavigation, setPendingFolderNavigation] = useState(null); - const newFolderInputRef = useRef(null); + + // State for new collection creation + const [newCollection, setNewCollection] = useState({ show: false, name: '', location: '', format: DEFAULT_COLLECTION_FORMAT }); const [selectedTargetCollectionPath, setSelectedTargetCollectionPath] = useState(null); const [isSelectingCollection, setIsSelectingCollection] = useState(isScratchCollection); @@ -111,6 +119,8 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp setPendingFolderNavigation(null); setSelectedTargetCollectionPath(null); setIsSelectingCollection(isScratchCollection); + // Reset new collection state + setNewCollection({ show: false, name: '', location: '', format: DEFAULT_COLLECTION_FORMAT }); }, [item?.name, isScratchCollection, reset]); useEffect(() => { @@ -119,12 +129,6 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp } }, [isOpen, item, resetForm]); - useEffect(() => { - if (showNewFolderInput && newFolderInputRef.current) { - newFolderInputRef.current.focus(); - } - }, [showNewFolderInput]); - useEffect(() => { if (pendingFolderNavigation) { const newFolder = currentFolders.find((f) => f.filename === pendingFolderNavigation); @@ -298,6 +302,48 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp } }; + // New Collection handlers + const handleShowNewCollection = () => { + setNewCollection({ show: true, name: '', location: defaultCollectionLocation, format: DEFAULT_COLLECTION_FORMAT }); + }; + + const handleCancelNewCollection = () => { + setNewCollection({ show: false, name: '', location: '', format: DEFAULT_COLLECTION_FORMAT }); + }; + + const handleBrowseCollectionLocation = () => { + dispatch(browseDirectory()) + .then((dirPath) => { + if (typeof dirPath === 'string') { + setNewCollection((prev) => ({ ...prev, location: dirPath })); + } + }) + .catch(() => {}); + }; + + const handleCreateNewCollection = async () => { + const trimmedName = newCollection.name.trim(); + if (!trimmedName) { + toast.error('Collection name is required'); + return; + } + if (!validateName(trimmedName)) { + toast.error(validateNameError(trimmedName)); + return; + } + if (!newCollection.location) { + toast.error('Location is required'); + return; + } + try { + await dispatch(createCollection(trimmedName, sanitizeName(trimmedName), newCollection.location, { format: newCollection.format })); + toast.success('Collection created!'); + handleCancelNewCollection(); + } catch (err) { + toast.error(err?.message || 'An error occurred while creating the collection'); + } + }; + const handleFolderClick = (folderUid) => { navigateIntoFolder(folderUid); setSearchText(''); @@ -377,7 +423,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp {isSelectingCollection ? (
- {availableCollections.length > 0 ? ( + {availableCollections.length > 0 || newCollection.show ? (
    {availableCollections.map((coll) => { const collPath = coll.path || coll.pathname; @@ -392,10 +438,117 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp /> ); })} + {newCollection.show && ( +
  • +
    + + node?.focus()} + type="text" + className="new-collection-input" + placeholder="Enter collection name" + value={newCollection.name} + onChange={(e) => setNewCollection((prev) => ({ ...prev, name: e.target.value }))} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + handleCreateNewCollection(); + } else if (e.key === 'Escape') { + e.stopPropagation(); + handleCancelNewCollection(); + } + }} + /> +
    + +
    + +
    + + +
    +
    + +
    + + +
    + +
    + + +
    +
  • + )}
) : (
- No collections available in workspace. Please add a collection to the workspace first. +

No collections Yet

+

Collections help you organize your requests. Create your first one to save this request.

)}
@@ -448,7 +601,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
node?.focus()} type="text" className="new-folder-input" placeholder="Untitled new folder" @@ -595,6 +748,17 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp New Folder )} + {isSelectingCollection && !newCollection.show && ( + + )}