diff --git a/packages/bruno-app/src/components/BodyModeSelector/index.js b/packages/bruno-app/src/components/BodyModeSelector/index.js new file mode 100644 index 0000000000..842c3f9ce6 --- /dev/null +++ b/packages/bruno-app/src/components/BodyModeSelector/index.js @@ -0,0 +1,83 @@ +import React, { useRef, forwardRef } from 'react'; +import { IconCaretDown } from '@tabler/icons'; +import Dropdown from 'components/Dropdown'; +import { humanizeRequestBodyMode } from 'utils/collections'; + +const DEFAULT_MODES = [ + { key: 'multipartForm', label: 'Multipart Form', category: 'Form' }, + { key: 'formUrlEncoded', label: 'Form URL Encoded', category: 'Form' }, + { key: 'json', label: 'JSON', category: 'Raw' }, + { key: 'xml', label: 'XML', category: 'Raw' }, + { key: 'text', label: 'TEXT', category: 'Raw' }, + { key: 'sparql', label: 'SPARQL', category: 'Raw' }, + { key: 'file', label: 'File / Binary', category: 'Other' }, + { key: 'none', label: 'None', category: 'Other' } +]; + +const BodyModeSelector = ({ + currentMode, + onModeChange, + modes = DEFAULT_MODES, + disabled = false, + className = '', + wrapperClassName = '', + showCategories = true, + placement = 'bottom-end' +}) => { + const dropdownTippyRef = useRef(); + const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref); + + const Icon = forwardRef((props, ref) => { + return ( +
+ {humanizeRequestBodyMode(currentMode)} + {' '} + +
+ ); + }); + + const onModeSelect = (mode) => { + dropdownTippyRef.current.hide(); + onModeChange(mode); + }; + + // Group modes by category for rendering + const groupedModes = modes.reduce((acc, mode) => { + const category = mode.category || 'Other'; + if (!acc[category]) { + acc[category] = []; + } + acc[category].push(mode); + return acc; + }, {}); + + return ( +
+ } + placement={placement} + disabled={disabled} + className={className} + > + {Object.entries(groupedModes).map(([category, categoryModes]) => ( + + {showCategories &&
{category}
} + {categoryModes.map((mode) => ( +
onModeSelect(mode.key)} + > + {mode.label} +
+ ))} +
+ ))} +
+
+ ); +}; + +export default BodyModeSelector; diff --git a/packages/bruno-app/src/components/Checkbox/StyledWrapper.js b/packages/bruno-app/src/components/Checkbox/StyledWrapper.js new file mode 100644 index 0000000000..ddfffe2250 --- /dev/null +++ b/packages/bruno-app/src/components/Checkbox/StyledWrapper.js @@ -0,0 +1,79 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .checkbox-container { + width: 1rem; + height: 1rem; + display: flex; + justify-content: center; + align-items: center; + position: relative; + cursor: pointer; + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + } + + .checkbox-checkmark { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + visibility: ${(props) => props.checked ? 'visible' : 'hidden'}; + pointer-events: none; + } + + .checkbox-input { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + width: 1rem; + height: 1rem; + border: 2px solid ${(props) => { + if (props.checked && props.disabled) { + return props.theme.colors.text.muted; + } + + if (props.checked && !props.disabled) { + return props.theme.colors.text.yellow; + } + + return props.theme.colors.text.muted; + }}; + border-radius: 4px; + background-color: ${(props) => { + if (props.checked && !props.disabled) { + return props.theme.colors.text.yellow; + } + + if (props.checked && props.disabled) { + return props.theme.colors.text.muted; + } + + return 'transparent'; + }}; + cursor: pointer; + position: relative; + transition: all 0.2s ease; + outline: none; + box-shadow: none; + + &:hover:not(:disabled) { + opacity: 0.8; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + + &:focus { + outline: none; + box-shadow: 0 0 0 2px ${(props) => props.theme.colors.text.yellow}40; + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/Checkbox/index.js b/packages/bruno-app/src/components/Checkbox/index.js new file mode 100644 index 0000000000..175292db0f --- /dev/null +++ b/packages/bruno-app/src/components/Checkbox/index.js @@ -0,0 +1,45 @@ +import React from 'react'; +import StyledWrapper from './StyledWrapper'; +import IconCheckMark from 'components/Icons/IconCheckMark'; +import { useTheme } from 'providers/Theme'; + +const Checkbox = ({ + checked = false, + disabled = false, + onChange, + className = '', + id, + name, + value, + dataTestId = 'checkbox' +}) => { + const { theme } = useTheme(); + + const handleChange = (e) => { + if (!disabled && onChange) { + onChange(e); + } + }; + + return ( + +
+ + +
+ +
+ ); +}; + +export default Checkbox; diff --git a/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js b/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js index bce574f3c5..8d2586c8b7 100644 --- a/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js +++ b/packages/bruno-app/src/components/CodeEditor/StyledWrapper.js @@ -1,6 +1,18 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` + &.read-only { + div.CodeMirror .CodeMirror-lines { + user-select: none !important; + -webkit-user-select: none !important; + -ms-user-select: none !important; + } + + div.CodeMirror .CodeMirror-cursor { + display: none !important; + } + } + div.CodeMirror { background: ${(props) => props.theme.codemirror.bg}; border: solid 1px ${(props) => props.theme.codemirror.border}; diff --git a/packages/bruno-app/src/components/CodeEditor/index.js b/packages/bruno-app/src/components/CodeEditor/index.js index bf6f72211a..721f35b42f 100644 --- a/packages/bruno-app/src/components/CodeEditor/index.js +++ b/packages/bruno-app/src/components/CodeEditor/index.js @@ -63,7 +63,7 @@ export default class CodeEditor extends React.Component { foldGutter: true, gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'], lint: this.lintOptions, - readOnly: this.props.readOnly, + readOnly: this.props.readOnly ? 'nocursor' : false, scrollbarStyle: 'overlay', theme: this.props.theme === 'dark' ? 'monokai' : 'default', extraKeys: { @@ -245,6 +245,10 @@ export default class CodeEditor extends React.Component { this.editor.setOption('mode', this.props.mode); } + if (this.props.readOnly !== prevProps.readOnly && this.editor) { + this.editor.setOption('readOnly', this.props.readOnly ? 'nocursor' : false); + } + this.ignoreChangeEvent = false; } @@ -262,7 +266,7 @@ export default class CodeEditor extends React.Component { } return ( { +const FilePickerEditor = ({ value, onChange, collection, isSingleFilePicker = false, readOnly = false }) => { const dispatch = useDispatch(); const filenames = (isSingleFilePicker ? [value] : value || []) .filter((v) => v != null && v != '') @@ -50,20 +50,24 @@ const FilePickerEditor = ({ value, onChange, collection, isSingleFilePicker = fa return filenames.length + ' file(s) selected'; }; + const buttonClass = `btn btn-secondary px-1 ${readOnly ? 'view-mode' : 'edit-mode'}`; + return filenames.length > 0 ? (
- -   + {!readOnly && ( + + )} + {!readOnly && <> } {renderButtonText(filenames)}
) : ( - ); diff --git a/packages/bruno-app/src/components/Icons/IconCaretDown/index.js b/packages/bruno-app/src/components/Icons/IconCaretDown/index.js new file mode 100644 index 0000000000..3eb4bb7cf1 --- /dev/null +++ b/packages/bruno-app/src/components/Icons/IconCaretDown/index.js @@ -0,0 +1,18 @@ +import React from 'react'; + +const IconCaretDown = ({ color = '#8C8C8C', ...props }) => { + return ( + + + + + + + + + + + ); +}; + +export default IconCaretDown; diff --git a/packages/bruno-app/src/components/Icons/IconCheckMark/index.js b/packages/bruno-app/src/components/Icons/IconCheckMark/index.js new file mode 100644 index 0000000000..86b5bd501f --- /dev/null +++ b/packages/bruno-app/src/components/Icons/IconCheckMark/index.js @@ -0,0 +1,11 @@ +import React from 'react'; + +const IconCheckMark = ({ color = '#cccccc', size = 16, ...props }) => { + return ( + + + + ); +}; + +export default IconCheckMark; diff --git a/packages/bruno-app/src/components/Icons/IconEdit/index.js b/packages/bruno-app/src/components/Icons/IconEdit/index.js new file mode 100644 index 0000000000..a02d7b6468 --- /dev/null +++ b/packages/bruno-app/src/components/Icons/IconEdit/index.js @@ -0,0 +1,19 @@ +import React from 'react'; + +const IconEdit = ({ color = '#F39D0E', size = 16, ...props }) => { + return ( + + + + + + + + + + + + ); +}; + +export default IconEdit; diff --git a/packages/bruno-app/src/components/Icons/examples/index.js b/packages/bruno-app/src/components/Icons/examples/index.js new file mode 100644 index 0000000000..aa7a5c47be --- /dev/null +++ b/packages/bruno-app/src/components/Icons/examples/index.js @@ -0,0 +1,21 @@ +import React from 'react'; + +const ExampleIcon = ({ color = 'white', size = 16, ...props }) => { + return ( + + + + + + + + + + + + + + ); +}; + +export default ExampleIcon; diff --git a/packages/bruno-app/src/components/Modal/index.js b/packages/bruno-app/src/components/Modal/index.js index 19d7557879..0a48fbeee7 100644 --- a/packages/bruno-app/src/components/Modal/index.js +++ b/packages/bruno-app/src/components/Modal/index.js @@ -26,7 +26,8 @@ const ModalFooter = ({ handleCancel, confirmDisabled, hideCancel, - hideFooter + hideFooter, + confirmButtonClass = 'btn-secondary' }) => { confirmText = confirmText || 'Save'; cancelText = cancelText || 'Cancel'; @@ -45,7 +46,7 @@ const ModalFooter = ({ + + ); +}; + +export default ExampleNotFound; diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index ec3878a7fc..f7880e5097 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -29,9 +29,11 @@ import CollectionOverview from 'components/CollectionSettings/Overview'; import RequestNotLoaded from './RequestNotLoaded'; import RequestIsLoading from './RequestIsLoading'; import FolderNotFound from './FolderNotFound'; +import ExampleNotFound from './ExampleNotFound'; import WsQueryUrl from 'components/RequestPane/WsQueryUrl'; import WSRequestPane from 'components/RequestPane/WSRequestPane'; import WSResponsePane from 'components/ResponsePane/WsResponsePane'; +import ResponseExample from 'components/ResponseExample'; const MIN_LEFT_PANE_WIDTH = 300; const MIN_RIGHT_PANE_WIDTH = 350; @@ -186,6 +188,16 @@ const RequestTabPanel = () => { return
Collection not found!
; } + if (focusedTab.type === 'response-example') { + const item = findItemInCollection(collection, focusedTab.itemUid); + const example = item?.examples?.find((ex) => ex.uid === focusedTab.uid); + + if (!example) { + return ; + } + return ; + } + const item = findItemInCollection(collection, activeTabUid); const isGrpcRequest = item?.type === 'grpc-request'; const isWsRequest = item?.type === 'ws-request'; diff --git a/packages/bruno-app/src/components/RequestTabs/ExampleTab/index.js b/packages/bruno-app/src/components/RequestTabs/ExampleTab/index.js new file mode 100644 index 0000000000..25ade712fc --- /dev/null +++ b/packages/bruno-app/src/components/RequestTabs/ExampleTab/index.js @@ -0,0 +1,144 @@ +import React, { useState, useRef, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs'; +import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections'; +import { hasExampleChanges, findItemInCollection } from 'utils/collections'; +import ExampleIcon from 'components/Icons/Examples'; +import ConfirmRequestClose from '../RequestTab/ConfirmRequestClose'; +import RequestTabNotFound from '../RequestTab/RequestTabNotFound'; +import StyledWrapper from '../RequestTab/StyledWrapper'; +import CloseTabIcon from '../RequestTab/CloseTabIcon'; +import DraftTabIcon from '../RequestTab/DraftTabIcon'; + +const ExampleTab = ({ tab, collection }) => { + const dispatch = useDispatch(); + const [showConfirmClose, setShowConfirmClose] = useState(false); + + const dropdownTippyRef = useRef(); + + // Get item and example data + const item = findItemInCollection(collection, tab.itemUid); + const example = useMemo(() => item?.examples?.find((ex) => ex.uid === tab.uid), [item?.examples, tab.uid]); + + const hasChanges = useMemo(() => hasExampleChanges(item, tab.uid), [item, tab.uid]); + + const handleCloseClick = (event) => { + event.stopPropagation(); + event.preventDefault(); + dispatch(closeTabs({ + tabUids: [tab.uid] + })); + }; + + const handleRightClick = (_event) => { + const menuDropdown = dropdownTippyRef.current; + if (!menuDropdown) { + return; + } + + if (menuDropdown.state.isShown) { + menuDropdown.hide(); + } else { + menuDropdown.show(); + } + }; + + const handleMouseUp = (e) => { + if (e.button === 1) { + e.preventDefault(); + e.stopPropagation(); + + // Close the tab + dispatch(closeTabs({ + tabUids: [tab.uid] + })); + } + }; + + if (!item || !example) { + return ( + { + if (e.button === 1) { + e.preventDefault(); + e.stopPropagation(); + + dispatch(closeTabs({ tabUids: [tab.uid] })); + } + }} + > + + + ); + } + + return ( + + {showConfirmClose && ( + setShowConfirmClose(false)} + onCloseWithoutSave={() => { + dispatch(deleteRequestDraft({ + itemUid: item.uid, + collectionUid: collection.uid + })); + dispatch(closeTabs({ + tabUids: [tab.uid] + })); + setShowConfirmClose(false); + }} + onSaveAndClose={() => { + // For examples, we don't have a separate save action + // The changes are saved automatically when the request is saved + dispatch(closeTabs({ + tabUids: [tab.uid] + })); + setShowConfirmClose(false); + }} + /> + )} +
dispatch(makeTabPermanent({ uid: tab.uid }))} + onMouseUp={(e) => { + if (!hasChanges) return handleMouseUp(e); + + if (e.button === 1) { + e.stopPropagation(); + e.preventDefault(); + setShowConfirmClose(true); + } + }} + > + + + {example.name} + +
+
{ + if (!hasChanges) { + return handleCloseClick(e); + } + + e.stopPropagation(); + e.preventDefault(); + setShowConfirmClose(true); + }} + > + {!hasChanges ? ( + + ) : ( + + )} +
+
+ ); +}; + +export default ExampleTab; diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/ConfirmRequestClose/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/ConfirmRequestClose/index.js index d02704636a..293ebb3a6f 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/ConfirmRequestClose/index.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/ConfirmRequestClose/index.js @@ -2,7 +2,11 @@ import React from 'react'; import { IconAlertTriangle } from '@tabler/icons'; import Modal from 'components/Modal'; -const ConfirmRequestClose = ({ item, onCancel, onCloseWithoutSave, onSaveAndClose }) => { +const ConfirmRequestClose = ({ item, example, onCancel, onCloseWithoutSave, onSaveAndClose }) => { + const isExample = !!example; + const itemName = isExample ? example.name : item.name; + const itemType = isExample ? 'example' : 'request'; + return ( Hold on..
- You have unsaved changes in request {item.name}. + You have unsaved changes in {itemType} {itemName}.
diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js index 71d57d940c..028c9bb2c5 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js @@ -1,4 +1,4 @@ -import React, { useCallback, useState, useRef, Fragment } from 'react'; +import React, { useCallback, useState, useRef, Fragment, useMemo } from 'react'; import get from 'lodash/get'; import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs'; import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; @@ -7,7 +7,7 @@ import { useTheme } from 'providers/Theme'; import { useDispatch } from 'react-redux'; import darkTheme from 'themes/dark'; import lightTheme from 'themes/light'; -import { findItemInCollection } from 'utils/collections'; +import { findItemInCollection, hasRequestChanges } from 'utils/collections'; import ConfirmRequestClose from './ConfirmRequestClose'; import RequestTabNotFound from './RequestTabNotFound'; import SpecialTab from './SpecialTab'; @@ -19,6 +19,7 @@ import CloseTabIcon from './CloseTabIcon'; import DraftTabIcon from './DraftTabIcon'; import { flattenItems } from 'utils/collections/index'; import { closeWsConnection } from 'utils/network/index'; +import ExampleTab from '../ExampleTab'; const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid }) => { const dispatch = useDispatch(); @@ -29,6 +30,8 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi const dropdownTippyRef = useRef(); const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref); + const item = findItemInCollection(collection, tab.uid); + const handleCloseClick = (event) => { event.stopPropagation(); event.preventDefault(); @@ -92,7 +95,19 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi ); } - const item = findItemInCollection(collection, tab.uid); + // Handle response-example tabs specially + if (tab.type === 'response-example') { + return ( + + ); + } + const getMethodText = useCallback((item) => { if (!item) return; @@ -129,6 +144,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi const isWS = item.type === 'ws-request'; const method = getMethodText(item); + const hasChanges = useMemo(() => hasRequestChanges(item), [item]); return ( @@ -172,7 +188,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi onContextMenu={handleRightClick} onDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} onMouseUp={(e) => { - if (!item.draft) return handleMouseUp(e); + if (!hasChanges) return handleMouseUp(e); if (e.button === 1) { e.stopPropagation(); @@ -200,7 +216,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
{ - if (!item.draft) { + if (!hasChanges) { isWS && closeWsConnection(item.uid); return handleCloseClick(e); }; @@ -210,7 +226,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi setShowConfirmClose(true); }} > - {!item.draft ? ( + {!hasChanges ? ( ) : ( @@ -227,6 +243,7 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col const totalTabs = collectionRequestTabs.length || 0; const currentTabUid = collectionRequestTabs[tabIndex]?.uid; const currentTabItem = findItemInCollection(collection, currentTabUid); + const currentTabHasChanges = useMemo(() => hasRequestChanges(currentTabItem), [currentTabItem]); const hasLeftTabs = tabIndex !== 0; const hasRightTabs = totalTabs > tabIndex + 1; @@ -243,7 +260,7 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col try { const item = findItemInCollection(collection, tabUid); // silently save unsaved changes before closing the tab - if (item.draft) { + if (hasRequestChanges(item)) { await dispatch(saveRequest(item.uid, collection.uid, true)); } @@ -295,7 +312,7 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col event.stopPropagation(); const items = flattenItems(collection?.items); - const savedTabs = items?.filter?.((item) => !item.draft); + const savedTabs = items?.filter?.((item) => !hasRequestChanges(item)); const savedTabIds = savedTabs?.map((item) => item.uid) || []; dispatch(closeTabs({ tabUids: savedTabIds })); } diff --git a/packages/bruno-app/src/components/ResponseExample/CreateExampleModal/index.js b/packages/bruno-app/src/components/ResponseExample/CreateExampleModal/index.js new file mode 100644 index 0000000000..636d61b4a3 --- /dev/null +++ b/packages/bruno-app/src/components/ResponseExample/CreateExampleModal/index.js @@ -0,0 +1,102 @@ +import { useState, useEffect } from 'react'; +import Modal from 'components/Modal'; +import Portal from 'components/Portal'; + +const CreateExampleModal = ({ isOpen, onClose, onSave, title = 'Create Response Example', initialName = '' }) => { + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [nameError, setNameError] = useState(''); + + const handleNameChange = (e) => { + setName(e.target.value); + // Clear error when user starts typing + if (nameError) { + setNameError(''); + } + }; + + const handleConfirm = () => { + if (name.trim()) { + onSave(name.trim(), description.trim()); + // Reset form + setName(''); + setDescription(''); + setNameError(''); + } else { + setNameError('Example name is required'); + } + }; + + const handleClose = () => { + // Reset form when closing + setName(''); + setDescription(''); + setNameError(''); + onClose(); + }; + + useEffect(() => { + if (isOpen) { + setName(initialName); + setDescription(''); + setNameError(''); + } + }, [isOpen, initialName]); + + if (!isOpen) { + return null; + } + + return ( + + +
+
+ + + {nameError && ( +
+ {nameError} +
+ )} +
+ +
+ +