mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-23 12:45:38 +00:00
feat(api-spec): drag-to-resize split pane with persisted width (#7866)
* Add drag-resize split pane for API Spec viewer Introduce a drag-to-resize split pane for the API Spec viewer and persist left pane width. Adds a new useDragResize hook to manage dragging state and clamping, plus UI: dragbar styles, a loading state for the Swagger preview (onComplete + loader), and memoization of the Swagger renderer. Wire up persisted widths via Redux: add updateApiSpecPanelLeftPaneWidth (apiSpec slice) and updateApiSpecTabLeftPaneWidth (tabs slice), and propagate leftPaneWidth / onLeftPaneWidthChange through ApiSpecPanel, OpenAPISpecTab, RequestTabPanel and SpecViewer. Misc: pass tab uid into OpenAPISpecTab and add .gstack/ to .gitignore. * Refactor SpecViewer and OpenAPISpecTab for improved loading and state management - Updated SpecViewer to enhance loading state handling for Swagger content, ensuring a smoother user experience by preventing flashes of unrendered content. - Refactored OpenAPISpecTab to streamline environment context management, optimizing the loading process for OpenAPI specifications. - Simplified the useDragResize hook by removing unnecessary references and improving the handling of drag events, ensuring better performance and responsiveness during resizing actions. * Enhance useDragResize hook to clamp width seed and improve test coverage - Updated the useDragResize hook to clamp the width seed value, ensuring it stays within defined bounds during drag events. - Added a new test case to verify that an out-of-bounds width seed is correctly clamped and persisted on immediate mouseup, enhancing the robustness of the drag-resize functionality. * Remove .gstack/ from .gitignore Delete the .gstack/ ignore entry and normalize the packages/bruno-converters/dist entry in .gitignore (deduplicated). No code changes; just tidy up ignore rules.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -67,4 +67,4 @@ AGENTS.md
|
||||
packages/bruno-filestore/dist
|
||||
packages/bruno-requests/dist
|
||||
packages/bruno-schema-types/dist
|
||||
packages/bruno-converters/dist
|
||||
packages/bruno-converters/dist
|
||||
@@ -1,14 +1,15 @@
|
||||
import { memo } from 'react';
|
||||
import SwaggerUI from 'swagger-ui-react';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const Swagger = ({ spec }) => {
|
||||
const Swagger = ({ spec, onComplete }) => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="swagger-root w-full">
|
||||
<SwaggerUI spec={spec} />
|
||||
<SwaggerUI spec={spec} onComplete={onComplete} />
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Swagger;
|
||||
export default memo(Swagger);
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
import React, { useState, useEffect, Suspense } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { IconDeviceFloppy } from '@tabler/icons';
|
||||
import { IconDeviceFloppy, IconLoader2 } from '@tabler/icons';
|
||||
import CodeEditor from './FileEditor/CodeEditor/index';
|
||||
import Swagger from './Renderers/Swagger';
|
||||
import { useDragResize } from 'hooks/useDragResize';
|
||||
|
||||
const MIN_LEFT_PANE_WIDTH = 300;
|
||||
const MIN_RIGHT_PANE_WIDTH = 450;
|
||||
|
||||
/**
|
||||
* Shared split-pane spec viewer: CodeEditor (left) + Swagger preview (right).
|
||||
*
|
||||
* Props:
|
||||
* - content (string) The spec content (YAML/JSON string)
|
||||
* - readOnly (boolean) If true, editor is not editable and save icon is hidden
|
||||
* - onSave (function) Called with current editor content on save (editable mode only)
|
||||
* - content (string) The spec content (YAML/JSON string)
|
||||
* - readOnly (boolean) If true, editor is not editable and save icon is hidden
|
||||
* - onSave (fn) Called with current editor content on save (editable mode only)
|
||||
* - leftPaneWidth (number|null) Persisted left pane width in px; null = use 50/50 default
|
||||
* - onLeftPaneWidthChange (fn) Persist the new width (called on mouseup / double-click / resize-clamp)
|
||||
*/
|
||||
const SpecViewer = ({ content, readOnly, onSave }) => {
|
||||
const SpecViewer = ({ content, readOnly, onSave, leftPaneWidth, onLeftPaneWidthChange }) => {
|
||||
const { displayedTheme, theme } = useTheme();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
const [editorContent, setEditorContent] = useState(content);
|
||||
|
||||
// Sync editor when saved content changes from outside (e.g. after save completes)
|
||||
useEffect(() => {
|
||||
setEditorContent(content);
|
||||
}, [content]);
|
||||
@@ -31,38 +36,85 @@ const SpecViewer = ({ content, readOnly, onSave }) => {
|
||||
if (onSave) onSave(editorContent);
|
||||
};
|
||||
|
||||
const mainSectionRef = useRef(null);
|
||||
const { dragging, dragWidth, dragbarProps } = useDragResize({
|
||||
containerRef: mainSectionRef,
|
||||
width: leftPaneWidth,
|
||||
onWidthChange: onLeftPaneWidthChange,
|
||||
minLeft: MIN_LEFT_PANE_WIDTH,
|
||||
minRight: MIN_RIGHT_PANE_WIDTH
|
||||
});
|
||||
|
||||
const effectiveWidth = dragging ? dragWidth : leftPaneWidth;
|
||||
const leftPaneStyle = effectiveWidth != null
|
||||
? { width: `${effectiveWidth}px`, flexShrink: 0 }
|
||||
: { flex: '1 1 50%', minWidth: 0 };
|
||||
|
||||
const [swaggerReady, setSwaggerReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSwaggerReady(false);
|
||||
}, [content]);
|
||||
|
||||
const handleSwaggerComplete = useCallback(() => {
|
||||
// Double rAF: wait for one full paint cycle so Swagger is actually on screen
|
||||
// before hiding the loader — avoids a flash of unrendered content.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => setSwaggerReady(true));
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="main flex flex-grow pl-4 relative">
|
||||
<div className="w-full grid grid-cols-2">
|
||||
<div className="col-span-1">
|
||||
<div className="flex flex-grow relative">
|
||||
<CodeEditor
|
||||
theme={displayedTheme}
|
||||
value={readOnly ? content : editorContent}
|
||||
readOnly={readOnly ? 'nocursor' : false}
|
||||
onEdit={readOnly ? undefined : (val) => setEditorContent(val)}
|
||||
onSave={readOnly ? undefined : handleSave}
|
||||
mode="yaml"
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
/>
|
||||
{!readOnly && onSave && (
|
||||
<IconDeviceFloppy
|
||||
onClick={handleSave}
|
||||
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
|
||||
strokeWidth={1.5}
|
||||
size={22}
|
||||
className={`absolute right-0 top-0 m-4 ${
|
||||
hasChanges ? 'cursor-pointer opacity-100' : 'cursor-default opacity-50'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<section
|
||||
ref={mainSectionRef}
|
||||
className={`main flex flex-grow pl-4 relative ${dragging ? 'dragging' : ''}`}
|
||||
>
|
||||
<div
|
||||
className="api-spec-left-pane flex flex-grow relative h-full"
|
||||
style={leftPaneStyle}
|
||||
>
|
||||
<CodeEditor
|
||||
theme={displayedTheme}
|
||||
value={readOnly ? content : editorContent}
|
||||
readOnly={readOnly ? 'nocursor' : false}
|
||||
onEdit={readOnly ? undefined : (val) => setEditorContent(val)}
|
||||
onSave={readOnly ? undefined : handleSave}
|
||||
mode="yaml"
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
/>
|
||||
{!readOnly && onSave && (
|
||||
<IconDeviceFloppy
|
||||
onClick={handleSave}
|
||||
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
|
||||
strokeWidth={1.5}
|
||||
size={22}
|
||||
className={`absolute right-0 top-0 m-4 ${
|
||||
hasChanges ? 'cursor-pointer opacity-100' : 'cursor-default opacity-50'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="dragbar-wrapper" {...dragbarProps}>
|
||||
<div className="dragbar-handle" />
|
||||
</div>
|
||||
<div
|
||||
className="api-spec-right-pane relative"
|
||||
style={{ flex: '1 1 50%', minWidth: 0 }}
|
||||
>
|
||||
<div style={{ visibility: swaggerReady ? 'visible' : 'hidden', height: '100%' }}>
|
||||
<Swagger spec={content} onComplete={handleSwaggerComplete} />
|
||||
</div>
|
||||
{!swaggerReady && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center gap-2"
|
||||
style={{ background: theme.bg }}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2 opacity-70">
|
||||
<IconLoader2 size={20} className="animate-spin" />
|
||||
<span>Generating preview…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Suspense fallback="">
|
||||
<Swagger spec={content} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,35 @@ const StyledWrapper = styled.div`
|
||||
.react-tooltip {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
section.main.dragging {
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
div.dragbar-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 10px;
|
||||
min-width: 10px;
|
||||
padding: 0;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
|
||||
div.dragbar-handle {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
|
||||
}
|
||||
|
||||
&:hover div.dragbar-handle {
|
||||
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { forwardRef, useRef } from 'react';
|
||||
import React, { forwardRef, useRef, useCallback } from 'react';
|
||||
import find from 'lodash/find';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { IconFileCode, IconDots } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import SpecViewer from './SpecViewer';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { openApiSpec, saveApiSpecToFile } from 'providers/ReduxStore/slices/apiSpec';
|
||||
import { openApiSpec, saveApiSpecToFile, updateApiSpecPanelLeftPaneWidth } from 'providers/ReduxStore/slices/apiSpec';
|
||||
import { useState } from 'react';
|
||||
import CreateApiSpec from 'components/Sidebar/ApiSpecs/CreateApiSpec';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -21,7 +21,16 @@ const ApiSpecPanel = () => {
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
|
||||
let apiSpec = find(apiSpecs, (c) => c.uid === activeApiSpecUid);
|
||||
const { filename, pathname, raw, uid } = apiSpec || {};
|
||||
const { filename, pathname, raw, uid, leftPaneWidth } = apiSpec || {};
|
||||
|
||||
const handleLeftPaneWidthChange = useCallback(
|
||||
(w) => {
|
||||
if (!uid) return;
|
||||
dispatch(updateApiSpecPanelLeftPaneWidth({ uid, leftPaneWidth: w }));
|
||||
},
|
||||
[dispatch, uid]
|
||||
);
|
||||
|
||||
if (!uid) {
|
||||
return <div className="p-4 opacity-50">API Spec not found!</div>;
|
||||
}
|
||||
@@ -79,6 +88,8 @@ const ApiSpecPanel = () => {
|
||||
<SpecViewer
|
||||
content={raw}
|
||||
onSave={(content) => dispatch(saveApiSpecToFile({ uid, content }))}
|
||||
leftPaneWidth={leftPaneWidth ?? null}
|
||||
onLeftPaneWidthChange={handleLeftPaneWidthChange}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import find from 'lodash/find';
|
||||
import { IconLoader2, IconCloud } from '@tabler/icons';
|
||||
import fastJsonFormat from 'fast-json-format';
|
||||
import SpecViewer from 'components/ApiSpecPanel/SpecViewer';
|
||||
import StyledWrapper from 'components/ApiSpecPanel/StyledWrapper';
|
||||
import { updateApiSpecTabLeftPaneWidth } from 'providers/ReduxStore/slices/tabs';
|
||||
|
||||
/**
|
||||
* Pretty-print JSON content for readable display. YAML content is returned as-is.
|
||||
@@ -17,7 +20,17 @@ const prettyPrintSpec = (content) => {
|
||||
}
|
||||
};
|
||||
|
||||
const OpenAPISpecTab = ({ collection }) => {
|
||||
const OpenAPISpecTab = ({ collection, tabUid }) => {
|
||||
const dispatch = useDispatch();
|
||||
const leftPaneWidth = useSelector((state) => {
|
||||
const tab = find(state.tabs.tabs, (t) => t.uid === tabUid);
|
||||
return tab?.apiSpecLeftPaneWidth ?? null;
|
||||
});
|
||||
const handleLeftPaneWidthChange = useCallback(
|
||||
(w) => dispatch(updateApiSpecTabLeftPaneWidth({ uid: tabUid, apiSpecLeftPaneWidth: w })),
|
||||
[dispatch, tabUid]
|
||||
);
|
||||
|
||||
const [specContent, setSpecContent] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
@@ -26,6 +39,16 @@ const OpenAPISpecTab = ({ collection }) => {
|
||||
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
|
||||
const sourceUrl = openApiSyncConfig?.sourceUrl;
|
||||
|
||||
// Latest env context for loadSpec's remote-fetch fallback. Kept out of
|
||||
// loadSpec's deps so toggling a variable doesn't refire the spec load.
|
||||
const envContextRef = useRef({});
|
||||
envContextRef.current = {
|
||||
activeEnvironmentUid: collection?.activeEnvironmentUid,
|
||||
environments: collection?.environments,
|
||||
runtimeVariables: collection?.runtimeVariables,
|
||||
globalEnvironmentVariables: collection?.globalEnvironmentVariables
|
||||
};
|
||||
|
||||
const loadSpec = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
@@ -42,12 +65,7 @@ const OpenAPISpecTab = ({ collection }) => {
|
||||
collectionUid: collection.uid,
|
||||
collectionPath: collection.pathname,
|
||||
sourceUrl,
|
||||
environmentContext: {
|
||||
activeEnvironmentUid: collection.activeEnvironmentUid,
|
||||
environments: collection.environments,
|
||||
runtimeVariables: collection.runtimeVariables,
|
||||
globalEnvironmentVariables: collection.globalEnvironmentVariables
|
||||
}
|
||||
environmentContext: envContextRef.current
|
||||
});
|
||||
if (fetchResult.content) {
|
||||
setSpecContent(prettyPrintSpec(fetchResult.content));
|
||||
@@ -64,7 +82,7 @@ const OpenAPISpecTab = ({ collection }) => {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [collection?.pathname, collection?.uid, collection?.activeEnvironmentUid, collection?.environments, collection?.runtimeVariables, collection?.globalEnvironmentVariables, sourceUrl]);
|
||||
}, [collection?.pathname, collection?.uid, sourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (collection?.pathname) {
|
||||
@@ -97,7 +115,12 @@ const OpenAPISpecTab = ({ collection }) => {
|
||||
<span>Showing spec file from {sourceUrl}.</span>
|
||||
</div>
|
||||
)}
|
||||
<SpecViewer content={specContent} readOnly />
|
||||
<SpecViewer
|
||||
content={specContent}
|
||||
readOnly
|
||||
leftPaneWidth={leftPaneWidth}
|
||||
onLeftPaneWidthChange={handleLeftPaneWidthChange}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -376,7 +376,7 @@ const RequestTabPanel = () => {
|
||||
}
|
||||
|
||||
if (focusedTab.type === 'openapi-spec') {
|
||||
return <OpenAPISpecTab collection={collection} />;
|
||||
return <OpenAPISpecTab collection={collection} tabUid={focusedTab.uid} />;
|
||||
}
|
||||
|
||||
if (!item || !item.uid) {
|
||||
|
||||
108
packages/bruno-app/src/hooks/useDragResize/index.js
Normal file
108
packages/bruno-app/src/hooks/useDragResize/index.js
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Drag-to-resize behavior for a horizontal split pane.
|
||||
*
|
||||
* Owns the transient drag state (cursor tracking, draft width, dragging flag)
|
||||
* and clamps to [minLeft, container.width - minRight]. The persisted width and
|
||||
* its setter are owned by the caller — pass them in as `width` / `onWidthChange`
|
||||
* and the hook treats it as a controlled value.
|
||||
*
|
||||
* Render pattern in the consumer:
|
||||
* const effectiveWidth = dragging ? dragWidth : width;
|
||||
*/
|
||||
export function useDragResize({ containerRef, width, onWidthChange, minLeft, minRight }) {
|
||||
// Mirror the live drag width in a ref so handleMouseUp can read the final
|
||||
// value without taking dragWidth as a dep (would re-create the handler on
|
||||
// every mousemove and re-run the listener-attach effect).
|
||||
const dragWidthRef = useRef(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [dragWidth, setDragWidth] = useState(null);
|
||||
|
||||
const clamp = useCallback(
|
||||
(w) => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect || rect.width === 0) return w;
|
||||
return Math.max(minLeft, Math.min(w, rect.width - minRight));
|
||||
},
|
||||
[containerRef, minLeft, minRight]
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e) => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
e.preventDefault();
|
||||
const clamped = clamp(e.clientX - rect.left);
|
||||
dragWidthRef.current = clamped;
|
||||
setDragWidth(clamped);
|
||||
},
|
||||
[containerRef, clamp]
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
const finalWidth = dragWidthRef.current;
|
||||
dragWidthRef.current = null;
|
||||
setDragging(false);
|
||||
setDragWidth(null);
|
||||
if (finalWidth != null && onWidthChange) {
|
||||
onWidthChange(finalWidth);
|
||||
}
|
||||
},
|
||||
[onWidthChange]
|
||||
);
|
||||
|
||||
const onMouseDown = useCallback(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
const seed = width != null ? width : rect ? rect.width / 2 : null;
|
||||
const seedClamped = seed != null ? clamp(seed) : null;
|
||||
dragWidthRef.current = seedClamped;
|
||||
setDragWidth(seedClamped);
|
||||
setDragging(true);
|
||||
},
|
||||
[containerRef, width, clamp]
|
||||
);
|
||||
|
||||
const onDoubleClick = useCallback(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
if (onWidthChange) onWidthChange(null);
|
||||
},
|
||||
[onWidthChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) return;
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [dragging, handleMouseMove, handleMouseUp]);
|
||||
|
||||
// Re-clamp the persisted width when the container resizes (e.g. window or
|
||||
// parent pane shrinks). Only dispatches if the clamped value differs.
|
||||
// widthRef avoids tearing down the observer on every width change — the
|
||||
// observer reads the latest width through the ref instead.
|
||||
const widthRef = useRef(width);
|
||||
widthRef.current = width;
|
||||
const hasWidth = width != null;
|
||||
useEffect(() => {
|
||||
if (!hasWidth || !containerRef.current) return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
const clamped = clamp(widthRef.current);
|
||||
if (clamped !== widthRef.current && onWidthChange) {
|
||||
onWidthChange(clamped);
|
||||
}
|
||||
});
|
||||
ro.observe(containerRef.current);
|
||||
return () => ro.disconnect();
|
||||
}, [hasWidth, clamp, onWidthChange, containerRef]);
|
||||
|
||||
return { dragging, dragWidth, dragbarProps: { onMouseDown, onDoubleClick } };
|
||||
}
|
||||
193
packages/bruno-app/src/hooks/useDragResize/index.spec.js
Normal file
193
packages/bruno-app/src/hooks/useDragResize/index.spec.js
Normal file
@@ -0,0 +1,193 @@
|
||||
const { describe, it, expect, beforeEach, afterEach, jest } = require('@jest/globals');
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useRef } from 'react';
|
||||
import { useDragResize } from './index';
|
||||
|
||||
const CONTAINER_WIDTH = 1000;
|
||||
const MIN_LEFT = 200;
|
||||
const MIN_RIGHT = 300;
|
||||
|
||||
const makeContainer = (width = CONTAINER_WIDTH) => {
|
||||
const el = document.createElement('div');
|
||||
el.getBoundingClientRect = jest.fn(() => ({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width,
|
||||
height: 600,
|
||||
right: width,
|
||||
bottom: 600,
|
||||
x: 0,
|
||||
y: 0
|
||||
}));
|
||||
return el;
|
||||
};
|
||||
|
||||
const renderDragResize = ({ width, onWidthChange = jest.fn(), container } = {}) => {
|
||||
const containerEl = container ?? makeContainer();
|
||||
const result = renderHook(
|
||||
({ width, onWidthChange }) => {
|
||||
const containerRef = useRef(containerEl);
|
||||
return useDragResize({
|
||||
containerRef,
|
||||
width,
|
||||
onWidthChange,
|
||||
minLeft: MIN_LEFT,
|
||||
minRight: MIN_RIGHT
|
||||
});
|
||||
},
|
||||
{ initialProps: { width, onWidthChange } }
|
||||
);
|
||||
return { ...result, containerEl, onWidthChange };
|
||||
};
|
||||
|
||||
const fireMouse = (type, clientX) => {
|
||||
act(() => {
|
||||
document.dispatchEvent(new MouseEvent(type, { clientX, bubbles: true }));
|
||||
});
|
||||
};
|
||||
|
||||
describe('useDragResize', () => {
|
||||
let observers;
|
||||
|
||||
beforeEach(() => {
|
||||
observers = [];
|
||||
global.ResizeObserver = jest.fn().mockImplementation((callback) => {
|
||||
const instance = {
|
||||
callback,
|
||||
observe: jest.fn(),
|
||||
disconnect: jest.fn()
|
||||
};
|
||||
observers.push(instance);
|
||||
return instance;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete global.ResizeObserver;
|
||||
});
|
||||
|
||||
it('returns false dragging and null dragWidth on initial render', () => {
|
||||
const { result } = renderDragResize({ width: 500 });
|
||||
expect(result.current.dragging).toBe(false);
|
||||
expect(result.current.dragWidth).toBe(null);
|
||||
});
|
||||
|
||||
it('onMouseDown seeds dragWidth from the width prop and flips dragging on', () => {
|
||||
const { result } = renderDragResize({ width: 500 });
|
||||
|
||||
act(() => {
|
||||
result.current.dragbarProps.onMouseDown({ preventDefault: jest.fn() });
|
||||
});
|
||||
|
||||
expect(result.current.dragging).toBe(true);
|
||||
expect(result.current.dragWidth).toBe(500);
|
||||
});
|
||||
|
||||
it('onMouseDown seeds dragWidth to half the container when width is null', () => {
|
||||
const { result } = renderDragResize({ width: null });
|
||||
|
||||
act(() => {
|
||||
result.current.dragbarProps.onMouseDown({ preventDefault: jest.fn() });
|
||||
});
|
||||
|
||||
expect(result.current.dragWidth).toBe(CONTAINER_WIDTH / 2);
|
||||
});
|
||||
|
||||
it('onMouseDown clamps an out-of-bounds width seed; immediate mouseup commits the clamped value', () => {
|
||||
// Persisted width is past the right bound (max = 1000 - 300 = 700).
|
||||
// Without clamping the seed, an immediate mouseup would persist 800.
|
||||
const { result, onWidthChange } = renderDragResize({ width: 800 });
|
||||
|
||||
act(() => {
|
||||
result.current.dragbarProps.onMouseDown({ preventDefault: jest.fn() });
|
||||
});
|
||||
|
||||
expect(result.current.dragWidth).toBe(CONTAINER_WIDTH - MIN_RIGHT);
|
||||
|
||||
fireMouse('mouseup', 800);
|
||||
|
||||
expect(onWidthChange).toHaveBeenCalledTimes(1);
|
||||
expect(onWidthChange).toHaveBeenCalledWith(CONTAINER_WIDTH - MIN_RIGHT);
|
||||
});
|
||||
|
||||
it('mousemove during drag updates dragWidth clamped to [minLeft, containerWidth - minRight]', () => {
|
||||
const { result } = renderDragResize({ width: 500 });
|
||||
|
||||
act(() => {
|
||||
result.current.dragbarProps.onMouseDown({ preventDefault: jest.fn() });
|
||||
});
|
||||
|
||||
// Within bounds
|
||||
fireMouse('mousemove', 600);
|
||||
expect(result.current.dragWidth).toBe(600);
|
||||
|
||||
// Below minLeft → clamps up
|
||||
fireMouse('mousemove', 50);
|
||||
expect(result.current.dragWidth).toBe(MIN_LEFT);
|
||||
|
||||
// Above containerWidth - minRight → clamps down
|
||||
fireMouse('mousemove', 950);
|
||||
expect(result.current.dragWidth).toBe(CONTAINER_WIDTH - MIN_RIGHT);
|
||||
});
|
||||
|
||||
it('mouseup commits the final width via onWidthChange and clears drag state', () => {
|
||||
const { result, onWidthChange } = renderDragResize({ width: 500 });
|
||||
|
||||
act(() => {
|
||||
result.current.dragbarProps.onMouseDown({ preventDefault: jest.fn() });
|
||||
});
|
||||
fireMouse('mousemove', 650);
|
||||
fireMouse('mouseup', 650);
|
||||
|
||||
expect(onWidthChange).toHaveBeenCalledTimes(1);
|
||||
expect(onWidthChange).toHaveBeenCalledWith(650);
|
||||
expect(result.current.dragging).toBe(false);
|
||||
expect(result.current.dragWidth).toBe(null);
|
||||
});
|
||||
|
||||
it('onDoubleClick calls onWidthChange(null) to reset', () => {
|
||||
const { result, onWidthChange } = renderDragResize({ width: 500 });
|
||||
|
||||
act(() => {
|
||||
result.current.dragbarProps.onDoubleClick({ preventDefault: jest.fn() });
|
||||
});
|
||||
|
||||
expect(onWidthChange).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('mousemove without an active drag is a no-op', () => {
|
||||
const { result } = renderDragResize({ width: 500 });
|
||||
|
||||
fireMouse('mousemove', 600);
|
||||
|
||||
expect(result.current.dragging).toBe(false);
|
||||
expect(result.current.dragWidth).toBe(null);
|
||||
});
|
||||
|
||||
it('ResizeObserver re-clamps the persisted width when the container shrinks', () => {
|
||||
const containerEl = makeContainer(1000);
|
||||
const onWidthChange = jest.fn();
|
||||
renderDragResize({ width: 800, onWidthChange, container: containerEl });
|
||||
|
||||
// 800 fits within 1000 - 300 (minRight) = 700? No: 800 > 700, so already
|
||||
// out of bounds — but the effect only re-clamps on observed resize, so
|
||||
// shrink the container and trigger the observer manually.
|
||||
containerEl.getBoundingClientRect = jest.fn(() => ({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 600,
|
||||
height: 600,
|
||||
right: 600,
|
||||
bottom: 600,
|
||||
x: 0,
|
||||
y: 0
|
||||
}));
|
||||
|
||||
act(() => {
|
||||
observers[0].callback();
|
||||
});
|
||||
|
||||
// 800 clamped to 600 - 300 = 300
|
||||
expect(onWidthChange).toHaveBeenCalledWith(300);
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,13 @@ export const apiSpecSlice = createSlice({
|
||||
setActiveApiSpecUid: (state, action) => {
|
||||
state.activeApiSpecUid = action.payload.uid;
|
||||
},
|
||||
updateApiSpecPanelLeftPaneWidth: (state, action) => {
|
||||
const { uid, leftPaneWidth } = action.payload;
|
||||
const apiSpec = findApiSpecByUid(state.apiSpecs, uid);
|
||||
if (apiSpec) {
|
||||
apiSpec.leftPaneWidth = leftPaneWidth;
|
||||
}
|
||||
},
|
||||
removeApiSpec: (state, action) => {
|
||||
const { uid } = action.payload;
|
||||
let apiSpecIndex = state.apiSpecs.findIndex((c) => c.uid == uid);
|
||||
@@ -70,7 +77,7 @@ export const apiSpecSlice = createSlice({
|
||||
}
|
||||
});
|
||||
|
||||
export const { apiSpecAddFileEvent, apiSpecChangeFileEvent, saveApiSpec, removeApiSpec, setActiveApiSpecUid } = apiSpecSlice.actions;
|
||||
export const { apiSpecAddFileEvent, apiSpecChangeFileEvent, saveApiSpec, removeApiSpec, setActiveApiSpecUid, updateApiSpecPanelLeftPaneWidth } = apiSpecSlice.actions;
|
||||
|
||||
export default apiSpecSlice.reducer;
|
||||
|
||||
|
||||
@@ -159,6 +159,13 @@ export const tabsSlice = createSlice({
|
||||
tab.requestPaneHeight = action.payload.requestPaneHeight;
|
||||
}
|
||||
},
|
||||
updateApiSpecTabLeftPaneWidth: (state, action) => {
|
||||
const tab = find(state.tabs, (t) => t.uid === action.payload.uid);
|
||||
|
||||
if (tab) {
|
||||
tab.apiSpecLeftPaneWidth = action.payload.apiSpecLeftPaneWidth;
|
||||
}
|
||||
},
|
||||
updateRequestPaneTab: (state, action) => {
|
||||
const tab = find(state.tabs, (t) => t.uid === action.payload.uid);
|
||||
|
||||
@@ -416,6 +423,7 @@ export const {
|
||||
switchTab,
|
||||
updateRequestPaneTabWidth,
|
||||
updateRequestPaneTabHeight,
|
||||
updateApiSpecTabLeftPaneWidth,
|
||||
updateRequestPaneTab,
|
||||
updateResponsePaneTab,
|
||||
updateResponseFormat,
|
||||
|
||||
Reference in New Issue
Block a user