mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-15 20:01:28 +00:00
feat: implement vertical layout for response pane and enhance drag (#4957)
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"assets/*": ["src/assets/*"],
|
||||
"ui/*": ["src/ui/*"],
|
||||
"components/*": ["src/components/*"],
|
||||
"hooks/*": ["src/hooks/*"],
|
||||
"themes/*": ["src/themes/*"],
|
||||
|
||||
@@ -3,6 +3,7 @@ import get from 'lodash/get';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const Font = ({ close }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -31,7 +32,10 @@ const Font = ({ close }) => {
|
||||
}
|
||||
})
|
||||
).then(() => {
|
||||
toast.success('Preferences saved successfully')
|
||||
close();
|
||||
}).catch(() => {
|
||||
toast.error('Failed to save preferences')
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -80,9 +80,9 @@ const General = ({ close }) => {
|
||||
storeCookies: newPreferences.storeCookies,
|
||||
sendCookies: newPreferences.sendCookies
|
||||
}
|
||||
})
|
||||
)
|
||||
}))
|
||||
.then(() => {
|
||||
toast.success('Preferences saved successfully')
|
||||
close();
|
||||
})
|
||||
.catch((err) => console.log(err) && toast.error('Failed to update preferences'));
|
||||
|
||||
@@ -84,7 +84,10 @@ const ProxySettings = ({ close }) => {
|
||||
proxy: validatedProxy
|
||||
})
|
||||
).then(() => {
|
||||
toast.success('Preferences saved successfully')
|
||||
close();
|
||||
}).catch(() => {
|
||||
toast.error('Failed to save preferences')
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -18,8 +18,9 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Documentation from 'components/Documentation/index';
|
||||
import GraphQLSchemaActions from '../GraphQLSchemaActions/index';
|
||||
import HeightBoundContainer from 'ui/HeightBoundContainer';
|
||||
|
||||
const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, toggleDocs, handleGqlClickReference }) => {
|
||||
const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handleGqlClickReference }) => {
|
||||
const dispatch = useDispatch();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
@@ -66,7 +67,6 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
|
||||
collection={collection}
|
||||
theme={displayedTheme}
|
||||
schema={schema}
|
||||
width={leftPaneWidth}
|
||||
onSave={onSave}
|
||||
value={query}
|
||||
onRun={onRun}
|
||||
@@ -154,7 +154,9 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
|
||||
</div>
|
||||
<GraphQLSchemaActions item={item} collection={collection} onSchemaLoad={setSchema} toggleDocs={toggleDocs} />
|
||||
</div>
|
||||
<section className="flex w-full mt-5 flex-1 relative">{getTabPanel(focusedTab.requestPaneTab)}</section>
|
||||
<section className="flex w-full mt-5 flex-1 relative">
|
||||
<HeightBoundContainer>{getTabPanel(focusedTab.requestPaneTab)}</HeightBoundContainer>
|
||||
</section>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import Tests from 'components/RequestPane/Tests';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { find, get } from 'lodash';
|
||||
import Documentation from 'components/Documentation/index';
|
||||
import HeightBoundContainer from 'ui/HeightBoundContainer';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const ContentIndicator = () => {
|
||||
@@ -33,7 +34,7 @@ const ErrorIndicator = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
|
||||
const HttpRequestPane = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
@@ -180,7 +181,9 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
|
||||
'mt-5': !isMultipleContentTab
|
||||
})}
|
||||
>
|
||||
{getTabPanel(focusedTab.requestPaneTab)}
|
||||
<HeightBoundContainer>
|
||||
{getTabPanel(focusedTab.requestPaneTab)}
|
||||
</HeightBoundContainer>
|
||||
</section>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -114,7 +114,7 @@ const QueryParams = ({ item, collection }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col absolute">
|
||||
<StyledWrapper className="w-full flex flex-col">
|
||||
<div className="flex-1 mt-2">
|
||||
<div className="mb-1 title text-xs">Query</div>
|
||||
<Table
|
||||
|
||||
@@ -3,9 +3,13 @@ import styled from 'styled-components';
|
||||
const StyledWrapper = styled.div`
|
||||
&.dragging {
|
||||
cursor: col-resize;
|
||||
|
||||
&.vertical-layout {
|
||||
cursor: row-resize;
|
||||
}
|
||||
}
|
||||
|
||||
div.drag-request {
|
||||
div.dragbar-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -15,18 +19,39 @@ const StyledWrapper = styled.div`
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
|
||||
div.drag-request-border {
|
||||
div.dragbar-handle {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
|
||||
}
|
||||
|
||||
&:hover div.drag-request-border {
|
||||
&:hover div.dragbar-handle {
|
||||
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
|
||||
}
|
||||
}
|
||||
|
||||
&.vertical-layout {
|
||||
div.dragbar-wrapper {
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
cursor: row-resize;
|
||||
|
||||
div.dragbar-handle {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
border-left: none;
|
||||
border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
&:hover div.dragbar-handle {
|
||||
border-left: none;
|
||||
border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.graphql-docs-explorer-container {
|
||||
background: white;
|
||||
outline: none;
|
||||
|
||||
@@ -29,7 +29,8 @@ import FolderNotFound from './FolderNotFound';
|
||||
|
||||
const MIN_LEFT_PANE_WIDTH = 300;
|
||||
const MIN_RIGHT_PANE_WIDTH = 350;
|
||||
const DEFAULT_PADDING = 5;
|
||||
const MIN_TOP_PANE_HEIGHT = 150;
|
||||
const MIN_BOTTOM_PANE_HEIGHT = 150;
|
||||
|
||||
const RequestTabPanel = () => {
|
||||
if (typeof window == 'undefined') {
|
||||
@@ -41,6 +42,8 @@ const RequestTabPanel = () => {
|
||||
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
|
||||
const _collections = useSelector((state) => state.collections.collections);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
|
||||
|
||||
// merge `globalEnvironmentVariables` into the active collection and rebuild `collections` immer proxy object
|
||||
let collections = produce(_collections, (draft) => {
|
||||
@@ -64,13 +67,15 @@ const RequestTabPanel = () => {
|
||||
let asideWidth = useSelector((state) => state.app.leftSidebarWidth);
|
||||
const [leftPaneWidth, setLeftPaneWidth] = useState(
|
||||
focusedTab && focusedTab.requestPaneWidth ? focusedTab.requestPaneWidth : (screenWidth - asideWidth) / 2.2
|
||||
); // 2.2 so that request pane is relatively smaller
|
||||
const [rightPaneWidth, setRightPaneWidth] = useState(screenWidth - asideWidth - leftPaneWidth - DEFAULT_PADDING);
|
||||
); // 2.2 is intentional to make both panes appear to be of equal width
|
||||
const [topPaneHeight, setTopPaneHeight] = useState(focusedTab?.requestPaneHeight || MIN_TOP_PANE_HEIGHT);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const dragOffset = useRef({ x: 0, y: 0 });
|
||||
|
||||
// Not a recommended pattern here to have the child component
|
||||
// make a callback to set state, but treating this as an exception
|
||||
const docExplorerRef = useRef(null);
|
||||
const mainSectionRef = useRef(null);
|
||||
const [schema, setSchema] = useState(null);
|
||||
const [showGqlDocs, setShowGqlDocs] = useState(false);
|
||||
const onSchemaLoad = (schema) => setSchema(schema);
|
||||
@@ -85,43 +90,72 @@ const RequestTabPanel = () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const leftPaneWidth = (screenWidth - asideWidth) / 2.2;
|
||||
setLeftPaneWidth(leftPaneWidth);
|
||||
}, [screenWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
setRightPaneWidth(screenWidth - asideWidth - leftPaneWidth - DEFAULT_PADDING);
|
||||
}, [screenWidth, asideWidth, leftPaneWidth]);
|
||||
// Initialize vertical heights when switching to vertical layout
|
||||
if (mainSectionRef.current) {
|
||||
const mainRect = mainSectionRef.current.getBoundingClientRect();
|
||||
if (isVerticalLayout) {
|
||||
const initialHeight = mainRect.height / 2;
|
||||
setTopPaneHeight(initialHeight);
|
||||
// In vertical mode, set leftPaneWidth to full container width
|
||||
setLeftPaneWidth(mainRect.width);
|
||||
} else {
|
||||
// In horizontal mode, set to roughly half width
|
||||
setLeftPaneWidth((screenWidth - asideWidth) / 2.2);
|
||||
}
|
||||
}
|
||||
}, [isVerticalLayout, screenWidth, asideWidth]);
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (dragging) {
|
||||
if (dragging && mainSectionRef.current) {
|
||||
e.preventDefault();
|
||||
let leftPaneXPosition = e.clientX + 2;
|
||||
if (
|
||||
leftPaneXPosition < asideWidth + DEFAULT_PADDING + MIN_LEFT_PANE_WIDTH ||
|
||||
leftPaneXPosition > screenWidth - MIN_RIGHT_PANE_WIDTH
|
||||
) {
|
||||
return;
|
||||
const mainRect = mainSectionRef.current.getBoundingClientRect();
|
||||
|
||||
if (isVerticalLayout) {
|
||||
const newHeight = e.clientY - mainRect.top - dragOffset.current.y;
|
||||
if (newHeight < MIN_TOP_PANE_HEIGHT || newHeight > mainRect.height - MIN_BOTTOM_PANE_HEIGHT) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTopPaneHeight(newHeight);
|
||||
} else {
|
||||
const newWidth = e.clientX - mainRect.left - dragOffset.current.x;
|
||||
if (newWidth < MIN_LEFT_PANE_WIDTH || newWidth > mainRect.width - MIN_RIGHT_PANE_WIDTH) {
|
||||
return;
|
||||
}
|
||||
setLeftPaneWidth(newWidth);
|
||||
}
|
||||
setLeftPaneWidth(leftPaneXPosition - asideWidth);
|
||||
setRightPaneWidth(screenWidth - e.clientX - DEFAULT_PADDING);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = (e) => {
|
||||
if (dragging) {
|
||||
if (dragging && mainSectionRef.current) {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
dispatch(
|
||||
updateRequestPaneTabWidth({
|
||||
uid: activeTabUid,
|
||||
requestPaneWidth: e.clientX - asideWidth - DEFAULT_PADDING
|
||||
})
|
||||
);
|
||||
if (!isVerticalLayout) {
|
||||
const mainRect = mainSectionRef.current.getBoundingClientRect();
|
||||
dispatch(
|
||||
updateRequestPaneTabWidth({
|
||||
uid: activeTabUid,
|
||||
requestPaneWidth: e.clientX - mainRect.left
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragbarMouseDown = (e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
|
||||
if (isVerticalLayout) {
|
||||
const dragBar = e.currentTarget;
|
||||
const dragBarRect = dragBar.getBoundingClientRect();
|
||||
dragOffset.current.y = e.clientY - dragBarRect.top;
|
||||
} else {
|
||||
const dragBar = e.currentTarget;
|
||||
const dragBarRect = dragBar.getBoundingClientRect();
|
||||
dragOffset.current.x = e.clientX - dragBarRect.left;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -132,7 +166,7 @@ const RequestTabPanel = () => {
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
};
|
||||
}, [dragging, asideWidth]);
|
||||
}, [dragging]);
|
||||
|
||||
if (!activeTabUid) {
|
||||
return <Welcome />;
|
||||
@@ -197,15 +231,19 @@ const RequestTabPanel = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''}`}>
|
||||
<StyledWrapper className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''} ${isVerticalLayout ? 'vertical-layout' : ''}`}>
|
||||
<div className="pt-4 pb-3 px-4">
|
||||
<QueryUrl item={item} collection={collection} handleRun={handleRun} />
|
||||
</div>
|
||||
<section className="main flex flex-grow pb-4 relative">
|
||||
<section ref={mainSectionRef} className={`main flex ${isVerticalLayout ? 'flex-col' : ''} flex-grow pb-4 relative`}>
|
||||
<section className="request-pane">
|
||||
<div
|
||||
className="px-4 h-full"
|
||||
style={{
|
||||
style={isVerticalLayout ? {
|
||||
height: `${Math.max(topPaneHeight, MIN_TOP_PANE_HEIGHT)}px`,
|
||||
minHeight: `${MIN_TOP_PANE_HEIGHT}px`,
|
||||
width: '100%'
|
||||
} : {
|
||||
width: `${Math.max(leftPaneWidth, MIN_LEFT_PANE_WIDTH)}px`
|
||||
}}
|
||||
>
|
||||
@@ -213,7 +251,6 @@ const RequestTabPanel = () => {
|
||||
<GraphQLRequestPane
|
||||
item={item}
|
||||
collection={collection}
|
||||
leftPaneWidth={leftPaneWidth}
|
||||
onSchemaLoad={onSchemaLoad}
|
||||
toggleDocs={toggleDocs}
|
||||
handleGqlClickReference={handleGqlClickReference}
|
||||
@@ -221,17 +258,17 @@ const RequestTabPanel = () => {
|
||||
) : null}
|
||||
|
||||
{item.type === 'http-request' ? (
|
||||
<HttpRequestPane item={item} collection={collection} leftPaneWidth={leftPaneWidth} />
|
||||
<HttpRequestPane item={item} collection={collection} />
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="drag-request" onMouseDown={handleDragbarMouseDown}>
|
||||
<div className="drag-request-border" />
|
||||
<div className="dragbar-wrapper" onMouseDown={handleDragbarMouseDown}>
|
||||
<div className="dragbar-handle" />
|
||||
</div>
|
||||
|
||||
<section className="response-pane flex-grow">
|
||||
<ResponsePane item={item} collection={collection} rightPaneWidth={rightPaneWidth} response={item.response} />
|
||||
<ResponsePane item={item} collection={collection} response={item.response} />
|
||||
</section>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -22,6 +22,15 @@ const StyledWrapper = styled.div`
|
||||
animation: rotateCounterClockwise 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
// spinner and request time content looks better centered vertically in vertical layout
|
||||
// while in horizontal layout, it looks better when the content is aligned to the top
|
||||
&.vertical-layout {
|
||||
div.overlay {
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import React from 'react';
|
||||
import { IconRefresh } from '@tabler/icons';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { cancelRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import StopWatch from '../../StopWatch';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const ResponseLoadingOverlay = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
|
||||
|
||||
const handleCancelRequest = () => {
|
||||
dispatch(cancelRequest(item.cancelTokenUid, item, collection));
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full">
|
||||
<StyledWrapper className={`w-full ${isVerticalLayout ? 'vertical-layout' : ''}`}>
|
||||
<div className="overlay">
|
||||
<div style={{ marginBottom: 15, fontSize: 26 }}>
|
||||
<div style={{ display: 'inline-block', fontSize: 20, marginLeft: 5, marginRight: 5 }}>
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 20%;
|
||||
width: 100%;
|
||||
|
||||
.send-icon {
|
||||
color: ${(props) => props.theme.requestTabPanel.responseSendIcon};
|
||||
}
|
||||
|
||||
&.vertical-layout {
|
||||
padding: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { IconSend } from '@tabler/icons';
|
||||
import { useSelector } from 'react-redux';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { isMacOS } from 'utils/common/platform';
|
||||
|
||||
@@ -8,9 +9,11 @@ const Placeholder = () => {
|
||||
const sendRequestShortcut = isMac ? 'Cmd + Enter' : 'Ctrl + Enter';
|
||||
const newRequestShortcut = isMac ? 'Cmd + B' : 'Ctrl + B';
|
||||
const editEnvironmentShortcut = isMac ? 'Cmd + E' : 'Ctrl + E';
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<StyledWrapper className={`${isVerticalLayout ? 'vertical-layout' : ''}`}>
|
||||
<div className="send-icon flex justify-center" style={{ fontSize: 200 }}>
|
||||
<IconSend size={150} strokeWidth={1} />
|
||||
</div>
|
||||
|
||||
@@ -74,7 +74,7 @@ const formatErrorMessage = (error) => {
|
||||
return error;
|
||||
};
|
||||
|
||||
const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEventListener, headers, error }) => {
|
||||
const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListener, headers, error }) => {
|
||||
const contentType = getContentType(headers);
|
||||
const mode = getCodeMirrorModeBasedOnContentType(contentType, data);
|
||||
const [filter, setFilter] = useState(null);
|
||||
@@ -164,7 +164,6 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
|
||||
return (
|
||||
<StyledWrapper
|
||||
className="w-full h-full relative flex"
|
||||
style={{ maxWidth: width }}
|
||||
queryFilterEnabled={queryFilterEnabled}
|
||||
>
|
||||
<div className="flex justify-end gap-2 text-xs" role="tablist">
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const IconDockToBottom = () => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
>
|
||||
<path stroke="none" fill="none" d="M0 0h24v24H0z" />
|
||||
<path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z" />
|
||||
<path d="M4 15l16 0" />
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M 5.5135136,19.111502 C 5.2542477,18.995986 5.0221761,18.756859 4.8928709,18.47199 4.7922381,18.250288 4.7788524,18.078909 4.7777079,16.997543 l -0.0013,-1.223586 H 12 19.223587 v 1.22675 c 0,1.194609 -0.0039,1.234605 -0.149369,1.526503 -0.09333,0.187285 -0.240773,0.363095 -0.392978,0.46858 l -0.243606,0.168829 -6.373606,0.0129 c -5.2129418,0.0105 -6.4058225,-0.0015 -6.5505114,-0.06597 z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const IconDockToRight = () => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
>
|
||||
<path fill="none" stroke="none" d="M 0,24 V 0 h 24 v 24 z" />
|
||||
<path d="m 4,20 m 2,0 A 2,2 0 0 1 4,18 V 6 A 2,2 0 0 1 6,4 h 12 a 2,2 0 0 1 2,2 v 12 a 2,2 0 0 1 -2,2 z" />
|
||||
<path d="M 15,20 V 4" />
|
||||
<path
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
d="m 19.111502,18.486486 c -0.115516,0.259266 -0.354643,0.491338 -0.639512,0.620643 -0.221702,0.100633 -0.393081,0.114019 -1.474447,0.115163 l -1.223586,0.0013 V 12 4.7764125 h 1.22675 c 1.194609,0 1.234605,0.0039 1.526503,0.14937 0.187285,0.09333 0.363095,0.2407725 0.46858,0.3929775 l 0.168829,0.243606 0.0129,6.373606 c 0.0105,5.212942 -0.0015,6.405822 -0.06597,6.550511 z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const ResponseLayoutToggle = () => {
|
||||
const dispatch = useDispatch();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const orientation = preferences?.layout?.responsePaneOrientation || 'horizontal';
|
||||
|
||||
const toggleOrientation = () => {
|
||||
const newOrientation = orientation === 'horizontal' ? 'vertical' : 'horizontal';
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
layout: {
|
||||
...preferences.layout,
|
||||
responsePaneOrientation: newOrientation
|
||||
}
|
||||
};
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="ml-2 flex items-center">
|
||||
<button
|
||||
onClick={toggleOrientation}
|
||||
title={orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout'}
|
||||
>
|
||||
{orientation === 'horizontal' ? (
|
||||
<IconDockToBottom />
|
||||
) : (
|
||||
<IconDockToRight />
|
||||
)}
|
||||
</button>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponseLayoutToggle;
|
||||
@@ -0,0 +1,173 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent} from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { ThemeProvider } from 'providers/Theme';
|
||||
import { configureStore, createSlice } from '@reduxjs/toolkit';
|
||||
import ResponseLayoutToggle from './index';
|
||||
|
||||
const mockSavePreferences = jest.fn((payload) => ({ type: 'app/savePreferences', payload }));
|
||||
|
||||
// Mock the savePreferences action
|
||||
jest.mock('providers/ReduxStore/slices/app', () => ({
|
||||
savePreferences: (payload) => mockSavePreferences(payload)
|
||||
}));
|
||||
|
||||
// Mock localStorage
|
||||
const mockLocalStorage = {
|
||||
getItem: jest.fn(() => 'dark'),
|
||||
setItem: jest.fn(),
|
||||
removeItem: jest.fn()
|
||||
};
|
||||
|
||||
// Mock matchMedia
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn()
|
||||
})),
|
||||
});
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: mockLocalStorage
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockSavePreferences.mockClear();
|
||||
});
|
||||
|
||||
const initialState = {
|
||||
app: {
|
||||
preferences: {
|
||||
layout: {
|
||||
responsePaneOrientation: 'horizontal'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const createTestStore = (initialState) => {
|
||||
const appSlice = createSlice({
|
||||
name: 'app',
|
||||
initialState: initialState.app,
|
||||
reducers: {
|
||||
savePreferences: (state, action) => {
|
||||
state.preferences = action.payload;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return configureStore({
|
||||
reducer: { app: appSlice.reducer }
|
||||
});
|
||||
};
|
||||
|
||||
const renderWithProviders = (component, customState = initialState) => {
|
||||
const store = createTestStore(customState);
|
||||
return {
|
||||
store,
|
||||
...render(
|
||||
<Provider store={store}>
|
||||
<ThemeProvider>
|
||||
{component}
|
||||
</ThemeProvider>
|
||||
</Provider>
|
||||
)
|
||||
};
|
||||
};
|
||||
|
||||
describe('ResponseLayoutToggle', () => {
|
||||
describe('Initial Render', () => {
|
||||
it('should render with horizontal orientation by default', () => {
|
||||
renderWithProviders(<ResponseLayoutToggle />);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveAttribute('title', 'Switch to vertical layout');
|
||||
});
|
||||
|
||||
it('should render with vertical orientation when specified', () => {
|
||||
const customState = {
|
||||
app: {
|
||||
preferences: {
|
||||
layout: {
|
||||
responsePaneOrientation: 'vertical'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
renderWithProviders(<ResponseLayoutToggle />, customState);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveAttribute('title', 'Switch to horizontal layout');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Interaction', () => {
|
||||
it('should switch to vertical layout when clicked in horizontal mode', () => {
|
||||
const { store } = renderWithProviders(<ResponseLayoutToggle />);
|
||||
const button = screen.getByRole('button');
|
||||
|
||||
// Initial state check
|
||||
expect(button).toHaveAttribute('title', 'Switch to vertical layout');
|
||||
|
||||
fireEvent.click(button);
|
||||
|
||||
// Check if action was called
|
||||
expect(mockSavePreferences).toHaveBeenCalledWith({
|
||||
layout: {
|
||||
responsePaneOrientation: 'vertical'
|
||||
}
|
||||
});
|
||||
|
||||
// Manually update store to simulate state change
|
||||
store.dispatch(mockSavePreferences({
|
||||
layout: {
|
||||
responsePaneOrientation: 'vertical'
|
||||
}
|
||||
}));
|
||||
|
||||
// Check if button title was updated
|
||||
expect(button).toHaveAttribute('title', 'Switch to horizontal layout');
|
||||
});
|
||||
|
||||
it('should switch to horizontal layout when clicked in vertical mode', () => {
|
||||
const customState = {
|
||||
app: {
|
||||
preferences: {
|
||||
layout: {
|
||||
responsePaneOrientation: 'vertical'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const { store } = renderWithProviders(<ResponseLayoutToggle />, customState);
|
||||
const button = screen.getByRole('button');
|
||||
|
||||
// Initial state check
|
||||
expect(button).toHaveAttribute('title', 'Switch to horizontal layout');
|
||||
|
||||
fireEvent.click(button);
|
||||
|
||||
// Check if action was called
|
||||
expect(mockSavePreferences).toHaveBeenCalledWith({
|
||||
layout: {
|
||||
responsePaneOrientation: 'horizontal'
|
||||
}
|
||||
});
|
||||
|
||||
// Manually update store to simulate state change
|
||||
store.dispatch(mockSavePreferences({
|
||||
layout: {
|
||||
responsePaneOrientation: 'horizontal'
|
||||
}
|
||||
}));
|
||||
|
||||
// Check if button title was updated
|
||||
expect(button).toHaveAttribute('title', 'Switch to vertical layout');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,8 +20,10 @@ import ResponseSave from 'src/components/ResponsePane/ResponseSave';
|
||||
import ResponseClear from 'src/components/ResponsePane/ResponseClear';
|
||||
import SkippedRequest from './SkippedRequest';
|
||||
import ClearTimeline from './ClearTimeline/index';
|
||||
import ResponseLayoutToggle from './ResponseLayoutToggle';
|
||||
import HeightBoundContainer from 'ui/HeightBoundContainer';
|
||||
|
||||
const ResponsePane = ({ rightPaneWidth, item, collection }) => {
|
||||
const ResponsePane = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
@@ -57,7 +59,6 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
|
||||
<QueryResult
|
||||
item={item}
|
||||
collection={collection}
|
||||
width={rightPaneWidth}
|
||||
data={response.data}
|
||||
dataBuffer={response.dataBuffer}
|
||||
headers={response.headers}
|
||||
@@ -70,7 +71,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
|
||||
return <ResponseHeaders headers={response.headers} />;
|
||||
}
|
||||
case 'timeline': {
|
||||
return <Timeline collection={collection} item={item} width={rightPaneWidth} />;
|
||||
return <Timeline collection={collection} item={item} />;
|
||||
}
|
||||
case 'tests': {
|
||||
return <TestResults
|
||||
@@ -105,9 +106,9 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
|
||||
|
||||
if (!item.response && !requestTimeline?.length) {
|
||||
return (
|
||||
<StyledWrapper className="flex h-full relative">
|
||||
<HeightBoundContainer>
|
||||
<Placeholder />
|
||||
</StyledWrapper>
|
||||
</HeightBoundContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,6 +160,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
|
||||
onClick={() => setShowScriptErrorCard(true)}
|
||||
/>
|
||||
)}
|
||||
<ResponseLayoutToggle />
|
||||
{focusedTab?.responsePaneTab === "timeline" ? (
|
||||
<ClearTimeline item={item} collection={collection} />
|
||||
) : (item?.response && !item?.response?.error) ? (
|
||||
@@ -193,7 +195,6 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
|
||||
<Timeline
|
||||
collection={collection}
|
||||
item={item}
|
||||
width={rightPaneWidth}
|
||||
/>
|
||||
) : null
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import filter from 'lodash/filter';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const initialState = {
|
||||
isDragging: false,
|
||||
@@ -103,14 +102,9 @@ export const savePreferences = (preferences) => (dispatch, getState) => {
|
||||
|
||||
ipcRenderer
|
||||
.invoke('renderer:save-preferences', preferences)
|
||||
.then(() => toast.success('Preferences saved successfully'))
|
||||
.then(() => dispatch(updatePreferences(preferences)))
|
||||
.then(resolve)
|
||||
.catch((err) => {
|
||||
toast.error('An error occurred while saving preferences');
|
||||
console.error(err);
|
||||
reject(err);
|
||||
});
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from 'react';
|
||||
import themes from 'themes/index';
|
||||
import useLocalStorage from 'hooks/useLocalStorage/index';
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
/* Primary container - establishes flex context */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
/* Flex shrink container - allows content to be constrained */
|
||||
.height-constraint {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Grid container - enforces boundaries */
|
||||
.grid-boundary {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
overflow-y: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
16
packages/bruno-app/src/ui/HeightBoundContainer/index.js
Normal file
16
packages/bruno-app/src/ui/HeightBoundContainer/index.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const HeightBoundContainer = ({children}) => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="height-constraint">
|
||||
<div className="grid-boundary">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeightBoundContainer;
|
||||
@@ -37,6 +37,9 @@ const defaultPreferences = {
|
||||
password: ''
|
||||
},
|
||||
bypassProxy: ''
|
||||
},
|
||||
layout: {
|
||||
responsePaneOrientation: 'horizontal'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -69,6 +72,9 @@ const preferencesSchema = Yup.object().shape({
|
||||
password: Yup.string().max(1024)
|
||||
}).optional(),
|
||||
bypassProxy: Yup.string().optional().max(1024)
|
||||
}),
|
||||
layout: Yup.object({
|
||||
responsePaneOrientation: Yup.string().oneOf(['horizontal', 'vertical'])
|
||||
})
|
||||
});
|
||||
|
||||
@@ -149,6 +155,9 @@ const preferencesUtil = {
|
||||
shouldSendCookies: () => {
|
||||
return get(getPreferences(), 'request.sendCookies', true);
|
||||
},
|
||||
getResponsePaneOrientation: () => {
|
||||
return get(getPreferences(), 'layout.responsePaneOrientation', 'horizontal');
|
||||
},
|
||||
getSystemProxyEnvVariables: () => {
|
||||
const { http_proxy, HTTP_PROXY, https_proxy, HTTPS_PROXY, no_proxy, NO_PROXY } = process.env;
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user