mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
refactor: update AppTitleBar and SidebarHeader components (#6341)
* refactor: update AppTitleBar and SidebarHeader components to use MenuDropdown and ActionIcon for improved UI consistency * refactor: update button locators in tests to use data-testid for consistency and improved readability
This commit is contained in:
@@ -29,7 +29,6 @@ const Wrapper = styled.div`
|
||||
.workspace-name-container,
|
||||
.dropdown-item,
|
||||
.home-button,
|
||||
.env-selector-trigger,
|
||||
.dropdown,
|
||||
button {
|
||||
-webkit-app-region: no-drag;
|
||||
@@ -49,25 +48,6 @@ const Wrapper = styled.div`
|
||||
margin-left: 0px;
|
||||
}
|
||||
|
||||
/* Home button */
|
||||
.home-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.sidebar.color};
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
}
|
||||
|
||||
/* Workspace Name Dropdown Trigger */
|
||||
.workspace-name-container {
|
||||
display: flex;
|
||||
@@ -112,7 +92,7 @@ const Wrapper = styled.div`
|
||||
.bruno-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.sidebar.muted};
|
||||
color: ${(props) => props.theme.text};
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
@@ -125,36 +105,6 @@ const Wrapper = styled.div`
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Action buttons in right section */
|
||||
.titlebar-action-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.sidebar.color};
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
|
||||
svg {
|
||||
color: ${(props) => props.theme.sidebar.color};
|
||||
}
|
||||
}
|
||||
|
||||
/* Draggable region */
|
||||
.drag-region {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
/* Workspace Dropdown Styles */
|
||||
.workspace-item {
|
||||
display: flex;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { IconCheck, IconChevronDown, IconFolder, IconHome, IconLayoutColumns, IconLayoutRows, IconPin, IconPinned, IconPlus } from '@tabler/icons';
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
@@ -9,7 +10,8 @@ import { openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slice
|
||||
import { sortWorkspaces, toggleWorkspacePin } from 'utils/workspaces';
|
||||
|
||||
import Bruno from 'components/Bruno';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import ActionIcon from 'ui/ActionIcon';
|
||||
import IconSidebarToggle from 'components/Icons/IconSidebarToggle';
|
||||
import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace';
|
||||
|
||||
@@ -53,13 +55,10 @@ const AppTitleBar = () => {
|
||||
}, [workspaces, preferences]);
|
||||
|
||||
const [createWorkspaceModalOpen, setCreateWorkspaceModalOpen] = useState(false);
|
||||
const [showWorkspaceDropdown, setShowWorkspaceDropdown] = useState(false);
|
||||
const workspaceDropdownTippyRef = useRef();
|
||||
const onWorkspaceDropdownCreate = (ref) => (workspaceDropdownTippyRef.current = ref);
|
||||
|
||||
const WorkspaceName = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="workspace-name-container" onClick={() => setShowWorkspaceDropdown(!showWorkspaceDropdown)}>
|
||||
<div ref={ref} className="workspace-name-container" {...props}>
|
||||
<span className="workspace-name">{toTitleCase(activeWorkspace?.name) || 'Default Workspace'}</span>
|
||||
<IconChevronDown size={14} stroke={1.5} className="chevron-icon" />
|
||||
</div>
|
||||
@@ -72,12 +71,10 @@ const AppTitleBar = () => {
|
||||
|
||||
const handleWorkspaceSwitch = (workspaceUid) => {
|
||||
dispatch(switchWorkspace(workspaceUid));
|
||||
setShowWorkspaceDropdown(false);
|
||||
toast.success(`Switched to ${workspaces.find((w) => w.uid === workspaceUid)?.name}`);
|
||||
};
|
||||
|
||||
const handleOpenWorkspace = async () => {
|
||||
setShowWorkspaceDropdown(false);
|
||||
try {
|
||||
await dispatch(openWorkspaceDialog());
|
||||
toast.success('Workspace opened successfully');
|
||||
@@ -87,7 +84,6 @@ const AppTitleBar = () => {
|
||||
};
|
||||
|
||||
const handleCreateWorkspace = () => {
|
||||
setShowWorkspaceDropdown(false);
|
||||
setCreateWorkspaceModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -124,6 +120,59 @@ const AppTitleBar = () => {
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
};
|
||||
|
||||
// Build workspace menu items
|
||||
const workspaceMenuItems = useMemo(() => {
|
||||
const items = sortedWorkspaces.map((workspace) => {
|
||||
const isActive = workspace.uid === activeWorkspaceUid;
|
||||
const isPinned = preferences?.workspaces?.pinnedWorkspaceUids?.includes(workspace.uid);
|
||||
|
||||
return {
|
||||
id: workspace.uid,
|
||||
label: toTitleCase(workspace.name),
|
||||
onClick: () => handleWorkspaceSwitch(workspace.uid),
|
||||
className: `workspace-item ${isActive ? 'active' : ''}`,
|
||||
rightSection: (
|
||||
<div className="workspace-actions">
|
||||
{workspace.type !== 'default' && (
|
||||
<ActionIcon
|
||||
className={`pin-btn ${isPinned ? 'pinned' : ''}`}
|
||||
onClick={(e) => handlePinWorkspace(workspace.uid, e)}
|
||||
label={isPinned ? 'Unpin workspace' : 'Pin workspace'}
|
||||
size="sm"
|
||||
>
|
||||
{isPinned ? (
|
||||
<IconPinned size={14} stroke={1.5} />
|
||||
) : (
|
||||
<IconPin size={14} stroke={1.5} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
)}
|
||||
{isActive && <IconCheck size={16} stroke={1.5} className="check-icon" />}
|
||||
</div>
|
||||
)
|
||||
};
|
||||
});
|
||||
|
||||
// Add label and action items
|
||||
items.push(
|
||||
{ type: 'label', label: 'Workspaces' },
|
||||
{
|
||||
id: 'create-workspace',
|
||||
leftSection: IconPlus,
|
||||
label: 'Create workspace',
|
||||
onClick: handleCreateWorkspace
|
||||
},
|
||||
{
|
||||
id: 'open-workspace',
|
||||
leftSection: IconFolder,
|
||||
label: 'Open workspace',
|
||||
onClick: handleOpenWorkspace
|
||||
}
|
||||
);
|
||||
|
||||
return items;
|
||||
}, [sortedWorkspaces, activeWorkspaceUid, preferences, handlePinWorkspace]);
|
||||
|
||||
return (
|
||||
<StyledWrapper className={`app-titlebar ${isFullScreen ? 'fullscreen' : ''}`}>
|
||||
{createWorkspaceModalOpen && (
|
||||
@@ -133,61 +182,24 @@ const AppTitleBar = () => {
|
||||
<div className="titlebar-content">
|
||||
{/* Left section: Home + Workspace */}
|
||||
<div className="titlebar-left">
|
||||
<button className="home-button" onClick={handleHomeClick} title="Home">
|
||||
<ActionIcon
|
||||
onClick={handleHomeClick}
|
||||
label="Home"
|
||||
size="lg"
|
||||
className="home-button"
|
||||
>
|
||||
<IconHome size={16} stroke={1.5} />
|
||||
</button>
|
||||
</ActionIcon>
|
||||
|
||||
{/* Workspace Dropdown */}
|
||||
<Dropdown
|
||||
onCreate={onWorkspaceDropdownCreate}
|
||||
icon={<WorkspaceName />}
|
||||
<MenuDropdown
|
||||
data-testid="workspace-menu"
|
||||
items={workspaceMenuItems}
|
||||
placement="bottom-start"
|
||||
style="new"
|
||||
visible={showWorkspaceDropdown}
|
||||
onClickOutside={() => setShowWorkspaceDropdown(false)}
|
||||
selectedItemId={activeWorkspaceUid}
|
||||
>
|
||||
{sortedWorkspaces.map((workspace) => {
|
||||
const isActive = workspace.uid === activeWorkspaceUid;
|
||||
const isPinned = preferences?.workspaces?.pinnedWorkspaceUids?.includes(workspace.uid);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={workspace.uid}
|
||||
className={`dropdown-item workspace-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => handleWorkspaceSwitch(workspace.uid)}
|
||||
>
|
||||
<span className="workspace-name">{toTitleCase(workspace.name)}</span>
|
||||
<div className="workspace-actions">
|
||||
{workspace.type !== 'default' && (
|
||||
<button
|
||||
className={`pin-btn ${isPinned ? 'pinned' : ''}`}
|
||||
onClick={(e) => handlePinWorkspace(workspace.uid, e)}
|
||||
title={isPinned ? 'Unpin workspace' : 'Pin workspace'}
|
||||
>
|
||||
{isPinned ? (
|
||||
<IconPinned size={14} stroke={1.5} />
|
||||
) : (
|
||||
<IconPin size={14} stroke={1.5} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{isActive && <IconCheck size={16} stroke={1.5} className="check-icon" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="label-item border-top">Workspaces</div>
|
||||
|
||||
<div className="dropdown-item" onClick={handleCreateWorkspace}>
|
||||
<IconPlus size={16} stroke={1.5} className="icon" />
|
||||
Create workspace
|
||||
</div>
|
||||
<div className="dropdown-item" onClick={handleOpenWorkspace}>
|
||||
<IconFolder size={16} stroke={1.5} className="icon" />
|
||||
Open workspace
|
||||
</div>
|
||||
</Dropdown>
|
||||
<WorkspaceName />
|
||||
</MenuDropdown>
|
||||
</div>
|
||||
|
||||
{/* Center section: Bruno logo + text */}
|
||||
@@ -199,33 +211,30 @@ const AppTitleBar = () => {
|
||||
{/* Right section: Action buttons */}
|
||||
<div className="titlebar-right">
|
||||
{/* Toggle sidebar */}
|
||||
<button
|
||||
className="titlebar-action-button"
|
||||
<ActionIcon
|
||||
onClick={handleToggleSidebar}
|
||||
title={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}
|
||||
aria-label="Toggle Sidebar"
|
||||
label={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}
|
||||
size="lg"
|
||||
data-testid="toggle-sidebar-button"
|
||||
>
|
||||
<IconSidebarToggle collapsed={sidebarCollapsed} size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</ActionIcon>
|
||||
|
||||
{/* Toggle devtools */}
|
||||
<button
|
||||
className="titlebar-action-button"
|
||||
<ActionIcon
|
||||
onClick={handleToggleDevtools}
|
||||
title={isConsoleOpen ? 'Hide devtools' : 'Show devtools'}
|
||||
aria-label="Toggle Devtools"
|
||||
label={isConsoleOpen ? 'Hide devtools' : 'Show devtools'}
|
||||
size="lg"
|
||||
data-testid="toggle-devtools-button"
|
||||
>
|
||||
<IconBottombarToggle collapsed={!isConsoleOpen} size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</ActionIcon>
|
||||
|
||||
{/* Toggle vertical layout */}
|
||||
<button
|
||||
className="titlebar-action-button"
|
||||
<ActionIcon
|
||||
onClick={handleToggleVerticalLayout}
|
||||
title={orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout'}
|
||||
aria-label="Toggle Vertical Layout"
|
||||
label={orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout'}
|
||||
size="lg"
|
||||
data-testid="toggle-vertical-layout-button"
|
||||
>
|
||||
{orientation === 'horizontal' ? (
|
||||
@@ -233,8 +242,7 @@ const AppTitleBar = () => {
|
||||
) : (
|
||||
<IconLayoutRows size={16} stroke={1.5} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -25,6 +25,16 @@ const Wrapper = styled.div`
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
|
||||
[role="menu"] {
|
||||
outline: none;
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.label-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -59,6 +69,10 @@ const Wrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
@@ -70,10 +84,31 @@ const Wrapper = styled.div`
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.dropdown-right-section {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: ${(props) => props.theme.dropdown.hoverBg};
|
||||
}
|
||||
|
||||
&.selected-focused:not(:disabled) {
|
||||
background-color: ${(props) => props.theme.dropdown.hoverBg};
|
||||
}
|
||||
|
||||
&:focus-visible:not(:disabled) {
|
||||
outline: none;
|
||||
background-color: ${(props) => props.theme.dropdown.hoverBg};
|
||||
}
|
||||
|
||||
&:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
padding: 8px 4px 6px 10px;
|
||||
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
@@ -9,6 +9,7 @@ const StyledWrapper = styled.div`
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
padding: 6px 4px 6px 10px;
|
||||
}
|
||||
|
||||
/* Section Title (single view mode) */
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
IconSquareX,
|
||||
IconTrash
|
||||
} from '@tabler/icons';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
@@ -22,7 +22,8 @@ import { sortCollections } from 'providers/ReduxStore/slices/collections/index';
|
||||
import { importCollectionInWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { openApiSpec } from 'providers/ReduxStore/slices/apiSpec';
|
||||
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import ActionIcon from 'ui/ActionIcon';
|
||||
import ImportCollection from 'components/Sidebar/ImportCollection';
|
||||
import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation';
|
||||
import CreateApiSpec from 'components/Sidebar/ApiSpecs/CreateApiSpec';
|
||||
@@ -75,12 +76,6 @@ const SidebarHeader = ({ setShowSearch }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const addDropdownTippyRef = useRef();
|
||||
const onAddDropdownCreate = (ref) => (addDropdownTippyRef.current = ref);
|
||||
|
||||
const actionsDropdownTippyRef = useRef();
|
||||
const onActionsDropdownCreate = (ref) => (actionsDropdownTippyRef.current = ref);
|
||||
|
||||
const handleToggleSearch = () => {
|
||||
if (setShowSearch) {
|
||||
setShowSearch((prev) => !prev);
|
||||
@@ -183,123 +178,112 @@ const SidebarHeader = ({ setShowSearch }) => {
|
||||
</>
|
||||
);
|
||||
|
||||
// Configuration for Add/Create dropdown items
|
||||
const addDropdownItems = [
|
||||
{
|
||||
id: 'create',
|
||||
leftSection: IconPlus,
|
||||
label: 'Create collection',
|
||||
onClick: () => {
|
||||
setCreateCollectionModalOpen(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'import',
|
||||
leftSection: IconDownload,
|
||||
label: 'Import collection',
|
||||
onClick: () => {
|
||||
setImportCollectionModalOpen(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'open',
|
||||
leftSection: IconFolder,
|
||||
label: 'Open collection',
|
||||
onClick: () => {
|
||||
handleOpenCollection();
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'label',
|
||||
label: 'API Specs'
|
||||
},
|
||||
{
|
||||
id: 'create-api-spec',
|
||||
leftSection: IconPlus,
|
||||
label: 'Create API Spec',
|
||||
onClick: () => {
|
||||
setCreateApiSpecModalOpen(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'open-api-spec',
|
||||
leftSection: IconFileCode,
|
||||
label: 'Open API Spec',
|
||||
onClick: () => {
|
||||
handleOpenApiSpec();
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Configuration for Actions dropdown items
|
||||
const actionsDropdownItems = [
|
||||
{
|
||||
id: 'sort',
|
||||
leftSection: getSortIcon(),
|
||||
label: getSortLabel(),
|
||||
onClick: () => {
|
||||
handleSortCollections();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'close-all',
|
||||
leftSection: IconSquareX,
|
||||
label: 'Close all',
|
||||
onClick: () => {
|
||||
selectAllCollectionsToClose();
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Render Collections-specific actions
|
||||
const renderCollectionsActions = () => (
|
||||
<>
|
||||
<button
|
||||
className="action-button"
|
||||
<ActionIcon
|
||||
onClick={handleToggleSearch}
|
||||
title="Search requests"
|
||||
label="Search requests"
|
||||
>
|
||||
<IconSearch size={14} stroke={1.5} />
|
||||
</button>
|
||||
{/* Add/Create dropdown */}
|
||||
<Dropdown
|
||||
onCreate={onAddDropdownCreate}
|
||||
icon={(
|
||||
<button className="action-button plus-icon-button" title="Add new">
|
||||
<IconPlus size={14} stroke={1.5} />
|
||||
</button>
|
||||
)}
|
||||
placement="bottom-end"
|
||||
style="new"
|
||||
>
|
||||
<div className="label-item">Collections</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
setCreateCollectionModalOpen(true);
|
||||
addDropdownTippyRef.current?.hide();
|
||||
}}
|
||||
>
|
||||
<IconPlus size={16} stroke={1.5} className="icon" />
|
||||
Create collection
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
addDropdownTippyRef.current?.hide();
|
||||
setImportCollectionModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<IconDownload size={16} stroke={1.5} className="icon" />
|
||||
Import collection
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
handleOpenCollection();
|
||||
addDropdownTippyRef.current?.hide();
|
||||
}}
|
||||
>
|
||||
<IconFolder size={16} stroke={1.5} className="icon" />
|
||||
Open collection
|
||||
</div>
|
||||
<IconSearch size={14} stroke={1.5} aria-hidden="true" />
|
||||
</ActionIcon>
|
||||
|
||||
<div className="label-item mt-2">API Specs</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
setCreateApiSpecModalOpen(true);
|
||||
addDropdownTippyRef.current?.hide();
|
||||
}}
|
||||
>
|
||||
<IconPlus size={16} stroke={1.5} className="icon" />
|
||||
Create API Spec
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
handleOpenApiSpec();
|
||||
addDropdownTippyRef.current?.hide();
|
||||
}}
|
||||
>
|
||||
<IconFileCode size={16} stroke={1.5} className="icon" />
|
||||
Open API Spec
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
||||
{/* Actions dropdown (sort, close all, etc.) */}
|
||||
<Dropdown
|
||||
onCreate={onActionsDropdownCreate}
|
||||
icon={(
|
||||
<button className="action-button" title="More actions">
|
||||
<IconDotsVertical size={14} stroke={1.5} />
|
||||
</button>
|
||||
)}
|
||||
{/* Add Collection dropdown */}
|
||||
<MenuDropdown
|
||||
data-testid="collections-header-add-menu"
|
||||
items={[
|
||||
{ type: 'label', label: 'Collections' },
|
||||
...addDropdownItems
|
||||
]}
|
||||
placement="bottom-end"
|
||||
style="new"
|
||||
>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
handleSortCollections();
|
||||
actionsDropdownTippyRef.current?.hide();
|
||||
}}
|
||||
aria-label="Sort collections"
|
||||
title="Sort collections"
|
||||
data-testid="sort-collections-button"
|
||||
<ActionIcon
|
||||
label="Add new collection"
|
||||
>
|
||||
{(() => {
|
||||
const SortIcon = getSortIcon();
|
||||
return <SortIcon size={16} stroke={1.5} className="icon" />;
|
||||
})()}
|
||||
{getSortLabel()}
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
selectAllCollectionsToClose();
|
||||
actionsDropdownTippyRef.current?.hide();
|
||||
}}
|
||||
aria-label="Close all collections"
|
||||
title="Close all collections"
|
||||
data-testid="close-all-collections-button"
|
||||
<IconPlus size={14} stroke={1.5} aria-hidden="true" />
|
||||
</ActionIcon>
|
||||
</MenuDropdown>
|
||||
|
||||
{/* More Actions dropdown (sort, close all, etc.) */}
|
||||
<MenuDropdown
|
||||
data-testid="collections-header-actions-menu"
|
||||
items={actionsDropdownItems}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<ActionIcon
|
||||
label="More actions"
|
||||
>
|
||||
<IconSquareX size={16} stroke={1.5} className="icon" />
|
||||
Close all
|
||||
</div>
|
||||
</Dropdown>
|
||||
<IconDotsVertical size={14} stroke={1.5} aria-hidden="true" />
|
||||
</ActionIcon>
|
||||
</MenuDropdown>
|
||||
|
||||
{collectionsToClose.length > 0 && (
|
||||
<RemoveCollectionsModal collectionUids={collectionsToClose} onClose={clearCollectionsToClose} />
|
||||
|
||||
53
packages/bruno-app/src/ui/ActionIcon/StyledWrapper.js
Normal file
53
packages/bruno-app/src/ui/ActionIcon/StyledWrapper.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
const sizeMap = {
|
||||
xs: 20,
|
||||
sm: 22,
|
||||
md: 24,
|
||||
lg: 28,
|
||||
xl: 32
|
||||
};
|
||||
|
||||
const variants = {
|
||||
subtle: css`
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: transparent;
|
||||
&:hover:not(:disabled) {
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) => props.theme.dropdown.hoverBg};
|
||||
}
|
||||
`
|
||||
};
|
||||
|
||||
const StyledWrapper = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
padding: 0;
|
||||
|
||||
width: ${(props) => sizeMap[props.$size] || props.$size}px;
|
||||
height: ${(props) => sizeMap[props.$size] || props.$size}px;
|
||||
|
||||
${(props) => variants[props.$variant] || variants.subtle}
|
||||
|
||||
svg {
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
${(props) => props.$colorOnHover && css`
|
||||
&:hover:not(:disabled) {
|
||||
color: ${props.$colorOnHover};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
52
packages/bruno-app/src/ui/ActionIcon/index.js
Normal file
52
packages/bruno-app/src/ui/ActionIcon/index.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
/**
|
||||
* ActionIcon - A reusable icon button component
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {ReactNode} props.children - The icon component to render
|
||||
* @param {string} props.variant - Visual variant: 'subtle' (default), 'filled', 'outline', etc.
|
||||
* @param {string} props.size - Size of the button: 'sm', 'md', 'lg', etc. (default: 'md')
|
||||
* @param {boolean} props.disabled - Whether the button is disabled
|
||||
* @param {string} props.className - Additional CSS class names
|
||||
* @param {string} props.component - Polymorphic component (default: 'button')
|
||||
* @param {string} props.label - Label for both title and aria-label (preferred)
|
||||
* @param {string} props.title - Title attribute (falls back to label or aria-label)
|
||||
* @param {string} [props.ariaLabel] - Accessibility label (falls back to label or title)
|
||||
* @param {string} props.colorOnHover - Color to apply to icon on hover/focus (e.g., 'red', '#ef4444', 'var(--color-danger)')
|
||||
* @param {Object} props...rest - Other props passed to the underlying element
|
||||
*/
|
||||
const ActionIcon = ({
|
||||
children,
|
||||
variant = 'subtle',
|
||||
size = 'md',
|
||||
disabled = false,
|
||||
className = '',
|
||||
component: Component = 'button',
|
||||
label,
|
||||
'aria-label': ariaLabel,
|
||||
colorOnHover,
|
||||
...rest
|
||||
}) => {
|
||||
// Build className array and filter out empty strings
|
||||
const classNames = ['action-icon', className].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<StyledWrapper
|
||||
as={Component}
|
||||
$variant={variant}
|
||||
$size={size}
|
||||
$colorOnHover={colorOnHover}
|
||||
disabled={disabled}
|
||||
className={classNames}
|
||||
title={label}
|
||||
aria-label={ariaLabel}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionIcon;
|
||||
279
packages/bruno-app/src/ui/MenuDropdown/index.js
Normal file
279
packages/bruno-app/src/ui/MenuDropdown/index.js
Normal file
@@ -0,0 +1,279 @@
|
||||
import React from 'react';
|
||||
import { useRef, useCallback, useState } from 'react';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
|
||||
// Constants
|
||||
const NAVIGATION_KEYS = ['ArrowDown', 'ArrowUp', 'Home', 'End', 'Escape'];
|
||||
const ACTION_KEYS = ['Enter', ' '];
|
||||
|
||||
// Calculate next index for keyboard navigation
|
||||
const getNextIndex = (currentIndex, total, key, noFocus) => {
|
||||
if (key === 'Home') return 0;
|
||||
if (key === 'End') return total - 1;
|
||||
if (key === 'ArrowDown') return noFocus ? 0 : (currentIndex + 1) % total;
|
||||
if (key === 'ArrowUp') return noFocus ? total - 1 : (currentIndex - 1 + total) % total;
|
||||
return currentIndex;
|
||||
};
|
||||
|
||||
/**
|
||||
* MenuDropdown - A reusable dropdown menu component with keyboard navigation
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Array} props.items - Array of menu items with structure:
|
||||
* - id: string (unique identifier)
|
||||
* - type: 'item' | 'label' | 'divider' (default: 'item')
|
||||
* - leftSection: React component or React element (rendered on the left side, for items only)
|
||||
* - rightSection: React component or React element (rendered on the right side, for items only)
|
||||
* - label: string (display text for items, or label text for labels; also used for aria-label and title if not provided)
|
||||
* - ariaLabel: string (accessibility label, falls back to label or title if not provided)
|
||||
* - onClick: function (handler when item is clicked, for items only)
|
||||
* - title: string (tooltip text, falls back to label or ariaLabel if not provided)
|
||||
* - testId: string (optional, for testing, for items only)
|
||||
* - disabled: boolean (optional, for items only)
|
||||
* - className: string (optional, additional CSS classes for the item)
|
||||
* @param {ReactNode} props.children - The trigger element (button, etc.)
|
||||
* @param {string} props.placement - Tippy placement (default: 'bottom-end')
|
||||
* @param {string} props.className - Optional className for the dropdown
|
||||
* @param {string} props.selectedItemId - Optional ID of the selected/active item to focus on open
|
||||
* @param {Object} props.dropdownProps - Other props passed to underlying Dropdown component
|
||||
*/
|
||||
const MenuDropdown = ({
|
||||
items = [],
|
||||
children,
|
||||
placement = 'bottom-end',
|
||||
className,
|
||||
selectedItemId,
|
||||
'data-testid': testId = 'menu-dropdown',
|
||||
...dropdownProps
|
||||
}) => {
|
||||
const tippyRef = useRef();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Get all focusable menu items from the menu dropdown
|
||||
const getMenuItems = useCallback(() => {
|
||||
const popper = tippyRef.current?.popper;
|
||||
if (!popper) return [];
|
||||
|
||||
const menuContainer = popper.querySelector('[role="menu"]');
|
||||
if (!menuContainer) return [];
|
||||
|
||||
return Array.from(
|
||||
menuContainer.querySelectorAll('[role="menuitem"]:not([aria-disabled="true"])')
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Handle item click and close dropdown
|
||||
const handleItemClick = useCallback((item) => {
|
||||
if (item.disabled) return;
|
||||
item.onClick?.();
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
// Focus a menu item
|
||||
const focusMenuItem = (item, addSelectedClass = false) => {
|
||||
if (item) {
|
||||
// Remove selected class from all items first
|
||||
const menuContainer = item.closest('[role="menu"]');
|
||||
if (menuContainer) {
|
||||
menuContainer.querySelectorAll('.selected-focused').forEach((el) => {
|
||||
el.classList.remove('selected-focused');
|
||||
});
|
||||
}
|
||||
|
||||
if (addSelectedClass) {
|
||||
item.classList.add('selected-focused');
|
||||
}
|
||||
item.focus();
|
||||
item.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard navigation handler (handles all keyboard events at menu level)
|
||||
const handleMenuKeyDown = useCallback((e) => {
|
||||
const itemsToNavigate = getMenuItems();
|
||||
if (itemsToNavigate.length === 0) return;
|
||||
|
||||
const currentIndex = itemsToNavigate.findIndex((el) => el === document.activeElement);
|
||||
const isNoMenuItemFocused = currentIndex === -1;
|
||||
|
||||
// Handle Escape
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle action keys (Enter, Space)
|
||||
if (ACTION_KEYS.includes(e.key) && !isNoMenuItemFocused) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const currentItem = itemsToNavigate[currentIndex];
|
||||
const itemId = currentItem?.getAttribute('data-item-id');
|
||||
const item = items.find((i) => i.id === itemId);
|
||||
if (item && !item.disabled) {
|
||||
handleItemClick(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle navigation keys
|
||||
if (NAVIGATION_KEYS.includes(e.key)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const nextIndex = getNextIndex(currentIndex, itemsToNavigate.length, e.key, isNoMenuItemFocused);
|
||||
focusMenuItem(itemsToNavigate[nextIndex], false);
|
||||
}
|
||||
}, [getMenuItems, items, handleItemClick]);
|
||||
|
||||
// Toggle dropdown visibility
|
||||
const handleTriggerClick = useCallback(() => {
|
||||
setIsOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
const handleClickOutside = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
// Setup Tippy instance
|
||||
const onDropdownCreate = useCallback((ref) => {
|
||||
tippyRef.current = ref;
|
||||
if (ref) {
|
||||
ref.setProps({
|
||||
onShow: () => {
|
||||
// Focus selected item if available, otherwise focus menu container
|
||||
setTimeout(() => {
|
||||
const menuContainer = ref.popper?.querySelector('[role="menu"]');
|
||||
if (!menuContainer) return;
|
||||
|
||||
// If selectedItemId is provided, find and focus that item
|
||||
if (selectedItemId) {
|
||||
const menuItems = Array.from(
|
||||
menuContainer.querySelectorAll('[role="menuitem"]:not([aria-disabled="true"])')
|
||||
);
|
||||
|
||||
const selectedItem = menuItems.find(
|
||||
(item) => item.getAttribute('data-item-id') === selectedItemId
|
||||
);
|
||||
|
||||
if (selectedItem) {
|
||||
focusMenuItem(selectedItem, true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: focus menu container
|
||||
menuContainer.focus();
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [selectedItemId]);
|
||||
|
||||
// Render section (left or right)
|
||||
const renderSection = (section) => {
|
||||
if (!section) return null;
|
||||
|
||||
// If it's a React component (function), render it with default icon props
|
||||
if (typeof section === 'function') {
|
||||
const SectionComponent = section;
|
||||
return <SectionComponent size={16} stroke={1.5} className="dropdown-icon" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
// If it's already a React element, render it as-is
|
||||
return section;
|
||||
};
|
||||
|
||||
// Render menu item
|
||||
const renderMenuItem = (item) => {
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`dropdown-item ${item.disabled ? 'disabled' : ''} ${item.className || ''}`.trim()}
|
||||
role="menuitem"
|
||||
data-item-id={item.id}
|
||||
onClick={() => !item.disabled && handleItemClick(item)}
|
||||
tabIndex={item.disabled ? -1 : 0}
|
||||
aria-label={item.ariaLabel}
|
||||
aria-disabled={item.disabled}
|
||||
title={item.title}
|
||||
data-testid={`${testId}-${item.id.toLowerCase()}`}
|
||||
>
|
||||
{renderSection(item.leftSection)}
|
||||
<span className="dropdown-label">{item.label}</span>
|
||||
{item.rightSection && (
|
||||
<div
|
||||
className="dropdown-right-section"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{renderSection(item.rightSection)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render label item
|
||||
const renderLabel = (item) => (
|
||||
<div key={item.id || `label-${item.label}`} className="label-item" role="presentation" data-testid={`${testId}-label-${item.label.toLowerCase().replace(/ /g, '-')}`}>
|
||||
{item.label}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render divider item
|
||||
const renderDivider = (item, index) => (
|
||||
<div key={item.id || `divider-${index}`} className="dropdown-separator" role="separator" />
|
||||
);
|
||||
|
||||
// Render menu content
|
||||
const renderMenuContent = () => {
|
||||
let dividerIndex = 0;
|
||||
|
||||
return items.map((item) => {
|
||||
const itemType = item.type || 'item';
|
||||
|
||||
if (itemType === 'label') {
|
||||
return renderLabel(item);
|
||||
}
|
||||
|
||||
if (itemType === 'divider') {
|
||||
return renderDivider(item, dividerIndex++);
|
||||
}
|
||||
|
||||
return renderMenuItem(item);
|
||||
});
|
||||
};
|
||||
|
||||
// Clone children to attach click handler
|
||||
const triggerElement = React.isValidElement(children)
|
||||
? React.cloneElement(children, {
|
||||
'onClick': (e) => {
|
||||
children.props.onClick?.(e);
|
||||
handleTriggerClick();
|
||||
},
|
||||
'data-testid': testId
|
||||
})
|
||||
: <div onClick={handleTriggerClick} data-testid={testId}>{children}</div>;
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
onCreate={onDropdownCreate}
|
||||
icon={triggerElement}
|
||||
placement={placement}
|
||||
className={className}
|
||||
visible={isOpen}
|
||||
onClickOutside={handleClickOutside}
|
||||
{...dropdownProps}
|
||||
>
|
||||
<div role="menu" tabIndex={-1} onKeyDown={handleMenuKeyDown}>
|
||||
{renderMenuContent()}
|
||||
</div>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuDropdown;
|
||||
@@ -17,7 +17,7 @@ const restartAppAndGetLocators = async (restartApp: (options?: { initUserDataPat
|
||||
};
|
||||
|
||||
// TODO: These tests need to be updated for the new workspace UI
|
||||
// The CollectionsHeader component (with close-all-collections-button) is not rendered in workspace mode
|
||||
// The CollectionsHeader component (with collections-header-actions-menu-close-all) is not rendered in workspace mode
|
||||
// The "Remove from workspace" flow is different from the old "Close collection" flow
|
||||
test.describe.skip('Close All Collections', () => {
|
||||
test.afterAll(async () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ test.describe('Create collection', () => {
|
||||
});
|
||||
|
||||
test('Create collection and add a simple HTTP request', async ({ page, createTmpDir }) => {
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }).click();
|
||||
await page.getByLabel('Name').click();
|
||||
await page.getByLabel('Name').fill('test-collection');
|
||||
|
||||
@@ -9,7 +9,7 @@ test.describe('Tag persistence', () => {
|
||||
|
||||
test('Verify tag persistence while moving requests within a collection', async ({ page, createTmpDir }) => {
|
||||
// Create first collection - click plus icon button to open dropdown
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }).click();
|
||||
await page.getByLabel('Name').fill('test-collection');
|
||||
const locationInput = page.locator('.bruno-modal').getByLabel('Location');
|
||||
@@ -79,7 +79,7 @@ test.describe('Tag persistence', () => {
|
||||
|
||||
test('verify tag persistence while moving requests between folders', async ({ page, createTmpDir }) => {
|
||||
// Create first collection - click plus icon button to open dropdown
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }).click();
|
||||
await page.getByLabel('Name').fill('test-collection');
|
||||
const locationInput = page.locator('.bruno-modal').getByLabel('Location');
|
||||
|
||||
@@ -9,7 +9,7 @@ test.describe('Move tabs', () => {
|
||||
|
||||
test('Verify tab move by drag and drop', async ({ page, createTmpDir }) => {
|
||||
// Create a collection
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }).click();
|
||||
await page.getByLabel('Name').fill('source-collection-drag-drop');
|
||||
const locationInput = page.locator('.bruno-modal').getByLabel('Location');
|
||||
@@ -100,7 +100,7 @@ test.describe('Move tabs', () => {
|
||||
|
||||
test('Verify tab move by keyboard shortcut', async ({ page, createTmpDir }) => {
|
||||
// Create a collection
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }).click();
|
||||
await page.getByLabel('Name').fill('source-collection-keyboard-shortcut');
|
||||
const locationInput2 = page.locator('.bruno-modal').getByLabel('Location');
|
||||
|
||||
@@ -57,7 +57,7 @@ test.describe('Open Multiple Collections', () => {
|
||||
await expect(page.locator('#sidebar-collection-name').getByText('Test Collection 1')).not.toBeVisible();
|
||||
|
||||
// Click on plus icon button and then "Open collection" in the dropdown
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Open collection' }).click();
|
||||
|
||||
// Wait for both collections to appear in the sidebar
|
||||
@@ -92,7 +92,7 @@ test.describe('Open Multiple Collections', () => {
|
||||
},
|
||||
{ collection1Dir, collection2Dir });
|
||||
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Open collection' }).click();
|
||||
|
||||
// Wait for error toasts to appear
|
||||
|
||||
@@ -12,7 +12,7 @@ test.describe('Collection Environment Import Tests', () => {
|
||||
const envFile = path.join(__dirname, 'fixtures', 'collection-env.json');
|
||||
|
||||
// Import test collection
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
const importModal = page.locator('[data-testid="import-collection-modal"]');
|
||||
|
||||
@@ -8,7 +8,7 @@ test.describe('Global Environment Import Tests', () => {
|
||||
const globalEnvFile = path.join(__dirname, 'fixtures', 'global-env.json');
|
||||
|
||||
// Import test collection
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
const importModal = page.locator('[data-testid="import-collection-modal"]');
|
||||
|
||||
@@ -5,7 +5,7 @@ test.describe('Import Corrupted Bruno Collection - Should Fail', () => {
|
||||
test('Import Bruno collection with invalid JSON structure should fail', async ({ page }) => {
|
||||
const brunoFile = path.resolve(__dirname, 'fixtures', 'bruno-malformed.json');
|
||||
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
|
||||
@@ -5,7 +5,7 @@ test.describe('Import Bruno Collection - Missing Required Schema Fields', () =>
|
||||
test('Import Bruno collection missing required version field should fail', async ({ page }) => {
|
||||
const brunoFile = path.resolve(__dirname, 'fixtures', 'bruno-missing-required-fields.json');
|
||||
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
|
||||
@@ -11,7 +11,7 @@ test.describe('Import Bruno Collection with Examples', () => {
|
||||
const brunoFile = path.resolve(__dirname, 'fixtures', 'bruno-with-examples.json');
|
||||
|
||||
await test.step('Open import collection modal', async () => {
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { test, expect } from '../../../playwright';
|
||||
|
||||
test.describe('File Input Acceptance', () => {
|
||||
test('File input accepts expected file types', async ({ page }) => {
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
// Check that file input exists (even if hidden)
|
||||
|
||||
@@ -5,7 +5,7 @@ test.describe('Invalid File Handling', () => {
|
||||
test('Handle invalid file without crashing', async ({ page }) => {
|
||||
const invalidFile = path.resolve(__dirname, 'fixtures', 'invalid.txt');
|
||||
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
|
||||
@@ -23,7 +23,7 @@ test.describe('Import Insomnia v4 Collection - Environment Import', () => {
|
||||
const insomniaFile = path.resolve(__dirname, 'fixtures', 'insomnia-v4-with-envs.json');
|
||||
|
||||
await test.step('Import Insomnia v4 collection with environments', async () => {
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
const importModal = page.getByTestId('import-collection-modal');
|
||||
|
||||
@@ -23,7 +23,7 @@ test.describe('Import Insomnia v5 Collection - Environment Import', () => {
|
||||
const insomniaFile = path.resolve(__dirname, 'fixtures', 'insomnia-v5-with-envs.yaml');
|
||||
|
||||
await test.step('Import Insomnia v5 collection with environments', async () => {
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
const importModal = page.getByTestId('import-collection-modal');
|
||||
|
||||
@@ -5,7 +5,7 @@ test.describe('Invalid Insomnia Collection - Missing Collection Array', () => {
|
||||
test('Handle Insomnia v5 collection missing collection array', async ({ page }) => {
|
||||
const insomniaFile = path.resolve(__dirname, 'fixtures', 'insomnia-v5-invalid-missing-collection.yaml');
|
||||
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
|
||||
@@ -5,7 +5,7 @@ test.describe('Invalid Insomnia Collection - Malformed Structure', () => {
|
||||
test('Handle malformed Insomnia collection structure', async ({ page }) => {
|
||||
const insomniaFile = path.resolve(__dirname, 'fixtures', 'insomnia-malformed.json');
|
||||
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
|
||||
@@ -12,7 +12,7 @@ test.describe('OpenAPI Duplicate Names Handling', () => {
|
||||
const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-duplicate-operation-name.yaml');
|
||||
|
||||
// start the import process
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
// wait for the import collection modal to appear
|
||||
|
||||
@@ -35,7 +35,7 @@ test.describe('Import OpenAPI Collection with Examples', () => {
|
||||
}, { importDir });
|
||||
|
||||
await test.step('Open import collection modal', async () => {
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
});
|
||||
|
||||
@@ -152,7 +152,7 @@ test.describe('Import OpenAPI Collection with Examples', () => {
|
||||
}, { importDir });
|
||||
|
||||
await test.step('Open import collection modal', async () => {
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ test.describe('Invalid OpenAPI - Malformed YAML', () => {
|
||||
test('Handle malformed OpenAPI YAML structure', async ({ page }) => {
|
||||
const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-malformed.yaml');
|
||||
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
|
||||
@@ -5,7 +5,7 @@ test.describe('Invalid OpenAPI - Missing Info Section', () => {
|
||||
test('Handle OpenAPI specification missing required info section', async ({ page }) => {
|
||||
const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-missing-info.yaml');
|
||||
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
|
||||
@@ -12,7 +12,7 @@ test.describe('OpenAPI Newline Handling', () => {
|
||||
const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-newline-in-operation-name.yaml');
|
||||
|
||||
// start the import process
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
// wait for the import collection modal to appear
|
||||
|
||||
@@ -12,7 +12,7 @@ test.describe('OpenAPI Path-Based Grouping', () => {
|
||||
const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-path-grouping.json');
|
||||
|
||||
// Start the import process
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
|
||||
@@ -35,7 +35,7 @@ test.describe('Import Postman Collection with Examples', () => {
|
||||
}, { importDir });
|
||||
|
||||
await test.step('Open import collection modal', async () => {
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ test.describe('Invalid Postman Collection - Invalid JSON', () => {
|
||||
test('Handle invalid JSON syntax', async ({ page }) => {
|
||||
const postmanFile = path.resolve(__dirname, 'fixtures', 'postman-invalid-schema.json');
|
||||
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
|
||||
@@ -5,7 +5,7 @@ test.describe('Invalid Postman Collection - Missing Info', () => {
|
||||
test('Handle Postman collection missing required info field', async ({ page }) => {
|
||||
const postmanFile = path.resolve(__dirname, 'fixtures', 'postman-invalid-missing-info.json');
|
||||
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
|
||||
@@ -5,7 +5,7 @@ test.describe('Invalid Postman Collection - Invalid Schema', () => {
|
||||
test('Handle Postman collection with invalid schema version', async ({ page }) => {
|
||||
const postmanFile = path.resolve(__dirname, 'fixtures', 'postman-invalid-schema.json');
|
||||
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
|
||||
@@ -5,7 +5,7 @@ test.describe('Invalid Postman Collection - Malformed Structure', () => {
|
||||
test('Handle malformed Postman collection structure', async ({ page }) => {
|
||||
const postmanFile = path.resolve(__dirname, 'fixtures', 'postman-malformed.json');
|
||||
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
|
||||
@@ -13,7 +13,7 @@ test.describe('Import WSDL Collection', () => {
|
||||
const wsdlFile = path.join(testDataDir, 'wsdl.xml');
|
||||
|
||||
await test.step('Open import collection modal', async () => {
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
@@ -71,7 +71,7 @@ test.describe('Import WSDL Collection', () => {
|
||||
const wsdlFile = path.join(testDataDir, 'wsdl-bruno.json');
|
||||
|
||||
await test.step('Open import collection modal', async () => {
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
// Wait for import collection modal to be ready
|
||||
|
||||
@@ -56,7 +56,7 @@ test.describe('Default Collection Location Feature', () => {
|
||||
|
||||
test('Should use default location in Create Collection modal', async ({ pageWithUserData: page }) => {
|
||||
// test Create Collection modal
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }).click();
|
||||
|
||||
// verify the default location is pre-filled (if location input is visible)
|
||||
|
||||
@@ -19,7 +19,7 @@ test.describe('Code Generation URL Encoding', () => {
|
||||
createTmpDir
|
||||
}) => {
|
||||
// Use plus icon button in new workspace UI
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }).click();
|
||||
await page.getByLabel('Name').fill('unencoded-test-collection');
|
||||
const locationInput = page.getByLabel('Location');
|
||||
@@ -65,7 +65,7 @@ test.describe('Code Generation URL Encoding', () => {
|
||||
createTmpDir
|
||||
}) => {
|
||||
// Use plus icon button in new workspace UI
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }).click();
|
||||
await page.getByLabel('Name').fill('encoded-test-collection');
|
||||
const locationInput = page.getByLabel('Location');
|
||||
|
||||
@@ -10,7 +10,7 @@ test('should persist request with newlines across app restarts', async ({ create
|
||||
const app1 = await launchElectronApp({ userDataPath });
|
||||
const page = await app1.firstWindow();
|
||||
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }).click();
|
||||
await page.locator('.bruno-modal').getByLabel('Name').fill('newlines-persistence');
|
||||
await page.locator('.bruno-modal').getByLabel('Location').fill(collectionPath);
|
||||
|
||||
@@ -9,7 +9,7 @@ const isRequestSaved = async (saveButton: Locator) => {
|
||||
};
|
||||
|
||||
const setup = async (page: Page, createTmpDir: (tag?: string | undefined) => Promise<string>) => {
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }).click();
|
||||
await page.getByLabel('Name').fill('source-collection');
|
||||
const locationInput = page.getByLabel('Location');
|
||||
|
||||
@@ -64,7 +64,7 @@ type CreateCollectionOptions = {
|
||||
*/
|
||||
const createCollection = async (page, collectionName: string, collectionLocation: string, options: CreateCollectionOptions = {}) => {
|
||||
await test.step(`Create collection "${collectionName}"`, async () => {
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }).click();
|
||||
|
||||
const createCollectionModal = page.locator('.bruno-modal-card').filter({ hasText: 'Create Collection' });
|
||||
@@ -184,7 +184,7 @@ const importCollection = async (
|
||||
await test.step(`Import collection from "${filePath}"`, async () => {
|
||||
const locators = buildCommonLocators(page);
|
||||
|
||||
await page.locator('.plus-icon-button').click();
|
||||
await page.getByTestId('collections-header-add-menu').click();
|
||||
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
||||
|
||||
// Wait for import modal
|
||||
|
||||
@@ -17,7 +17,7 @@ export const buildCommonLocators = (page: Page) => ({
|
||||
const folderWrapper = page.locator('.collection-item-name').filter({ hasText: folderName }).locator('..');
|
||||
return folderWrapper.locator('.collection-item-name').filter({ hasText: requestName });
|
||||
},
|
||||
closeAllCollectionsButton: () => page.getByTestId('close-all-collections-button'),
|
||||
closeAllCollectionsButton: () => page.getByTestId('collections-header-actions-menu-close-all'),
|
||||
collectionRow: (name: string) => page.locator('.collection-name').filter({
|
||||
has: page.locator('#sidebar-collection-name', { hasText: name })
|
||||
})
|
||||
@@ -81,7 +81,7 @@ export const buildCommonLocators = (page: Page) => ({
|
||||
copyButton: () => page.locator('button[title="Copy response to clipboard"]')
|
||||
},
|
||||
plusMenu: {
|
||||
button: () => page.locator('.plus-icon-button'),
|
||||
button: () => page.getByTestId('collections-header-add-menu'),
|
||||
createCollection: () => page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }),
|
||||
importCollection: () => page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' })
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user