mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
Prototype/simplify request creation (#6295)
* feat: add dropdown for quick request creation in tab bar - Create reusable CreateUntitledRequest component with customizable trigger - Add generateUniqueRequestName utility for unique request naming - Replace modal-based request creation with dropdown in tab bar - Support HTTP, GraphQL, WebSocket, and gRPC request types - Generate unique names (Untitled, Untitled1, etc.) automatically - Create requests at collection root level * Update request creation and collection components * Fix dropdown positioning and styling when appended to document.body - Change appendTo from 'parent' to document.body for absolute positioning - Add comprehensive styling via onShow handler to ensure proper width, padding, text color, and opacity - Add global styles as fallback for dropdown elements - Ensure dropdown overlaps parent without expanding it * Update RequestTabs and Collection components * Add curl paste detection and parsing for HTTP requests * Fix generateUniqueRequestName to check filesystem for existing files * feat: add placeholder text to HTTP request URL input Add helpful placeholder text 'Enter URL or paste a cURL request' to the HTTP request URL input field. This guides users on how to use the input field, indicating they can either enter a URL directly or paste a cURL command which will be automatically parsed. * Simplify request creation in collection menu * fix: fixed issues with cURL paste for GraphQL requests in the URL input bar * fix: added icons to create request dropdown * fix: fixed the icon | text gap in dropdown * fix: removed unnecessary updates on the Dropdown Component * added onCreate to Dropdown to remove unwanted diffs * fix: simplified the generateUniqueRequestName function. ai writes complex code * chore: formatting and removed unnecessary diffs * Update packages/bruno-app/src/components/RequestPane/QueryUrl/index.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * chore: format * Fix failing E2E tests by updating to new request creation flow - Replace #create-new-tab selector with new dropdown flow using createUntitledRequest helper - Update generateUniqueRequestName to handle .bru, .yml, and .yaml file extensions - Add createUntitledRequest helper function with optional URL and tag parameters - Update all failing tests to use the new helper function - Fix selectors from .collection-item-name to .item-name where needed - All 13 previously failing tests now pass * chore: removed unused import --------- Co-authored-by: Sid <siddharth@usebruno.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
03e8f2d67d
commit
f6363389d0
@@ -0,0 +1,8 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
172
packages/bruno-app/src/components/CreateUntitledRequest/index.js
Normal file
172
packages/bruno-app/src/components/CreateUntitledRequest/index.js
Normal file
@@ -0,0 +1,172 @@
|
||||
import React, { useRef, forwardRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { newHttpRequest, newGrpcRequest, newWsRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { generateUniqueRequestName } from 'utils/collections';
|
||||
import { sanitizeName } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IconApi, IconBrandGraphql, IconPlugConnected, IconCode, IconPlus } from '@tabler/icons';
|
||||
|
||||
const CreateUntitledRequest = ({ collectionUid, itemUid = null, onRequestCreated, placement = 'bottom' }) => {
|
||||
const dispatch = useDispatch();
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
const collection = collections?.find((c) => c.uid === collectionUid);
|
||||
const dropdownTippyRef = useRef();
|
||||
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
|
||||
if (!collection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleCreateHttpRequest = async () => {
|
||||
dropdownTippyRef.current?.hide();
|
||||
const uniqueName = await generateUniqueRequestName(collection, 'Untitled', itemUid);
|
||||
const filename = sanitizeName(uniqueName);
|
||||
|
||||
dispatch(
|
||||
newHttpRequest({
|
||||
requestName: uniqueName,
|
||||
filename: filename,
|
||||
requestType: 'http-request',
|
||||
requestUrl: '',
|
||||
requestMethod: 'GET',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: itemUid
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
toast.success('New request created!');
|
||||
onRequestCreated?.();
|
||||
})
|
||||
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
|
||||
};
|
||||
|
||||
const handleCreateGraphQLRequest = async () => {
|
||||
dropdownTippyRef.current?.hide();
|
||||
const uniqueName = await generateUniqueRequestName(collection, 'Untitled', itemUid);
|
||||
const filename = sanitizeName(uniqueName);
|
||||
|
||||
dispatch(
|
||||
newHttpRequest({
|
||||
requestName: uniqueName,
|
||||
filename: filename,
|
||||
requestType: 'graphql-request',
|
||||
requestUrl: '',
|
||||
requestMethod: 'POST',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: itemUid,
|
||||
body: {
|
||||
mode: 'graphql',
|
||||
graphql: {
|
||||
query: '',
|
||||
variables: ''
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
toast.success('New request created!');
|
||||
onRequestCreated?.();
|
||||
})
|
||||
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
|
||||
};
|
||||
|
||||
const handleCreateWebSocketRequest = async () => {
|
||||
dropdownTippyRef.current?.hide();
|
||||
const uniqueName = await generateUniqueRequestName(collection, 'Untitled', itemUid);
|
||||
const filename = sanitizeName(uniqueName);
|
||||
|
||||
dispatch(
|
||||
newWsRequest({
|
||||
requestName: uniqueName,
|
||||
filename: filename,
|
||||
requestUrl: '',
|
||||
requestMethod: 'ws',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: itemUid
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
toast.success('New request created!');
|
||||
onRequestCreated?.();
|
||||
})
|
||||
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
|
||||
};
|
||||
|
||||
const handleCreateGrpcRequest = async () => {
|
||||
dropdownTippyRef.current?.hide();
|
||||
const uniqueName = await generateUniqueRequestName(collection, 'Untitled', itemUid);
|
||||
const filename = sanitizeName(uniqueName);
|
||||
|
||||
dispatch(
|
||||
newGrpcRequest({
|
||||
requestName: uniqueName,
|
||||
filename: filename,
|
||||
requestUrl: '',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: itemUid
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
toast.success('New request created!');
|
||||
onRequestCreated?.();
|
||||
})
|
||||
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<IconPlus size={16} strokeWidth={2} />} placement={placement}>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleCreateHttpRequest();
|
||||
}}
|
||||
>
|
||||
<span className="dropdown-icon">
|
||||
<IconApi size={16} strokeWidth={2} />
|
||||
</span>
|
||||
HTTP
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleCreateGraphQLRequest();
|
||||
}}
|
||||
>
|
||||
<span className="dropdown-icon">
|
||||
<IconBrandGraphql size={16} strokeWidth={2} />
|
||||
</span>
|
||||
GraphQL
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleCreateWebSocketRequest();
|
||||
}}
|
||||
>
|
||||
<span className="dropdown-icon">
|
||||
<IconPlugConnected size={16} strokeWidth={2} />
|
||||
</span>
|
||||
WebSocket
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
dropdownTippyRef.current.hide();
|
||||
handleCreateGrpcRequest();
|
||||
}}
|
||||
>
|
||||
<span className="dropdown-icon">
|
||||
<IconCode size={16} strokeWidth={2} />
|
||||
</span>
|
||||
gRPC
|
||||
</div>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateUntitledRequest;
|
||||
@@ -1,8 +1,19 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { requestUrlChanged, updateRequestMethod } from 'providers/ReduxStore/slices/collections';
|
||||
import { cancelRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import {
|
||||
requestUrlChanged,
|
||||
updateRequestMethod,
|
||||
setRequestHeaders,
|
||||
updateRequestBodyMode,
|
||||
updateRequestBody,
|
||||
updateRequestGraphqlQuery,
|
||||
updateRequestGraphqlVariables,
|
||||
updateRequestAuthMode,
|
||||
updateAuth
|
||||
} from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest, cancelRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { getRequestFromCurlCommand } from 'utils/curl';
|
||||
import HttpMethodSelector from './HttpMethodSelector';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { IconDeviceFloppy, IconArrowRight, IconCode, IconSquareRoundedX } from '@tabler/icons';
|
||||
@@ -81,12 +92,289 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleGraphqlPaste = useCallback((event) => {
|
||||
if (item.type !== 'graphql-request') {
|
||||
return;
|
||||
}
|
||||
|
||||
const clipboardData = event.clipboardData || window.clipboardData;
|
||||
const pastedData = clipboardData.getData('Text');
|
||||
|
||||
const curlCommandRegex = /^\s*curl\s/i;
|
||||
if (!curlCommandRegex.test(pastedData)) {
|
||||
toast.error('Invalid cURL command');
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
try {
|
||||
const request = getRequestFromCurlCommand(pastedData, 'graphql-request');
|
||||
if (!request || !request.url) {
|
||||
toast.error('Invalid cURL command');
|
||||
return;
|
||||
}
|
||||
// Update URL
|
||||
dispatch(requestUrlChanged({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
url: request.url
|
||||
}));
|
||||
|
||||
// Update method
|
||||
dispatch(updateRequestMethod({
|
||||
method: request.method.toUpperCase(), // Convert to uppercase
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
}));
|
||||
|
||||
// Update headers
|
||||
if (request.headers && request.headers.length > 0) {
|
||||
dispatch(setRequestHeaders({
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
headers: request.headers
|
||||
}));
|
||||
}
|
||||
|
||||
// Update body
|
||||
if (request.body) {
|
||||
const bodyMode = request.body.mode;
|
||||
if (bodyMode === 'graphql') {
|
||||
dispatch(updateRequestGraphqlQuery({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
query: request.body.graphql.query
|
||||
}));
|
||||
let variables = request.body.graphql.variables;
|
||||
try {
|
||||
variables = JSON.parse(variables);
|
||||
} catch (error) {
|
||||
// Keep variables as-is if JSON parsing fails
|
||||
}
|
||||
dispatch(updateRequestGraphqlVariables({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
variables: variables
|
||||
}));
|
||||
}
|
||||
|
||||
toast.success('GraphQL query imported successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing cURL command:', error);
|
||||
toast.error('Failed to parse GraphQL query');
|
||||
}
|
||||
}, [dispatch, item.uid, collection.uid]);
|
||||
|
||||
const handleHttpPaste = useCallback((event) => {
|
||||
// Only enable curl paste detection for HTTP requests
|
||||
if (item.type !== 'http-request') {
|
||||
return;
|
||||
}
|
||||
|
||||
const clipboardData = event.clipboardData || window.clipboardData;
|
||||
const pastedData = clipboardData.getData('Text');
|
||||
|
||||
// Check if pasted data looks like a cURL command
|
||||
const curlCommandRegex = /^\s*curl\s/i;
|
||||
if (!curlCommandRegex.test(pastedData)) {
|
||||
// Not a curl command, allow normal paste behavior
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent the default paste behavior
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
// Parse the curl command
|
||||
const request = getRequestFromCurlCommand(pastedData);
|
||||
if (!request || !request.url) {
|
||||
toast.error('Invalid cURL command');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update URL
|
||||
dispatch(
|
||||
requestUrlChanged({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
url: request.url
|
||||
})
|
||||
);
|
||||
|
||||
// Update method
|
||||
if (request.method) {
|
||||
dispatch(
|
||||
updateRequestMethod({
|
||||
method: request.method.toUpperCase(), // Convert to uppercase
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Update headers
|
||||
if (request.headers && request.headers.length > 0) {
|
||||
dispatch(
|
||||
setRequestHeaders({
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
headers: request.headers
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Update body
|
||||
if (request.body) {
|
||||
const bodyMode = request.body.mode;
|
||||
|
||||
// Set body mode first
|
||||
dispatch(
|
||||
updateRequestBodyMode({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
mode: bodyMode
|
||||
})
|
||||
);
|
||||
|
||||
// Set body content based on mode
|
||||
if (bodyMode === 'json' && request.body.json) {
|
||||
dispatch(
|
||||
updateRequestBody({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
content: request.body.json
|
||||
})
|
||||
);
|
||||
} else if (bodyMode === 'text' && request.body.text) {
|
||||
dispatch(
|
||||
updateRequestBody({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
content: request.body.text
|
||||
})
|
||||
);
|
||||
} else if (bodyMode === 'xml' && request.body.xml) {
|
||||
dispatch(
|
||||
updateRequestBody({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
content: request.body.xml
|
||||
})
|
||||
);
|
||||
} else if (bodyMode === 'graphql' && request.body.graphql) {
|
||||
if (request.body.graphql.query) {
|
||||
dispatch(
|
||||
updateRequestGraphqlQuery({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
query: request.body.graphql.query
|
||||
})
|
||||
);
|
||||
}
|
||||
if (request.body.graphql.variables) {
|
||||
dispatch(
|
||||
updateRequestGraphqlVariables({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
variables: request.body.graphql.variables
|
||||
})
|
||||
);
|
||||
}
|
||||
} else if (bodyMode === 'formUrlEncoded' && request.body.formUrlEncoded) {
|
||||
// For formUrlEncoded, we need to set each param individually
|
||||
// This is a limitation - we'd need to clear existing params first
|
||||
// For now, we'll set the body mode and the user can manually adjust
|
||||
// TODO: Implement proper formUrlEncoded param setting
|
||||
} else if (bodyMode === 'multipartForm' && request.body.multipartForm) {
|
||||
// For multipartForm, similar limitation
|
||||
// TODO: Implement proper multipartForm param setting
|
||||
}
|
||||
}
|
||||
|
||||
// Update auth
|
||||
if (request.auth) {
|
||||
const authMode = request.auth.mode;
|
||||
if (authMode) {
|
||||
dispatch(
|
||||
updateRequestAuthMode({
|
||||
itemUid: item.uid,
|
||||
collectionUid: collection.uid,
|
||||
mode: authMode
|
||||
})
|
||||
);
|
||||
|
||||
// Set auth content based on mode
|
||||
if (request.auth.basic) {
|
||||
dispatch(
|
||||
updateAuth({
|
||||
mode: 'basic',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: request.auth.basic
|
||||
})
|
||||
);
|
||||
} else if (request.auth.bearer) {
|
||||
dispatch(
|
||||
updateAuth({
|
||||
mode: 'bearer',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: request.auth.bearer
|
||||
})
|
||||
);
|
||||
} else if (request.auth.digest) {
|
||||
dispatch(
|
||||
updateAuth({
|
||||
mode: 'digest',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: request.auth.digest
|
||||
})
|
||||
);
|
||||
} else if (request.auth.ntlm) {
|
||||
dispatch(
|
||||
updateAuth({
|
||||
mode: 'ntlm',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: request.auth.ntlm
|
||||
})
|
||||
);
|
||||
} else if (request.auth.awsv4) {
|
||||
dispatch(
|
||||
updateAuth({
|
||||
mode: 'awsv4',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: request.auth.awsv4
|
||||
})
|
||||
);
|
||||
} else if (request.auth.apikey) {
|
||||
dispatch(
|
||||
updateAuth({
|
||||
mode: 'apikey',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content: request.auth.apikey
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toast.success('cURL command imported successfully');
|
||||
} catch (error) {
|
||||
console.error('Error parsing cURL command:', error);
|
||||
toast.error('Failed to parse cURL command');
|
||||
}
|
||||
},
|
||||
[dispatch, item.uid, item.type, collection.uid]
|
||||
);
|
||||
const handleCancelRequest = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dispatch(cancelRequest(item.cancelTokenUid, item, collection));
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex items-center">
|
||||
<div className="flex flex-1 items-center h-full method-selector-container">
|
||||
@@ -110,10 +398,12 @@ const QueryUrl = ({ item, collection, handleRun }) => {
|
||||
<SingleLineEditor
|
||||
ref={editorRef}
|
||||
value={url}
|
||||
placeholder="Enter URL or paste a cURL request"
|
||||
onSave={(finalValue) => onSave(finalValue)}
|
||||
theme={storedTheme}
|
||||
onChange={(newValue) => onUrlChange(newValue)}
|
||||
onRun={handleRun}
|
||||
onPaste={item.type === 'http-request' ? handleHttpPaste : item.type === 'graphql-request' ? handleGraphqlPaste : null}
|
||||
collection={collection}
|
||||
highlightPathParams={true}
|
||||
item={item}
|
||||
|
||||
@@ -10,6 +10,7 @@ import CollectionToolBar from './CollectionToolBar';
|
||||
import RequestTab from './RequestTab';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import DraggableTab from './DraggableTab';
|
||||
import CreateUntitledRequest from 'components/CreateUntitledRequest';
|
||||
|
||||
const RequestTabs = () => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -78,8 +79,6 @@ const RequestTabs = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const createNewTab = () => setNewRequestModalOpen(true);
|
||||
|
||||
if (!activeTabUid) {
|
||||
return null;
|
||||
}
|
||||
@@ -178,19 +177,16 @@ const RequestTabs = () => {
|
||||
</div>
|
||||
</li>
|
||||
) : null}
|
||||
<li className="select-none short-tab" id="create-new-tab" onClick={createNewTab}>
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="22"
|
||||
height="22"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" />
|
||||
</svg>
|
||||
</div>
|
||||
</li>
|
||||
<div className="flex items-center short-tab">
|
||||
|
||||
{activeCollection && (
|
||||
<CreateUntitledRequest
|
||||
collectionUid={activeCollection.uid}
|
||||
itemUid={null}
|
||||
placement="bottom-start"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Moved to post mvp */}
|
||||
{/* <li className="select-none new-tab choose-request">
|
||||
<div className="flex items-center">
|
||||
|
||||
@@ -1629,3 +1629,53 @@ export const isVariableSecret = (scopeInfo) => {
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a unique request name by checking existing filenames in the collection and filesystem
|
||||
* @param {Object} collection - The collection object
|
||||
* @param {string} baseName - The base name (default: 'Untitled')
|
||||
* @param {string} itemUid - The parent item UID (null for root level, folder UID for folder level)
|
||||
* @returns {Promise<string>} - A unique request name (Untitled, Untitled1, Untitled2, etc.)
|
||||
*/
|
||||
export const generateUniqueRequestName = async (collection, baseName = 'Untitled', itemUid = null) => {
|
||||
if (!collection) {
|
||||
return baseName;
|
||||
}
|
||||
|
||||
const trim = require('lodash/trim');
|
||||
const parentItem = itemUid ? findItemInCollection(collection, itemUid) : null;
|
||||
const parentItems = parentItem ? (parentItem.items || []) : (collection.items || []);
|
||||
const baseNamePattern = new RegExp(`^${baseName}(\\d+)?$`);
|
||||
// Support .bru, .yml, and .yaml file extensions
|
||||
const requestExtensions = /\.(bru|yml|yaml)$/i;
|
||||
const matchingItems = parentItems
|
||||
.filter((item) => {
|
||||
if (item.type === 'folder') return false;
|
||||
|
||||
const filename = trim(item.filename);
|
||||
if (!requestExtensions.test(filename)) return false;
|
||||
|
||||
const filenameWithoutExt = filename.replace(requestExtensions, '');
|
||||
return baseNamePattern.test(filenameWithoutExt);
|
||||
})
|
||||
.map((item) => {
|
||||
const filenameWithoutExt = trim(item.filename).replace(requestExtensions, '');
|
||||
const match = filenameWithoutExt.match(baseNamePattern);
|
||||
|
||||
if (!match) return null;
|
||||
|
||||
const number = match[1] ? parseInt(match[1], 10) : 0;
|
||||
return { name: filenameWithoutExt, number: isNaN(number) ? null : number };
|
||||
})
|
||||
.filter((item) => item !== null && item.number !== null);
|
||||
|
||||
if (matchingItems.length === 0) {
|
||||
return baseName;
|
||||
}
|
||||
|
||||
const sortedMatches = matchingItems.sort((a, b) => a.number - b.number);
|
||||
const lastElement = sortedMatches[sortedMatches.length - 1];
|
||||
const nextNumber = lastElement.number + 1;
|
||||
|
||||
return `${baseName}${nextNumber}`;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import { closeAllCollections } from '../../utils/page';
|
||||
import { closeAllCollections, createUntitledRequest } from '../../utils/page';
|
||||
|
||||
test.describe('Create collection', () => {
|
||||
test.afterEach(async ({ page }) => {
|
||||
@@ -24,12 +24,13 @@ test.describe('Create collection', () => {
|
||||
await page.getByLabel('Safe Mode').check();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
// Create a new request
|
||||
await page.locator('#create-new-tab').getByRole('img').click();
|
||||
await page.getByPlaceholder('Request Name').fill('r1');
|
||||
await page.locator('#new-request-url .CodeMirror').click();
|
||||
// Create a new request using the new dropdown flow
|
||||
await createUntitledRequest(page, { requestType: 'HTTP' });
|
||||
|
||||
// Set the URL
|
||||
await page.locator('#request-url .CodeMirror').click();
|
||||
await page.locator('textarea').fill('http://localhost:8081');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
await page.locator('#send-request').getByTitle('Save Request').click();
|
||||
|
||||
// Send a request
|
||||
await page.locator('#request-url .CodeMirror').click();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import { closeAllCollections, createCollection } from '../../utils/page';
|
||||
import { closeAllCollections, createCollection, createUntitledRequest } from '../../utils/page';
|
||||
|
||||
test.describe('Cross-Collection Drag and Drop', () => {
|
||||
test.afterEach(async ({ page }) => {
|
||||
@@ -11,14 +11,15 @@ test.describe('Cross-Collection Drag and Drop', () => {
|
||||
// Create first collection - open with sandbox mode
|
||||
await createCollection(page, 'source-collection', await createTmpDir('source-collection'), { openWithSandboxMode: 'safe' });
|
||||
|
||||
// Create a request in the first collection
|
||||
await page.locator('#create-new-tab').getByRole('img').click();
|
||||
await page.getByPlaceholder('Request Name').fill('test-request');
|
||||
await page.locator('#new-request-url .CodeMirror').click();
|
||||
await page.locator('textarea').fill('https://echo.usebruno.com');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
// Create a request in the first collection using the new dropdown flow
|
||||
await createUntitledRequest(page, { requestType: 'HTTP' });
|
||||
|
||||
await expect(page.locator('.collection-item-name').filter({ hasText: 'test-request' })).toBeVisible();
|
||||
// Set the URL
|
||||
await page.locator('#request-url .CodeMirror').click();
|
||||
await page.locator('textarea').fill('https://echo.usebruno.com');
|
||||
await page.locator('#send-request').getByTitle('Save Request').click();
|
||||
|
||||
await expect(page.locator('.item-name').filter({ hasText: /^Untitled/ })).toBeVisible();
|
||||
|
||||
// Create second collection - open with sandbox mode
|
||||
await createCollection(page, 'target-collection', await createTmpDir('target-collection'), { openWithSandboxMode: 'safe' });
|
||||
@@ -27,7 +28,7 @@ test.describe('Cross-Collection Drag and Drop', () => {
|
||||
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'target-collection' })).toBeVisible();
|
||||
|
||||
// Locate the request in source collection
|
||||
const sourceRequest = page.locator('.collection-item-name').filter({ hasText: 'test-request' });
|
||||
const sourceRequest = page.locator('.item-name').filter({ hasText: /^Untitled/ }).first();
|
||||
await expect(sourceRequest).toBeVisible();
|
||||
|
||||
// Locate the target collection area (the collection name element)
|
||||
@@ -47,7 +48,7 @@ test.describe('Cross-Collection Drag and Drop', () => {
|
||||
.filter({ hasText: 'target-collection' })
|
||||
.locator('..');
|
||||
await expect(
|
||||
targetCollectionContainer.locator('.collection-item-name').filter({ hasText: 'test-request' })
|
||||
targetCollectionContainer.locator('.item-name').filter({ hasText: /^Untitled/ })
|
||||
).toBeVisible();
|
||||
|
||||
// Verify the request is no longer in the source collection
|
||||
@@ -56,7 +57,7 @@ test.describe('Cross-Collection Drag and Drop', () => {
|
||||
.filter({ hasText: 'source-collection' })
|
||||
.locator('..');
|
||||
await expect(
|
||||
sourceCollectionContainer.locator('.collection-item-name').filter({ hasText: 'test-request' })
|
||||
sourceCollectionContainer.locator('.item-name').filter({ hasText: /^Untitled/ })
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
@@ -67,29 +68,31 @@ test.describe('Cross-Collection Drag and Drop', () => {
|
||||
// Create first collection (source-collection)
|
||||
await createCollection(page, 'source-collection', await createTmpDir('source-collection'), { openWithSandboxMode: 'safe' });
|
||||
|
||||
// Create a request in the first collection (request-1)
|
||||
await page.locator('#create-new-tab').getByRole('img').click();
|
||||
await page.getByPlaceholder('Request Name').fill('request-1');
|
||||
await page.locator('#new-request-url .CodeMirror').click();
|
||||
await page.locator('textarea').fill('https://echo.usebruno.com');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
// Create a request in the first collection using the new dropdown flow
|
||||
await createUntitledRequest(page, { requestType: 'HTTP' });
|
||||
|
||||
// check if request-1 is created and visible in sidebar
|
||||
await expect(page.locator('.collection-item-name').filter({ hasText: 'request-1' })).toBeVisible();
|
||||
// Set the URL
|
||||
await page.locator('#request-url .CodeMirror').click();
|
||||
await page.locator('textarea').fill('https://echo.usebruno.com');
|
||||
await page.locator('#send-request').getByTitle('Save Request').click();
|
||||
|
||||
// check if untitled request is created and visible in sidebar
|
||||
await expect(page.locator('.item-name').filter({ hasText: /^Untitled/ })).toBeVisible();
|
||||
|
||||
// Create second collection (target-collection)
|
||||
await createCollection(page, 'target-collection', await createTmpDir('target-collection'), { openWithSandboxMode: 'safe' });
|
||||
|
||||
// Create a request in the target collection with the same name (request-1)
|
||||
await page.locator('#create-new-tab').getByRole('img').click();
|
||||
await page.getByPlaceholder('Request Name').fill('request-1');
|
||||
await page.locator('#new-request-url .CodeMirror').click();
|
||||
// Create a request in the target collection using the new dropdown flow
|
||||
await createUntitledRequest(page, { requestType: 'HTTP' });
|
||||
|
||||
// Set the URL
|
||||
await page.locator('#request-url .CodeMirror').click();
|
||||
await page.locator('textarea').fill('https://echo.usebruno.com');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
await page.locator('#send-request').getByTitle('Save Request').click();
|
||||
|
||||
// Go back to source collection to drag the request
|
||||
await page.locator('#sidebar-collection-name').filter({ hasText: 'source-collection' }).click();
|
||||
const sourceRequest = page.locator('.collection-item-name').filter({ hasText: 'request-1' }).first();
|
||||
const sourceRequest = page.locator('.item-name').filter({ hasText: /^Untitled/ }).first();
|
||||
await expect(sourceRequest).toBeVisible();
|
||||
|
||||
// Locate the target collection area
|
||||
@@ -108,7 +111,7 @@ test.describe('Cross-Collection Drag and Drop', () => {
|
||||
.filter({ hasText: 'target-collection' })
|
||||
.locator('..');
|
||||
await expect(
|
||||
targetCollectionContainer.locator('.collection-item-name').filter({ hasText: 'request-1' })
|
||||
targetCollectionContainer.locator('.item-name').filter({ hasText: /^Untitled/ })
|
||||
).toBeVisible();
|
||||
|
||||
const sourceCollectionContainer = page
|
||||
@@ -116,7 +119,7 @@ test.describe('Cross-Collection Drag and Drop', () => {
|
||||
.filter({ hasText: 'source-collection' })
|
||||
.locator('..');
|
||||
await expect(
|
||||
sourceCollectionContainer.locator('.collection-item-name').filter({ hasText: 'request-1' })
|
||||
sourceCollectionContainer.locator('.item-name').filter({ hasText: /^Untitled/ })
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import { closeAllCollections } from '../../utils/page';
|
||||
import { closeAllCollections, createUntitledRequest } from '../../utils/page';
|
||||
|
||||
test.describe('Tag persistence', () => {
|
||||
test.afterEach(async ({ page }) => {
|
||||
@@ -20,57 +20,48 @@ test.describe('Tag persistence', () => {
|
||||
await page.locator('#sidebar-collection-name').filter({ hasText: 'test-collection' }).click();
|
||||
await page.getByLabel('Safe Mode').check();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await page.waitForTimeout(1000);
|
||||
// Create three requests, each with URL and tag (auto-saved after each is completely created)
|
||||
// The createUntitledRequest function now waits for each request to be fully created
|
||||
// before returning, ensuring unique names are generated
|
||||
await createUntitledRequest(page, {
|
||||
requestType: 'HTTP',
|
||||
url: 'https://httpfaker.org/api/echo',
|
||||
tag: 'smoke'
|
||||
});
|
||||
await createUntitledRequest(page, {
|
||||
requestType: 'HTTP',
|
||||
url: 'https://httpfaker.org/api/echo',
|
||||
tag: 'smoke'
|
||||
});
|
||||
await createUntitledRequest(page, {
|
||||
requestType: 'HTTP',
|
||||
url: 'https://httpfaker.org/api/echo',
|
||||
tag: 'smoke'
|
||||
});
|
||||
|
||||
// Create a new request
|
||||
await page.locator('#create-new-tab').getByRole('img').click();
|
||||
await page.getByRole('textbox', { name: 'Request Name' }).fill('request-1');
|
||||
await page.locator('#new-request-url textarea').fill('https://httpfaker.org/api/echo');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
// Wait for all 3 requests to be visible in the sidebar
|
||||
const untitledRequests = page.locator('.item-name').filter({ hasText: /^Untitled/ });
|
||||
await expect(untitledRequests).toHaveCount(3);
|
||||
|
||||
// create another request
|
||||
await page.locator('#create-new-tab').getByRole('img').click();
|
||||
await page.getByRole('textbox', { name: 'Request Name' }).fill('request-2');
|
||||
await page.locator('#new-request-url textarea').fill('https://httpfaker.org/api/echo');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
// create another request
|
||||
await page.locator('#create-new-tab').getByRole('img').click();
|
||||
await page.getByRole('textbox', { name: 'Request Name' }).fill('request-3');
|
||||
await page.locator('#new-request-url textarea').fill('https://httpfaker.org/api/echo');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Add a tag to the request
|
||||
await page.getByRole('tab', { name: 'Settings' }).click();
|
||||
await page.waitForTimeout(200);
|
||||
const tagInput = await page.getByTestId('tag-input').getByRole('textbox');
|
||||
await tagInput.fill('smoke');
|
||||
await tagInput.press('Enter');
|
||||
await page.waitForTimeout(200);
|
||||
// Verify the tag was added
|
||||
await expect(page.locator('.tag-item', { hasText: 'smoke' })).toBeVisible();
|
||||
await page.keyboard.press('Meta+s');
|
||||
|
||||
// Move the request-3 request to just above request-1 within the same collection
|
||||
const r3Request = page.locator('.collection-item-name').filter({ hasText: 'request-3' });
|
||||
const r1Request = page.locator('.collection-item-name').filter({ hasText: 'request-1' });
|
||||
// Move the last untitled request to just above the first untitled request within the same collection
|
||||
const r3Request = untitledRequests.nth(2); // Third request (0-indexed)
|
||||
const r1Request = untitledRequests.first(); // First request
|
||||
|
||||
await expect(r3Request).toBeVisible();
|
||||
await expect(r1Request).toBeVisible();
|
||||
|
||||
// Perform drag and drop operation to move request-3 below request-1 using source position
|
||||
// Perform drag and drop operation to move the last request above the first using source position
|
||||
await r3Request.dragTo(r1Request, {
|
||||
targetPosition: { x: 0, y: 1 }
|
||||
});
|
||||
|
||||
// Verify the requests are still in the collection and request-3 is now above request-1
|
||||
await expect(page.locator('.collection-item-name').filter({ hasText: 'request-3' })).toBeVisible();
|
||||
await expect(page.locator('.collection-item-name').filter({ hasText: 'request-1' })).toBeVisible();
|
||||
// Verify the requests are still in the collection
|
||||
await expect(untitledRequests).toHaveCount(3);
|
||||
|
||||
// Click on request-3 to verify the tag persisted after the move
|
||||
await page.locator('.collection-item-name').filter({ hasText: 'request-3' }).click();
|
||||
await page.locator('.request-tab.active').filter({ hasText: 'request-3' }).waitFor({ state: 'visible' });
|
||||
// Click on the moved request (now first) to verify the tag persisted after the move
|
||||
await untitledRequests.first().click();
|
||||
await page.locator('.request-tab.active').waitFor({ state: 'visible' });
|
||||
await page.getByRole('tab', { name: 'Settings' }).click();
|
||||
await page.waitForTimeout(200);
|
||||
// Verify the tag is still present after the move
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import { closeAllCollections } from '../../utils/page';
|
||||
import { closeAllCollections, createUntitledRequest } from '../../utils/page';
|
||||
|
||||
test.describe('Code Generation URL Encoding', () => {
|
||||
test.afterEach(async ({ page }) => {
|
||||
@@ -33,15 +33,14 @@ test.describe('Code Generation URL Encoding', () => {
|
||||
await page.getByLabel('Safe Mode').check();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await page.locator('#create-new-tab').getByRole('img').click();
|
||||
await page.getByPlaceholder('Request Name').fill('unencoded-request');
|
||||
await page.locator('#new-request-url .CodeMirror').click();
|
||||
await page.locator('textarea').fill('http://base.source?name=John Doe');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
// Create a new request using the new dropdown flow
|
||||
await createUntitledRequest(page, {
|
||||
requestType: 'HTTP',
|
||||
url: 'http://base.source?name=John Doe'
|
||||
});
|
||||
|
||||
await expect(page.locator('.collection-item-name').filter({ hasText: 'unencoded-request' })).toBeVisible();
|
||||
|
||||
await page.locator('.collection-item-name').filter({ hasText: 'unencoded-request' }).click();
|
||||
// Find the untitled request and click on it
|
||||
await page.locator('.item-name').filter({ hasText: /^Untitled/ }).first().click();
|
||||
|
||||
await page.locator('#send-request .infotip').first().click();
|
||||
|
||||
@@ -79,15 +78,14 @@ test.describe('Code Generation URL Encoding', () => {
|
||||
await page.getByLabel('Safe Mode').check();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await page.locator('#create-new-tab').getByRole('img').click();
|
||||
await page.getByPlaceholder('Request Name').fill('encoded-request');
|
||||
await page.locator('#new-request-url .CodeMirror').click();
|
||||
await page.locator('textarea').fill('http://base.source?name=John%20Doe');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
// Create a new request using the new dropdown flow
|
||||
await createUntitledRequest(page, {
|
||||
requestType: 'HTTP',
|
||||
url: 'http://base.source?name=John%20Doe'
|
||||
});
|
||||
|
||||
await expect(page.locator('.collection-item-name').filter({ hasText: 'encoded-request' })).toBeVisible();
|
||||
|
||||
await page.locator('.collection-item-name').filter({ hasText: 'encoded-request' }).click();
|
||||
// Find the untitled request and click on it
|
||||
await page.locator('.item-name').filter({ hasText: /^Untitled/ }).first().click();
|
||||
|
||||
await page.locator('#send-request .infotip').first().click();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from '../../playwright';
|
||||
import { closeAllCollections, createCollection } from '../utils/page/actions';
|
||||
import { closeAllCollections, createCollection, createUntitledRequest } from '../utils/page/actions';
|
||||
|
||||
test.describe('Large Response Crash/High Memory Usage Prevention', () => {
|
||||
// Increase timeout to 1 minute for all tests in this describe block, default is 30 seconds.
|
||||
@@ -17,14 +17,11 @@ test.describe('Large Response Crash/High Memory Usage Prevention', () => {
|
||||
// Create collection
|
||||
await createCollection(page, collectionName, await createTmpDir(collectionName), { openWithSandboxMode: 'safe' });
|
||||
|
||||
// Create request
|
||||
await page.locator('#create-new-tab').getByRole('img').click();
|
||||
|
||||
const createRequestModal = page.locator('.bruno-modal-card').filter({ hasText: 'New Request' });
|
||||
await createRequestModal.getByPlaceholder('Request Name').fill('size-check');
|
||||
await createRequestModal.locator('#new-request-url .CodeMirror').click();
|
||||
await createRequestModal.locator('textarea').fill('https://samples.json-format.com/employees/json/employees_50MB.json');
|
||||
await createRequestModal.getByRole('button', { name: 'Create' }).click();
|
||||
// Create request using the new dropdown flow
|
||||
await createUntitledRequest(page, {
|
||||
requestType: 'HTTP',
|
||||
url: 'https://samples.json-format.com/employees/json/employees_50MB.json'
|
||||
});
|
||||
|
||||
// Send request
|
||||
const sendButton = page.getByTestId('send-arrow-icon');
|
||||
|
||||
@@ -89,6 +89,68 @@ type CreateRequestOptions = {
|
||||
inFolder?: boolean;
|
||||
};
|
||||
|
||||
type CreateUntitledRequestOptions = {
|
||||
requestType?: 'HTTP' | 'GraphQL' | 'WebSocket' | 'gRPC';
|
||||
requestName?: string;
|
||||
url?: string;
|
||||
tag?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an untitled request using the new dropdown flow (from tabs area)
|
||||
* @param page - The page object
|
||||
* @param options - Optional settings (requestType, url, tag)
|
||||
* @returns void
|
||||
*/
|
||||
const createUntitledRequest = async (
|
||||
page: Page,
|
||||
options: CreateUntitledRequestOptions = {}
|
||||
) => {
|
||||
const { requestType = 'HTTP', url, tag } = options;
|
||||
|
||||
await test.step(`Create untitled ${requestType} request${url ? ' with URL' : ''}${tag ? ' with tag' : ''}`, async () => {
|
||||
// Click the + icon to open the dropdown
|
||||
const createButton = page.locator('.short-tab').locator('svg').first();
|
||||
await createButton.waitFor({ state: 'visible' });
|
||||
await createButton.click();
|
||||
|
||||
// Select the request type from dropdown
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: requestType }).waitFor({ state: 'visible' });
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: requestType }).click();
|
||||
|
||||
// Wait for the request tab to be active
|
||||
await page.locator('.request-tab.active').waitFor({ state: 'visible' });
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Fill URL if provided
|
||||
if (url) {
|
||||
await page.locator('#request-url .CodeMirror').click();
|
||||
await page.locator('#request-url textarea').fill(url);
|
||||
await page.locator('#send-request').getByTitle('Save Request').click();
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
// Add tag if provided
|
||||
if (tag) {
|
||||
await page.getByRole('tab', { name: 'Settings' }).click();
|
||||
await page.waitForTimeout(200);
|
||||
const tagInput = await page.getByTestId('tag-input').getByRole('textbox');
|
||||
await tagInput.fill(tag);
|
||||
await tagInput.press('Enter');
|
||||
await page.waitForTimeout(200);
|
||||
await expect(page.locator('.tag-item', { hasText: tag })).toBeVisible();
|
||||
await page.keyboard.press('Meta+s');
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
// Wait for toast message to ensure request creation is complete
|
||||
// This helps prevent race conditions when creating multiple requests
|
||||
await expect(page.getByText('New request created!')).toBeVisible({ timeout: 10000 }).catch(() => {
|
||||
// Toast might have already disappeared, that's okay
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a request in a collection or folder
|
||||
* @param page - The page object
|
||||
@@ -521,6 +583,7 @@ export {
|
||||
openCollectionAndAcceptSandbox,
|
||||
createCollection,
|
||||
createRequest,
|
||||
createUntitledRequest,
|
||||
deleteRequest,
|
||||
importCollection,
|
||||
removeCollection,
|
||||
@@ -539,4 +602,4 @@ export {
|
||||
expectResponseContains
|
||||
};
|
||||
|
||||
export type { SandboxMode, EnvironmentType, EnvironmentVariable, CreateCollectionOptions, ImportCollectionOptions, CreateRequestOptions };
|
||||
export type { SandboxMode, EnvironmentType, EnvironmentVariable, CreateCollectionOptions, ImportCollectionOptions, CreateRequestOptions, CreateUntitledRequestOptions };
|
||||
|
||||
Reference in New Issue
Block a user