mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-15 20:01:28 +00:00
Feature/transient request (#6878)
* feat: add functionality to retrieve collection path from transient file requests in IPC module * feat: implement transient directory handling in collection mounting process * add action to store transient directory paths in Redux state * update IPC handler to create and return a temporary directory for collections * modify collection mount action to dispatch transient directory addition * feat: add CreateTransientRequest component for managing transient requests * implement CreateTransientRequest component to facilitate the creation of HTTP, GraphQL, gRPC, and WebSocket transient requests * integrate the component into RequestTabs for user interaction * update collection and request handling to differentiate between transient and non-transient requests * enhance Redux actions to support transient request creation and management * feat: enhance transient request handling and add temp directory watcher * refactor Redux actions for HTTP, gRPC, and WebSocket requests to utilize a unified task queue for transient requests * implement a new helper function to retrieve collection paths from temporary directory metadata * add functionality to watch transient directories for file changes, excluding metadata.json * integrate transient directory watcher into the collection mounting process * feat: enhance transient request management with temp directory integration * update generateTransientRequestName function to accept tempDirectory parameter for improved request naming * modify CreateTransientRequest component to utilize tempDirectory for transient request creation * adjust middleware to check for transient status based on tempDirectory * implement transient file and directory identification in collections slice for better state management * feat: add SaveTransientRequest component for managing transient requests * implement SaveTransientRequest component to facilitate saving transient requests to selected folders * create StyledWrapper for component styling * introduce useCollectionFolderTree hook for managing folder navigation and state * update Redux actions to handle saving requests from transient state * feat: implement SaveTransientRequestContainer and enhance modal management * add SaveTransientRequestContainer to manage multiple transient request modals * refactor SaveTransientRequest component to utilize Redux for modal state management * implement open and close actions for transient request modals in Redux slice * update Bruno page to include SaveTransientRequestContainer for improved UI integration * feat: enhance SaveTransientRequest component with new folder creation functionality * add input for creating new folders within the SaveTransientRequest component * implement validation for new folder names and filesystem names * integrate folder creation logic with Redux actions for better state management * update styling for new folder input elements in StyledWrapper * improve modal behavior to reset state when opened * feat: update CreateTransientRequest to utilize collection presets for request URLs * Refactored CreateTransientRequest component to retrieve request URLs from collection presets. * Enhanced request handling by dynamically setting request URLs based on the selected collection's configuration. * refactor: clean up unused imports and adjust request handling in collections actions * Removed unused imports from actions.js to streamline the code. * Updated the saveRequest function to reject the modal instead of resolving it when handling transient requests. * Cleaned up comments in index.js for better clarity. * refactor: streamline transient request handling and improve save functionality * Removed success toast notifications from CreateTransientRequest component to simplify user feedback. * Enhanced SaveTransientRequest component to handle transient requests more effectively, including improved filename resolution and validation. * Added IPC handler for saving transient requests, ensuring proper file management and error handling. * Updated Redux actions to check for duplicate transient request names within the temporary directory. * feat: enhance request handling in ConfirmCollectionCloseDrafts component * Added logic to differentiate between transient and non-transient drafts, ensuring transient requests are saved individually before closing the collection. * Improved user feedback by displaying unsaved changes for both regular and transient requests. * Updated save and discard functionality to handle all drafts appropriately, enhancing overall user experience. * fix:fixed useCallback dependency array * fix:added request name checks before save * fix: added isTransient to files * fix: added watcher cleanup for temp directory * refactor: enhance transient request handling and optimize component logic * Updated CreateTransientRequest to utilize useMemo for improved performance and prevent unnecessary re-renders. * Refactored generateTransientRequestName to focus solely on transient requests, removing tempDirectory dependency. * Streamlined SaveTransientRequest by consolidating form reset logic and removing unused state variables. * Improved ConfirmCollectionCloseDrafts to differentiate between transient and non-transient drafts more effectively. * Cleaned up imports and optimized Redux actions for better maintainability. * feat: implement transient request file deletion on tab close * Added middleware to handle the deletion of transient request files when tabs are closed. * Enhanced collection-watcher to unlink temporary files, ensuring metadata.json is skipped and only request files are processed. * Improved error handling for file deletion operations. * feat: enhance autosave middleware to skip transient requests * Updated autosave middleware to check for transient requests and skip auto-save operations accordingly. * fix: update ConfirmCollectionCloseDrafts to display all transient drafts * Modified the ConfirmCollectionCloseDrafts component to show all transient drafts without limiting the display to a maximum number. * Removed the conditional message for additional drafts not shown, enhancing the user experience by providing complete visibility of transient requests. * feat: enhance SaveTransientRequest component for better modal management * Refactored SaveTransientRequest and its container to improve modal handling for unsaved transient requests. * Introduced state management for opening specific modals and added functionality to discard all unsaved requests. * Updated Redux actions to manage transient request modals more effectively, ensuring no duplicates are added. * Enhanced user interface to display a list of unsaved requests with options to save or discard them. * feat: improve modal management in SaveTransientRequestContainer * Added useEffect to reset openItemUid when the corresponding modal is no longer present. * Implemented functionality to close all tabs associated with transient requests and show a success message upon discarding them. * Removed unnecessary modal close handler and streamlined modal opening logic for better clarity and performance. * refactor: streamline code formatting and improve readability in collection actions * Consolidated multiple lines of code into single lines for better readability in ConfirmCollectionCloseDrafts and actions.js. * Enhanced consistency in the formatting of function parameters and return statements across the collections slice. * Removed unnecessary line breaks and improved the structure of the code for easier maintenance. * refactor: improve code readability and structure in middleware and actions * Consolidated multiple lines of code into single lines for better readability in middleware.js and actions.js. * Enhanced consistency in formatting function parameters and return statements across the collections slice. * Removed unnecessary line breaks and improved the structure of the code for easier maintenance. * Streamlined dispatch calls for better clarity and performance. * refactor: enhance code readability and consistency in middleware and actions * Improved formatting and structure in middleware.js for dispatch calls. * Streamlined comments and indentation in actions.js for better clarity. * Consolidated multiple lines into single lines where appropriate to enhance readability. * refactor: enhance transient request handling and modal interactions * Improved the modal handling logic for removing collections to differentiate between regular and drafts confirmation modals. * Added new tests for creating and saving transient requests (HTTP, GraphQL, gRPC, WebSocket) ensuring they do not appear in the sidebar until saved. * Introduced utility functions for creating transient requests and filling request URLs, improving code reusability and clarity. * refactor: simplify transient request modal rendering and improve collection watcher logic * Introduced a new TransientRequestModalsRenderer component to streamline modal rendering based on the number of transient requests. * Refactored the collection watcher logic to enhance readability by removing unnecessary setTimeout and consolidating file handling functions. * Improved error handling and logging for the temp directory watcher. * fix: correct spelling of 'WebSocket' in transient request components and tests * Updated the spelling of 'Websocket' to 'WebSocket' in CreateTransientRequest component, transient requests test, and action type definitions for consistency and accuracy.
This commit is contained in:
committed by
GitHub
parent
4b724ebd85
commit
ca4d0dd40b
@@ -0,0 +1,245 @@
|
||||
import React, { useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { IconPlus, IconApi, IconBrandGraphql, IconPlugConnected, IconCode } from '@tabler/icons';
|
||||
import ActionIcon from 'ui/ActionIcon/index';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { newHttpRequest, newGrpcRequest, newWsRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { sanitizeName } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { flattenItems, isItemARequest, isItemTransientRequest } from 'utils/collections';
|
||||
import filter from 'lodash/filter';
|
||||
import { get } from 'lodash';
|
||||
|
||||
const REQUEST_TYPE = {
|
||||
HTTP: 'http',
|
||||
GRAPHQL: 'graphql',
|
||||
GRPC: 'grpc',
|
||||
WEBSOCKET: 'websocket'
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a request name for transient requests in the pattern "Untitled {Count}"
|
||||
* @param {Object} collection - The collection object
|
||||
* @returns {string} A request name like "Untitled 1", "Untitled 2", etc.
|
||||
*/
|
||||
const generateTransientRequestName = (collection) => {
|
||||
if (!collection || !collection.items) {
|
||||
return 'Untitled 1';
|
||||
}
|
||||
const allItems = flattenItems(collection.items);
|
||||
const transientRequests = filter(allItems, (item) => {
|
||||
return isItemTransientRequest(item);
|
||||
});
|
||||
|
||||
// Find the highest "Untitled X" number among transient requests
|
||||
let maxNumber = 0;
|
||||
transientRequests.forEach((item) => {
|
||||
const match = item.name?.match(/^Untitled (\d+)$/);
|
||||
if (match) {
|
||||
const number = parseInt(match[1], 10);
|
||||
if (number > maxNumber) {
|
||||
maxNumber = number;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Increment from the highest number found, or start at 1 if none found
|
||||
const count = maxNumber + 1;
|
||||
|
||||
return `Untitled ${count}`;
|
||||
};
|
||||
|
||||
const CreateTransientRequest = ({ collectionUid }) => {
|
||||
const [dropdownVisible, setDropdownVisible] = useState(false);
|
||||
const dropdownTippyRef = useRef();
|
||||
const dispatch = useDispatch();
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
|
||||
const collection = useMemo(() => {
|
||||
return collections?.find((c) => c.uid === collectionUid);
|
||||
}, [collections]);
|
||||
|
||||
const collectionPresets = useMemo(() => {
|
||||
return get(collection, collection?.draft?.brunoConfig ? 'draft.brunoConfig.presets' : 'brunoConfig.presets', {
|
||||
requestType: 'http',
|
||||
requestUrl: ''
|
||||
});
|
||||
}, [collection]);
|
||||
|
||||
const onDropdownCreate = (ref) => {
|
||||
dropdownTippyRef.current = ref;
|
||||
if (ref) {
|
||||
ref.setProps({
|
||||
onHide: () => {
|
||||
setDropdownVisible(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleLeftClick = () => {
|
||||
handleItemClick(collectionPresets.requestType);
|
||||
};
|
||||
|
||||
const handleRightClick = (e) => {
|
||||
e.preventDefault();
|
||||
setDropdownVisible(true);
|
||||
};
|
||||
|
||||
const handleCreateHttpRequest = useCallback(() => {
|
||||
if (!collection) return;
|
||||
|
||||
const uniqueName = generateTransientRequestName(collection);
|
||||
const filename = sanitizeName(uniqueName);
|
||||
|
||||
dispatch(
|
||||
newHttpRequest({
|
||||
requestName: uniqueName,
|
||||
filename: filename,
|
||||
requestType: 'http-request',
|
||||
requestUrl: collectionPresets.requestUrl,
|
||||
requestMethod: 'GET',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: null,
|
||||
isTransient: true
|
||||
})
|
||||
).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
|
||||
}, [dispatch, collection, collectionPresets.requestUrl]);
|
||||
|
||||
const handleCreateGraphQLRequest = useCallback(() => {
|
||||
if (!collection) return;
|
||||
|
||||
const uniqueName = generateTransientRequestName(collection);
|
||||
const filename = sanitizeName(uniqueName);
|
||||
|
||||
dispatch(
|
||||
newHttpRequest({
|
||||
requestName: uniqueName,
|
||||
filename: filename,
|
||||
requestType: 'graphql-request',
|
||||
requestUrl: collectionPresets.requestUrl,
|
||||
requestMethod: 'POST',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: null,
|
||||
isTransient: true,
|
||||
body: {
|
||||
mode: 'graphql',
|
||||
graphql: {
|
||||
query: '',
|
||||
variables: ''
|
||||
}
|
||||
}
|
||||
})
|
||||
).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
|
||||
}, [dispatch, collection, collectionPresets.requestUrl]);
|
||||
|
||||
const handleCreateWebSocketRequest = useCallback(() => {
|
||||
if (!collection) return;
|
||||
|
||||
const uniqueName = generateTransientRequestName(collection);
|
||||
const filename = sanitizeName(uniqueName);
|
||||
|
||||
dispatch(
|
||||
newWsRequest({
|
||||
requestName: uniqueName,
|
||||
filename: filename,
|
||||
requestUrl: collectionPresets.requestUrl,
|
||||
requestMethod: 'ws',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: null,
|
||||
isTransient: true
|
||||
})
|
||||
).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
|
||||
}, [dispatch, collection, collectionPresets.requestUrl]);
|
||||
|
||||
const handleCreateGrpcRequest = useCallback(() => {
|
||||
if (!collection) return;
|
||||
|
||||
const uniqueName = generateTransientRequestName(collection);
|
||||
const filename = sanitizeName(uniqueName);
|
||||
|
||||
dispatch(
|
||||
newGrpcRequest({
|
||||
requestName: uniqueName,
|
||||
filename: filename,
|
||||
requestUrl: collectionPresets.requestUrl,
|
||||
collectionUid: collection.uid,
|
||||
itemUid: null,
|
||||
isTransient: true
|
||||
})
|
||||
).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
|
||||
}, [dispatch, collection, collectionPresets.requestUrl]);
|
||||
|
||||
const handleItemClick = (type) => {
|
||||
if (dropdownTippyRef.current) {
|
||||
dropdownTippyRef.current.hide();
|
||||
}
|
||||
switch (type) {
|
||||
case REQUEST_TYPE.HTTP:
|
||||
handleCreateHttpRequest();
|
||||
break;
|
||||
case REQUEST_TYPE.GRAPHQL:
|
||||
handleCreateGraphQLRequest();
|
||||
break;
|
||||
case REQUEST_TYPE.GRPC:
|
||||
handleCreateGrpcRequest();
|
||||
break;
|
||||
case REQUEST_TYPE.WEBSOCKET:
|
||||
handleCreateWebSocketRequest();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if (!collection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const IconButton = (
|
||||
<ActionIcon
|
||||
onClick={handleLeftClick}
|
||||
onContextMenu={handleRightClick}
|
||||
aria-label="New Transient Request"
|
||||
size="lg"
|
||||
style={{ marginBottom: '3px' }}
|
||||
>
|
||||
<IconPlus size={18} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
icon={IconButton}
|
||||
visible={dropdownVisible}
|
||||
onCreate={onDropdownCreate}
|
||||
onClickOutside={() => setDropdownVisible(false)}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<div className="dropdown-item" onClick={() => handleItemClick(REQUEST_TYPE.HTTP)}>
|
||||
<div className="dropdown-icon">
|
||||
<IconApi size={16} strokeWidth={2} />
|
||||
</div>
|
||||
<div className="dropdown-label">HTTP</div>
|
||||
</div>
|
||||
<div className="dropdown-item" onClick={() => handleItemClick(REQUEST_TYPE.GRAPHQL)}>
|
||||
<div className="dropdown-icon">
|
||||
<IconBrandGraphql size={16} strokeWidth={2} />
|
||||
</div>
|
||||
<div className="dropdown-label">GraphQL</div>
|
||||
</div>
|
||||
<div className="dropdown-item" onClick={() => handleItemClick(REQUEST_TYPE.GRPC)}>
|
||||
<div className="dropdown-icon">
|
||||
<IconCode size={16} strokeWidth={2} />
|
||||
</div>
|
||||
<div className="dropdown-label">gRPC</div>
|
||||
</div>
|
||||
<div className="dropdown-item" onClick={() => handleItemClick(REQUEST_TYPE.WEBSOCKET)}>
|
||||
<div className="dropdown-icon">
|
||||
<IconPlugConnected size={16} strokeWidth={2} />
|
||||
</div>
|
||||
<div className="dropdown-label">WebSocket</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateTransientRequest;
|
||||
@@ -10,8 +10,7 @@ import CollectionToolBar from './CollectionToolBar';
|
||||
import RequestTab from './RequestTab';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import DraggableTab from './DraggableTab';
|
||||
import CreateUntitledRequest from 'components/CreateUntitledRequest';
|
||||
import { IconPlus } from '@tabler/icons';
|
||||
import CreateTransientRequest from 'components/CreateTransientRequest';
|
||||
import ActionIcon from 'ui/ActionIcon/index';
|
||||
|
||||
const RequestTabs = () => {
|
||||
@@ -161,12 +160,7 @@ const RequestTabs = () => {
|
||||
</div>
|
||||
|
||||
{activeCollection && (
|
||||
<ActionIcon onClick={() => setNewRequestModalOpen(true)} aria-label="New Request" size="lg" style={{ marginBottom: '3px' }}>
|
||||
<IconPlus
|
||||
size={18}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</ActionIcon>
|
||||
<CreateTransientRequest collectionUid={activeCollection.uid} />
|
||||
)}
|
||||
|
||||
<div className={classnames('scroll-chevrons', { hidden: !showChevrons })}>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { pluralizeWord } from 'utils/common';
|
||||
import { IconAlertTriangle, IconDeviceFloppy } from '@tabler/icons';
|
||||
import { clearAllSaveTransientRequestModals } from 'providers/ReduxStore/slices/collections';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import toast from 'react-hot-toast';
|
||||
import Modal from 'components/Modal';
|
||||
import Button from 'ui/Button';
|
||||
import SaveTransientRequest from './index';
|
||||
|
||||
const SaveTransientRequestContainer = () => {
|
||||
const dispatch = useDispatch();
|
||||
const modals = useSelector((state) => state.collections.saveTransientRequestModals);
|
||||
const [openItemUid, setOpenItemUid] = useState(null);
|
||||
|
||||
// Reset openItemUid if the modal no longer exists in the array
|
||||
useEffect(() => {
|
||||
if (openItemUid && !modals.find((modal) => modal.item.uid === openItemUid)) {
|
||||
setOpenItemUid(null);
|
||||
}
|
||||
}, [modals, openItemUid]);
|
||||
|
||||
const handleDiscardAll = () => {
|
||||
// Close all tabs for the transient requests (this will also delete the transient files)
|
||||
const tabUids = modals.map((modal) => modal.item.uid);
|
||||
dispatch(closeTabs({ tabUids }));
|
||||
|
||||
// Clear all modals
|
||||
dispatch(clearAllSaveTransientRequestModals());
|
||||
|
||||
// Show success message
|
||||
toast.success(`Discarded ${modals.length} ${pluralizeWord('request', modals.length)}`);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
// Clear all modals on close
|
||||
dispatch(clearAllSaveTransientRequestModals());
|
||||
};
|
||||
|
||||
const handleOpenSpecificModal = (itemUid) => {
|
||||
setOpenItemUid(itemUid);
|
||||
};
|
||||
|
||||
// If a specific modal is open, show it
|
||||
if (openItemUid) {
|
||||
const modalToOpen = modals.find((modal) => modal.item.uid === openItemUid);
|
||||
if (modalToOpen) {
|
||||
return (
|
||||
<SaveTransientRequest
|
||||
item={modalToOpen.item}
|
||||
collection={modalToOpen.collection}
|
||||
isOpen={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Show list of multiple modals
|
||||
return (
|
||||
<Modal
|
||||
size="md"
|
||||
title="Unsaved Transient Requests"
|
||||
hideFooter={true}
|
||||
disableEscapeKey={true}
|
||||
disableCloseOnOutsideClick={true}
|
||||
handleCancel={handleCancel}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<IconAlertTriangle size={32} strokeWidth={1.5} className="text-yellow-600" />
|
||||
<h1 className="ml-2 text-lg font-medium">You have unsaved transient requests</h1>
|
||||
</div>
|
||||
<p className="mt-4">
|
||||
You have <span className="font-medium">{modals.length}</span>{' '}
|
||||
{pluralizeWord('request', modals.length)} that need to be saved.
|
||||
</p>
|
||||
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium mb-2">
|
||||
Transient {pluralizeWord('Request', modals.length)} ({modals.length})
|
||||
</p>
|
||||
<p className="text-xs text-orange-600 mb-3">
|
||||
These requests need to be saved before you can proceed.
|
||||
</p>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto pr-1">
|
||||
{modals.map((modal) => {
|
||||
const { item, collection } = modal;
|
||||
return (
|
||||
<div
|
||||
key={item.uid}
|
||||
className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded border border-gray-200"
|
||||
>
|
||||
<div className="flex flex-col flex-1 min-w-0 mr-3">
|
||||
<span className="text-sm text-gray-700 truncate">{item.name}</span>
|
||||
<span className="text-xs text-gray-500 truncate">
|
||||
{collection.name}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
color="primary"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleOpenSpecificModal(item.uid)}
|
||||
icon={<IconDeviceFloppy size={14} strokeWidth={1.5} />}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-6 pt-4 border-t">
|
||||
<Button color="danger" onClick={handleDiscardAll}>
|
||||
Discard All
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SaveTransientRequestContainer;
|
||||
@@ -0,0 +1,287 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.save-request-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.form-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
line-height: 1.42857143;
|
||||
padding: 0.45rem;
|
||||
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};
|
||||
transition: border-color ease-in-out 0.1s;
|
||||
|
||||
&:focus {
|
||||
border: solid 1px ${(props) => props.theme.input.focusBorder} !important;
|
||||
outline: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.collections-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.collections-label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.collection-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.collection-name-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.collection-name-breadcrumb {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.collection-name-chevron {
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.folder-list {
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
max-height: 256px;
|
||||
overflow-y: auto;
|
||||
background-color: ${(props) => props.theme.modal.body.bg};
|
||||
padding: 8px 8px;
|
||||
}
|
||||
|
||||
.folder-list-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
}
|
||||
|
||||
.folder-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
color: ${(props) => props.theme.text};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
user-select: none;
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.plainGrid.hoverBg};
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: ${(props) => props.theme.plainGrid.hoverBg};
|
||||
}
|
||||
}
|
||||
|
||||
.folder-item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.folder-item-name {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.folder-empty-state {
|
||||
padding: 16px 12px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.custom-modal-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 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};
|
||||
border-bottom-right-radius: ${(props) => props.theme.border.radius.base};
|
||||
}
|
||||
|
||||
.footer-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.new-folder-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid ${(props) => props.theme.border.border1};
|
||||
margin-top: 4px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.new-folder-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.new-folder-inputs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.new-folder-name-input-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.new-folder-name-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.new-folder-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.new-folder-input {
|
||||
flex: 1;
|
||||
padding: 6px 8px;
|
||||
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};
|
||||
}
|
||||
}
|
||||
|
||||
.new-folder-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.new-folder-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.plainGrid.hoverBg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.new-folder-filesystem-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.new-folder-filesystem-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.new-folder-toggle-filesystem-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
margin-top: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
font-size: 12px;
|
||||
transition: all 0.15s ease;
|
||||
align-self: flex-start;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.plainGrid.hoverBg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.new-folder-error {
|
||||
color: ${(props) => props.theme.colors.danger};
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
412
packages/bruno-app/src/components/SaveTransientRequest/index.js
Normal file
412
packages/bruno-app/src/components/SaveTransientRequest/index.js
Normal file
@@ -0,0 +1,412 @@
|
||||
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import Modal from 'components/Modal';
|
||||
import SearchInput from 'components/SearchInput';
|
||||
import Button from 'ui/Button';
|
||||
import { IconFolder, IconChevronRight, IconCheck, IconX, IconEye, IconEyeOff } from '@tabler/icons';
|
||||
import filter from 'lodash/filter';
|
||||
import toast from 'react-hot-toast';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import useCollectionFolderTree from 'hooks/useCollectionFolderTree';
|
||||
import { removeSaveTransientRequestModal, deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
|
||||
import { newFolder } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
|
||||
import { resolveRequestFilename } from 'utils/common/platform';
|
||||
import { transformRequestToSaveToFilesystem, findCollectionByUid, findItemInCollection } from 'utils/collections';
|
||||
import { itemSchema } from '@usebruno/schema';
|
||||
|
||||
const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOpen = false, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const latestCollection = useSelector((state) =>
|
||||
collectionProp ? findCollectionByUid(state.collections.collections, collectionProp.uid) : null
|
||||
);
|
||||
const latestItem = latestCollection && itemProp ? findItemInCollection(latestCollection, itemProp.uid) : itemProp;
|
||||
|
||||
const item = itemProp;
|
||||
const collection = collectionProp;
|
||||
|
||||
const handleClose = () => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
// Remove from Redux array
|
||||
dispatch(removeSaveTransientRequestModal({ itemUid: item.uid }));
|
||||
};
|
||||
const [requestName, setRequestName] = useState(item?.name || '');
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [showNewFolderInput, setShowNewFolderInput] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
const [newFolderDirectoryName, setNewFolderDirectoryName] = useState('');
|
||||
const [showFilesystemName, setShowFilesystemName] = useState(false);
|
||||
const newFolderInputRef = useRef(null);
|
||||
|
||||
const {
|
||||
currentFolders,
|
||||
breadcrumbs,
|
||||
selectedFolderUid,
|
||||
navigateIntoFolder,
|
||||
navigateToRoot,
|
||||
navigateToBreadcrumb,
|
||||
getCurrentParentFolder,
|
||||
getCurrentSelectedFolder,
|
||||
reset,
|
||||
isAtRoot
|
||||
} = useCollectionFolderTree(collection?.uid);
|
||||
|
||||
const resetForm = () => {
|
||||
setRequestName(item.name || '');
|
||||
setSearchText('');
|
||||
reset();
|
||||
setShowNewFolderInput(false);
|
||||
setNewFolderName('');
|
||||
setNewFolderDirectoryName('');
|
||||
setShowFilesystemName(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
isOpen && item && resetForm();
|
||||
}, [isOpen, item]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showNewFolderInput && newFolderInputRef.current) {
|
||||
newFolderInputRef.current.focus();
|
||||
}
|
||||
}, [showNewFolderInput]);
|
||||
|
||||
const filteredFolders = useMemo(() => {
|
||||
if (!searchText.trim()) {
|
||||
return currentFolders;
|
||||
}
|
||||
const searchLower = searchText.toLowerCase();
|
||||
return filter(currentFolders, (folder) => folder.name.toLowerCase().includes(searchLower));
|
||||
}, [currentFolders, searchText]);
|
||||
|
||||
const handleCancel = () => {
|
||||
resetForm();
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!item || !collection || !latestItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
const selectedFolder = getCurrentSelectedFolder();
|
||||
const targetDirname = selectedFolder ? selectedFolder.pathname : collection.pathname;
|
||||
|
||||
const trimmedName = requestName.trim();
|
||||
if (!trimmedName || trimmedName.length === 0) {
|
||||
toast.error('Request name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitizedFilename = sanitizeName(trimmedName);
|
||||
|
||||
const itemToSave = latestItem.draft ? { ...latestItem, ...latestItem.draft } : { ...latestItem };
|
||||
itemToSave.name = sanitizedFilename;
|
||||
delete itemToSave.draft;
|
||||
|
||||
const transformedItem = transformRequestToSaveToFilesystem(itemToSave);
|
||||
await itemSchema.validate(transformedItem);
|
||||
|
||||
const format = collection.format || 'bru';
|
||||
const targetFilename = resolveRequestFilename(sanitizedFilename, format);
|
||||
|
||||
await ipcRenderer.invoke('renderer:save-transient-request', {
|
||||
sourcePathname: item.pathname,
|
||||
targetDirname,
|
||||
targetFilename,
|
||||
request: transformedItem,
|
||||
format
|
||||
});
|
||||
|
||||
dispatch(
|
||||
closeTabs({
|
||||
tabUids: [item.uid]
|
||||
})
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: 'collections/deleteItem',
|
||||
payload: {
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
}
|
||||
});
|
||||
|
||||
toast.success('Request saved successfully');
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
toast.error(err?.message || 'Failed to save request');
|
||||
console.error('Error saving request:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowNewFolder = () => {
|
||||
setShowNewFolderInput(true);
|
||||
setNewFolderName('');
|
||||
setNewFolderDirectoryName('');
|
||||
setShowFilesystemName(false);
|
||||
};
|
||||
|
||||
const handleCancelNewFolder = () => {
|
||||
setShowNewFolderInput(false);
|
||||
setNewFolderName('');
|
||||
setNewFolderDirectoryName('');
|
||||
setShowFilesystemName(false);
|
||||
};
|
||||
|
||||
const handleNewFolderNameChange = (value) => {
|
||||
setNewFolderName(value);
|
||||
if (!showFilesystemName) {
|
||||
setNewFolderDirectoryName(sanitizeName(value));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDirectoryNameChange = (value) => {
|
||||
setNewFolderDirectoryName(value);
|
||||
};
|
||||
|
||||
const handleCreateNewFolder = async () => {
|
||||
const directoryName = newFolderDirectoryName.trim() || sanitizeName(newFolderName.trim());
|
||||
const parentFolder = getCurrentParentFolder();
|
||||
|
||||
try {
|
||||
await dispatch(newFolder(newFolderName.trim(), directoryName, collection?.uid, parentFolder?.uid));
|
||||
toast.success('New folder created!');
|
||||
handleCancelNewFolder();
|
||||
} catch (err) {
|
||||
const errorMessage = err?.message || 'An error occurred while adding the folder';
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFolderClick = (folderUid) => {
|
||||
navigateIntoFolder(folderUid);
|
||||
setSearchText('');
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<Modal
|
||||
size="md"
|
||||
title="Save Request"
|
||||
handleCancel={handleCancel}
|
||||
handleConfirm={handleConfirm}
|
||||
confirmText="Save"
|
||||
cancelText="Cancel"
|
||||
hideFooter={true}
|
||||
>
|
||||
<div className="save-request-form">
|
||||
<div className="form-section">
|
||||
<label htmlFor="request-name" className="form-label">
|
||||
Request name
|
||||
</label>
|
||||
<input
|
||||
id="request-name"
|
||||
type="text"
|
||||
className="form-input textbox"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
value={requestName}
|
||||
onChange={(e) => setRequestName(e.target.value)}
|
||||
autoFocus={true}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="collections-section">
|
||||
<div className="collections-label">Save to Collections</div>
|
||||
{collection && (
|
||||
<div
|
||||
className={`collection-name ${!isAtRoot ? 'collection-name-clickable' : ''}`}
|
||||
onClick={!isAtRoot ? navigateToRoot : undefined}
|
||||
>
|
||||
<span>{collection.name}</span>
|
||||
{breadcrumbs.length > 0 && (
|
||||
<>
|
||||
{breadcrumbs.map((breadcrumb, index) => (
|
||||
<React.Fragment key={breadcrumb.uid}>
|
||||
<IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />
|
||||
<span
|
||||
className="collection-name-breadcrumb"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigateToBreadcrumb(index);
|
||||
setSearchText('');
|
||||
}}
|
||||
>
|
||||
{breadcrumb.name}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{isAtRoot && <IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="search-container">
|
||||
<SearchInput
|
||||
searchText={searchText}
|
||||
setSearchText={setSearchText}
|
||||
placeholder="Search for folder"
|
||||
autoFocus={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="folder-list">
|
||||
{filteredFolders.length > 0 || showNewFolderInput ? (
|
||||
<ul className="folder-list-items">
|
||||
{filteredFolders.map((folder) => (
|
||||
<li
|
||||
key={folder.uid}
|
||||
className={`folder-item ${selectedFolderUid === folder.uid ? 'selected' : ''}`}
|
||||
onClick={() => handleFolderClick(folder.uid)}
|
||||
>
|
||||
<div className="folder-item-content">
|
||||
<IconFolder size={16} strokeWidth={1.5} />
|
||||
<span className="folder-item-name">{folder.name}</span>
|
||||
</div>
|
||||
<IconChevronRight size={16} strokeWidth={1.5} />
|
||||
</li>
|
||||
))}
|
||||
{showNewFolderInput && (
|
||||
<li className="new-folder-item">
|
||||
<div className="new-folder-content">
|
||||
<IconFolder size={16} strokeWidth={1.5} />
|
||||
<div className="new-folder-inputs">
|
||||
<div className="new-folder-name-input-wrapper">
|
||||
{showFilesystemName && (
|
||||
<label className="new-folder-name-label">New Folder name (in bruno)</label>
|
||||
)}
|
||||
<div className="new-folder-input-row">
|
||||
<input
|
||||
ref={newFolderInputRef}
|
||||
type="text"
|
||||
className="new-folder-input"
|
||||
placeholder="Untitled new folder"
|
||||
value={newFolderName}
|
||||
onChange={(e) => handleNewFolderNameChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleCreateNewFolder();
|
||||
} else if (e.key === 'Escape') {
|
||||
handleCancelNewFolder();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="new-folder-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="new-folder-action-btn"
|
||||
onClick={handleCancelNewFolder}
|
||||
title="Cancel"
|
||||
>
|
||||
<IconX size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="new-folder-action-btn"
|
||||
onClick={handleCreateNewFolder}
|
||||
title="Create folder"
|
||||
>
|
||||
<IconCheck size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFilesystemName && (
|
||||
<div className="new-folder-filesystem-wrapper">
|
||||
<label className="new-folder-filesystem-label">Name on filesystem</label>
|
||||
<input
|
||||
type="text"
|
||||
className="new-folder-input"
|
||||
value={newFolderDirectoryName}
|
||||
onChange={(e) => handleDirectoryNameChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleCreateNewFolder();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="new-folder-toggle-filesystem-btn"
|
||||
onClick={() => {
|
||||
setShowFilesystemName(!showFilesystemName);
|
||||
setNewFolderDirectoryName(sanitizeName(newFolderName));
|
||||
}}
|
||||
>
|
||||
{showFilesystemName ? (
|
||||
<>
|
||||
<IconEyeOff size={16} strokeWidth={1.5} />
|
||||
<span>Hide filesystem name</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconEye size={16} strokeWidth={1.5} />
|
||||
<span>Show filesystem name</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="folder-empty-state">
|
||||
{searchText.trim() ? 'No folders found' : 'No folders available'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="custom-modal-footer">
|
||||
<div className="footer-left">
|
||||
{!showNewFolderInput && (
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
variant="ghost"
|
||||
icon={<IconFolder size={16} strokeWidth={1.5} />}
|
||||
onClick={handleShowNewFolder}
|
||||
>
|
||||
New Folder
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="footer-right">
|
||||
<Button type="button" color="secondary" variant="ghost" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" color="primary" onClick={handleConfirm}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default SaveTransientRequest;
|
||||
@@ -502,8 +502,8 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
setCreateExampleModalOpen(false);
|
||||
};
|
||||
|
||||
const folderItems = sortByNameThenSequence(filter(item.items, (i) => isItemAFolder(i)));
|
||||
const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i)));
|
||||
const folderItems = sortByNameThenSequence(filter(item.items, (i) => isItemAFolder(i) && !i.isTransient));
|
||||
const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i) && !i.isTransient));
|
||||
|
||||
const handleGenerateCode = () => {
|
||||
if (
|
||||
|
||||
@@ -1,49 +1,89 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import filter from 'lodash/filter';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { flattenItems, isItemARequest, hasRequestChanges } from 'utils/collections';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { flattenItems, isItemARequest, hasRequestChanges, findCollectionByUid } from 'utils/collections';
|
||||
import { pluralizeWord } from 'utils/common';
|
||||
import { saveMultipleRequests } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { saveRequest, saveMultipleRequests } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
|
||||
import { removeCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { IconAlertTriangle } from '@tabler/icons';
|
||||
import { IconAlertTriangle, IconDeviceFloppy } from '@tabler/icons';
|
||||
import Modal from 'components/Modal';
|
||||
import toast from 'react-hot-toast';
|
||||
import Button from 'ui/Button';
|
||||
|
||||
const MAX_UNSAVED_REQUESTS_TO_SHOW = 5;
|
||||
|
||||
const ConfirmCollectionCloseDrafts = ({ onClose, collection, collectionUid }) => {
|
||||
const MAX_UNSAVED_REQUESTS_TO_SHOW = 5;
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Get all draft items in the collection
|
||||
const currentDrafts = React.useMemo(() => {
|
||||
if (!collection) return [];
|
||||
const items = flattenItems(collection.items);
|
||||
const collectionDrafts = filter(items, (item) => isItemARequest(item) && hasRequestChanges(item));
|
||||
return collectionDrafts.map((draft) => ({
|
||||
...draft,
|
||||
collectionUid: collectionUid
|
||||
}));
|
||||
}, [collection, collectionUid]);
|
||||
const latestCollection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid));
|
||||
|
||||
const activeCollection = latestCollection || collection;
|
||||
|
||||
const currentDrafts = useMemo(() => {
|
||||
if (!activeCollection) return [];
|
||||
const items = flattenItems(activeCollection.items);
|
||||
return items
|
||||
?.filter((item) => isItemARequest(item) && hasRequestChanges(item) && !item.isTransient)
|
||||
.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
collectionUid: collectionUid
|
||||
};
|
||||
});
|
||||
}, [activeCollection, collectionUid]);
|
||||
|
||||
const currentTransientDrafts = useMemo(() => {
|
||||
if (!activeCollection) return [];
|
||||
const items = flattenItems(activeCollection.items);
|
||||
return items
|
||||
?.filter((item) => isItemARequest(item) && hasRequestChanges(item) && item.isTransient)
|
||||
.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
collectionUid: collectionUid
|
||||
};
|
||||
});
|
||||
}, [activeCollection, collectionUid]);
|
||||
|
||||
const allDrafts = useMemo(() => {
|
||||
return [...currentDrafts, ...currentTransientDrafts];
|
||||
}, [currentDrafts, currentTransientDrafts]);
|
||||
|
||||
const handleSaveAll = () => {
|
||||
dispatch(saveMultipleRequests(currentDrafts))
|
||||
.then(() => {
|
||||
dispatch(removeCollection(collectionUid))
|
||||
.then(() => {
|
||||
toast.success('Collection removed from workspace');
|
||||
onClose();
|
||||
})
|
||||
.catch(() => toast.error('An error occurred while removing the collection'));
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('Failed to save requests!');
|
||||
});
|
||||
// If there are transient drafts, we can't proceed with batch save
|
||||
if (currentTransientDrafts.length > 0) {
|
||||
toast.error('Please save or discard transient requests first');
|
||||
return;
|
||||
}
|
||||
// Save only non-transient drafts
|
||||
if (currentDrafts.length > 0) {
|
||||
dispatch(saveMultipleRequests(currentDrafts))
|
||||
.then(() => {
|
||||
dispatch(removeCollection(collectionUid))
|
||||
.then(() => {
|
||||
toast.success('Collection removed from workspace');
|
||||
onClose();
|
||||
})
|
||||
.catch(() => toast.error('An error occurred while removing the collection'));
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('Failed to save requests!');
|
||||
});
|
||||
} else {
|
||||
// No non-transient drafts, just remove the collection
|
||||
dispatch(removeCollection(collectionUid))
|
||||
.then(() => {
|
||||
toast.success('Collection removed from workspace');
|
||||
onClose();
|
||||
})
|
||||
.catch(() => toast.error('An error occurred while removing the collection'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscardAll = () => {
|
||||
// Discard all drafts
|
||||
currentDrafts.forEach((draft) => {
|
||||
// Discard all drafts (both regular and transient)
|
||||
allDrafts.forEach((draft) => {
|
||||
dispatch(deleteRequestDraft({
|
||||
collectionUid: collectionUid,
|
||||
itemUid: draft.uid
|
||||
@@ -59,7 +99,11 @@ const ConfirmCollectionCloseDrafts = ({ onClose, collection, collectionUid }) =>
|
||||
.catch(() => toast.error('An error occurred while removing the collection'));
|
||||
};
|
||||
|
||||
if (!currentDrafts.length) {
|
||||
const handleSaveTransient = (draft) => {
|
||||
dispatch(saveRequest(draft.uid, collectionUid));
|
||||
};
|
||||
|
||||
if (!currentDrafts.length && !currentTransientDrafts.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -80,38 +124,82 @@ const ConfirmCollectionCloseDrafts = ({ onClose, collection, collectionUid }) =>
|
||||
<h1 className="ml-2 text-lg font-medium">Hold on..</h1>
|
||||
</div>
|
||||
<p className="mt-4">
|
||||
Do you want to save the changes you made to the following{' '}
|
||||
<span className="font-medium">{currentDrafts.length}</span> {pluralizeWord('request', currentDrafts.length)}?
|
||||
You have unsaved changes in <span className="font-medium">{allDrafts.length}</span>{' '}
|
||||
{pluralizeWord('request', allDrafts.length)}.
|
||||
</p>
|
||||
|
||||
<ul className="mt-4">
|
||||
{currentDrafts.slice(0, MAX_UNSAVED_REQUESTS_TO_SHOW).map((item) => {
|
||||
return (
|
||||
<li key={item.uid} className="mt-1 text-xs">
|
||||
{item.filename}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{/* Regular (saved) requests with changes */}
|
||||
{currentDrafts.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium mb-2">
|
||||
Saved {pluralizeWord('Request', currentDrafts.length)} ({currentDrafts.length})
|
||||
</p>
|
||||
<ul className="ml-2">
|
||||
{currentDrafts.slice(0, MAX_UNSAVED_REQUESTS_TO_SHOW).map((item) => {
|
||||
return (
|
||||
<li key={item.uid} className="mt-1 text-xs text-gray-600">
|
||||
• {item.filename || item.name}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{currentDrafts.length > MAX_UNSAVED_REQUESTS_TO_SHOW && (
|
||||
<p className="ml-2 mt-1 text-xs text-gray-500">
|
||||
...{currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW} additional{' '}
|
||||
{pluralizeWord('request', currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW)} not shown
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentDrafts.length > MAX_UNSAVED_REQUESTS_TO_SHOW && (
|
||||
<p className="mt-1 text-xs">
|
||||
...{currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW} additional{' '}
|
||||
{pluralizeWord('request', currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW)} not shown
|
||||
</p>
|
||||
{/* Transient (unsaved) requests */}
|
||||
{currentTransientDrafts.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium mb-2">
|
||||
Transient {pluralizeWord('Request', currentTransientDrafts.length)} ({currentTransientDrafts.length})
|
||||
</p>
|
||||
<p className="text-xs text-orange-600 mb-3">
|
||||
These requests need to be saved individually before closing the collection.
|
||||
</p>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto pr-1">
|
||||
{currentTransientDrafts.map((item) => {
|
||||
return (
|
||||
<div
|
||||
key={item.uid}
|
||||
className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded border border-gray-200"
|
||||
>
|
||||
<span className="text-sm text-gray-700 truncate mr-3">{item.name}</span>
|
||||
<Button
|
||||
color="primary"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleSaveTransient(item)}
|
||||
icon={<IconDeviceFloppy size={14} strokeWidth={1.5} />}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between mt-6">
|
||||
<div>
|
||||
<Button color="danger" onClick={handleDiscardAll}>
|
||||
Discard and Remove
|
||||
Discard All and Remove
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button className="mr-2" color="secondary" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSaveAll}>
|
||||
<Button
|
||||
onClick={handleSaveAll}
|
||||
disabled={currentTransientDrafts.length > 0}
|
||||
title={currentTransientDrafts.length > 0 ? 'Please save or discard transient requests first' : ''}
|
||||
>
|
||||
{currentDrafts.length > 1 ? 'Save All and Remove' : 'Save and Remove'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -276,8 +276,8 @@ const Collection = ({ collection, searchText }) => {
|
||||
return items.sort((a, b) => a.seq - b.seq);
|
||||
};
|
||||
|
||||
const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i)));
|
||||
const folderItems = sortByNameThenSequence(filter(collection.items, (i) => isItemAFolder(i)));
|
||||
const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i) && !i.isTransient));
|
||||
const folderItems = sortByNameThenSequence(filter(collection.items, (i) => isItemAFolder(i) && !i.isTransient));
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
|
||||
162
packages/bruno-app/src/hooks/useCollectionFolderTree/index.js
Normal file
162
packages/bruno-app/src/hooks/useCollectionFolderTree/index.js
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { isItemAFolder } from 'utils/collections';
|
||||
import { sortByNameThenSequence } from 'utils/common/index';
|
||||
import filter from 'lodash/filter';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { findCollectionByUid } from 'utils/collections';
|
||||
|
||||
const buildTree = (items) => {
|
||||
const tree = {};
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return tree;
|
||||
}
|
||||
|
||||
const folders = filter(items, (i) => isItemAFolder(i) && !i.isTransient);
|
||||
const sortedFolders = sortByNameThenSequence(folders);
|
||||
|
||||
for (const folder of sortedFolders) {
|
||||
tree[folder.name] = {
|
||||
uid: folder.uid,
|
||||
name: folder.name,
|
||||
item: folder,
|
||||
children: folder.items && folder.items.length > 0 ? buildTree(folder.items) : {}
|
||||
};
|
||||
}
|
||||
|
||||
return tree;
|
||||
};
|
||||
|
||||
const findFolderByUidInTree = (tree, uid) => {
|
||||
for (const folderName in tree) {
|
||||
const folder = tree[folderName];
|
||||
if (folder.uid === uid) {
|
||||
return folder;
|
||||
}
|
||||
if (folder.children && Object.keys(folder.children).length > 0) {
|
||||
const found = findFolderByUidInTree(folder.children, uid);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getFoldersAtPath = (tree, path) => {
|
||||
if (path.length === 0) {
|
||||
return Object.values(tree).map((folder) => folder.item);
|
||||
}
|
||||
|
||||
let currentTree = tree;
|
||||
for (const folderUid of path) {
|
||||
const folder = findFolderByUidInTree(currentTree, folderUid);
|
||||
if (folder && folder.children) {
|
||||
currentTree = folder.children;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(currentTree).map((folder) => folder.item);
|
||||
};
|
||||
|
||||
const useCollectionFolderTree = (collectionUid) => {
|
||||
const collection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid));
|
||||
const [currentFolderPath, setCurrentFolderPath] = useState([]);
|
||||
const [selectedFolderUid, setSelectedFolderUid] = useState(null);
|
||||
const tree = useMemo(() => {
|
||||
if (!collection || !collection.items) {
|
||||
return {};
|
||||
}
|
||||
return buildTree(collection.items);
|
||||
}, [collection]);
|
||||
|
||||
const currentFolders = useMemo(() => {
|
||||
return getFoldersAtPath(tree, currentFolderPath);
|
||||
}, [tree, currentFolderPath]);
|
||||
|
||||
const breadcrumbs = useMemo(() => {
|
||||
if (currentFolderPath.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const breadcrumbParts = [];
|
||||
let currentTree = tree;
|
||||
|
||||
for (const folderUid of currentFolderPath) {
|
||||
const folder = findFolderByUidInTree(currentTree, folderUid);
|
||||
if (folder) {
|
||||
breadcrumbParts.push({
|
||||
uid: folder.uid,
|
||||
name: folder.name
|
||||
});
|
||||
currentTree = folder.children;
|
||||
}
|
||||
}
|
||||
|
||||
return breadcrumbParts;
|
||||
}, [tree, currentFolderPath]);
|
||||
|
||||
const navigateIntoFolder = useCallback((folderUid) => {
|
||||
setCurrentFolderPath((prev) => [...prev, folderUid]);
|
||||
setSelectedFolderUid(folderUid);
|
||||
}, []);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
setCurrentFolderPath((prev) => {
|
||||
if (prev.length > 0) {
|
||||
return prev.slice(0, -1);
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
setSelectedFolderUid(null);
|
||||
}, []);
|
||||
|
||||
const navigateToRoot = useCallback(() => {
|
||||
setCurrentFolderPath([]);
|
||||
setSelectedFolderUid(null);
|
||||
}, []);
|
||||
|
||||
const navigateToBreadcrumb = useCallback((index) => {
|
||||
setCurrentFolderPath((prev) => prev.slice(0, index + 1));
|
||||
setSelectedFolderUid(null);
|
||||
}, []);
|
||||
|
||||
const getCurrentParentFolder = useCallback(() => {
|
||||
if (currentFolderPath.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const lastFolderUid = currentFolderPath[currentFolderPath.length - 1];
|
||||
const folder = findFolderByUidInTree(tree, lastFolderUid);
|
||||
return folder ? folder.item : null;
|
||||
}, [tree, currentFolderPath]);
|
||||
|
||||
const getCurrentSelectedFolder = useCallback(() => {
|
||||
if (selectedFolderUid) {
|
||||
const folder = findFolderByUidInTree(tree, selectedFolderUid);
|
||||
return folder ? folder.item : null;
|
||||
}
|
||||
return null;
|
||||
}, [tree, selectedFolderUid]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setCurrentFolderPath([]);
|
||||
setSelectedFolderUid(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
currentFolders,
|
||||
breadcrumbs,
|
||||
selectedFolderUid,
|
||||
setSelectedFolderUid,
|
||||
navigateIntoFolder,
|
||||
goBack,
|
||||
navigateToRoot,
|
||||
navigateToBreadcrumb,
|
||||
getCurrentParentFolder,
|
||||
getCurrentSelectedFolder,
|
||||
reset,
|
||||
isAtRoot: currentFolderPath.length === 0
|
||||
};
|
||||
};
|
||||
|
||||
export default useCollectionFolderTree;
|
||||
@@ -20,6 +20,8 @@ import Devtools from 'components/Devtools';
|
||||
import useGrpcEventListeners from 'utils/network/grpc-event-listeners';
|
||||
import useWsEventListeners from 'utils/network/ws-event-listeners';
|
||||
import Portal from 'components/Portal';
|
||||
import SaveTransientRequestContainer from 'components/SaveTransientRequest/Container';
|
||||
import SaveTransientRequest from 'components/SaveTransientRequest';
|
||||
|
||||
require('codemirror/mode/javascript/javascript');
|
||||
require('codemirror/mode/xml/xml');
|
||||
@@ -53,6 +55,24 @@ require('utils/codemirror/brunoVarInfo');
|
||||
require('utils/codemirror/javascript-lint');
|
||||
require('utils/codemirror/autocomplete');
|
||||
|
||||
const TransientRequestModalsRenderer = ({ modals }) => {
|
||||
if (modals.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (modals.length === 1) {
|
||||
return (
|
||||
<SaveTransientRequest
|
||||
item={modals[0].item}
|
||||
collection={modals[0].collection}
|
||||
isOpen={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <SaveTransientRequestContainer />;
|
||||
};
|
||||
|
||||
export default function Main() {
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const activeApiSpecUid = useSelector((state) => state.apiSpec.activeApiSpecUid);
|
||||
@@ -61,6 +81,7 @@ export default function Main() {
|
||||
const showApiSpecPage = useSelector((state) => state.app.showApiSpecPage);
|
||||
const showManageWorkspacePage = useSelector((state) => state.app.showManageWorkspacePage);
|
||||
const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen);
|
||||
const saveTransientRequestModals = useSelector((state) => state.collections.saveTransientRequestModals);
|
||||
const mainSectionRef = useRef(null);
|
||||
const [showRosettaBanner, setShowRosettaBanner] = useState(false);
|
||||
|
||||
@@ -137,6 +158,7 @@ export default function Main() {
|
||||
|
||||
<Devtools mainSectionRef={mainSectionRef} />
|
||||
<StatusBar />
|
||||
<TransientRequestModalsRenderer modals={saveTransientRequestModals} />
|
||||
</div>
|
||||
// </ErrorCapture>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { saveRequest, saveCollectionSettings, saveFolderRoot, saveEnvironment } from '../../slices/collections/actions';
|
||||
import { saveGlobalEnvironment } from '../../slices/global-environments';
|
||||
import { flattenItems, isItemARequest, isItemAFolder } from 'utils/collections';
|
||||
import { flattenItems, isItemARequest, isItemAFolder, findItemInCollection, findCollectionByUid, isItemTransientRequest } from 'utils/collections';
|
||||
|
||||
const actionsToIntercept = [
|
||||
// Request-level actions
|
||||
@@ -134,6 +134,10 @@ const saveExistingDrafts = (dispatch, getState, interval) => {
|
||||
allItems.forEach((item) => {
|
||||
if (item.draft) {
|
||||
if (isItemARequest(item)) {
|
||||
// Skip auto-save for transient requests
|
||||
if (isItemTransientRequest(item)) {
|
||||
return;
|
||||
}
|
||||
const key = `request-${item.uid}`;
|
||||
scheduleAutoSave(key, () => dispatch(saveRequest(item.uid, collection.uid, true)), interval);
|
||||
} else if (isItemAFolder(item)) {
|
||||
@@ -199,6 +203,16 @@ const determineSaveHandler = (actionType, payload, dispatch, getState) => {
|
||||
|
||||
// Handle request actions
|
||||
if (itemUid) {
|
||||
// Check if this is a transient request and skip auto-save
|
||||
const state = getState();
|
||||
const collection = findCollectionByUid(state.collections.collections, collectionUid);
|
||||
if (collection) {
|
||||
const item = findItemInCollection(collection, itemUid);
|
||||
if (item && isItemTransientRequest(item)) {
|
||||
return null; // Skip auto-save for transient requests
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
key: `request-${itemUid}`,
|
||||
save: () => dispatch(saveRequest(itemUid, collectionUid, true))
|
||||
|
||||
@@ -3,9 +3,9 @@ import each from 'lodash/each';
|
||||
import filter from 'lodash/filter';
|
||||
import { createListenerMiddleware } from '@reduxjs/toolkit';
|
||||
import { removeTaskFromQueue } from 'providers/ReduxStore/slices/app';
|
||||
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { addTab, closeTabs, closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs';
|
||||
import { collectionAddFileEvent, collectionChangeFileEvent } from 'providers/ReduxStore/slices/collections';
|
||||
import { findCollectionByUid, findItemInCollectionByPathname, getDefaultRequestPaneTab, findItemInCollectionByItemUid } from 'utils/collections/index';
|
||||
import { findCollectionByUid, findItemInCollectionByPathname, getDefaultRequestPaneTab, findItemInCollectionByItemUid, findItemInCollection, flattenItems } from 'utils/collections/index';
|
||||
import { taskTypes } from './utils';
|
||||
|
||||
const taskMiddleware = createListenerMiddleware();
|
||||
@@ -29,12 +29,14 @@ taskMiddleware.startListening({
|
||||
const collection = findCollectionByUid(state.collections.collections, collectionUid);
|
||||
if (collection && collection.mountStatus === 'mounted' && !collection.isLoading) {
|
||||
const item = findItemInCollectionByPathname(collection, task.itemPathname);
|
||||
const isTransient = item?.isTransient ?? false;
|
||||
if (item) {
|
||||
listenerApi.dispatch(
|
||||
addTab({
|
||||
uid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
requestPaneTab: getDefaultRequestPaneTab(item)
|
||||
requestPaneTab: getDefaultRequestPaneTab(item),
|
||||
preview: !isTransient
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -91,4 +93,39 @@ taskMiddleware.startListening({
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* When tabs are closed, check if any of them are transient requests.
|
||||
* If so, delete the temporary files from the filesystem.
|
||||
* Note: If a transient request was saved (moved to permanent location),
|
||||
* the file will already be deleted, which is expected behavior.
|
||||
*/
|
||||
taskMiddleware.startListening({
|
||||
actionCreator: closeTabs,
|
||||
effect: (action, listenerApi) => {
|
||||
const state = listenerApi.getState();
|
||||
const tabUids = action.payload.tabUids || [];
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
each(tabUids, (tabUid) => {
|
||||
const collections = state.collections.collections;
|
||||
|
||||
for (const collection of collections) {
|
||||
const item = findItemInCollection(collection, tabUid);
|
||||
const isTransient = item?.isTransient ?? false;
|
||||
if (item && isTransient) {
|
||||
ipcRenderer
|
||||
.invoke('renderer:delete-item', item.pathname, item.type, collection.pathname)
|
||||
.then(() => {})
|
||||
.catch((err) => {
|
||||
if (err.message && !err.message.includes('does not exist')) {
|
||||
console.error(`Failed to delete transient request file: ${item.pathname}`, err);
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
export default taskMiddleware;
|
||||
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
isItemARequest,
|
||||
getAllVariables,
|
||||
transformRequestToSaveToFilesystem,
|
||||
transformCollectionRootToSave
|
||||
transformCollectionRootToSave,
|
||||
flattenItems
|
||||
} from 'utils/collections';
|
||||
import { uuid, waitForNextTick } from 'utils/common';
|
||||
import { cancelNetworkRequest, connectWS, sendGrpcRequest, sendNetworkRequest, sendWsRequest } from 'utils/network/index';
|
||||
@@ -56,6 +57,8 @@ import {
|
||||
updateFolderVar,
|
||||
addCollectionVar,
|
||||
updateCollectionVar,
|
||||
addTransientDirectory,
|
||||
addSaveTransientRequestModal,
|
||||
updatePathParam
|
||||
} from './index';
|
||||
|
||||
@@ -137,7 +140,7 @@ export const renameCollection = (newName, collectionUid) => (dispatch, getState)
|
||||
export const saveRequest = (itemUid, collectionUid, silent = false) => (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const collection = findCollectionByUid(state.collections.collections, collectionUid);
|
||||
|
||||
const tempDirectory = state.collections.tempDirectories?.[collectionUid];
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!collection) {
|
||||
return reject(new Error('Collection not found'));
|
||||
@@ -149,6 +152,12 @@ export const saveRequest = (itemUid, collectionUid, silent = false) => (dispatch
|
||||
return reject(new Error('Not able to locate item'));
|
||||
}
|
||||
|
||||
const isTransient = tempDirectory && item.pathname.startsWith(tempDirectory);
|
||||
if (isTransient) {
|
||||
dispatch(addSaveTransientRequestModal({ item, collection }));
|
||||
return reject();
|
||||
}
|
||||
|
||||
const itemToSave = transformRequestToSaveToFilesystem(item);
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
@@ -1235,7 +1244,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
|
||||
headers,
|
||||
body,
|
||||
auth,
|
||||
settings
|
||||
settings,
|
||||
isTransient = false
|
||||
} = params;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -1245,6 +1255,9 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
|
||||
return reject(new Error('Collection not found'));
|
||||
}
|
||||
|
||||
// Get temp directory if isTransient is true
|
||||
const tempDirectory = isTransient ? state.collections.tempDirectories?.[collectionUid] : null;
|
||||
|
||||
const parts = splitOnFirst(requestUrl, '?');
|
||||
const queryParams = parseQueryParams(parts[1]);
|
||||
each(queryParams, (urlParam) => {
|
||||
@@ -1265,6 +1278,7 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
|
||||
type: requestType,
|
||||
name: requestName,
|
||||
filename,
|
||||
isTransient: isTransient,
|
||||
request: {
|
||||
method: requestMethod,
|
||||
url: requestUrl,
|
||||
@@ -1295,8 +1309,45 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
|
||||
};
|
||||
|
||||
// itemUid is null when we are creating a new request at the root level
|
||||
// For transient requests, itemUid is always null
|
||||
const resolvedFilename = resolveRequestFilename(filename, collection.format);
|
||||
if (!itemUid) {
|
||||
|
||||
if (isTransient) {
|
||||
// Transient requests are always created in temp directory
|
||||
// Check for duplicates only among other transient requests
|
||||
const allItems = flattenItems(collection.items);
|
||||
const transientRequests = filter(
|
||||
allItems,
|
||||
(i) => isItemARequest(i) && i.pathname && i.pathname.startsWith(tempDirectory)
|
||||
);
|
||||
const reqWithSameNameExists = find(transientRequests, (i) => trim(i.filename) === trim(resolvedFilename));
|
||||
const items = filter(collection.items, (i) => isItemAFolder(i) || isItemARequest(i));
|
||||
item.seq = items.length + 1;
|
||||
|
||||
if (!reqWithSameNameExists) {
|
||||
const fullName = path.join(tempDirectory, resolvedFilename);
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
ipcRenderer
|
||||
.invoke('renderer:new-request', fullName, item)
|
||||
.then(() => {
|
||||
// task middleware will track this and open the new request in a new tab once request is created
|
||||
dispatch(
|
||||
insertTaskIntoQueue({
|
||||
uid: uuid(),
|
||||
type: 'OPEN_REQUEST',
|
||||
collectionUid,
|
||||
itemPathname: fullName
|
||||
})
|
||||
);
|
||||
resolve();
|
||||
})
|
||||
.catch(reject);
|
||||
} else {
|
||||
return reject(new Error('Duplicate request names are not allowed under the same folder'));
|
||||
}
|
||||
} else if (!itemUid) {
|
||||
// Regular request at root level
|
||||
const reqWithSameNameExists = find(
|
||||
collection.items,
|
||||
(i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)
|
||||
@@ -1362,7 +1413,7 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
|
||||
};
|
||||
|
||||
export const newGrpcRequest = (params) => (dispatch, getState) => {
|
||||
const { requestName, filename, requestUrl, collectionUid, body, auth, headers, itemUid } = params;
|
||||
const { requestName, filename, requestUrl, collectionUid, body, auth, headers, itemUid, isTransient = false } = params;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const state = getState();
|
||||
@@ -1370,6 +1421,10 @@ export const newGrpcRequest = (params) => (dispatch, getState) => {
|
||||
if (!collection) {
|
||||
return reject(new Error('Collection not found'));
|
||||
}
|
||||
|
||||
// Get temp directory if isTransient is true
|
||||
const tempDirectory = isTransient ? state.collections.tempDirectories?.[collectionUid] : null;
|
||||
|
||||
// do we need to handle query, path params for grpc requests?
|
||||
// skipping for now
|
||||
|
||||
@@ -1378,6 +1433,7 @@ export const newGrpcRequest = (params) => (dispatch, getState) => {
|
||||
name: requestName,
|
||||
filename,
|
||||
type: 'grpc-request',
|
||||
isTransient: isTransient,
|
||||
headers: headers ?? [],
|
||||
request: {
|
||||
url: requestUrl,
|
||||
@@ -1407,42 +1463,84 @@ export const newGrpcRequest = (params) => (dispatch, getState) => {
|
||||
};
|
||||
|
||||
// itemUid is null when we are creating a new request at the root level
|
||||
const parentItem = itemUid ? findItemInCollection(collection, itemUid) : collection;
|
||||
// For transient requests, itemUid is always null
|
||||
const resolvedFilename = resolveRequestFilename(filename, collection.format);
|
||||
|
||||
if (!parentItem) {
|
||||
return reject(new Error('Parent item not found'));
|
||||
if (isTransient) {
|
||||
// Transient requests are always created in temp directory
|
||||
// Check for duplicates only among other transient requests
|
||||
const allItems = flattenItems(collection.items);
|
||||
const transientRequests = filter(
|
||||
allItems,
|
||||
(i) => isItemARequest(i) && i.pathname && i.pathname.startsWith(tempDirectory)
|
||||
);
|
||||
const reqWithSameNameExists = find(transientRequests, (i) => trim(i.filename) === trim(resolvedFilename));
|
||||
|
||||
if (reqWithSameNameExists) {
|
||||
return reject(new Error('Duplicate request names are not allowed under the same folder'));
|
||||
}
|
||||
|
||||
const items = filter(collection.items, (i) => isItemAFolder(i) || isItemARequest(i));
|
||||
item.seq = items.length + 1;
|
||||
const fullName = path.join(tempDirectory, resolvedFilename);
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke('renderer:new-request', fullName, item)
|
||||
.then(() => {
|
||||
// task middleware will track this and open the new request in a new tab once request is created
|
||||
dispatch(
|
||||
insertTaskIntoQueue({
|
||||
uid: uuid(),
|
||||
type: 'OPEN_REQUEST',
|
||||
collectionUid,
|
||||
itemPathname: fullName
|
||||
})
|
||||
);
|
||||
resolve();
|
||||
})
|
||||
.catch(reject);
|
||||
} else {
|
||||
// Regular request (can be at root or in a folder)
|
||||
const parentItem = itemUid ? findItemInCollection(collection, itemUid) : collection;
|
||||
|
||||
if (!parentItem) {
|
||||
return reject(new Error('Parent item not found'));
|
||||
}
|
||||
|
||||
const reqWithSameNameExists = find(
|
||||
parentItem.items,
|
||||
(i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)
|
||||
);
|
||||
|
||||
if (reqWithSameNameExists) {
|
||||
return reject(new Error('Duplicate request names are not allowed under the same folder'));
|
||||
}
|
||||
|
||||
const items = filter(parentItem.items, (i) => isItemAFolder(i) || isItemARequest(i));
|
||||
item.seq = items.length + 1;
|
||||
const fullName = path.join(parentItem.pathname, resolvedFilename);
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke('renderer:new-request', fullName, item)
|
||||
.then(() => {
|
||||
// task middleware will track this and open the new request in a new tab once request is created
|
||||
dispatch(
|
||||
insertTaskIntoQueue({
|
||||
uid: uuid(),
|
||||
type: 'OPEN_REQUEST',
|
||||
collectionUid,
|
||||
itemPathname: fullName
|
||||
})
|
||||
);
|
||||
resolve();
|
||||
})
|
||||
.catch(reject);
|
||||
}
|
||||
|
||||
const reqWithSameNameExists = find(parentItem.items,
|
||||
(i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename));
|
||||
|
||||
if (reqWithSameNameExists) {
|
||||
return reject(new Error('Duplicate request names are not allowed under the same folder'));
|
||||
}
|
||||
|
||||
const items = filter(parentItem.items, (i) => isItemAFolder(i) || isItemARequest(i));
|
||||
item.seq = items.length + 1;
|
||||
const fullName = path.join(parentItem.pathname, resolvedFilename);
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke('renderer:new-request', fullName, item)
|
||||
.then(() => {
|
||||
// task middleware will track this and open the new request in a new tab once request is created
|
||||
dispatch(insertTaskIntoQueue({
|
||||
uid: uuid(),
|
||||
type: 'OPEN_REQUEST',
|
||||
collectionUid,
|
||||
itemPathname: fullName
|
||||
}));
|
||||
resolve();
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
export const newWsRequest = (params) => (dispatch, getState) => {
|
||||
const { requestName, requestMethod, filename, requestUrl, collectionUid, body, auth, headers, itemUid } = params;
|
||||
const { requestName, requestMethod, filename, requestUrl, collectionUid, body, auth, headers, itemUid, isTransient = false } = params;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const state = getState();
|
||||
@@ -1451,11 +1549,15 @@ export const newWsRequest = (params) => (dispatch, getState) => {
|
||||
return reject(new Error('Collection not found'));
|
||||
}
|
||||
|
||||
// Get temp directory if isTransient is true
|
||||
const tempDirectory = isTransient ? state.collections.tempDirectories?.[collectionUid] : null;
|
||||
|
||||
const item = {
|
||||
uid: uuid(),
|
||||
name: requestName,
|
||||
filename,
|
||||
type: 'ws-request',
|
||||
isTransient: isTransient,
|
||||
headers: headers ?? [],
|
||||
request: {
|
||||
url: requestUrl,
|
||||
@@ -1488,37 +1590,79 @@ export const newWsRequest = (params) => (dispatch, getState) => {
|
||||
};
|
||||
|
||||
// itemUid is null when we are creating a new request at the root level
|
||||
const parentItem = itemUid ? findItemInCollection(collection, itemUid) : collection;
|
||||
// For transient requests, itemUid is always null
|
||||
const resolvedFilename = resolveRequestFilename(filename, collection.format);
|
||||
|
||||
if (!parentItem) {
|
||||
return reject(new Error('Parent item not found'));
|
||||
if (isTransient) {
|
||||
// Transient requests are always created in temp directory
|
||||
// Check for duplicates only among other transient requests
|
||||
const allItems = flattenItems(collection.items);
|
||||
const transientRequests = filter(
|
||||
allItems,
|
||||
(i) => isItemARequest(i) && i.pathname && i.pathname.startsWith(tempDirectory)
|
||||
);
|
||||
const reqWithSameNameExists = find(transientRequests, (i) => trim(i.filename) === trim(resolvedFilename));
|
||||
|
||||
if (reqWithSameNameExists) {
|
||||
return reject(new Error('Duplicate request names are not allowed under the same folder'));
|
||||
}
|
||||
|
||||
const items = filter(collection.items, (i) => isItemAFolder(i) || isItemARequest(i));
|
||||
item.seq = items.length + 1;
|
||||
const fullName = path.join(tempDirectory, resolvedFilename);
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke('renderer:new-request', fullName, item)
|
||||
.then(() => {
|
||||
// task middleware will track this and open the new request in a new tab once request is created
|
||||
dispatch(
|
||||
insertTaskIntoQueue({
|
||||
uid: uuid(),
|
||||
type: 'OPEN_REQUEST',
|
||||
collectionUid,
|
||||
itemPathname: fullName
|
||||
})
|
||||
);
|
||||
resolve();
|
||||
})
|
||||
.catch(reject);
|
||||
} else {
|
||||
// Regular request (can be at root or in a folder)
|
||||
const parentItem = itemUid ? findItemInCollection(collection, itemUid) : collection;
|
||||
|
||||
if (!parentItem) {
|
||||
return reject(new Error('Parent item not found'));
|
||||
}
|
||||
|
||||
const reqWithSameNameExists = find(
|
||||
parentItem.items,
|
||||
(i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)
|
||||
);
|
||||
|
||||
if (reqWithSameNameExists) {
|
||||
return reject(new Error('Duplicate request names are not allowed under the same folder'));
|
||||
}
|
||||
|
||||
const items = filter(parentItem.items, (i) => isItemAFolder(i) || isItemARequest(i));
|
||||
item.seq = items.length + 1;
|
||||
const fullName = path.join(parentItem.pathname, resolvedFilename);
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke('renderer:new-request', fullName, item)
|
||||
.then(() => {
|
||||
// task middleware will track this and open the new request in a new tab once request is created
|
||||
dispatch(
|
||||
insertTaskIntoQueue({
|
||||
uid: uuid(),
|
||||
type: 'OPEN_REQUEST',
|
||||
collectionUid,
|
||||
itemPathname: fullName
|
||||
})
|
||||
);
|
||||
resolve();
|
||||
})
|
||||
.catch(reject);
|
||||
}
|
||||
|
||||
const reqWithSameNameExists = find(parentItem.items,
|
||||
(i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename));
|
||||
|
||||
if (reqWithSameNameExists) {
|
||||
return reject(new Error('Duplicate request names are not allowed under the same folder'));
|
||||
}
|
||||
|
||||
const items = filter(parentItem.items, (i) => isItemAFolder(i) || isItemARequest(i));
|
||||
item.seq = items.length + 1;
|
||||
const fullName = path.join(parentItem.pathname, resolvedFilename);
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer
|
||||
.invoke('renderer:new-request', fullName, item)
|
||||
.then(() => {
|
||||
// task middleware will track this and open the new request in a new tab once request is created
|
||||
dispatch(insertTaskIntoQueue({
|
||||
uid: uuid(),
|
||||
type: 'OPEN_REQUEST',
|
||||
collectionUid,
|
||||
itemPathname: fullName
|
||||
}));
|
||||
resolve();
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2666,7 +2810,10 @@ export const mountCollection
|
||||
dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'mounting' }));
|
||||
return new Promise(async (resolve, reject) => {
|
||||
callIpc('renderer:mount-collection', { collectionUid, collectionPathname, brunoConfig })
|
||||
.then(() => dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'mounted' })))
|
||||
.then((transientDirPath) => {
|
||||
dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'mounted' }));
|
||||
dispatch(addTransientDirectory({ collectionUid, pathname: transientDirPath }));
|
||||
})
|
||||
.then(resolve)
|
||||
.catch(() => {
|
||||
dispatch(updateCollectionMountStatus({ collectionUid, mountStatus: 'unmounted' }));
|
||||
|
||||
@@ -69,7 +69,9 @@ const wsStatusCodes = {
|
||||
const initialState = {
|
||||
collections: [],
|
||||
collectionSortOrder: 'default',
|
||||
activeConnections: []
|
||||
activeConnections: [],
|
||||
tempDirectories: {},
|
||||
saveTransientRequestModals: []
|
||||
};
|
||||
|
||||
const initiatedGrpcResponse = {
|
||||
@@ -775,6 +777,7 @@ export const collectionsSlice = createSlice({
|
||||
uid: action.payload.uid,
|
||||
name: action.payload.requestName,
|
||||
type: action.payload.requestType,
|
||||
isTransient: false,
|
||||
request: {
|
||||
url: action.payload.requestUrl,
|
||||
method: action.payload.requestMethod,
|
||||
@@ -2568,6 +2571,10 @@ export const collectionsSlice = createSlice({
|
||||
|
||||
if (collection) {
|
||||
const dirname = path.dirname(file.meta.pathname);
|
||||
|
||||
const tempDirectory = state.tempDirectories?.[file.meta.collectionUid];
|
||||
const isTransientFile = tempDirectory && file.meta.pathname.startsWith(tempDirectory);
|
||||
|
||||
const subDirectories = getSubdirectoriesFromRoot(collection.pathname, dirname);
|
||||
let currentPath = collection.pathname;
|
||||
let currentSubItems = collection.items;
|
||||
@@ -2581,9 +2588,13 @@ export const collectionsSlice = createSlice({
|
||||
name: directoryName,
|
||||
collapsed: true,
|
||||
type: 'folder',
|
||||
isTransient: isTransientFile,
|
||||
items: []
|
||||
};
|
||||
currentSubItems.push(childItem);
|
||||
} else if (isTransientFile && !childItem.isTransient) {
|
||||
// Update existing folder to be transient if the file is transient
|
||||
childItem.isTransient = true;
|
||||
}
|
||||
currentSubItems = childItem.items;
|
||||
}
|
||||
@@ -2608,6 +2619,7 @@ export const collectionsSlice = createSlice({
|
||||
currentItem.loading = file.loading;
|
||||
currentItem.size = file.size;
|
||||
currentItem.error = file.error;
|
||||
currentItem.isTransient = isTransientFile;
|
||||
} else {
|
||||
currentSubItems.push({
|
||||
uid: file.data.uid,
|
||||
@@ -2624,7 +2636,8 @@ export const collectionsSlice = createSlice({
|
||||
partial: file.partial,
|
||||
loading: file.loading,
|
||||
size: file.size,
|
||||
error: file.error
|
||||
error: file.error,
|
||||
isTransient: isTransientFile
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2636,6 +2649,10 @@ export const collectionsSlice = createSlice({
|
||||
const collection = findCollectionByUid(state.collections, dir.meta.collectionUid);
|
||||
|
||||
if (collection) {
|
||||
// Check if this directory is in a temp directory (transient request)
|
||||
const tempDirectory = state.tempDirectories?.[dir.meta.collectionUid];
|
||||
const isTransientDir = tempDirectory && dir.meta.pathname.startsWith(tempDirectory);
|
||||
|
||||
const subDirectories = getSubdirectoriesFromRoot(collection.pathname, dir.meta.pathname);
|
||||
let currentPath = collection.pathname;
|
||||
let currentSubItems = collection.items;
|
||||
@@ -2651,9 +2668,13 @@ export const collectionsSlice = createSlice({
|
||||
filename: directoryName,
|
||||
collapsed: true,
|
||||
type: 'folder',
|
||||
isTransient: isTransientDir,
|
||||
items: []
|
||||
};
|
||||
currentSubItems.push(childItem);
|
||||
} else if (isTransientDir && !childItem.isTransient) {
|
||||
// Update existing folder to be transient if the directory is transient
|
||||
childItem.isTransient = true;
|
||||
}
|
||||
currentSubItems = childItem.items;
|
||||
}
|
||||
@@ -3363,6 +3384,26 @@ export const collectionsSlice = createSlice({
|
||||
}
|
||||
},
|
||||
|
||||
addTransientDirectory: (state, action) => {
|
||||
state.tempDirectories[action.payload.collectionUid] = action.payload.pathname;
|
||||
},
|
||||
addSaveTransientRequestModal: (state, action) => {
|
||||
const { item, collection } = action.payload;
|
||||
// Avoid duplicates - check if this item is already in the array
|
||||
const exists = state.saveTransientRequestModals.some((modal) => modal.item.uid === item.uid);
|
||||
if (!exists) {
|
||||
state.saveTransientRequestModals.push({ item, collection });
|
||||
}
|
||||
},
|
||||
removeSaveTransientRequestModal: (state, action) => {
|
||||
const { itemUid } = action.payload;
|
||||
state.saveTransientRequestModals = state.saveTransientRequestModals.filter(
|
||||
(modal) => modal.item.uid !== itemUid
|
||||
);
|
||||
},
|
||||
clearAllSaveTransientRequestModals: (state) => {
|
||||
state.saveTransientRequestModals = [];
|
||||
},
|
||||
/* Response Example Actions */
|
||||
addResponseExample: exampleReducers.addResponseExample,
|
||||
cloneResponseExample: exampleReducers.cloneResponseExample,
|
||||
@@ -3588,8 +3629,12 @@ export const {
|
||||
deleteResponseExampleRequestHeader,
|
||||
moveResponseExampleRequestHeader,
|
||||
setResponseExampleRequestHeaders,
|
||||
setResponseExampleParams
|
||||
setResponseExampleParams,
|
||||
/* Response Example Actions - End */
|
||||
addTransientDirectory,
|
||||
addSaveTransientRequestModal,
|
||||
removeSaveTransientRequestModal,
|
||||
clearAllSaveTransientRequestModals
|
||||
} = collectionsSlice.actions;
|
||||
|
||||
export default collectionsSlice.reducer;
|
||||
|
||||
@@ -1704,3 +1704,7 @@ export const generateUniqueRequestName = async (collection, baseName = 'Untitled
|
||||
|
||||
return `${baseName}${nextNumber}`;
|
||||
};
|
||||
|
||||
export const isItemTransientRequest = (item) => {
|
||||
return isItemARequest(item) && item?.isTransient;
|
||||
};
|
||||
|
||||
@@ -8,14 +8,14 @@ export const doesRequestMatchSearchText = (request, searchText = '') => {
|
||||
|
||||
export const doesFolderHaveItemsMatchSearchText = (item, searchText = '') => {
|
||||
let flattenedItems = flattenItems(item.items);
|
||||
let requestItems = filter(flattenedItems, (item) => isItemARequest(item));
|
||||
let requestItems = filter(flattenedItems, (item) => isItemARequest(item) && !item.isTransient);
|
||||
|
||||
return find(requestItems, (request) => doesRequestMatchSearchText(request, searchText));
|
||||
};
|
||||
|
||||
export const doesCollectionHaveItemsMatchingSearchText = (collection, searchText = '') => {
|
||||
let flattenedItems = flattenItems(collection.items);
|
||||
let requestItems = filter(flattenedItems, (item) => isItemARequest(item));
|
||||
let requestItems = filter(flattenedItems, (item) => isItemARequest(item) && !item.isTransient);
|
||||
|
||||
return find(requestItems, (request) => doesRequestMatchSearchText(request, searchText));
|
||||
};
|
||||
|
||||
@@ -666,6 +666,7 @@ class CollectionWatcher {
|
||||
constructor() {
|
||||
this.watchers = {};
|
||||
this.loadingStates = {};
|
||||
this.tempDirectoryMap = {};
|
||||
}
|
||||
|
||||
// Initialize loading state tracking for a collection
|
||||
@@ -823,6 +824,13 @@ class CollectionWatcher {
|
||||
this.watchers[watchPath] = null;
|
||||
}
|
||||
|
||||
const tempDirectoryPath = this.tempDirectoryMap[watchPath];
|
||||
if (tempDirectoryPath && this.watchers[tempDirectoryPath]) {
|
||||
this.watchers[tempDirectoryPath].close();
|
||||
delete this.watchers[tempDirectoryPath];
|
||||
delete this.tempDirectoryMap[watchPath];
|
||||
}
|
||||
|
||||
if (collectionUid) {
|
||||
this.cleanupLoadingState(collectionUid);
|
||||
}
|
||||
@@ -855,6 +863,106 @@ class CollectionWatcher {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get collection path from temp directory metadata
|
||||
getCollectionPathFromTempDirectory(tempDirectoryPath) {
|
||||
const metadataPath = path.join(tempDirectoryPath, 'metadata.json');
|
||||
try {
|
||||
const metadataContent = fs.readFileSync(metadataPath, 'utf8');
|
||||
const metadata = JSON.parse(metadataContent);
|
||||
return metadata.collectionPath;
|
||||
} catch (error) {
|
||||
console.error(`Error reading metadata from temp directory ${tempDirectoryPath}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Add watcher for transient directory
|
||||
// The tempDirectoryPath is stored in this.tempDirectoryMap[collectionPath] so removeWatcher can clean it up
|
||||
addTempDirectoryWatcher(win, tempDirectoryPath, collectionUid, collectionPath) {
|
||||
if (this.watchers[tempDirectoryPath]) {
|
||||
this.watchers[tempDirectoryPath].close();
|
||||
}
|
||||
|
||||
// Store the mapping from collectionPath to tempDirectoryPath for cleanup in removeWatcher
|
||||
this.tempDirectoryMap[collectionPath] = tempDirectoryPath;
|
||||
|
||||
// Ignore metadata.json file
|
||||
const ignored = (filepath) => {
|
||||
const basename = path.basename(filepath);
|
||||
return basename === 'metadata.json';
|
||||
};
|
||||
|
||||
const watcher = chokidar.watch(tempDirectoryPath, {
|
||||
ignoreInitial: true, // Don't process existing files
|
||||
usePolling: isWSLPath(tempDirectoryPath) ? true : false,
|
||||
ignored,
|
||||
persistent: true,
|
||||
ignorePermissionErrors: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 80,
|
||||
pollInterval: 10
|
||||
},
|
||||
depth: 1, // Only watch the temp directory itself, not subdirectories
|
||||
disableGlobbing: true
|
||||
});
|
||||
|
||||
// Wrapper function to handle temp directory files
|
||||
const addTempFile = async (pathname) => {
|
||||
// Skip metadata.json
|
||||
if (path.basename(pathname) === 'metadata.json') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the actual collection path from metadata
|
||||
const actualCollectionPath = this.getCollectionPathFromTempDirectory(tempDirectoryPath);
|
||||
if (!actualCollectionPath) {
|
||||
console.error(`Could not determine collection path for temp directory: ${tempDirectoryPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the collection format from the actual collection
|
||||
const format = getCollectionFormat(actualCollectionPath);
|
||||
|
||||
// Only process request files
|
||||
if (hasRequestExtension(pathname, format)) {
|
||||
// Call the regular add function with the actual collection path
|
||||
// This will hydrate and send the file to the renderer
|
||||
await add(win, pathname, collectionUid, actualCollectionPath, false, this);
|
||||
}
|
||||
};
|
||||
const unlinkTempFile = async (pathname) => {
|
||||
// Skip metadata.json
|
||||
if (path.basename(pathname) === 'metadata.json') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the actual collection path from metadata
|
||||
const actualCollectionPath = this.getCollectionPathFromTempDirectory(tempDirectoryPath);
|
||||
if (!actualCollectionPath) {
|
||||
console.error(`Could not determine collection path for temp directory: ${tempDirectoryPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the collection format from the actual collection
|
||||
const format = getCollectionFormat(actualCollectionPath);
|
||||
|
||||
// Only process request files
|
||||
if (hasRequestExtension(pathname, format)) {
|
||||
// Call the regular unlink function with the actual collection path
|
||||
await unlink(win, pathname, collectionUid, actualCollectionPath);
|
||||
}
|
||||
};
|
||||
|
||||
watcher
|
||||
.on('add', (pathname) => addTempFile(pathname))
|
||||
.on('unlink', (pathname) => unlinkTempFile(pathname))
|
||||
.on('error', (error) => {
|
||||
console.error(`An error occurred in the temp directory watcher for: ${tempDirectoryPath}`, error);
|
||||
});
|
||||
|
||||
this.watchers[tempDirectoryPath] = watcher;
|
||||
}
|
||||
|
||||
getAllWatcherPaths() {
|
||||
return Object.entries(this.watchers)
|
||||
.filter(([path, watcher]) => !!watcher)
|
||||
|
||||
@@ -84,6 +84,25 @@ const envHasSecrets = (environment = {}) => {
|
||||
};
|
||||
|
||||
const findCollectionPathByItemPath = (filePath) => {
|
||||
const tmpDir = os.tmpdir();
|
||||
const parts = filePath.split(path.sep);
|
||||
const index = parts.findIndex((part) => part.startsWith('bruno-'));
|
||||
|
||||
if (filePath.startsWith(tmpDir) && index !== -1) {
|
||||
const transientDirPath = parts.slice(0, index + 1).join(path.sep);
|
||||
const metadataPath = path.join(transientDirPath, 'metadata.json');
|
||||
try {
|
||||
const metadataContent = fs.readFileSync(metadataPath, 'utf8');
|
||||
const metadata = JSON.parse(metadataContent);
|
||||
if (metadata.collectionPath) {
|
||||
return metadata.collectionPath;
|
||||
}
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const allCollectionPaths = collectionWatcher.getAllWatcherPaths();
|
||||
|
||||
// Find the collection path that contains this file
|
||||
@@ -363,6 +382,52 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
|
||||
}
|
||||
});
|
||||
|
||||
// save transient request (handles move from temp to permanent location)
|
||||
ipcMain.handle('renderer:save-transient-request', async (event, { sourcePathname, targetDirname, targetFilename, request, format }) => {
|
||||
try {
|
||||
// Validate source exists
|
||||
if (!fs.existsSync(sourcePathname)) {
|
||||
throw new Error(`Source path: ${sourcePathname} does not exist`);
|
||||
}
|
||||
|
||||
// Validate target directory exists
|
||||
if (!fs.existsSync(targetDirname)) {
|
||||
throw new Error(`Target directory: ${targetDirname} does not exist`);
|
||||
}
|
||||
|
||||
// Check if the target directory is inside a collection
|
||||
validatePathIsInsideCollection(targetDirname);
|
||||
|
||||
// Use provided target filename or fall back to source filename
|
||||
const filename = targetFilename || path.basename(sourcePathname);
|
||||
const targetPathname = path.join(targetDirname, filename);
|
||||
|
||||
// Check for filename conflicts and throw error if exists
|
||||
if (fs.existsSync(targetPathname)) {
|
||||
throw new Error(`A file with the name "${filename}" already exists in the target location`);
|
||||
}
|
||||
|
||||
// Step 1: Save the updated content to the transient file
|
||||
syncExampleUidsCache(sourcePathname, request.examples);
|
||||
const content = await stringifyRequestViaWorker(request, { format });
|
||||
await writeFile(sourcePathname, content);
|
||||
|
||||
// Step 2: Read the file content from temp (this is the actual file content)
|
||||
const fileContent = await fs.promises.readFile(sourcePathname, 'utf8');
|
||||
|
||||
// Step 3: Create new file at target location with the content
|
||||
await writeFile(targetPathname, fileContent);
|
||||
|
||||
// Step 4: Delete the old temp file
|
||||
await removePath(sourcePathname);
|
||||
|
||||
// Return the new pathname (file watcher will handle adding to Redux)
|
||||
return { newPathname: targetPathname };
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
// save multiple requests
|
||||
ipcMain.handle('renderer:save-multiple-requests', async (event, requestsToSave) => {
|
||||
try {
|
||||
@@ -1607,6 +1672,16 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:mount-collection', async (event, { collectionUid, collectionPathname, brunoConfig }) => {
|
||||
let tempDirectoryPath = null;
|
||||
try {
|
||||
tempDirectoryPath = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-'));
|
||||
const metadata = {
|
||||
collectionPath: collectionPathname
|
||||
};
|
||||
fs.writeFileSync(path.join(tempDirectoryPath, 'metadata.json'), JSON.stringify(metadata));
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
const {
|
||||
size,
|
||||
filesCount,
|
||||
@@ -1619,6 +1694,11 @@ const registerRendererEventHandlers = (mainWindow, watcher) => {
|
||||
|| (maxFileSize > MAX_SINGLE_FILE_SIZE_IN_COLLECTION_IN_MB);
|
||||
|
||||
watcher.addWatcher(mainWindow, collectionPathname, collectionUid, brunoConfig, false, shouldLoadCollectionAsync);
|
||||
|
||||
// Add watcher for transient directory
|
||||
watcher.addTempDirectoryWatcher(mainWindow, tempDirectoryPath, collectionUid, collectionPathname);
|
||||
|
||||
return tempDirectoryPath;
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:show-in-folder', async (event, filePath) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ const { getRequestUid, getExampleUid } = require('../cache/requestUids');
|
||||
const { uuid } = require('./common');
|
||||
const os = require('os');
|
||||
const { preferencesUtil } = require('../store/preferences');
|
||||
const path = require('path');
|
||||
|
||||
const mergeHeaders = (collection, request, requestTreePath) => {
|
||||
let headers = new Map();
|
||||
@@ -402,6 +403,8 @@ const parseFileMeta = (data, format = 'bru') => {
|
||||
|
||||
const hydrateRequestWithUuid = (request, pathname) => {
|
||||
request.uid = getRequestUid(pathname);
|
||||
const prefix = path.join(os.tmpdir(), 'bruno-');
|
||||
request.isTransient = pathname.startsWith(prefix);
|
||||
|
||||
const params = get(request, 'request.params', []);
|
||||
const headers = get(request, 'request.headers', []);
|
||||
|
||||
@@ -92,9 +92,20 @@ test.describe('Onboarding', () => {
|
||||
await expect(removeOption).toBeVisible();
|
||||
await removeOption.click();
|
||||
|
||||
// Confirm removal in the modal
|
||||
await page.locator('[data-testid="close-collection-modal-title"]', { hasText: 'Remove Collection' }).waitFor({ state: 'visible' });
|
||||
await page.locator('.bruno-modal-footer .submit').click();
|
||||
// Wait for modal to appear - could be either regular remove or drafts confirmation
|
||||
const removeModal = page.locator('.bruno-modal').filter({ hasText: 'Remove Collection' });
|
||||
await removeModal.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// Check if it's the drafts confirmation modal (has "Discard All and Remove" button)
|
||||
const hasDiscardButton = await page.getByRole('button', { name: 'Discard All and Remove' }).isVisible().catch(() => false);
|
||||
|
||||
if (hasDiscardButton) {
|
||||
// Drafts modal - click "Discard All and Remove"
|
||||
await page.getByRole('button', { name: 'Discard All and Remove' }).click();
|
||||
} else {
|
||||
// Regular modal - click the submit button
|
||||
await page.locator('.bruno-modal-footer .submit').click();
|
||||
}
|
||||
|
||||
// Verify collection is closed (no longer visible in sidebar)
|
||||
await expect(sampleCollection).not.toBeVisible();
|
||||
|
||||
225
tests/transient-requests/transient-requests.spec.ts
Normal file
225
tests/transient-requests/transient-requests.spec.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { test, expect } from '../../playwright';
|
||||
import { createTransientRequest, fillRequestUrl, closeAllCollections, createCollection, sendRequest, clickResponseAction, selectRequestPaneTab } from '../utils/page';
|
||||
import { buildCommonLocators, buildWebsocketCommonLocators } from '../utils/page/locators';
|
||||
|
||||
test.describe.serial('Transient Requests', () => {
|
||||
let locators: ReturnType<typeof buildCommonLocators>;
|
||||
|
||||
test.beforeAll(async ({ page, createTmpDir }) => {
|
||||
locators = buildCommonLocators(page);
|
||||
|
||||
// Create a temporary collection
|
||||
const collectionPath = await createTmpDir('transient-collection');
|
||||
await createCollection(page, 'transient-requests-test', collectionPath);
|
||||
|
||||
// Verify the collection is loaded
|
||||
await test.step('Verify test collection is loaded', async () => {
|
||||
await expect(locators.sidebar.collection('transient-requests-test')).toBeVisible();
|
||||
await locators.sidebar.collection('transient-requests-test').click();
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async ({ page }) => {
|
||||
// Clean up all collections
|
||||
await closeAllCollections(page);
|
||||
});
|
||||
|
||||
test('Create transient HTTP request - should not appear in sidebar', async ({ page }) => {
|
||||
await test.step('Create transient HTTP request', async () => {
|
||||
await createTransientRequest(page, {
|
||||
requestType: 'HTTP'
|
||||
});
|
||||
await fillRequestUrl(page, 'http://localhost:8081/ping');
|
||||
});
|
||||
|
||||
await test.step('Verify HTTP request tab is open', async () => {
|
||||
const activeTab = page.locator('.request-tab.active');
|
||||
await expect(activeTab).toBeVisible();
|
||||
await expect(activeTab).toContainText('Untitled');
|
||||
});
|
||||
|
||||
await test.step('Verify request is NOT in sidebar', async () => {
|
||||
// Click on the collection to ensure it's expanded
|
||||
await locators.sidebar.collection('transient-requests-test').click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Check that there are no requests in the collection
|
||||
// Transient requests should not appear in the sidebar
|
||||
const collectionItems = page.locator('.collection-item-name');
|
||||
await expect(collectionItems).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('Create transient GraphQL request - should not appear in sidebar', async ({ page }) => {
|
||||
await test.step('Create transient GraphQL request', async () => {
|
||||
await createTransientRequest(page, {
|
||||
requestType: 'GraphQL'
|
||||
});
|
||||
await fillRequestUrl(page, 'https://api.example.com/graphql');
|
||||
});
|
||||
|
||||
await test.step('Verify GraphQL request tab is open', async () => {
|
||||
const activeTab = page.locator('.request-tab.active');
|
||||
await expect(activeTab).toBeVisible();
|
||||
await expect(activeTab).toContainText('Untitled');
|
||||
});
|
||||
|
||||
await test.step('Verify request is NOT in sidebar', async () => {
|
||||
// Check that there are still no requests in the collection
|
||||
const collectionItems = page.locator('.collection-item-name');
|
||||
await expect(collectionItems).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('Create transient gRPC request - should not appear in sidebar', async ({ page }) => {
|
||||
await test.step('Create transient gRPC request', async () => {
|
||||
await createTransientRequest(page, {
|
||||
requestType: 'gRPC'
|
||||
});
|
||||
await fillRequestUrl(page, 'grpc://localhost:50051');
|
||||
});
|
||||
|
||||
await test.step('Verify gRPC request tab is open', async () => {
|
||||
const activeTab = page.locator('.request-tab.active');
|
||||
await expect(activeTab).toBeVisible();
|
||||
await expect(activeTab).toContainText('Untitled');
|
||||
});
|
||||
|
||||
await test.step('Verify request is NOT in sidebar', async () => {
|
||||
// Check that there are still no requests in the collection
|
||||
const collectionItems = page.locator('.collection-item-name');
|
||||
await expect(collectionItems).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('Create transient WebSocket request - should not appear in sidebar', async ({ page }) => {
|
||||
await test.step('Create transient WebSocket request', async () => {
|
||||
await createTransientRequest(page, {
|
||||
requestType: 'WebSocket'
|
||||
});
|
||||
await fillRequestUrl(page, 'ws://localhost:8082');
|
||||
});
|
||||
|
||||
await test.step('Verify WebSocket request tab is open', async () => {
|
||||
const activeTab = page.locator('.request-tab.active');
|
||||
await expect(activeTab).toBeVisible();
|
||||
await expect(activeTab).toContainText('Untitled');
|
||||
});
|
||||
|
||||
await test.step('Verify request is NOT in sidebar', async () => {
|
||||
// Check that there are still no requests in the collection
|
||||
const collectionItems = page.locator('.collection-item-name');
|
||||
await expect(collectionItems).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('Save transient HTTP request - should appear in sidebar after save', async ({ page }) => {
|
||||
await test.step('Create transient HTTP request', async () => {
|
||||
await createTransientRequest(page, {
|
||||
requestType: 'HTTP'
|
||||
});
|
||||
await fillRequestUrl(page, 'http://localhost:8081/echo');
|
||||
});
|
||||
|
||||
await test.step('Trigger save action using keyboard shortcut', async () => {
|
||||
// Try to save using Cmd+S (Mac) or Ctrl+S (other platforms)
|
||||
await page.keyboard.press('Meta+s');
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
await test.step('Fill in save dialog', async () => {
|
||||
// Wait for save modal to appear
|
||||
const saveModal = page.locator('.bruno-modal-card').filter({ hasText: 'Save Request' });
|
||||
await expect(saveModal).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Fill in request name
|
||||
const requestNameInput = saveModal.locator('#request-name');
|
||||
await requestNameInput.clear();
|
||||
await requestNameInput.fill('Saved HTTP Request');
|
||||
|
||||
// Click Save button
|
||||
await saveModal.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
// Wait for success toast
|
||||
await page.waitForTimeout(1000);
|
||||
});
|
||||
|
||||
await test.step('Verify saved request appears in sidebar', async () => {
|
||||
// Check collection is expanded
|
||||
await locators.sidebar.collection('transient-requests-test').click();
|
||||
|
||||
// Look for the saved request in sidebar
|
||||
const savedRequest = locators.sidebar.request('Saved HTTP Request');
|
||||
await expect(savedRequest).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Cleanup: Delete the saved request', async () => {
|
||||
await locators.sidebar.request('Saved HTTP Request').hover();
|
||||
await locators.actions.collectionItemActions('Saved HTTP Request').click();
|
||||
await locators.dropdown.item('Delete').click();
|
||||
await locators.modal.button('Delete').click();
|
||||
await expect(locators.sidebar.request('Saved HTTP Request')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test('Save transient GraphQL request - should appear in sidebar after save', async ({ page }) => {
|
||||
await test.step('Create transient GraphQL request', async () => {
|
||||
await createTransientRequest(page, {
|
||||
requestType: 'GraphQL'
|
||||
});
|
||||
await fillRequestUrl(page, 'https://api.example.com/graphql');
|
||||
});
|
||||
|
||||
await test.step('Trigger save action using keyboard shortcut', async () => {
|
||||
await page.keyboard.press('Meta+s');
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
await test.step('Fill in save dialog', async () => {
|
||||
const saveModal = page.locator('.bruno-modal-card').filter({ hasText: 'Save Request' });
|
||||
await expect(saveModal).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const requestNameInput = saveModal.locator('#request-name');
|
||||
await requestNameInput.clear();
|
||||
await requestNameInput.fill('Saved GraphQL Request');
|
||||
|
||||
await saveModal.getByRole('button', { name: 'Save' }).click();
|
||||
await page.waitForTimeout(1000);
|
||||
});
|
||||
|
||||
await test.step('Verify saved request appears in sidebar', async () => {
|
||||
await locators.sidebar.collection('transient-requests-test').click();
|
||||
const savedRequest = locators.sidebar.request('Saved GraphQL Request');
|
||||
await expect(savedRequest).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Cleanup: Delete the saved request', async () => {
|
||||
await locators.sidebar.request('Saved GraphQL Request').hover();
|
||||
await locators.actions.collectionItemActions('Saved GraphQL Request').click();
|
||||
await locators.dropdown.item('Delete').click();
|
||||
await locators.modal.button('Delete').click();
|
||||
await expect(locators.sidebar.request('Saved GraphQL Request')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test('Send transient HTTP request - verify response', async ({ page }) => {
|
||||
await test.step('Create transient HTTP request', async () => {
|
||||
await createTransientRequest(page, {
|
||||
requestType: 'HTTP'
|
||||
});
|
||||
await fillRequestUrl(page, 'http://localhost:8081/ping');
|
||||
});
|
||||
|
||||
await test.step('Send request and verify response', async () => {
|
||||
// Send request using the helper function
|
||||
await sendRequest(page, 200);
|
||||
|
||||
// Copy response to clipboard and verify
|
||||
await clickResponseAction(page, 'response-copy-btn');
|
||||
await expect(page.getByText('Response copied to clipboard')).toBeVisible();
|
||||
|
||||
const clipboardText = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(clipboardText).toBe('pong');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,11 +17,24 @@ const closeAllCollections = async (page) => {
|
||||
await firstCollection.hover();
|
||||
await firstCollection.locator('.collection-actions .icon').click();
|
||||
await page.locator('.dropdown-item').getByText('Remove').click();
|
||||
// Wait for the remove collection modal to be visible
|
||||
await page.getByTestId('close-collection-modal-title').filter({ hasText: 'Remove Collection' }).waitFor({ state: 'visible' });
|
||||
await page.locator('.bruno-modal-footer .submit').click();
|
||||
// Wait for the remove collection modal to be hidden
|
||||
await page.getByTestId('close-collection-modal-title').filter({ hasText: 'Remove Collection' }).waitFor({ state: 'hidden' });
|
||||
|
||||
// Wait for modal to appear - could be either regular remove or drafts confirmation
|
||||
const removeModal = page.locator('.bruno-modal').filter({ hasText: 'Remove Collection' });
|
||||
await removeModal.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// Check if it's the drafts confirmation modal (has "Discard All and Remove" button)
|
||||
const hasDiscardButton = await page.getByRole('button', { name: 'Discard All and Remove' }).isVisible().catch(() => false);
|
||||
|
||||
if (hasDiscardButton) {
|
||||
// Drafts modal - click "Discard All and Remove"
|
||||
await page.getByRole('button', { name: 'Discard All and Remove' }).click();
|
||||
} else {
|
||||
// Regular modal - click the submit button
|
||||
await page.locator('.bruno-modal-footer .submit').click();
|
||||
}
|
||||
|
||||
// Wait for modal to close
|
||||
await removeModal.waitFor({ state: 'hidden', timeout: 5000 });
|
||||
}
|
||||
|
||||
// Wait until no collections are left open (check sidebar only)
|
||||
@@ -143,6 +156,77 @@ const createUntitledRequest = async (
|
||||
});
|
||||
};
|
||||
|
||||
type CreateTransientRequestOptions = {
|
||||
requestType?: 'HTTP' | 'GraphQL' | 'gRPC' | 'WebSocket';
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a transient request using the + icon button in the tabs area
|
||||
* Based on the CreateTransientRequest component behavior
|
||||
* @param page - The page object
|
||||
* @param options - Optional settings (requestType)
|
||||
* @returns void
|
||||
*/
|
||||
const createTransientRequest = async (
|
||||
page: Page,
|
||||
options: CreateTransientRequestOptions = {}
|
||||
) => {
|
||||
const { requestType = 'HTTP' } = options;
|
||||
|
||||
await test.step(`Create transient ${requestType} request`, async () => {
|
||||
// Find the + icon button (ActionIcon with aria-label="New Transient Request")
|
||||
const createButton = page.getByRole('button', { name: 'New Transient Request' });
|
||||
await createButton.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// Click the + icon to open the dropdown
|
||||
await createButton.click({
|
||||
button: 'right'
|
||||
});
|
||||
|
||||
// Wait for dropdown to be visible
|
||||
await page.locator('.dropdown-item').first().waitFor({ state: 'visible' });
|
||||
|
||||
// Select the request type from dropdown
|
||||
// The dropdown items have both icon and label, we match by the label text
|
||||
await page.locator('.dropdown-item').filter({ hasText: requestType }).click();
|
||||
|
||||
// Wait for the request tab to be active (transient requests show as "Untitled X")
|
||||
await page.locator('.request-tab.active').waitFor({ state: 'visible' });
|
||||
await expect(page.locator('.request-tab.active')).toContainText('Untitled');
|
||||
await page.waitForTimeout(300);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Fill the URL field in the currently active request
|
||||
* Works with HTTP, GraphQL, gRPC, and WebSocket requests
|
||||
* @param page - The page object
|
||||
* @param url - The URL to fill
|
||||
* @returns void
|
||||
*/
|
||||
const fillRequestUrl = async (page: Page, url: string) => {
|
||||
await test.step(`Fill request URL: ${url}`, async () => {
|
||||
// HTTP/GraphQL requests use #request-url
|
||||
// gRPC/WebSocket don't have a specific ID, so we need to find the CodeMirror in the active request pane
|
||||
const httpGraphqlUrl = page.locator('#request-url .CodeMirror');
|
||||
const grpcWsUrl = page.locator('.input-container .CodeMirror').first();
|
||||
|
||||
// Try HTTP/GraphQL selector first
|
||||
const isHttpOrGraphql = await httpGraphqlUrl.isVisible().catch(() => false);
|
||||
|
||||
if (isHttpOrGraphql) {
|
||||
await httpGraphqlUrl.click();
|
||||
await page.locator('#request-url textarea').fill(url);
|
||||
} else {
|
||||
// Fall back to generic selector for gRPC/WebSocket
|
||||
await grpcWsUrl.click();
|
||||
await page.locator('.input-container textarea').first().fill(url);
|
||||
}
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a request in a collection or folder
|
||||
* @param page - The page object
|
||||
@@ -293,10 +377,23 @@ const removeCollection = async (page: Page, collectionName: string) => {
|
||||
await collectionRow.locator('.collection-actions .icon').click();
|
||||
await locators.dropdown.item('Remove').click();
|
||||
|
||||
// Wait for and confirm modal
|
||||
await page.getByTestId('close-collection-modal-title').filter({ hasText: 'Remove Collection' }).waitFor({ state: 'visible' });
|
||||
await locators.modal.button('Remove').click();
|
||||
await page.getByTestId('close-collection-modal-title').filter({ hasText: 'Remove Collection' }).waitFor({ state: 'hidden' });
|
||||
// Wait for modal to appear - could be either regular remove or drafts confirmation
|
||||
const removeModal = page.locator('.bruno-modal').filter({ hasText: 'Remove Collection' });
|
||||
await removeModal.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// Check if it's the drafts confirmation modal (has "Discard All and Remove" button)
|
||||
const hasDiscardButton = await page.getByRole('button', { name: 'Discard All and Remove' }).isVisible().catch(() => false);
|
||||
|
||||
if (hasDiscardButton) {
|
||||
// Drafts modal - click "Discard All and Remove"
|
||||
await page.getByRole('button', { name: 'Discard All and Remove' }).click();
|
||||
} else {
|
||||
// Regular modal - click Remove button
|
||||
await locators.modal.button('Remove').click();
|
||||
}
|
||||
|
||||
// Wait for modal to close
|
||||
await removeModal.waitFor({ state: 'hidden', timeout: 5000 });
|
||||
|
||||
// Verify collection is removed
|
||||
await expect(
|
||||
@@ -864,6 +961,8 @@ export {
|
||||
createCollection,
|
||||
createRequest,
|
||||
createUntitledRequest,
|
||||
createTransientRequest,
|
||||
fillRequestUrl,
|
||||
deleteRequest,
|
||||
importCollection,
|
||||
removeCollection,
|
||||
@@ -892,4 +991,4 @@ export {
|
||||
saveRequest
|
||||
};
|
||||
|
||||
export type { SandboxMode, EnvironmentType, EnvironmentVariable, ImportCollectionOptions, CreateRequestOptions, CreateUntitledRequestOptions, AssertionInput };
|
||||
export type { SandboxMode, EnvironmentType, EnvironmentVariable, ImportCollectionOptions, CreateRequestOptions, CreateUntitledRequestOptions, CreateTransientRequestOptions, AssertionInput };
|
||||
|
||||
Reference in New Issue
Block a user