mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-27 22:54:07 +00:00
feat: add collection creation flow in SaveTransientRequest modal (#7328)
This commit is contained in:
committed by
GitHub
parent
caf073c185
commit
574324e784
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user