feat: add collection creation flow in SaveTransientRequest modal (#7328)

This commit is contained in:
Chirag Chandrashekhar
2026-03-03 19:24:20 +05:30
committed by GitHub
parent caf073c185
commit 574324e784
5 changed files with 279 additions and 24 deletions

View File

@@ -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 {

View File

@@ -325,8 +325,7 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
icon={(
<button className="switcher-trigger">
<DisplayIcon size={18} strokeWidth={1.5} />
<span className="switcher-name">{displayName}</span>
{tabCount > 0 && <span className="tab-count">{tabCount}</span>}
<span className={classNames('switcher-name', { 'scratch-collection': isScratchCollection })}>{displayName}</span>
<IconChevronDown size={14} strokeWidth={1.5} className="chevron" />
</button>
)}

View File

@@ -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 && (
<IconLoader2 size={16} strokeWidth={1.5} className="animate-spin" />
)}
{isFullyLoaded && (
<IconCheck size={16} strokeWidth={1.5} className="icon-success" />
)}
</li>
);
});

View File

@@ -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;

View File

@@ -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 ? (
<div className="collection-list">
{availableCollections.length > 0 ? (
{availableCollections.length > 0 || newCollection.show ? (
<ul className="collection-list-items">
{availableCollections.map((coll) => {
const collPath = coll.path || coll.pathname;
@@ -392,10 +438,117 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
/>
);
})}
{newCollection.show && (
<li className="new-collection-item">
<div className="new-collection-field">
<label className="new-collection-label">
Collection name
</label>
<input
ref={(node) => 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();
}
}}
/>
</div>
<div className="new-collection-field">
<label className="new-collection-label flex items-center">
Location
<Help width={250} placement="top">
<p>
Bruno stores your collections on your computer's filesystem.
</p>
<p className="mt-2">
Choose the location where you want to store this collection.
</p>
</Help>
</label>
<div className="new-collection-location-row">
<input
type="text"
className="new-collection-input cursor-pointer"
placeholder="Select location"
value={newCollection.location}
readOnly
onClick={handleBrowseCollectionLocation}
/>
<Button
type="button"
variant="outline"
color="secondary"
size="sm"
rounded="sm"
onClick={handleBrowseCollectionLocation}
>
Browse
</Button>
</div>
</div>
<div className="new-collection-field">
<label className="new-collection-label flex items-center">
File Format
<Help width={300} placement="top">
<p>
Choose the file format for storing requests in this collection.
</p>
<p className="mt-2">
<strong>OpenCollection (YAML):</strong> Industry-standard YAML format (.yml files)
</p>
<p className="mt-1">
<strong>BRU:</strong> Bruno's native file format (.bru files)
</p>
</Help>
</label>
<select
className="new-collection-select"
value={newCollection.format}
onChange={(e) => setNewCollection((prev) => ({ ...prev, format: e.target.value }))}
>
<option value="yml">OpenCollection (YAML)</option>
<option value="bru">BRU Format (.bru)</option>
</select>
</div>
<div className="new-collection-actions-footer">
<Button
type="button"
color="secondary"
variant="ghost"
size="sm"
onClick={handleCancelNewCollection}
>
Cancel
</Button>
<Button
type="button"
color="primary"
size="sm"
onClick={handleCreateNewCollection}
>
Save
</Button>
</div>
</li>
)}
</ul>
) : (
<div className="collection-empty-state">
No collections available in workspace. Please add a collection to the workspace first.
<p>No collections Yet</p>
<p className="collection-empty-state-subtitle">Collections help you organize your requests. Create your first one to save this request.</p>
</div>
)}
</div>
@@ -448,7 +601,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
</div>
<div className="new-folder-input-row">
<input
ref={newFolderInputRef}
ref={(node) => 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
</Button>
)}
{isSelectingCollection && !newCollection.show && (
<Button
type="button"
color="primary"
variant="ghost"
icon={<IconFolder size={16} strokeWidth={1.5} />}
onClick={handleShowNewCollection}
>
New collection
</Button>
)}
</div>
<div className="footer-right">
<Button type="button" color="secondary" variant="ghost" onClick={handleCancel}>