mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
feat: add copy paste feature for folder (#6097)
* feat: add copy paste feature for folder * add: playwright test * fix * fix * add: keyboardFocusBg in light mode * fix: copy paste in yaml collection * improvement
This commit is contained in:
@@ -131,6 +131,15 @@ const Wrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
&.item-keyboard-focused {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.keyboardFocusBg};
|
||||
outline: none;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.keyboardFocusBg} !important;
|
||||
}
|
||||
}
|
||||
|
||||
div.tippy-box {
|
||||
position: relative;
|
||||
top: -0.625rem;
|
||||
|
||||
@@ -77,6 +77,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
const [runCollectionModalOpen, setRunCollectionModalOpen] = useState(false);
|
||||
const [itemInfoModalOpen, setItemInfoModalOpen] = useState(false);
|
||||
const [examplesExpanded, setExamplesExpanded] = useState(false);
|
||||
const [isKeyboardFocused, setIsKeyboardFocused] = useState(false);
|
||||
const hasSearchText = searchText && searchText?.trim()?.length;
|
||||
const itemIsCollapsed = hasSearchText ? false : item.collapsed;
|
||||
const isFolder = isItemAFolder(item);
|
||||
@@ -186,10 +187,11 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
});
|
||||
|
||||
const itemRowClassName = classnames('flex collection-item-name relative items-center', {
|
||||
'item-focused-in-tab': isTabForItemActive,
|
||||
'item-focused-in-tab': isTabForItemActive && !isKeyboardFocused,
|
||||
'item-hovered': isOver && canDrop,
|
||||
'drop-target': isOver && dropType === 'inside',
|
||||
'drop-target-above': isOver && dropType === 'adjacent'
|
||||
'drop-target-above': isOver && dropType === 'adjacent',
|
||||
'item-keyboard-focused': isKeyboardFocused
|
||||
});
|
||||
|
||||
const handleRun = async () => {
|
||||
@@ -393,23 +395,56 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyRequest = () => {
|
||||
const handleCopyItem = () => {
|
||||
dropdownTippyRef.current.hide();
|
||||
dispatch(copyRequest(item));
|
||||
toast.success('Request copied to clipboard');
|
||||
const itemType = isFolder ? 'Folder' : 'Request';
|
||||
toast.success(`${itemType} copied to clipboard`);
|
||||
};
|
||||
|
||||
const handlePasteRequest = () => {
|
||||
const handlePasteItem = () => {
|
||||
dropdownTippyRef.current.hide();
|
||||
|
||||
// Only allow paste into folders
|
||||
if (!isFolder) {
|
||||
toast.error('Paste is only available for folders');
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(pasteItem(collectionUid, item.uid))
|
||||
.then(() => {
|
||||
toast.success('Request pasted successfully');
|
||||
toast.success('Item pasted successfully');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err ? err.message : 'An error occurred while pasting the request');
|
||||
toast.error(err ? err.message : 'An error occurred while pasting the item');
|
||||
});
|
||||
};
|
||||
|
||||
// Keyboard shortcuts handler
|
||||
const handleKeyDown = (e) => {
|
||||
// Detect Mac by checking both metaKey and platform
|
||||
const isMac = navigator.userAgent?.includes('Mac') || navigator.platform?.startsWith('Mac');
|
||||
const isModifierPressed = isMac ? e.metaKey : e.ctrlKey;
|
||||
|
||||
if (isModifierPressed && e.key.toLowerCase() === 'c') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCopyItem();
|
||||
} else if (isModifierPressed && e.key.toLowerCase() === 'v') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handlePasteItem();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsKeyboardFocused(true);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsKeyboardFocused(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className={className}>
|
||||
{renameItemModalOpen && (
|
||||
@@ -449,6 +484,10 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
ref.current = node;
|
||||
drag(drop(node));
|
||||
}}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
>
|
||||
<div className="flex items-center h-full w-full">
|
||||
{indents && indents.length
|
||||
@@ -568,21 +607,19 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
</span>
|
||||
Clone
|
||||
</div>
|
||||
{!isFolder && (
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={handleCopyRequest}
|
||||
>
|
||||
<span className="dropdown-icon">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={handleCopyItem}
|
||||
>
|
||||
<span className="dropdown-icon">
|
||||
<IconCopy size={16} strokeWidth={2} />
|
||||
</span>
|
||||
Copy
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
Copy
|
||||
</div>
|
||||
{isFolder && hasCopiedItems && (
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={handlePasteRequest}
|
||||
onClick={handlePasteItem}
|
||||
>
|
||||
<span className="dropdown-icon">
|
||||
<IconClipboard size={16} strokeWidth={2} />
|
||||
|
||||
@@ -94,6 +94,15 @@ const Wrapper = styled.div`
|
||||
background: ${(props) => props.theme.sidebar.collection.item.bg} !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.collection-keyboard-focused {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.keyboardFocusBg};
|
||||
outline: none;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.keyboardFocusBg} !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#sidebar-collection-name {
|
||||
|
||||
@@ -51,6 +51,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
const [showShareCollectionModal, setShowShareCollectionModal] = useState(false);
|
||||
const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false);
|
||||
const [dropType, setDropType] = useState(null);
|
||||
const [isKeyboardFocused, setIsKeyboardFocused] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
const isLoading = areItemsLoading(collection);
|
||||
const collectionRef = useRef(null);
|
||||
@@ -161,17 +162,38 @@ const Collection = ({ collection, searchText }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const handlePasteRequest = () => {
|
||||
const handlePasteItem = () => {
|
||||
menuDropdownTippyRef.current.hide();
|
||||
dispatch(pasteItem(collection.uid, null))
|
||||
.then(() => {
|
||||
toast.success('Request pasted successfully');
|
||||
toast.success('Item pasted successfully');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err ? err.message : 'An error occurred while pasting the request');
|
||||
toast.error(err ? err.message : 'An error occurred while pasting the item');
|
||||
});
|
||||
};
|
||||
|
||||
// Keyboard shortcuts handler for collection
|
||||
const handleKeyDown = (e) => {
|
||||
// Detect Mac by checking both metaKey and platform
|
||||
const isMac = navigator.userAgent?.includes('Mac') || navigator.platform?.startsWith('Mac');
|
||||
const isModifierPressed = isMac ? e.metaKey : e.ctrlKey;
|
||||
|
||||
if (isModifierPressed && e.key.toLowerCase() === 'v') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handlePasteItem();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsKeyboardFocused(true);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsKeyboardFocused(false);
|
||||
};
|
||||
|
||||
const isCollectionItem = (itemType) => {
|
||||
return itemType === 'collection-item';
|
||||
};
|
||||
@@ -227,9 +249,10 @@ const Collection = ({ collection, searchText }) => {
|
||||
}
|
||||
|
||||
const collectionRowClassName = classnames('flex py-1 collection-name items-center', {
|
||||
'item-hovered': isOver && dropType === 'adjacent', // For collection-to-collection moves (show line)
|
||||
'drop-target': isOver && dropType === 'inside', // For collection-item drops (highlight full area)
|
||||
'collection-focused-in-tab': isCollectionFocused
|
||||
'item-hovered': isOver && dropType === 'adjacent', // For collection-to-collection moves (show line)
|
||||
'drop-target': isOver && dropType === 'inside', // For collection-item drops (highlight full area)
|
||||
'collection-focused-in-tab': isCollectionFocused && !isKeyboardFocused,
|
||||
'collection-keyboard-focused': isKeyboardFocused
|
||||
});
|
||||
|
||||
// we need to sort request items by seq property
|
||||
@@ -262,6 +285,10 @@ const Collection = ({ collection, searchText }) => {
|
||||
collectionRef.current = node;
|
||||
drag(drop(node));
|
||||
}}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
>
|
||||
<div
|
||||
className="flex flex-grow items-center overflow-hidden"
|
||||
@@ -337,7 +364,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
{hasCopiedItems && (
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={handlePasteRequest}
|
||||
onClick={handlePasteItem}
|
||||
>
|
||||
<span className="dropdown-icon">
|
||||
<IconClipboard size={16} strokeWidth={2} />
|
||||
|
||||
@@ -81,6 +81,42 @@ import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { updateSettingsSelectedTab } from './index';
|
||||
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
|
||||
// generate a unique names
|
||||
const generateUniqueName = (originalName, existingItems, isFolder) => {
|
||||
// Extract base name by removing any existing " (number)" suffix
|
||||
const baseName = originalName.replace(/\s*\(\d+\)$/, '');
|
||||
const baseFilename = sanitizeName(baseName);
|
||||
|
||||
// Get normalized filenames for items of the same type
|
||||
const existingFilenames = existingItems
|
||||
.filter((item) => isFolder ? item.type === 'folder' : item.type !== 'folder')
|
||||
.map((item) => {
|
||||
let filename = trim(item.filename);
|
||||
// For requests, remove file extension (.bru, .yml, .yaml)
|
||||
return isFolder ? filename : filename.replace(/\.(bru|yml|yaml)$/, '');
|
||||
});
|
||||
|
||||
// Check if base name conflicts with existing items
|
||||
if (!existingFilenames.includes(baseFilename)) {
|
||||
return { newName: baseName, newFilename: baseFilename };
|
||||
}
|
||||
|
||||
// Find highest counter among conflicting names
|
||||
const counters = existingFilenames
|
||||
.filter((filename) => filename === baseFilename || filename.startsWith(`${baseFilename} (`))
|
||||
.map((filename) => {
|
||||
if (filename === baseFilename) return 0;
|
||||
const match = filename.match(/\((\d+)\)$/);
|
||||
return match ? parseInt(match[1], 10) : 0;
|
||||
});
|
||||
|
||||
const nextCounter = Math.max(0, ...counters) + 1;
|
||||
return {
|
||||
newName: `${baseName} (${nextCounter})`,
|
||||
newFilename: `${baseFilename} (${nextCounter})`
|
||||
};
|
||||
};
|
||||
|
||||
export const renameCollection = (newName, collectionUid) => (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const collection = findCollectionByUid(state.collections.collections, collectionUid);
|
||||
@@ -853,7 +889,7 @@ export const cloneItem = (newName, newFilename, itemUid, collectionUid) => (disp
|
||||
const collectionPath = path.join(parentFolder.pathname, newFilename);
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
ipcRenderer.invoke('renderer:clone-folder', item, collectionPath).then(resolve).catch(reject);
|
||||
ipcRenderer.invoke('renderer:clone-folder', item, collectionPath, collection.pathname).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -943,11 +979,6 @@ export const pasteItem = (targetCollectionUid, targetItemUid = null) => (dispatc
|
||||
for (const clipboardItem of clipboardResult.items) {
|
||||
const copiedItem = cloneDeep(clipboardItem);
|
||||
|
||||
// Only allow pasting requests (not folders)
|
||||
if (isItemAFolder(copiedItem)) {
|
||||
return reject(new Error('Pasting folders is not supported'));
|
||||
}
|
||||
|
||||
const targetCollectionCopy = cloneDeep(targetCollection);
|
||||
let targetItem = null;
|
||||
let targetParentPathname = targetCollection.pathname;
|
||||
@@ -964,39 +995,47 @@ export const pasteItem = (targetCollectionUid, targetItemUid = null) => (dispatc
|
||||
targetParentPathname = targetItem.pathname;
|
||||
}
|
||||
|
||||
// Generate a unique filename for the pasted item
|
||||
let newName = copiedItem.name;
|
||||
let newFilename = sanitizeName(copiedItem.name);
|
||||
let counter = 1;
|
||||
|
||||
const existingItems = targetItem ? targetItem.items : targetCollection.items;
|
||||
|
||||
// Check for duplicate names and append counter if needed
|
||||
while (find(existingItems, (i) => i.type !== 'folder' && trim(i.filename) === trim(resolveRequestFilename(newFilename, targetCollection.format)))) {
|
||||
newName = `${copiedItem.name} (${counter})`;
|
||||
newFilename = `${sanitizeName(copiedItem.name)} (${counter})`;
|
||||
counter++;
|
||||
// Handle folder pasting
|
||||
if (isItemAFolder(copiedItem)) {
|
||||
// Generate unique name for folder
|
||||
const { newName, newFilename } = generateUniqueName(copiedItem.name, existingItems, true);
|
||||
|
||||
set(copiedItem, 'name', newName);
|
||||
set(copiedItem, 'filename', newFilename);
|
||||
set(copiedItem, 'root.meta.name', newName);
|
||||
set(copiedItem, 'root.meta.seq', (existingItems?.length ?? 0) + 1);
|
||||
|
||||
const fullPathname = path.join(targetParentPathname, newFilename);
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
await ipcRenderer.invoke('renderer:clone-folder', copiedItem, fullPathname, targetCollection.pathname);
|
||||
} else {
|
||||
// Handle request pasting
|
||||
// Generate unique name for request
|
||||
const { newName, newFilename } = generateUniqueName(copiedItem.name, existingItems, false);
|
||||
|
||||
const filename = resolveRequestFilename(newFilename, targetCollection.format);
|
||||
const itemToSave = refreshUidsInItem(transformRequestToSaveToFilesystem(copiedItem));
|
||||
set(itemToSave, 'name', trim(newName));
|
||||
set(itemToSave, 'filename', trim(filename));
|
||||
|
||||
const fullPathname = path.join(targetParentPathname, filename);
|
||||
const { ipcRenderer } = window;
|
||||
const requestItems = filter(existingItems, (i) => i.type !== 'folder');
|
||||
itemToSave.seq = requestItems ? requestItems.length + 1 : 1;
|
||||
|
||||
await itemSchema.validate(itemToSave);
|
||||
await ipcRenderer.invoke('renderer:new-request', fullPathname, itemToSave, targetCollection.format);
|
||||
|
||||
dispatch(insertTaskIntoQueue({
|
||||
uid: uuid(),
|
||||
type: 'OPEN_REQUEST',
|
||||
collectionUid: targetCollectionUid,
|
||||
itemPathname: fullPathname
|
||||
}));
|
||||
}
|
||||
|
||||
const filename = resolveRequestFilename(newFilename, targetCollection.format);
|
||||
const itemToSave = refreshUidsInItem(transformRequestToSaveToFilesystem(copiedItem));
|
||||
set(itemToSave, 'name', trim(newName));
|
||||
set(itemToSave, 'filename', trim(filename));
|
||||
|
||||
const fullPathname = path.join(targetParentPathname, filename);
|
||||
const { ipcRenderer } = window;
|
||||
const requestItems = filter(existingItems, (i) => i.type !== 'folder');
|
||||
itemToSave.seq = requestItems ? requestItems.length + 1 : 1;
|
||||
|
||||
await itemSchema.validate(itemToSave);
|
||||
await ipcRenderer.invoke('renderer:new-request', fullPathname, itemToSave, targetCollection.format);
|
||||
|
||||
dispatch(insertTaskIntoQueue({
|
||||
uid: uuid(),
|
||||
type: 'OPEN_REQUEST',
|
||||
collectionUid: targetCollectionUid,
|
||||
itemPathname: fullPathname
|
||||
}));
|
||||
}
|
||||
|
||||
resolve();
|
||||
@@ -1021,7 +1060,7 @@ export const deleteItem = (itemUid, collectionUid) => (dispatch, getState) => {
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
ipcRenderer
|
||||
.invoke('renderer:delete-item', item.pathname, item.type)
|
||||
.invoke('renderer:delete-item', item.pathname, item.type, collection.pathname)
|
||||
.then(async () => {
|
||||
// Reorder items in parent directory after deletion
|
||||
if (parentDirectoryItem.items) {
|
||||
|
||||
@@ -115,6 +115,7 @@ const darkTheme = {
|
||||
item: {
|
||||
bg: '#37373D',
|
||||
hoverBg: '#2A2D2F',
|
||||
keyboardFocusBg: 'rgba(10, 132, 255, 0.2)',
|
||||
indentBorder: 'solid 1px #585858',
|
||||
active: {
|
||||
indentBorder: 'solid 1px #4c4c4c'
|
||||
|
||||
@@ -118,6 +118,7 @@ const lightTheme = {
|
||||
item: {
|
||||
bg: colors.GRAY_2,
|
||||
hoverBg: colors.GRAY_2,
|
||||
keyboardFocusBg: 'rgba(10, 132, 255, 0.2)',
|
||||
indentBorder: `solid 1px ${colors.GRAY_3}`,
|
||||
active: {
|
||||
indentBorder: `solid 1px ${colors.GRAY_3}`
|
||||
|
||||
@@ -773,7 +773,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
||||
});
|
||||
|
||||
// delete file/folder
|
||||
ipcMain.handle('renderer:delete-item', async (event, pathname, type) => {
|
||||
ipcMain.handle('renderer:delete-item', async (event, pathname, type, collectionPathname) => {
|
||||
try {
|
||||
if (type === 'folder') {
|
||||
if (!fs.existsSync(pathname)) {
|
||||
@@ -781,7 +781,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
||||
}
|
||||
|
||||
// delete the request uid mappings
|
||||
const requestFilesAtSource = await searchForRequestFiles(pathname);
|
||||
const requestFilesAtSource = await searchForRequestFiles(pathname, collectionPathname);
|
||||
for (let requestFile of requestFilesAtSource) {
|
||||
deleteRequestUid(requestFile);
|
||||
}
|
||||
@@ -947,7 +947,12 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
||||
items.forEach(async (item) => {
|
||||
if (['http-request', 'graphql-request', 'grpc-request'].includes(item.type)) {
|
||||
const content = await stringifyRequestViaWorker(item, { format });
|
||||
const filePath = path.join(currentPath, item.filename);
|
||||
|
||||
// Use the correct file extension based on target format
|
||||
const baseName = path.parse(item.filename).name;
|
||||
const newFilename = format === 'yml' ? `${baseName}.yml` : `${baseName}.bru`;
|
||||
const filePath = path.join(currentPath, newFilename);
|
||||
|
||||
safeWriteFileSync(filePath, content);
|
||||
}
|
||||
if (item.type === 'folder') {
|
||||
|
||||
@@ -9,14 +9,17 @@ export const toOpenCollectionVariables = (variables: BrunoFolderRequest['vars']
|
||||
const reqVars = hasReqRes ? variables.req : variables as BrunoVariables;
|
||||
const resVars = hasReqRes && 'res' in variables ? variables.res : [];
|
||||
|
||||
const allVars = [...(reqVars || []), ...(resVars || [])];
|
||||
const reqVarsArray = Array.isArray(reqVars) ? reqVars : [];
|
||||
const resVarsArray = Array.isArray(resVars) ? resVars : [];
|
||||
|
||||
const allVars = [...reqVarsArray, ...resVarsArray];
|
||||
|
||||
if (!allVars.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const ocVariables: Variable[] = allVars.map((v: BrunoVariable, index: number): Variable => {
|
||||
const isResVar = reqVars && index >= (reqVars?.length || 0);
|
||||
const isResVar = index >= reqVarsArray.length;
|
||||
const variable: Variable = {
|
||||
name: v.name || '',
|
||||
value: v.value || ''
|
||||
|
||||
92
tests/request/copy-request/copy-folder.spec.ts
Normal file
92
tests/request/copy-request/copy-folder.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import { closeAllCollections, createCollection } from '../../utils/page';
|
||||
|
||||
test.describe('Copy and Paste Folders', () => {
|
||||
test.afterAll(async ({ page }) => {
|
||||
await closeAllCollections(page);
|
||||
});
|
||||
|
||||
test('should copy and paste a folder within the same collection', async ({ page, createTmpDir }) => {
|
||||
await createCollection(page, 'test-collection', await createTmpDir('test-collection'), { openWithSandboxMode: 'safe' });
|
||||
const collection = page.locator('.collection-name').filter({ hasText: 'test-collection' });
|
||||
|
||||
// Create a new folder with a request inside
|
||||
await collection.locator('.collection-actions').hover();
|
||||
await collection.locator('.collection-actions .icon').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'New Folder' }).click();
|
||||
await page.locator('#folder-name').fill('folder-to-copy');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
const folder = page.locator('.collection-item-name').filter({ hasText: 'folder-to-copy' });
|
||||
await expect(folder).toBeVisible();
|
||||
|
||||
// Add a request to the folder
|
||||
await folder.hover();
|
||||
await folder.locator('.menu-icon').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).click();
|
||||
await page.getByPlaceholder('Request Name').fill('request-in-folder');
|
||||
await page.locator('#new-request-url .CodeMirror').click();
|
||||
await page.locator('textarea').fill('https://echo.usebruno.com/test');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
await folder.click();
|
||||
await expect(page.locator('.collection-item-name').filter({ hasText: 'request-in-folder' })).toBeVisible();
|
||||
|
||||
// Copy the folder
|
||||
await folder.hover();
|
||||
await folder.locator('.menu-icon').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Copy' }).click();
|
||||
|
||||
// Paste into the collection root
|
||||
await collection.hover();
|
||||
await collection.locator('.collection-actions .icon').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Paste' }).click();
|
||||
|
||||
// Verify the pasted folder appears
|
||||
await expect(page.locator('.collection-item-name').filter({ hasText: 'folder-to-copy' })).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('should copy and paste a folder into a different collection', async ({ page, createTmpDir }) => {
|
||||
// Create second collection
|
||||
await createCollection(page, 'test-collection-2', await createTmpDir('test-collection-2'), { openWithSandboxMode: 'safe' });
|
||||
const collection2 = page.locator('.collection-name').filter({ hasText: 'test-collection-2' });
|
||||
|
||||
// Paste the folder from clipboard into the new collection
|
||||
await collection2.hover();
|
||||
await collection2.locator('.collection-actions .icon').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Paste' }).click();
|
||||
|
||||
// Verify the pasted folder appears in the new collection
|
||||
await expect(page.locator('.collection-item-name').filter({ hasText: 'folder-to-copy' })).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('should paste folder into another folder', async ({ page }) => {
|
||||
const collection = page.locator('.collection-name').filter({ hasText: 'test-collection-2' });
|
||||
const folderToCopy = page.locator('.collection-item-name').filter({ hasText: 'folder-to-copy' }).first();
|
||||
|
||||
// Create a target folder
|
||||
await collection.locator('.collection-actions').hover();
|
||||
await collection.locator('.collection-actions .icon').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'New Folder' }).click();
|
||||
await page.locator('#folder-name').fill('target-folder');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
const targetFolder = page.locator('.collection-item-name').filter({ hasText: 'target-folder' });
|
||||
await expect(targetFolder).toBeVisible();
|
||||
await targetFolder.click();
|
||||
|
||||
// Copy folder-to-copy
|
||||
await folderToCopy.hover();
|
||||
await folderToCopy.locator('.menu-icon').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Copy' }).click();
|
||||
await folderToCopy.click();
|
||||
|
||||
// Paste into target folder
|
||||
await targetFolder.hover();
|
||||
await targetFolder.locator('.menu-icon').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Paste' }).click();
|
||||
|
||||
// Verify folder was pasted inside target folder
|
||||
await expect(page.locator('.collection-item-name').filter({ hasText: 'folder-to-copy' })).toHaveCount(4);
|
||||
});
|
||||
});
|
||||
@@ -50,7 +50,6 @@ test.describe('Copy and Paste Requests', () => {
|
||||
await folder.locator('.menu-icon').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'Paste' }).click();
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(page.locator('.collection-item-name').filter({ hasText: 'original-request' })).toHaveCount(3);
|
||||
});
|
||||
|
||||
|
||||
93
tests/request/copy-request/keyboard-shortcuts.spec.ts
Normal file
93
tests/request/copy-request/keyboard-shortcuts.spec.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import { closeAllCollections, createCollection } from '../../utils/page';
|
||||
|
||||
test.describe('Copy and Paste with Keyboard Shortcuts', () => {
|
||||
test.afterAll(async ({ page }) => {
|
||||
await closeAllCollections(page);
|
||||
});
|
||||
|
||||
test('should copy and paste request using keyboard shortcuts', async ({ page, createTmpDir }) => {
|
||||
await createCollection(page, 'keyboard-test', await createTmpDir('keyboard-test'), { openWithSandboxMode: 'safe' });
|
||||
const collection = page.locator('.collection-name').filter({ hasText: 'keyboard-test' });
|
||||
|
||||
// Create a request
|
||||
await collection.locator('.collection-actions').hover();
|
||||
await collection.locator('.collection-actions .icon').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).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();
|
||||
|
||||
const requestItem = page.locator('.collection-item-name').filter({ hasText: 'test-request' });
|
||||
await expect(requestItem).toBeVisible();
|
||||
|
||||
// Focus the request item
|
||||
await requestItem.click();
|
||||
await requestItem.focus();
|
||||
|
||||
// Wait for keyboard focus indicator
|
||||
await expect(requestItem).toHaveClass(/item-keyboard-focused/);
|
||||
|
||||
// Use Cmd+C on Mac, Ctrl+C on Windows/Linux
|
||||
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control';
|
||||
await page.keyboard.press(`${modifier}+KeyC`);
|
||||
|
||||
// Verify copy success (toast message)
|
||||
await expect(page.getByText(/copied to clipboard/i).first()).toBeVisible();
|
||||
|
||||
// Focus the collection to paste
|
||||
await collection.click();
|
||||
await collection.focus();
|
||||
|
||||
// Use Cmd+V on Mac, Ctrl+V on Windows/Linux
|
||||
await page.keyboard.press(`${modifier}+KeyV`);
|
||||
|
||||
// Verify paste success
|
||||
await expect(page.getByText(/pasted successfully/i).first()).toBeVisible();
|
||||
|
||||
// Verify the pasted request appears
|
||||
await expect(page.locator('.collection-item-name').filter({ hasText: 'test-request' })).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('should copy and paste folder using keyboard shortcuts', async ({ page }) => {
|
||||
const collection = page.locator('.collection-name').filter({ hasText: 'keyboard-test' });
|
||||
|
||||
// Create a folder
|
||||
await collection.locator('.collection-actions').hover();
|
||||
await collection.locator('.collection-actions .icon').click();
|
||||
await page.locator('.dropdown-item').filter({ hasText: 'New Folder' }).click();
|
||||
await page.locator('#folder-name').fill('test-folder');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
|
||||
const folder = page.locator('.collection-item-name').filter({ hasText: 'test-folder' });
|
||||
await expect(folder).toBeVisible();
|
||||
|
||||
// Focus the folder
|
||||
await folder.click();
|
||||
await folder.focus();
|
||||
|
||||
// Wait for keyboard focus indicator
|
||||
await expect(folder).toHaveClass(/item-keyboard-focused/);
|
||||
|
||||
// Use keyboard shortcut to copy
|
||||
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control';
|
||||
await page.keyboard.press(`${modifier}+KeyC`);
|
||||
|
||||
// Verify copy success
|
||||
await expect(page.getByText(/copied to clipboard/i).first()).toBeVisible();
|
||||
|
||||
// Focus the collection to paste
|
||||
await collection.click();
|
||||
await collection.focus();
|
||||
|
||||
// Use keyboard shortcut to paste
|
||||
await page.keyboard.press(`${modifier}+KeyV`);
|
||||
|
||||
// Verify paste success
|
||||
await expect(page.getByText(/pasted successfully/i).first()).toBeVisible();
|
||||
|
||||
// Verify the pasted folder appears
|
||||
await expect(page.locator('.collection-item-name').filter({ hasText: 'test-folder' })).toHaveCount(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user