diff --git a/packages/bruno-app/src/components/CollectionSettings/Headers/index.js b/packages/bruno-app/src/components/CollectionSettings/Headers/index.js index e0846dff5..b9a7df1c8 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Headers/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Headers/index.js @@ -1,78 +1,77 @@ -import React, { useState } from 'react'; +import React, { useState, useCallback } from 'react'; import get from 'lodash/get'; -import cloneDeep from 'lodash/cloneDeep'; -import { IconTrash } from '@tabler/icons'; import { useDispatch } from 'react-redux'; import { useTheme } from 'providers/Theme'; -import { - addCollectionHeader, - updateCollectionHeader, - deleteCollectionHeader, - setCollectionHeaders -} from 'providers/ReduxStore/slices/collections'; +import { setCollectionHeaders } from 'providers/ReduxStore/slices/collections'; import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions'; import SingleLineEditor from 'components/SingleLineEditor'; +import EditableTable from 'components/EditableTable'; import StyledWrapper from './StyledWrapper'; import { headers as StandardHTTPHeaders } from 'know-your-http-well'; import { MimeTypes } from 'utils/codemirror/autocompleteConstants'; import BulkEditor from 'components/BulkEditor/index'; + const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header); const Headers = ({ collection }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); - const headers = collection.draft?.root ? get(collection, 'draft.root.request.headers', []) : get(collection, 'root.request.headers', []); + const headers = collection.draft?.root + ? get(collection, 'draft.root.request.headers', []) + : get(collection, 'root.request.headers', []); const [isBulkEditMode, setIsBulkEditMode] = useState(false); const toggleBulkEditMode = () => { setIsBulkEditMode(!isBulkEditMode); }; - const handleBulkHeadersChange = (newHeaders) => { - dispatch(setCollectionHeaders({ collectionUid: collection.uid, headers: newHeaders })); - }; - - const addHeader = () => { - dispatch( - addCollectionHeader({ - collectionUid: collection.uid - }) - ); - }; + const handleHeadersChange = useCallback((updatedHeaders) => { + dispatch(setCollectionHeaders({ collectionUid: collection.uid, headers: updatedHeaders })); + }, [dispatch, collection.uid]); const handleSave = () => dispatch(saveCollectionSettings(collection.uid)); - const handleHeaderValueChange = (e, _header, type) => { - const header = cloneDeep(_header); - switch (type) { - case 'name': { - // Strip newlines from header keys - header.name = e.target.value.replace(/[\r\n]/g, ''); - break; - } - case 'value': { - header.value = e.target.value; - break; - } - case 'enabled': { - header.enabled = e.target.checked; - break; - } - } - dispatch( - updateCollectionHeader({ - header: header, - collectionUid: collection.uid - }) - ); - }; - const handleRemoveHeader = (header) => { - dispatch( - deleteCollectionHeader({ - headerUid: header.uid, - collectionUid: collection.uid - }) - ); + const columns = [ + { + key: 'name', + name: 'Name', + isKeyField: true, + placeholder: 'Name', + width: '30%', + render: ({ row, value, onChange, isLastEmptyRow }) => ( + onChange(newValue.replace(/[\r\n]/g, ''))} + autocomplete={headerAutoCompleteList} + collection={collection} + placeholder={isLastEmptyRow ? 'Name' : ''} + /> + ) + }, + { + key: 'value', + name: 'Value', + placeholder: 'Value', + render: ({ row, value, onChange, isLastEmptyRow }) => ( + + ) + } + ]; + + const defaultRow = { + name: '', + value: '', + description: '' }; if (isBulkEditMode) { @@ -83,7 +82,7 @@ const Headers = ({ collection }) => { @@ -96,86 +95,17 @@ const Headers = ({ collection }) => {
Add request headers that will be sent with every request in this collection.
- - - - - - - - - - {headers && headers.length - ? headers.map((header) => { - return ( - - - - - - ); - }) - : null} - -
NameValue
- - handleHeaderValueChange( - { - target: { - value: newValue - } - }, - header, - 'name' - )} - autocomplete={headerAutoCompleteList} - collection={collection} - /> - - - handleHeaderValueChange( - { - target: { - value: newValue - } - }, - header, - 'value' - )} - collection={collection} - autocomplete={MimeTypes} - /> - -
- handleHeaderValueChange(e, header, 'enabled')} - /> - -
-
-
- + +
-
+ ); }; + export default VarsTable; diff --git a/packages/bruno-app/src/components/EditableTable/StyledWrapper.js b/packages/bruno-app/src/components/EditableTable/StyledWrapper.js new file mode 100644 index 000000000..1202a210d --- /dev/null +++ b/packages/bruno-app/src/components/EditableTable/StyledWrapper.js @@ -0,0 +1,154 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + + .table-container { + overflow-y: auto; + border-radius: ${(props) => props.theme.border.radius.base}; + border: ${(props) => props.theme.workspace.environments.indentBorder}; + } + + table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + font-size: ${(props) => props.theme.font.size.base}; + } + + thead { + color: ${(props) => props.theme.colors.text} !important; + background: ${(props) => props.theme.sidebar.bg}; + font-size: ${(props) => props.theme.font.size.base}; + user-select: none; + + border: none !important; + + td { + padding: 8px 10px; + border-top: none !important; + border-left: none !important; + border-bottom: ${(props) => props.theme.workspace.environments.indentBorder}; + border-right: ${(props) => props.theme.workspace.environments.indentBorder}; + vertical-align: middle; + + &:nth-child(1) { + width: 25px !important; + border-right: none; + } + + &:last-child { + border-right: none; + } + } + } + + tbody { + tr { + transition: background 0.1s ease; + + &:last-child td { + border-bottom: none; + } + + td { + padding: 2px 10px; + border-top: none !important; + border-left: none !important; + border-bottom: ${(props) => props.theme.workspace.environments.indentBorder}; + border-right: ${(props) => props.theme.workspace.environments.indentBorder}; + vertical-align: middle; + + &:nth-child(1) { + width: 25px; + border-right: none; + text-align: center; + vertical-align: middle; + line-height: 1; + + input[type='checkbox'] { + vertical-align: baseline; + display: inline-block; + } + } + + &:last-child { + border-right: none; + } + } + } + } + + .tooltip-mod { + font-size: 11px !important; + max-width: 200px !important; + } + + input[type='text'] { + width: 100%; + outline: none !important; + background-color: transparent; + color: ${(props) => props.theme.text}; + padding: 0; + font-size: 12px; + border-radius: 4px; + transition: all 0.15s ease; + + &:focus { + outline: none !important; + } + } + + input[type='checkbox'] { + cursor: pointer; + width: 14px; + height: 14px; + accent-color: ${(props) => props.theme.workspace.accent}; + vertical-align: middle; + margin: 0; + } + + button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px; + color: ${(props) => props.theme.colors.text.muted}; + background: transparent; + border: none; + cursor: pointer; + border-radius: 4px; + transition: color 0.15s ease, background 0.15s ease; + + &:hover { + color: ${(props) => props.theme.colors.text.danger}; + } + } + + .drag-handle { + .icon-grip, + .icon-minus { + color: ${(props) => props.theme.colors.text.muted}; + } + } + + select { + background-color: transparent; + color: ${(props) => props.theme.text}; + border: none; + outline: none; + padding: 2px 8px; + font-size: 12px; + cursor: pointer; + + option { + background-color: ${(props) => props.theme.bg}; + color: ${(props) => props.theme.text}; + } + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/EditableTable/index.js b/packages/bruno-app/src/components/EditableTable/index.js new file mode 100644 index 000000000..7a78e3ab4 --- /dev/null +++ b/packages/bruno-app/src/components/EditableTable/index.js @@ -0,0 +1,318 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { IconTrash, IconAlertCircle, IconGripVertical, IconMinusVertical } from '@tabler/icons'; +import { Tooltip } from 'react-tooltip'; +import { uuid } from 'utils/common'; +import StyledWrapper from './StyledWrapper'; + +const EditableTable = ({ + columns, + rows, + onChange, + defaultRow, + getRowError, + showCheckbox = true, + showDelete = true, + checkboxLabel = '', + checkboxKey = 'enabled', + reorderable = false, + onReorder, + showAddRow = true +}) => { + const tableRef = useRef(null); + const emptyRowUidRef = useRef(null); + const [hoveredRow, setHoveredRow] = useState(null); + const [dragStart, setDragStart] = useState(null); + + const createEmptyRow = useCallback(() => { + const newUid = uuid(); + emptyRowUidRef.current = newUid; + return { + uid: newUid, + [checkboxKey]: true, + ...defaultRow + }; + }, [defaultRow, checkboxKey]); + + const rowsWithEmpty = useMemo(() => { + if (!showAddRow) { + return rows; + } + + if (rows.length === 0) { + return [createEmptyRow()]; + } + + const lastRow = rows[rows.length - 1]; + const keyColumn = columns.find((col) => col.isKeyField); + + if (keyColumn) { + const lastRowKeyValue = keyColumn.getValue ? keyColumn.getValue(lastRow) : lastRow[keyColumn.key]; + const isLastRowEmpty = !lastRowKeyValue || (typeof lastRowKeyValue === 'string' && lastRowKeyValue.trim() === ''); + + if (isLastRowEmpty) { + return rows; + } + } + + if (!emptyRowUidRef.current || rows.some((r) => r.uid === emptyRowUidRef.current)) { + emptyRowUidRef.current = uuid(); + } + + return [...rows, { + uid: emptyRowUidRef.current, + [checkboxKey]: true, + ...defaultRow + }]; + }, [rows, columns, defaultRow, checkboxKey, createEmptyRow, showAddRow]); + + const isEmptyRow = useCallback((row) => { + const keyColumn = columns.find((col) => col.isKeyField); + if (!keyColumn) return false; + + const value = keyColumn.getValue ? keyColumn.getValue(row) : row[keyColumn.key]; + return !value || (typeof value === 'string' && value.trim() === ''); + }, [columns]); + + const isLastEmptyRow = useCallback((row, index) => { + if (!showAddRow) return false; + return index === rowsWithEmpty.length - 1 && isEmptyRow(row); + }, [rowsWithEmpty.length, isEmptyRow, showAddRow]); + + const handleValueChange = useCallback((rowUid, key, value) => { + const rowIndex = rowsWithEmpty.findIndex((r) => r.uid === rowUid); + if (rowIndex === -1) return; + + const currentRow = rowsWithEmpty[rowIndex]; + const isLast = rowIndex === rowsWithEmpty.length - 1; + const wasEmpty = isEmptyRow(currentRow); + + const keyColumn = columns.find((col) => col.isKeyField); + const isKeyFieldChange = keyColumn && keyColumn.key === key; + + let updatedRows = rowsWithEmpty.map((row) => { + if (row.uid === rowUid) { + return { ...row, [key]: value }; + } + return row; + }); + + // Only add a new empty row when the key field is filled + if (showAddRow && isLast && wasEmpty && isKeyFieldChange && value && value.trim() !== '') { + emptyRowUidRef.current = uuid(); + updatedRows.push({ + uid: emptyRowUidRef.current, + [checkboxKey]: true, + ...defaultRow + }); + } + + const hasAnyValue = (row) => { + for (const col of columns) { + const val = col.getValue ? col.getValue(row) : row[col.key]; + const defaultVal = defaultRow[col.key]; + if (val && val !== defaultVal && (typeof val !== 'string' || val.trim() !== '')) { + return true; + } + } + return false; + }; + + const result = updatedRows.filter((row, i) => { + if (showAddRow && i === updatedRows.length - 1) { + return hasAnyValue(row); + } + return true; + }); + + onChange(result); + }, [rowsWithEmpty, columns, onChange, checkboxKey, defaultRow, isEmptyRow, showAddRow]); + + const handleCheckboxChange = useCallback((rowUid, checked) => { + handleValueChange(rowUid, checkboxKey, checked); + }, [handleValueChange, checkboxKey]); + + const handleRemoveRow = useCallback((rowUid) => { + const filteredRows = rows.filter((row) => row.uid !== rowUid); + onChange(filteredRows); + }, [rows, onChange]); + + const getColumnWidth = useCallback((column) => { + if (column.width) return column.width; + return 'auto'; + }, []); + + const handleDragStart = useCallback((e, index) => { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', index); + setDragStart(index); + }, []); + + const handleDragOver = useCallback((e, index) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setHoveredRow(index); + }, []); + + const handleDrop = useCallback((e, toIndex) => { + e.preventDefault(); + const fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10); + if (fromIndex !== toIndex && onReorder) { + const reorderableRows = showAddRow ? rowsWithEmpty.slice(0, -1) : rowsWithEmpty; + const updatedOrder = [...reorderableRows]; + const [movedRow] = updatedOrder.splice(fromIndex, 1); + updatedOrder.splice(toIndex, 0, movedRow); + onReorder({ updateReorderedItem: updatedOrder.map((row) => row.uid) }); + } + setDragStart(null); + setHoveredRow(null); + }, [onReorder, rowsWithEmpty, showAddRow]); + + const handleDragEnd = useCallback(() => { + setDragStart(null); + setHoveredRow(null); + }, []); + + const renderCell = useCallback((column, row, rowIndex) => { + const isEmpty = isLastEmptyRow(row, rowIndex); + const value = column.getValue ? column.getValue(row) : row[column.key]; + const error = getRowError?.(row, rowIndex, column.key); + + if (column.render) { + return column.render({ + row, + value, + rowIndex, + isLastEmptyRow: isEmpty, + onChange: (newValue) => handleValueChange(row.uid, column.key, newValue), + error + }); + } + + return ( +
+ handleValueChange(row.uid, column.key, e.target.value)} + /> + {error && !isEmpty && ( + + + + + )} +
+ ); + }, [isLastEmptyRow, getRowError, handleValueChange]); + + const reorderableRowCount = showAddRow ? rowsWithEmpty.length - 1 : rowsWithEmpty.length; + + return ( + +
+ + + + {showCheckbox && ( + + )} + {columns.map((column) => ( + + ))} + {showDelete && ( + + )} + + + + {rowsWithEmpty.map((row, rowIndex) => { + const isEmpty = isLastEmptyRow(row, rowIndex); + const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount; + + return ( + handleDragStart(e, rowIndex) : undefined} + onDragOver={canDrag ? (e) => handleDragOver(e, rowIndex) : undefined} + onDrop={canDrag ? (e) => handleDrop(e, rowIndex) : undefined} + onDragEnd={canDrag ? handleDragEnd : undefined} + onMouseEnter={() => setHoveredRow(rowIndex)} + onMouseLeave={() => setHoveredRow(null)} + > + {showCheckbox && ( + + )} + {columns.map((column) => ( + + ))} + {showDelete && ( + + )} + + ); + })} + +
{checkboxLabel} + {column.name} +
+ {reorderable && canDrag && ( +
+ {hoveredRow === rowIndex && ( + <> + + + + )} +
+ )} + {!isEmpty && ( + handleCheckboxChange(row.uid, e.target.checked)} + /> + )} +
+ {renderCell(column, row, rowIndex)} + + {!isEmpty && ( + + )} +
+
+
+ ); +}; + +export default EditableTable; diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js index 751e56dd7..0dc900d13 100644 --- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js +++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js @@ -11,6 +11,7 @@ const Wrapper = styled.div` td { border: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder}; padding: 4px 10px; + vertical-align: middle; &:nth-child(1), &:nth-child(4) { @@ -58,8 +59,8 @@ const Wrapper = styled.div` input[type='checkbox'] { cursor: pointer; - position: relative; - top: 1px; + vertical-align: middle; + margin: 0; } `; diff --git a/packages/bruno-app/src/components/FolderSettings/Headers/index.js b/packages/bruno-app/src/components/FolderSettings/Headers/index.js index 88ede7cc8..5c570d82c 100644 --- a/packages/bruno-app/src/components/FolderSettings/Headers/index.js +++ b/packages/bruno-app/src/components/FolderSettings/Headers/index.js @@ -1,76 +1,82 @@ -import React, { useState } from 'react'; +import React, { useState, useCallback } from 'react'; import get from 'lodash/get'; -import cloneDeep from 'lodash/cloneDeep'; -import { IconTrash } from '@tabler/icons'; import { useDispatch } from 'react-redux'; import { useTheme } from 'providers/Theme'; -import { addFolderHeader, updateFolderHeader, deleteFolderHeader, setFolderHeaders } from 'providers/ReduxStore/slices/collections'; +import { setFolderHeaders } from 'providers/ReduxStore/slices/collections'; import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'; import SingleLineEditor from 'components/SingleLineEditor'; +import EditableTable from 'components/EditableTable'; import StyledWrapper from './StyledWrapper'; import { headers as StandardHTTPHeaders } from 'know-your-http-well'; import { MimeTypes } from 'utils/codemirror/autocompleteConstants'; import BulkEditor from 'components/BulkEditor/index'; + const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header); const Headers = ({ collection, folder }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); - const headers = folder.draft ? get(folder, 'draft.request.headers', []) : get(folder, 'root.request.headers', []); + const headers = folder.draft + ? get(folder, 'draft.request.headers', []) + : get(folder, 'root.request.headers', []); const [isBulkEditMode, setIsBulkEditMode] = useState(false); const toggleBulkEditMode = () => { setIsBulkEditMode(!isBulkEditMode); }; - const handleBulkHeadersChange = (newHeaders) => { - dispatch(setFolderHeaders({ collectionUid: collection.uid, folderUid: folder.uid, headers: newHeaders })); - }; - - const addHeader = () => { - dispatch( - addFolderHeader({ - collectionUid: collection.uid, - folderUid: folder.uid - }) - ); - }; + const handleHeadersChange = useCallback((updatedHeaders) => { + dispatch(setFolderHeaders({ + collectionUid: collection.uid, + folderUid: folder.uid, + headers: updatedHeaders + })); + }, [dispatch, collection.uid, folder.uid]); const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid)); - const handleHeaderValueChange = (e, _header, type) => { - const header = cloneDeep(_header); - switch (type) { - case 'name': { - // Strip newlines from header keys - header.name = e.target.value.replace(/[\r\n]/g, ''); - break; - } - case 'value': { - header.value = e.target.value; - break; - } - case 'enabled': { - header.enabled = e.target.checked; - break; - } - } - dispatch( - updateFolderHeader({ - header: header, - collectionUid: collection.uid, - folderUid: folder.uid - }) - ); - }; - const handleRemoveHeader = (header) => { - dispatch( - deleteFolderHeader({ - headerUid: header.uid, - collectionUid: collection.uid, - folderUid: folder.uid - }) - ); + const columns = [ + { + key: 'name', + name: 'Name', + isKeyField: true, + placeholder: 'Name', + width: '30%', + render: ({ row, value, onChange, isLastEmptyRow }) => ( + onChange(newValue.replace(/[\r\n]/g, ''))} + autocomplete={headerAutoCompleteList} + collection={collection} + placeholder={isLastEmptyRow ? 'Name' : ''} + /> + ) + }, + { + key: 'value', + name: 'Value', + placeholder: 'Value', + render: ({ row, value, onChange, isLastEmptyRow }) => ( + + ) + } + ]; + + const defaultRow = { + name: '', + value: '', + description: '' }; if (isBulkEditMode) { @@ -81,7 +87,7 @@ const Headers = ({ collection, folder }) => {
@@ -94,87 +100,17 @@ const Headers = ({ collection, folder }) => {
Request headers that will be sent with every request inside this folder.
- - - - - - - - - - {headers && headers.length - ? headers.map((header) => { - return ( - - - - - - ); - }) - : null} - -
NameValue
- - handleHeaderValueChange( - { - target: { - value: newValue - } - }, - header, - 'name' - )} - autocomplete={headerAutoCompleteList} - collection={collection} - /> - - - handleHeaderValueChange( - { - target: { - value: newValue - } - }, - header, - 'value' - )} - collection={collection} - item={folder} - autocomplete={MimeTypes} - /> - -
- handleHeaderValueChange(e, header, 'enabled')} - /> - -
-
-
- + +
-
+ ); }; + export default VarsTable; diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js index 751e56dd7..0dc900d13 100644 --- a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js +++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js @@ -11,6 +11,7 @@ const Wrapper = styled.div` td { border: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder}; padding: 4px 10px; + vertical-align: middle; &:nth-child(1), &:nth-child(4) { @@ -58,8 +59,8 @@ const Wrapper = styled.div` input[type='checkbox'] { cursor: pointer; - position: relative; - top: 1px; + vertical-align: middle; + margin: 0; } `; diff --git a/packages/bruno-app/src/components/RequestPane/Assertions/index.js b/packages/bruno-app/src/components/RequestPane/Assertions/index.js index b8cb7d601..2c12049e3 100644 --- a/packages/bruno-app/src/components/RequestPane/Assertions/index.js +++ b/packages/bruno-app/src/components/RequestPane/Assertions/index.js @@ -1,122 +1,171 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import get from 'lodash/get'; -import cloneDeep from 'lodash/cloneDeep'; import { useDispatch } from 'react-redux'; -import { addAssertion, updateAssertion, deleteAssertion } from 'providers/ReduxStore/slices/collections'; +import { useTheme } from 'providers/Theme'; +import { moveAssertion, setRequestAssertions } from 'providers/ReduxStore/slices/collections'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; -import AssertionRow from './AssertionRow'; +import SingleLineEditor from 'components/SingleLineEditor'; +import AssertionOperator from './AssertionOperator'; +import EditableTable from 'components/EditableTable'; import StyledWrapper from './StyledWrapper'; -import Table from 'components/Table/index'; -import ReorderTable from 'components/ReorderTable/index'; -import { moveAssertion } from 'providers/ReduxStore/slices/collections/index'; + +const unaryOperators = [ + 'isEmpty', + 'isNotEmpty', + 'isNull', + 'isUndefined', + 'isDefined', + 'isTruthy', + 'isFalsy', + 'isJson', + 'isNumber', + 'isString', + 'isBoolean', + 'isArray' +]; + +const parseAssertionOperator = (str = '') => { + if (!str || typeof str !== 'string' || !str.length) { + return { operator: 'eq', value: str }; + } + + const operators = [ + 'eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn', + 'contains', 'notContains', 'length', 'matches', 'notMatches', + 'startsWith', 'endsWith', 'between', ...unaryOperators + ]; + + const [operator, ...rest] = str.split(' '); + const value = rest.join(' '); + + if (unaryOperators.includes(operator)) { + return { operator, value: '' }; + } + + if (operators.includes(operator)) { + return { operator, value }; + } + + return { operator: 'eq', value: str }; +}; + +const isUnaryOperator = (operator) => unaryOperators.includes(operator); const Assertions = ({ item, collection }) => { const dispatch = useDispatch(); + const { storedTheme } = useTheme(); const assertions = item.draft ? get(item, 'draft.request.assertions') : get(item, 'request.assertions'); - const handleAddAssertion = () => { - dispatch( - addAssertion({ - itemUid: item.uid, - collectionUid: collection.uid - }) - ); - }; - const onSave = () => dispatch(saveRequest(item.uid, collection.uid)); const handleRun = () => dispatch(sendRequest(item, collection.uid)); - const handleAssertionChange = (e, _assertion, type) => { - const assertion = cloneDeep(_assertion); - switch (type) { - case 'name': { - assertion.name = e.target.value; - break; + + const handleAssertionsChange = useCallback((updatedAssertions) => { + dispatch(setRequestAssertions({ + collectionUid: collection.uid, + itemUid: item.uid, + assertions: updatedAssertions + })); + }, [dispatch, collection.uid, item.uid]); + + const handleAssertionDrag = useCallback(({ updateReorderedItem }) => { + dispatch(moveAssertion({ + collectionUid: collection.uid, + itemUid: item.uid, + updateReorderedItem + })); + }, [dispatch, collection.uid, item.uid]); + + const columns = [ + { + key: 'name', + name: 'Expr', + isKeyField: true, + placeholder: 'Expr', + width: '30%' + }, + { + key: 'operator', + name: 'Operator', + width: '120px', + getValue: (row) => parseAssertionOperator(row.value).operator, + render: ({ row, rowIndex, isLastEmptyRow }) => { + const { operator } = parseAssertionOperator(row.value); + const assertionValue = parseAssertionOperator(row.value).value; + + const handleOperatorChange = (newOperator) => { + const currentAssertions = assertions || []; + const existingAssertion = currentAssertions.find((a) => a.uid === row.uid); + const newValue = isUnaryOperator(newOperator) ? newOperator : `${newOperator} ${assertionValue}`; + + if (existingAssertion) { + const updatedAssertions = currentAssertions.map((assertion) => { + if (assertion.uid === row.uid) { + return { + ...assertion, + value: newValue + }; + } + return assertion; + }); + handleAssertionsChange(updatedAssertions); + } else { + handleAssertionsChange([...currentAssertions, { ...row, value: newValue }]); + } + }; + + return ( + + ); } - case 'value': { - assertion.value = e.target.value; - break; - } - case 'enabled': { - assertion.enabled = e.target.checked; - break; + }, + { + key: 'value', + name: 'Value', + width: '30%', + render: ({ row, value, onChange, isLastEmptyRow }) => { + const { operator, value: assertionValue } = parseAssertionOperator(value); + + if (isUnaryOperator(operator)) { + return ; + } + + return ( + onChange(`${operator} ${newValue}`)} + onRun={handleRun} + collection={collection} + item={item} + placeholder={isLastEmptyRow ? 'Value' : ''} + /> + ); } } - dispatch( - updateAssertion({ - assertion: assertion, - itemUid: item.uid, - collectionUid: collection.uid - }) - ); - }; + ]; - const handleRemoveAssertion = (assertion) => { - dispatch( - deleteAssertion({ - assertUid: assertion.uid, - itemUid: item.uid, - collectionUid: collection.uid - }) - ); - }; - - const handleAssertionDrag = ({ updateReorderedItem }) => { - dispatch( - moveAssertion({ - collectionUid: collection.uid, - itemUid: item.uid, - updateReorderedItem - }) - ); + const defaultRow = { + name: '', + value: 'eq ', + operator: 'eq' }; return ( - - - {assertions && assertions.length - ? assertions.map((assertion) => { - return ( - - - - - ); - }) - : null} - -
- handleAssertionChange(e, assertion, 'name')} - /> -
- +
); }; + export default Assertions; diff --git a/packages/bruno-app/src/components/RequestPane/FormUrlEncodedParams/index.js b/packages/bruno-app/src/components/RequestPane/FormUrlEncodedParams/index.js index eff560436..e86a04523 100644 --- a/packages/bruno-app/src/components/RequestPane/FormUrlEncodedParams/index.js +++ b/packages/bruno-app/src/components/RequestPane/FormUrlEncodedParams/index.js @@ -1,153 +1,86 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import get from 'lodash/get'; -import cloneDeep from 'lodash/cloneDeep'; -import { IconTrash } from '@tabler/icons'; import { useDispatch } from 'react-redux'; import { useTheme } from 'providers/Theme'; import { - addFormUrlEncodedParam, - updateFormUrlEncodedParam, - deleteFormUrlEncodedParam, - moveFormUrlEncodedParam + moveFormUrlEncodedParam, + setFormUrlEncodedParams } from 'providers/ReduxStore/slices/collections'; import MultiLineEditor from 'components/MultiLineEditor'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; +import EditableTable from 'components/EditableTable'; import StyledWrapper from './StyledWrapper'; -import ReorderTable from 'components/ReorderTable/index'; -import Table from 'components/Table/index'; const FormUrlEncodedParams = ({ item, collection }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); const params = item.draft ? get(item, 'draft.request.body.formUrlEncoded') : get(item, 'request.body.formUrlEncoded'); - const addParam = () => { - dispatch( - addFormUrlEncodedParam({ - itemUid: item.uid, - collectionUid: collection.uid - }) - ); - }; - const onSave = () => dispatch(saveRequest(item.uid, collection.uid)); const handleRun = () => dispatch(sendRequest(item, collection.uid)); - const handleParamChange = (e, _param, type) => { - const param = cloneDeep(_param); - switch (type) { - case 'name': { - param.name = e.target.value; - break; - } - case 'value': { - param.value = e.target.value; - break; - } - case 'enabled': { - param.enabled = e.target.checked; - break; - } + + const handleParamsChange = useCallback((updatedParams) => { + dispatch(setFormUrlEncodedParams({ + collectionUid: collection.uid, + itemUid: item.uid, + params: updatedParams + })); + }, [dispatch, collection.uid, item.uid]); + + const handleParamDrag = useCallback(({ updateReorderedItem }) => { + dispatch(moveFormUrlEncodedParam({ + collectionUid: collection.uid, + itemUid: item.uid, + updateReorderedItem + })); + }, [dispatch, collection.uid, item.uid]); + + const columns = [ + { + key: 'name', + name: 'Key', + isKeyField: true, + placeholder: 'Key', + width: '30%' + }, + { + key: 'value', + name: 'Value', + placeholder: 'Value', + render: ({ row, value, onChange, isLastEmptyRow }) => ( + + ) } - dispatch( - updateFormUrlEncodedParam({ - param: param, - itemUid: item.uid, - collectionUid: collection.uid - }) - ); - }; + ]; - const handleRemoveParams = (param) => { - dispatch( - deleteFormUrlEncodedParam({ - paramUid: param.uid, - itemUid: item.uid, - collectionUid: collection.uid - }) - ); - }; - - const handleParamDrag = ({ updateReorderedItem }) => { - dispatch( - moveFormUrlEncodedParam({ - collectionUid: collection.uid, - itemUid: item.uid, - updateReorderedItem - }) - ); + const defaultRow = { + name: '', + value: '', + description: '' }; return ( - - - {params && params.length - ? params.map((param, index) => { - return ( - - - - - - ); - }) - : null} - -
- handleParamChange(e, param, 'name')} - /> - - - handleParamChange( - { - target: { - value: newValue - } - }, - param, - 'value' - )} - allowNewlines={true} - onRun={handleRun} - collection={collection} - item={item} - /> - -
- handleParamChange(e, param, 'enabled')} - /> - -
-
- +
); }; + export default FormUrlEncodedParams; diff --git a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/StyledWrapper.js index 44bf208c5..e500eae7e 100644 --- a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/StyledWrapper.js @@ -1,48 +1,41 @@ import styled from 'styled-components'; const Wrapper = styled.div` - table { - width: 100%; - border-collapse: collapse; - font-weight: 500; - table-layout: fixed; - - thead, - td { - border: 1px solid ${(props) => props.theme.table.border}; - } - - thead { - color: ${(props) => props.theme.table.thead.color}; - font-size: ${(props) => props.theme.font.size.base}; - user-select: none; - } - td { - padding: 6px 10px; - } - } - - .btn-add-param { - font-size: ${(props) => props.theme.font.size.base}; - } - - input[type='text'] { - width: 100%; - border: solid 1px transparent; - outline: none !important; - color: ${(props) => props.theme.table.input.color}; + .upload-btn, + .clear-file-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + color: ${(props) => props.theme.colors.text.muted}; background: transparent; + border: none; + cursor: pointer; + border-radius: 4px; + transition: color 0.15s ease; - &:focus { - outline: none !important; - border: solid 1px transparent; + &:hover { + color: ${(props) => props.theme.colors.text.link}; } } - input[type='checkbox'] { - cursor: pointer; - position: relative; - top: 1px; + .clear-file-btn:hover { + color: ${(props) => props.theme.colors.text.danger}; + } + + .file-value-cell { + padding: 4px 0; + + .file-name { + font-size: 12px; + color: ${(props) => props.theme.text}; + } + } + + .value-cell { + .flex-1 { + min-width: 0; + } } `; diff --git a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js index 9dad1dbb1..9eae0a9b9 100644 --- a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js +++ b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js @@ -1,216 +1,215 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import get from 'lodash/get'; -import cloneDeep from 'lodash/cloneDeep'; -import { IconTrash } from '@tabler/icons'; import { useDispatch } from 'react-redux'; import { useTheme } from 'providers/Theme'; +import { IconUpload, IconX, IconFile } from '@tabler/icons'; import { - addMultipartFormParam, - updateMultipartFormParam, - deleteMultipartFormParam, - moveMultipartFormParam + moveMultipartFormParam, + setMultipartFormParams } from 'providers/ReduxStore/slices/collections'; +import { browseFiles } from 'providers/ReduxStore/slices/collections/actions'; import MultiLineEditor from 'components/MultiLineEditor'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; +import EditableTable from 'components/EditableTable'; import StyledWrapper from './StyledWrapper'; -import FilePickerEditor from 'components/FilePickerEditor'; -import Table from 'components/Table/index'; -import ReorderTable from 'components/ReorderTable/index'; +import path from 'utils/common/path'; +import { isWindowsOS } from 'utils/common/platform'; const MultipartFormParams = ({ item, collection }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); const params = item.draft ? get(item, 'draft.request.body.multipartForm') : get(item, 'request.body.multipartForm'); - const addParam = () => { - dispatch( - addMultipartFormParam({ - itemUid: item.uid, - collectionUid: collection.uid, - type: 'text', - value: '' - }) - ); - }; - - const addFile = () => { - dispatch( - addMultipartFormParam({ - itemUid: item.uid, - collectionUid: collection.uid, - type: 'file', - value: [] - }) - ); - }; - const onSave = () => dispatch(saveRequest(item.uid, collection.uid)); const handleRun = () => dispatch(sendRequest(item, collection.uid)); - const handleParamChange = (e, _param, type) => { - const param = cloneDeep(_param); - switch (type) { - case 'name': { - param.name = e.target.value; - break; - } - case 'value': { - param.value = e.target.value; - break; - } - case 'contentType': { - param.contentType = e.target.value; - break; - } - case 'enabled': { - param.enabled = e.target.checked; - break; + + const handleParamsChange = useCallback((updatedParams) => { + dispatch(setMultipartFormParams({ + collectionUid: collection.uid, + itemUid: item.uid, + params: updatedParams + })); + }, [dispatch, collection.uid, item.uid]); + + const handleParamDrag = useCallback(({ updateReorderedItem }) => { + dispatch(moveMultipartFormParam({ + collectionUid: collection.uid, + itemUid: item.uid, + updateReorderedItem + })); + }, [dispatch, collection.uid, item.uid]); + + const handleBrowseFiles = useCallback((row, onChange) => { + dispatch(browseFiles()) + .then((filePaths) => { + const processedPaths = filePaths.map((filePath) => { + const collectionDir = collection.pathname; + if (filePath.startsWith(collectionDir)) { + return path.relative(collectionDir, filePath); + } + return filePath; + }); + + const currentParams = item.draft + ? get(item, 'draft.request.body.multipartForm') + : get(item, 'request.body.multipartForm'); + const updatedParams = (currentParams || []).map((p) => { + if (p.uid === row.uid) { + return { ...p, type: 'file', value: processedPaths }; + } + return p; + }); + handleParamsChange(updatedParams); + }) + .catch((error) => { + console.error(error); + }); + }, [dispatch, collection.pathname, item, handleParamsChange]); + + const handleClearFile = useCallback((row) => { + const currentParams = params || []; + const updatedParams = currentParams.map((p) => { + if (p.uid === row.uid) { + return { ...p, type: 'text', value: '' }; } + return p; + }); + handleParamsChange(updatedParams); + }, [params, handleParamsChange]); + + const handleValueChange = useCallback((row, newValue, onChange) => { + const currentParams = params || []; + const existingParam = currentParams.find((p) => p.uid === row.uid); + if (existingParam) { + const updatedParams = currentParams.map((p) => { + if (p.uid === row.uid) { + return { ...p, type: 'text', value: newValue }; + } + return p; + }); + handleParamsChange(updatedParams); + } else { + onChange(newValue); } - dispatch( - updateMultipartFormParam({ - param: param, - itemUid: item.uid, - collectionUid: collection.uid - }) - ); + }, [params, handleParamsChange]); + + const getFileName = (filePaths) => { + if (!filePaths || (Array.isArray(filePaths) && filePaths.length === 0)) { + return null; + } + const paths = Array.isArray(filePaths) ? filePaths : [filePaths]; + const validPaths = paths.filter((v) => v != null && v !== ''); + if (validPaths.length === 0) return null; + + const separator = isWindowsOS() ? '\\' : '/'; + if (validPaths.length === 1) { + return validPaths[0].split(separator).pop(); + } + return `${validPaths.length} file(s)`; }; - const handleRemoveParams = (param) => { - dispatch( - deleteMultipartFormParam({ - paramUid: param.uid, - itemUid: item.uid, - collectionUid: collection.uid - }) - ); - }; + const columns = [ + { + key: 'name', + name: 'Key', + isKeyField: true, + placeholder: 'Key', + width: '30%' + }, + { + key: 'value', + name: 'Value', + placeholder: 'Value', + width: '35%', + render: ({ row, value, onChange, isLastEmptyRow }) => { + const isFile = row.type === 'file'; + const fileName = isFile ? getFileName(value) : null; + const hasTextValue = !isFile && value && value.length > 0; - const handleParamDrag = ({ updateReorderedItem }) => { - dispatch( - moveMultipartFormParam({ - collectionUid: collection.uid, - itemUid: item.uid, - updateReorderedItem - }) - ); + if (fileName) { + return ( +
+ + + {fileName} + + +
+ ); + } + + return ( +
+
+ handleValueChange(row, newValue, onChange)} + onRun={handleRun} + allowNewlines={true} + collection={collection} + item={item} + placeholder={isLastEmptyRow ? 'Value' : ''} + /> +
+ {!hasTextValue && !isLastEmptyRow && ( + + )} +
+ ); + } + }, + { + key: 'contentType', + name: 'Content-Type', + placeholder: 'Auto', + width: '20%', + render: ({ row, value, onChange, isLastEmptyRow }) => ( + + ) + } + ]; + + const defaultRow = { + name: '', + value: '', + contentType: '', + type: 'text' }; return ( - - - {params && params.length - ? params.map((param, index) => { - return ( - - - - - - - ); - }) - : null} - -
- handleParamChange(e, param, 'name')} - /> - - {param.type === 'file' ? ( - - handleParamChange( - { - target: { - value: newValue - } - }, - param, - 'value' - )} - collection={collection} - /> - ) : ( - - handleParamChange( - { - target: { - value: newValue - } - }, - param, - 'value' - )} - onRun={handleRun} - allowNewlines={true} - collection={collection} - item={item} - /> - )} - - - handleParamChange( - { - target: { - value: newValue - } - }, - param, - 'contentType' - )} - onRun={handleRun} - collection={collection} - /> - -
- handleParamChange(e, param, 'enabled')} - /> - -
-
-
- -
-
- -
+
); }; + export default MultipartFormParams; diff --git a/packages/bruno-app/src/components/RequestPane/QueryParams/index.js b/packages/bruno-app/src/components/RequestPane/QueryParams/index.js index f02ec98d0..50ff9c03f 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryParams/index.js +++ b/packages/bruno-app/src/components/RequestPane/QueryParams/index.js @@ -1,24 +1,17 @@ -import React, { useState } from 'react'; +import React, { useState, useCallback } from 'react'; import get from 'lodash/get'; -import cloneDeep from 'lodash/cloneDeep'; import InfoTip from 'components/InfoTip'; -import { IconTrash } from '@tabler/icons'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { useTheme } from 'providers/Theme'; import { - addQueryParam, - updateQueryParam, - deleteQueryParam, moveQueryParam, updatePathParam, setQueryParams } from 'providers/ReduxStore/slices/collections'; import MultiLineEditor from 'components/MultiLineEditor'; import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions'; - +import EditableTable from 'components/EditableTable'; import StyledWrapper from './StyledWrapper'; -import Table from 'components/Table/index'; -import ReorderTable from 'components/ReorderTable'; import BulkEditor from '../../BulkEditor'; const QueryParams = ({ item, collection }) => { @@ -30,100 +23,100 @@ const QueryParams = ({ item, collection }) => { const [isBulkEditMode, setIsBulkEditMode] = useState(false); - const handleAddQueryParam = () => { - dispatch( - addQueryParam({ - itemUid: item.uid, - collectionUid: collection.uid - }) - ); - }; - const onSave = () => dispatch(saveRequest(item.uid, collection.uid)); const handleRun = () => dispatch(sendRequest(item, collection.uid)); - const handleQueryParamChange = (e, data, key) => { - let value; + const handleQueryParamsChange = useCallback((updatedParams) => { + const paramsWithType = updatedParams.map((p) => ({ ...p, type: 'query' })); + dispatch(setQueryParams({ + collectionUid: collection.uid, + itemUid: item.uid, + params: paramsWithType + })); + }, [dispatch, collection.uid, item.uid]); - switch (key) { - case 'name': { - value = e.target.value; - break; - } - case 'value': { - value = e.target.value; - break; - } - case 'enabled': { - value = e.target.checked; - break; - } - } - - let queryParam = cloneDeep(data); - - if (queryParam[key] === value) { - return; - } - - queryParam[key] = value; - - dispatch( - updateQueryParam({ - queryParam, + const handlePathParamChange = useCallback((rowUid, key, value) => { + const pathParam = pathParams.find((p) => p.uid === rowUid); + if (pathParam) { + dispatch(updatePathParam({ + pathParam: { ...pathParam, [key]: value }, itemUid: item.uid, collectionUid: collection.uid - }) - ); - }; - - const handlePathParamChange = (e, data) => { - let value = e.target.value; - - let pathParam = cloneDeep(data); - - if (pathParam['value'] === value) { - return; + })); } + }, [dispatch, pathParams, item.uid, collection.uid]); - pathParam['value'] = value; - - dispatch( - updatePathParam({ - pathParam, - itemUid: item.uid, - collectionUid: collection.uid - }) - ); - }; - - const handleRemoveQueryParam = (param) => { - dispatch( - deleteQueryParam({ - paramUid: param.uid, - itemUid: item.uid, - collectionUid: collection.uid - }) - ); - }; - - const handleQueryParamDrag = ({ updateReorderedItem }) => { - dispatch( - moveQueryParam({ - collectionUid: collection.uid, - itemUid: item.uid, - updateReorderedItem - }) - ); - }; + const handleQueryParamDrag = useCallback(({ updateReorderedItem }) => { + dispatch(moveQueryParam({ + collectionUid: collection.uid, + itemUid: item.uid, + updateReorderedItem + })); + }, [dispatch, collection.uid, item.uid]); const toggleBulkEditMode = () => { setIsBulkEditMode(!isBulkEditMode); }; - const handleBulkParamsChange = (newParams) => { - const paramsWithType = newParams.map((item) => ({ ...item, type: 'query' })); - dispatch(setQueryParams({ collectionUid: collection.uid, itemUid: item.uid, params: paramsWithType })); + const queryColumns = [ + { + key: 'name', + name: 'Name', + isKeyField: true, + placeholder: 'Name', + width: '30%' + }, + { + key: 'value', + name: 'Value', + placeholder: 'Value', + render: ({ row, value, onChange, isLastEmptyRow }) => ( + + ) + } + ]; + + const pathColumns = [ + { + key: 'name', + name: 'Name', + isKeyField: true, + width: '30%', + readOnly: true + }, + { + key: 'value', + name: 'Value', + placeholder: 'Value', + render: ({ row, value, onChange }) => ( + handlePathParamChange(row.uid, 'value', newValue)} + onRun={handleRun} + collection={collection} + item={item} + /> + ) + } + ]; + + const defaultQueryRow = { + name: '', + value: '', + description: '', + type: 'query' }; if (isBulkEditMode) { @@ -131,7 +124,7 @@ const QueryParams = ({ item, collection }) => { {
Query
- - - {queryParams && queryParams.length - ? queryParams.map((param, index) => ( - - - - - - )) - : null} - -
- handleQueryParamChange(e, param, 'name')} - /> - - handleQueryParamChange({ target: { value: newValue } }, param, 'value')} - onRun={handleRun} - collection={collection} - item={item} - variablesAutocomplete={true} - /> - -
- handleQueryParamChange(e, param, 'enabled')} - /> - -
-
- -
- + +
+
Path @@ -220,58 +164,22 @@ const QueryParams = ({ item, collection }) => {
- - - - - - - - - {pathParams && pathParams.length - ? pathParams.map((path, index) => { - return ( - - - - - ); - }) - : null} - -
NameValue
- - - - handlePathParamChange( - { - target: { - value: newValue - } - }, - path - )} - onRun={handleRun} - collection={collection} - item={item} - /> -
- {!(pathParams && pathParams.length) ?
: null} + {pathParams && pathParams.length > 0 ? ( + {}} + defaultRow={{}} + showCheckbox={false} + showDelete={false} + showAddRow={false} + /> + ) : ( +
+ )}
); }; + export default QueryParams; diff --git a/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js b/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js index 82f1ffb48..c190f7238 100644 --- a/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js +++ b/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js @@ -1,17 +1,14 @@ -import React, { useState } from 'react'; +import React, { useState, useCallback } from 'react'; import get from 'lodash/get'; -import cloneDeep from 'lodash/cloneDeep'; -import { IconTrash } from '@tabler/icons'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { useTheme } from 'providers/Theme'; -import { addRequestHeader, updateRequestHeader, deleteRequestHeader, moveRequestHeader, setRequestHeaders } from 'providers/ReduxStore/slices/collections'; +import { moveRequestHeader, setRequestHeaders } from 'providers/ReduxStore/slices/collections'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import SingleLineEditor from 'components/SingleLineEditor'; +import EditableTable from 'components/EditableTable'; import StyledWrapper from './StyledWrapper'; import { headers as StandardHTTPHeaders } from 'know-your-http-well'; import { MimeTypes } from 'utils/codemirror/autocompleteConstants'; -import Table from 'components/Table/index'; -import ReorderTable from 'components/ReorderTable/index'; import BulkEditor from '../../BulkEditor'; const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header); @@ -20,74 +17,76 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); const headers = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers'); - const [isBulkEditMode, setIsBulkEditMode] = useState(false); - const addHeader = () => { - dispatch( - addRequestHeader({ - itemUid: item.uid, - collectionUid: collection.uid - }) - ); - }; - const onSave = () => dispatch(saveRequest(item.uid, collection.uid)); const handleRun = () => dispatch(sendRequest(item, collection.uid)); - const handleHeaderValueChange = (e, _header, type) => { - const header = cloneDeep(_header); - switch (type) { - case 'name': { - // Strip newlines from header keys - header.name = e.target.value.replace(/[\r\n]/g, ''); - break; - } - case 'value': { - header.value = e.target.value; - break; - } - case 'enabled': { - header.enabled = e.target.checked; - break; - } - } + const handleHeadersChange = useCallback((updatedHeaders) => { + dispatch(setRequestHeaders({ + collectionUid: collection.uid, + itemUid: item.uid, + headers: updatedHeaders + })); + }, [dispatch, collection.uid, item.uid]); - dispatch( - updateRequestHeader({ - header: header, - itemUid: item.uid, - collectionUid: collection.uid - }) - ); - }; - - const handleRemoveHeader = (header) => { - dispatch( - deleteRequestHeader({ - headerUid: header.uid, - itemUid: item.uid, - collectionUid: collection.uid - }) - ); - }; - - const handleHeaderDrag = ({ updateReorderedItem }) => { - dispatch( - moveRequestHeader({ - collectionUid: collection.uid, - itemUid: item.uid, - updateReorderedItem - }) - ); - }; + const handleHeaderDrag = useCallback(({ updateReorderedItem }) => { + dispatch(moveRequestHeader({ + collectionUid: collection.uid, + itemUid: item.uid, + updateReorderedItem + })); + }, [dispatch, collection.uid, item.uid]); const toggleBulkEditMode = () => { setIsBulkEditMode(!isBulkEditMode); }; - const handleBulkHeadersChange = (newHeaders) => { - dispatch(setRequestHeaders({ collectionUid: collection.uid, itemUid: item.uid, headers: newHeaders })); + const columns = [ + { + key: 'name', + name: 'Name', + isKeyField: true, + placeholder: 'Name', + width: '30%', + render: ({ row, value, onChange, isLastEmptyRow }) => ( + onChange(newValue.replace(/[\r\n]/g, ''))} + autocomplete={headerAutoCompleteList} + onRun={handleRun} + collection={collection} + item={item} + placeholder={isLastEmptyRow ? 'Name' : ''} + /> + ) + }, + { + key: 'value', + name: 'Value', + placeholder: 'Value', + render: ({ row, value, onChange, isLastEmptyRow }) => ( + + ) + } + ]; + + const defaultRow = { + name: '', + value: '', + description: '' }; if (isBulkEditMode) { @@ -95,7 +94,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => { { return ( - - - {headers && headers.length - ? headers.map((header) => { - return ( - - - - - - ); - }) - : null} - -
- - handleHeaderValueChange( - { - target: { - value: newValue - } - }, - header, - 'name' - )} - autocomplete={headerAutoCompleteList} - onRun={handleRun} - collection={collection} - /> - - - handleHeaderValueChange( - { - target: { - value: newValue - } - }, - header, - 'value' - )} - onRun={handleRun} - autocomplete={MimeTypes} - collection={collection} - item={item} - /> - -
- handleHeaderValueChange(e, header, 'enabled')} - /> - -
-
-
- + +
@@ -190,4 +121,5 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => { ); }; + export default RequestHeaders; diff --git a/packages/bruno-app/src/components/RequestPane/Vars/VarsTable/index.js b/packages/bruno-app/src/components/RequestPane/Vars/VarsTable/index.js index d19c7c859..df9534a8c 100644 --- a/packages/bruno-app/src/components/RequestPane/Vars/VarsTable/index.js +++ b/packages/bruno-app/src/components/RequestPane/Vars/VarsTable/index.js @@ -1,170 +1,100 @@ -import React from 'react'; -import cloneDeep from 'lodash/cloneDeep'; -import { IconTrash } from '@tabler/icons'; +import React, { useCallback } from 'react'; import { useDispatch } from 'react-redux'; import { useTheme } from 'providers/Theme'; -import { addVar, updateVar, deleteVar, moveVar } from 'providers/ReduxStore/slices/collections'; +import { moveVar, setRequestVars } from 'providers/ReduxStore/slices/collections'; import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import MultiLineEditor from 'components/MultiLineEditor'; import InfoTip from 'components/InfoTip'; +import EditableTable from 'components/EditableTable'; import StyledWrapper from './StyledWrapper'; import toast from 'react-hot-toast'; import { variableNameRegex } from 'utils/common/regex'; -import Table from 'components/Table/index'; -import ReorderTable from 'components/ReorderTable/index'; const VarsTable = ({ item, collection, vars, varType }) => { const dispatch = useDispatch(); const { storedTheme } = useTheme(); - const handleAddVar = () => { - dispatch( - addVar({ - type: varType, - itemUid: item.uid, - collectionUid: collection.uid - }) - ); - }; - const onSave = () => dispatch(saveRequest(item.uid, collection.uid)); const handleRun = () => dispatch(sendRequest(item, collection.uid)); - const handleVarChange = (e, v, type) => { - const _var = cloneDeep(v); - switch (type) { - case 'name': { - const value = e.target.value; - if (variableNameRegex.test(value) === false) { - toast.error( - 'Variable contains invalid characters! Variables must only contain alpha-numeric characters, "-", "_", "."' - ); - return; - } + const handleVarsChange = useCallback((updatedVars) => { + dispatch(setRequestVars({ + collectionUid: collection.uid, + itemUid: item.uid, + vars: updatedVars, + type: varType + })); + }, [dispatch, collection.uid, item.uid, varType]); - _var.name = value; - break; - } - case 'value': { - _var.value = e.target.value; - break; - } - case 'enabled': { - _var.enabled = e.target.checked; - break; - } + const handleVarDrag = useCallback(({ updateReorderedItem }) => { + dispatch(moveVar({ + type: varType, + collectionUid: collection.uid, + itemUid: item.uid, + updateReorderedItem + })); + }, [dispatch, varType, collection.uid, item.uid]); + + const getRowError = useCallback((row, index, key) => { + if (key !== 'name') return null; + if (!row.name || row.name.trim() === '') return null; + if (!variableNameRegex.test(row.name)) { + return 'Variable contains invalid characters. Must only contain alphanumeric characters, "-", "_", "."'; } - dispatch( - updateVar({ - type: varType, - var: _var, - itemUid: item.uid, - collectionUid: collection.uid - }) - ); - }; + return null; + }, []); - const handleRemoveVar = (_var) => { - dispatch( - deleteVar({ - type: varType, - varUid: _var.uid, - itemUid: item.uid, - collectionUid: collection.uid - }) - ); - }; + const columns = [ + { + key: 'name', + name: 'Name', + isKeyField: true, + placeholder: 'Name', + width: '35%' + }, + { + key: 'value', + name: varType === 'request' ? 'Value' : ( +
+ Expr + +
+ ), + placeholder: varType === 'request' ? 'Value' : 'Expr', + render: ({ row, value, onChange, isLastEmptyRow }) => ( + + ) + } + ]; - const handleVarDrag = ({ updateReorderedItem }) => { - dispatch( - moveVar({ - type: varType, - collectionUid: collection.uid, - itemUid: item.uid, - updateReorderedItem - }) - ); + const defaultRow = { + name: '', + value: '', + ...(varType === 'response' ? { local: false } : {}) }; return ( - - Value - - ) : ( -
- Expr - -
- ), accessor: 'value', width: '46%' }, - { name: '', accessor: '', width: '14%' } - ]} - > - - {vars && vars.length - ? vars.map((_var) => { - return ( - - - - - - ); - }) - : null} - -
- handleVarChange(e, _var, 'name')} - /> - - - handleVarChange( - { - target: { - value: newValue - } - }, - _var, - 'value' - )} - onRun={handleRun} - collection={collection} - item={item} - /> - -
- handleVarChange(e, _var, 'enabled')} - /> - -
-
- +
); }; + export default VarsTable; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js index 88d57a40f..376337c5a 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/StyledWrapper.js @@ -18,11 +18,14 @@ const Wrapper = styled.div` table-layout: fixed; font-size: 12px; - thead, td { - padding: 4px 12px; + vertical-align: middle; + padding: 2px 10px; - &:nth-child(1), + &:nth-child(1) { + width: 25px; + border-right: none; + } &:nth-child(4) { width: 80px; } @@ -36,19 +39,15 @@ const Wrapper = styled.div` } thead { - color: ${(props) => props.theme.colors.text.muted}; + color: ${(props) => props.theme.colors.text}; background: ${(props) => props.theme.sidebar.bg}; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.3px; + font-size: ${(props) => props.theme.font.size.base}; user-select: none; td { padding: 8px 10px; border-bottom: ${(props) => props.theme.workspace.environments.indentBorder}; border-right: ${(props) => props.theme.workspace.environments.indentBorder}; - font-weight: 600; &:last-child { border-right: none; @@ -60,10 +59,6 @@ const Wrapper = styled.div` tr { transition: background 0.1s ease; - &:hover { - background: ${(props) => props.theme.sidebar.bg}; - } - &:last-child td { border-bottom: none; } @@ -111,7 +106,7 @@ const Wrapper = styled.div` outline: none !important; background-color: transparent; color: ${(props) => props.theme.text}; - padding: 5px 8px; + padding: 0; font-size: 12px; border-radius: 4px; transition: all 0.15s ease; @@ -126,6 +121,8 @@ const Wrapper = styled.div` width: 14px; height: 14px; accent-color: ${(props) => props.theme.workspace.accent}; + vertical-align: middle; + margin: 0; } button { diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js index e173b765c..515d9f1fc 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js @@ -231,7 +231,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV - + diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/StyledWrapper.js new file mode 100644 index 000000000..d4b26793d --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/StyledWrapper.js @@ -0,0 +1,133 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + background: ${(props) => props.theme.bg}; + + .header { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px 8px 20px; + flex-shrink: 0; + + .title { + font-size: 13px; + font-weight: 600; + color: ${(props) => props.theme.text}; + margin: 0; + } + + .title-container { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + + &.renaming { + .title-input { + flex: 1; + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + outline: none; + color: ${(props) => props.theme.text}; + font-size: 15px; + font-weight: 600; + padding: 4px 8px; + border-radius: 5px; + } + + .inline-actions { + display: flex; + gap: 2px; + } + + .inline-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + padding: 0; + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + transition: all 0.15s ease; + + &.save { + color: ${(props) => props.theme.textLink}; + + &:hover { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + } + } + + &.cancel { + color: ${(props) => props.theme.colors.text.muted}; + + &:hover { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + color: ${(props) => props.theme.text}; + } + } + } + } + } + + .title-error { + position: absolute; + top: 100%; + left: 0; + margin-top: 4px; + padding: 4px 8px; + font-size: 11px; + color: ${(props) => props.theme.colors.text.danger}; + background: ${(props) => `${props.theme.colors.text.danger}15`}; + border-radius: 4px; + white-space: nowrap; + } + + .actions { + display: flex; + gap: 2px; + + button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + color: ${(props) => props.theme.colors.text.muted}; + background: transparent; + border: none; + border-radius: 5px; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + color: ${(props) => props.theme.text}; + } + + &:last-child:hover { + color: ${(props) => props.theme.colors.text.danger}; + } + } + } + } + + .content { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; + padding: 0 20px 20px 20px; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/index.js index 230457af6..362e33ac6 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/EnvironmentDetails/index.js @@ -4,140 +4,10 @@ import { useDispatch, useSelector } from 'react-redux'; import { renameGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments'; import { validateName, validateNameError } from 'utils/common/regex'; import toast from 'react-hot-toast'; -import styled from 'styled-components'; import CopyEnvironment from '../../CopyEnvironment'; import DeleteEnvironment from '../../DeleteEnvironment'; import EnvironmentVariables from './EnvironmentVariables'; - -const StyledWrapper = styled.div` - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - background: ${(props) => props.theme.bg}; - - .header { - position: relative; - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 20px 12px 20px; - flex-shrink: 0; - - .title { - font-size: 15px; - font-weight: 600; - color: ${(props) => props.theme.text}; - margin: 0; - } - - .title-container { - display: flex; - align-items: center; - gap: 8px; - flex: 1; - - &.renaming { - .title-input { - flex: 1; - background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; - outline: none; - color: ${(props) => props.theme.text}; - font-size: 15px; - font-weight: 600; - padding: 4px 8px; - border-radius: 5px; - } - - .inline-actions { - display: flex; - gap: 2px; - } - - .inline-action-btn { - display: flex; - align-items: center; - justify-content: center; - width: 26px; - height: 26px; - padding: 0; - background: transparent; - border: none; - border-radius: 4px; - cursor: pointer; - transition: all 0.15s ease; - - &.save { - color: ${(props) => props.theme.textLink}; - - &:hover { - background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; - } - } - - &.cancel { - color: ${(props) => props.theme.colors.text.muted}; - - &:hover { - background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; - color: ${(props) => props.theme.text}; - } - } - } - } - } - - .title-error { - position: absolute; - top: 100%; - left: 0; - margin-top: 4px; - padding: 4px 8px; - font-size: 11px; - color: ${(props) => props.theme.colors.text.danger}; - background: ${(props) => `${props.theme.colors.text.danger}15`}; - border-radius: 4px; - white-space: nowrap; - } - - .actions { - display: flex; - gap: 2px; - - button { - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - padding: 0; - color: ${(props) => props.theme.colors.text.muted}; - background: transparent; - border: none; - border-radius: 5px; - cursor: pointer; - transition: all 0.15s ease; - - &:hover { - background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; - color: ${(props) => props.theme.text}; - } - - &:last-child:hover { - color: ${(props) => props.theme.colors.text.danger}; - } - } - } - } - - .content { - flex: 1; - overflow: hidden; - display: flex; - flex-direction: column; - padding: 0 20px 20px 20px; - } -`; +import StyledWrapper from './StyledWrapper'; const EnvironmentDetails = ({ environment, setIsModified, collection }) => { const dispatch = useDispatch(); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index 233c8b7de..70cc7e20b 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -939,12 +939,12 @@ export const collectionsSlice = createSlice({ item.draft = cloneDeep(item); } const existingOtherParams = item.draft.request.params?.filter((p) => p.type !== 'query') || []; - const newQueryParams = map(params, ({ name = '', value = '', enabled = true }) => ({ - uid: uuid(), + const newQueryParams = map(params, ({ uid, name = '', value = '', description = '', type = 'query', enabled = true }) => ({ + uid: uid || uuid(), name, value, - description: '', - type: 'query', + description, + type, enabled })); @@ -1185,12 +1185,12 @@ export const collectionsSlice = createSlice({ if (!item.draft) { item.draft = cloneDeep(item); } - item.draft.request.headers = map(action.payload.headers, ({ name = '', value = '', enabled = true }) => ({ - uid: uuid(), - name: name, - value: value, - description: '', - enabled: enabled + item.draft.request.headers = map(action.payload.headers, ({ uid, name = '', value = '', description = '', enabled = true }) => ({ + uid: uid || uuid(), + name, + value, + description, + enabled })); }, setCollectionHeaders: (state, action) => { @@ -1203,16 +1203,22 @@ export const collectionsSlice = createSlice({ if (!collection.draft) { collection.draft = { - root: cloneDeep(collection.root) + root: cloneDeep(collection.root) || {} }; } + if (!collection.draft.root) { + collection.draft.root = {}; + } + if (!collection.draft.root.request) { + collection.draft.root.request = {}; + } - collection.draft.root.request.headers = map(headers, ({ name = '', value = '', enabled = true }) => ({ - uid: uuid(), - name: name, - value: value, - description: '', - enabled: enabled + collection.draft.root.request.headers = map(headers, ({ uid, name = '', value = '', description = '', enabled = true }) => ({ + uid: uid || uuid(), + name, + value, + description, + enabled })); }, setFolderHeaders: (state, action) => { @@ -1228,12 +1234,18 @@ export const collectionsSlice = createSlice({ return; } - folder.root.request.headers = map(headers, ({ name = '', value = '', enabled = true }) => ({ - uid: uuid(), - name: name, - value: value, - description: '', - enabled: enabled + if (!folder.draft) { + folder.draft = cloneDeep(folder.root) || {}; + } + if (!folder.draft.request) { + folder.draft.request = {}; + } + folder.draft.request.headers = map(headers, ({ uid, name = '', value = '', description = '', enabled = true }) => ({ + uid: uid || uuid(), + name, + value, + description, + enabled })); }, addFormUrlEncodedParam: (state, action) => { @@ -1294,6 +1306,25 @@ export const collectionsSlice = createSlice({ } } }, + setFormUrlEncodedParams: (state, action) => { + const { collectionUid, itemUid, params } = action.payload; + const collection = findCollectionByUid(state.collections, collectionUid); + if (!collection) return; + + const item = findItemInCollection(collection, itemUid); + if (!item || !isItemARequest(item)) return; + + if (!item.draft) { + item.draft = cloneDeep(item); + } + item.draft.request.body.formUrlEncoded = map(params, ({ uid, name = '', value = '', description = '', enabled = true }) => ({ + uid: uid || uuid(), + name, + value, + description, + enabled + })); + }, moveFormUrlEncodedParam: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); @@ -1301,12 +1332,10 @@ export const collectionsSlice = createSlice({ const item = findItemInCollection(collection, action.payload.itemUid); if (item && isItemARequest(item)) { - // Ensure item.draft is a deep clone of item if not already present if (!item.draft) { item.draft = cloneDeep(item); } - // Extract payload data const { updateReorderedItem } = action.payload; const params = item.draft.request.body.formUrlEncoded; @@ -1378,6 +1407,26 @@ export const collectionsSlice = createSlice({ } } }, + setMultipartFormParams: (state, action) => { + const { collectionUid, itemUid, params } = action.payload; + const collection = findCollectionByUid(state.collections, collectionUid); + if (!collection) return; + + const item = findItemInCollection(collection, itemUid); + if (!item || !isItemARequest(item)) return; + + if (!item.draft) { + item.draft = cloneDeep(item); + } + item.draft.request.body.multipartForm = map(params, ({ uid, name = '', value = '', contentType = '', type = 'text', enabled = true }) => ({ + uid: uid || uuid(), + name, + value, + contentType, + type, + enabled + })); + }, moveMultipartFormParam: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); @@ -1385,12 +1434,10 @@ export const collectionsSlice = createSlice({ const item = findItemInCollection(collection, action.payload.itemUid); if (item && isItemARequest(item)) { - // Ensure item.draft is a deep clone of item if not already present if (!item.draft) { item.draft = cloneDeep(item); } - // Extract payload data const { updateReorderedItem } = action.payload; const params = item.draft.request.body.multipartForm; @@ -1711,6 +1758,25 @@ export const collectionsSlice = createSlice({ } } }, + setRequestAssertions: (state, action) => { + const { collectionUid, itemUid, assertions } = action.payload; + const collection = findCollectionByUid(state.collections, collectionUid); + if (!collection) return; + + const item = findItemInCollection(collection, itemUid); + if (!item || !isItemARequest(item)) return; + + if (!item.draft) { + item.draft = cloneDeep(item); + } + item.draft.request.assertions = map(assertions, ({ uid, name = '', value = '', operator = 'eq', enabled = true }) => ({ + uid: uid || uuid(), + name, + value, + operator, + enabled + })); + }, moveAssertion: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); @@ -1718,12 +1784,10 @@ export const collectionsSlice = createSlice({ const item = findItemInCollection(collection, action.payload.itemUid); if (item && isItemARequest(item)) { - // Ensure item.draft is a deep clone of item if not already present if (!item.draft) { item.draft = cloneDeep(item); } - // Extract payload data const { updateReorderedItem } = action.payload; const params = item.draft.request.assertions; @@ -1828,6 +1892,31 @@ export const collectionsSlice = createSlice({ } } }, + setRequestVars: (state, action) => { + const { collectionUid, itemUid, vars, type } = action.payload; + const collection = findCollectionByUid(state.collections, collectionUid); + if (!collection) return; + + const item = findItemInCollection(collection, itemUid); + if (!item || !isItemARequest(item)) return; + + if (!item.draft) { + item.draft = cloneDeep(item); + } + item.draft.request.vars = item.draft.request.vars || {}; + const mappedVars = map(vars, ({ uid, name = '', value = '', enabled = true, local = false }) => ({ + uid: uid || uuid(), + name, + value, + enabled, + ...(type === 'response' ? { local } : {}) + })); + if (type === 'request') { + item.draft.request.vars.req = mappedVars; + } else if (type === 'response') { + item.draft.request.vars.res = mappedVars; + } + }, moveVar: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); const type = action.payload.type; @@ -2134,6 +2223,29 @@ export const collectionsSlice = createSlice({ } } }, + setFolderVars: (state, action) => { + const { collectionUid, folderUid, vars, type } = action.payload; + const collection = findCollectionByUid(state.collections, collectionUid); + const folder = collection ? findItemInCollection(collection, folderUid) : null; + if (!folder) { + return; + } + if (!folder.draft) { + folder.draft = cloneDeep(folder.root); + } + const mappedVars = map(vars, ({ uid, name = '', value = '', enabled = true, local = false }) => ({ + uid: uid || uuid(), + name, + value, + enabled, + ...(type === 'response' ? { local } : {}) + })); + if (type === 'request') { + set(folder, 'draft.request.vars.req', mappedVars); + } else if (type === 'response') { + set(folder, 'draft.request.vars.res', mappedVars); + } + }, updateFolderRequestScript: (state, action) => { const collection = findCollectionByUid(state.collections, action.payload.collectionUid); const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null; @@ -2345,6 +2457,30 @@ export const collectionsSlice = createSlice({ } } }, + setCollectionVars: (state, action) => { + const { collectionUid, vars, type } = action.payload; + const collection = findCollectionByUid(state.collections, collectionUid); + if (!collection) { + return; + } + if (!collection.draft) { + collection.draft = { + root: cloneDeep(collection.root) + }; + } + const mappedVars = map(vars, ({ uid, name = '', value = '', enabled = true, local = false }) => ({ + uid: uid || uuid(), + name, + value, + enabled, + ...(type === 'response' ? { local } : {}) + })); + if (type === 'request') { + set(collection, 'draft.root.request.vars.req', mappedVars); + } else if (type === 'response') { + set(collection, 'draft.root.request.vars.res', mappedVars); + } + }, collectionAddFileEvent: (state, action) => { const file = action.payload.file; const isCollectionRoot = file.meta.collectionRoot ? true : false; @@ -3259,10 +3395,12 @@ export const { addFormUrlEncodedParam, updateFormUrlEncodedParam, deleteFormUrlEncodedParam, + setFormUrlEncodedParams, moveFormUrlEncodedParam, addMultipartFormParam, updateMultipartFormParam, deleteMultipartFormParam, + setMultipartFormParams, addFile, updateFile, deleteFile, @@ -3280,10 +3418,12 @@ export const { addAssertion, updateAssertion, deleteAssertion, + setRequestAssertions, moveAssertion, addVar, updateVar, deleteVar, + setRequestVars, moveVar, addFolderHeader, updateFolderHeader, @@ -3291,6 +3431,7 @@ export const { addFolderVar, updateFolderVar, deleteFolderVar, + setFolderVars, updateFolderRequestScript, updateFolderResponseScript, updateFolderTests, @@ -3300,6 +3441,7 @@ export const { addCollectionVar, updateCollectionVar, deleteCollectionVar, + setCollectionVars, updateCollectionAuthMode, updateCollectionAuth, updateCollectionRequestScript, diff --git a/tests/collection/create/create-collection.spec.ts b/tests/collection/create/create-collection.spec.ts index 0e31277f1..ca7ff58d3 100644 --- a/tests/collection/create/create-collection.spec.ts +++ b/tests/collection/create/create-collection.spec.ts @@ -29,12 +29,12 @@ test.describe('Create collection', () => { // Set the URL await page.locator('#request-url .CodeMirror').click(); - await page.locator('textarea').fill('http://localhost:8081'); + await page.locator('#request-url').locator('textarea').fill('http://localhost:8081'); await page.locator('#send-request').getByTitle('Save Request').click(); // Send a request await page.locator('#request-url .CodeMirror').click(); - await page.locator('textarea').fill('/ping'); + await page.locator('#request-url').locator('textarea').fill('/ping'); await page.locator('#send-request').getByTitle('Save Request').click(); await page.locator('#send-request').getByRole('img').nth(2).click(); diff --git a/tests/collection/draft/draft-indicator.spec.ts b/tests/collection/draft/draft-indicator.spec.ts index 1d512e5b6..be293f154 100644 --- a/tests/collection/draft/draft-indicator.spec.ts +++ b/tests/collection/draft/draft-indicator.spec.ts @@ -24,23 +24,15 @@ test.describe('Draft indicator in collection and folder settings', () => { await expect(collectionTab.locator('.close-icon')).toBeVisible(); await expect(collectionTab.locator('.has-changes-icon')).not.toBeVisible(); - // Click on Headers tab await page.locator('.tab.headers').click(); - // Add a new header - await page.getByRole('button', { name: 'Add Header' }).click(); - - // Fill in header name and value in the table - // Target the table and get the first row's CodeMirror editors const headerTable = page.locator('table').first(); const headerRow = headerTable.locator('tbody tr').first(); - // Fill in the name field (first CodeMirror in the row) const nameEditor = headerRow.locator('.CodeMirror').first(); await nameEditor.click(); await page.keyboard.type('X-Custom-Header'); - // Fill in the value field (second CodeMirror in the row) const valueEditor = headerRow.locator('.CodeMirror').nth(1); await valueEditor.click(); await page.keyboard.type('custom-value'); @@ -196,25 +188,17 @@ test.describe('Draft indicator in collection and folder settings', () => { // Verify initially there is NO draft indicator await expect(collectionTab.locator('.close-icon')).toBeVisible(); - // Click on Vars tab await page.locator('.tab.vars').click(); - // Add a new variable in the Pre Request section - await page.locator('.btn-add-var').first().click(); - - // Fill in variable name and value in the table - // Target the vars table and get the first row const varsTable = page.locator('table').first(); const varRow = varsTable.locator('tbody tr').first(); - // Fill in the name field (regular input) const varNameInput = varRow.locator('input[type="text"]'); await varNameInput.click(); await varNameInput.fill('testVar'); - // Fill in the value field (CodeMirror editor) - const valueEditor = varRow.locator('.CodeMirror'); - await valueEditor.click(); + const varValueEditor = varRow.locator('.CodeMirror'); + await varValueEditor.click(); await page.keyboard.type('testValue'); // Verify draft indicator appears @@ -252,23 +236,16 @@ test.describe('Draft indicator in collection and folder settings', () => { const folderTab = page.locator('.request-tab').filter({ has: page.locator('.tab-label', { hasText: 'test-folder' }) }); await expect(folderTab).toBeVisible(); - // Verify initially there is NO draft indicator await expect(folderTab.locator('.close-icon')).toBeVisible(); await expect(folderTab.locator('.has-changes-icon')).not.toBeVisible(); - // Headers tab should be selected by default, add a new header - await page.getByRole('button', { name: 'Add Header' }).click(); - - // Fill in header name and value in the table const headerTable = page.locator('table').first(); const headerRow = headerTable.locator('tbody tr').first(); - // Fill in the name field (first CodeMirror in the row) const nameEditor = headerRow.locator('.CodeMirror').first(); await nameEditor.click(); await page.keyboard.type('X-Folder-Header'); - // Fill in the value field (second CodeMirror in the row) const valueEditor = headerRow.locator('.CodeMirror').nth(1); await valueEditor.click(); await page.keyboard.type('folder-value'); @@ -323,27 +300,19 @@ test.describe('Draft indicator in collection and folder settings', () => { const folderTab = page.locator('.request-tab').filter({ has: page.locator('.tab-label', { hasText: 'test-folder' }) }); await expect(folderTab).toBeVisible(); - // Verify initially no draft indicator await expect(folderTab.locator('.close-icon')).toBeVisible(); - // Click on Vars tab await page.locator('.tab.vars').click(); - // Add a new variable in the Pre Request section - await page.locator('.btn-add-var').first().click(); - - // Fill in variable name and value in the table const varsTable = page.locator('table').first(); const varRow = varsTable.locator('tbody tr').first(); - // Fill in the name field (regular input) const varNameInput = varRow.locator('input[type="text"]'); await varNameInput.click(); await varNameInput.fill('folderVar'); - // Fill in the value field (CodeMirror editor) - const valueEditor = varRow.locator('.CodeMirror'); - await valueEditor.click(); + const folderVarValueEditor = varRow.locator('.CodeMirror'); + await folderVarValueEditor.click(); await page.keyboard.type('folderValue'); // Verify draft indicator appears diff --git a/tests/collection/draft/draft-values-in-requests.spec.ts b/tests/collection/draft/draft-values-in-requests.spec.ts index 5fd7c0950..225d82424 100644 --- a/tests/collection/draft/draft-values-in-requests.spec.ts +++ b/tests/collection/draft/draft-values-in-requests.spec.ts @@ -17,23 +17,15 @@ test.describe('Draft values are used in requests', () => { // Verify the collection settings tab is open await expect(page.locator('.request-tab .tab-label').filter({ hasText: 'Collection' })).toBeVisible(); - // Add collection header in draft (unsaved) - // Click on Headers tab await page.locator('.tab.headers').click(); - // Add a new header - await page.getByRole('button', { name: 'Add Header' }).click(); - - // Fill in header name and value const headerTable = page.locator('table').first(); const headerRow = headerTable.locator('tbody tr').first(); - // Fill in the name field const nameEditor = headerRow.locator('.CodeMirror').first(); await nameEditor.click(); await page.keyboard.type('X-Draft-Header'); - // Fill in the value field const valueEditor = headerRow.locator('.CodeMirror').nth(1); await valueEditor.click(); await page.keyboard.type('draft-value-123'); @@ -52,17 +44,18 @@ test.describe('Draft values are used in requests', () => { await page.getByRole('button', { name: 'Create', exact: true }).click(); await page.locator('.collection-item-name').filter({ hasText: 'Test Folder' }).click(); - // Wait for the folder to be created await expect(page.locator('.collection-item-name').filter({ hasText: 'Test Folder' })).toBeVisible(); const folder = page.locator('.collection-item-name').filter({ hasText: 'Test Folder' }); - // Add a header to the folder - await page.getByRole('button', { name: 'Add Header' }).click(); + const folderHeaderTable = page.locator('table').first(); + const folderHeaderRow = folderHeaderTable.locator('tbody tr').first(); - await nameEditor.click(); + const folderNameEditor = folderHeaderRow.locator('.CodeMirror').first(); + await folderNameEditor.click(); await page.keyboard.type('X-Folder-Draft-Header'); - await valueEditor.click(); + const folderValueEditor = folderHeaderRow.locator('.CodeMirror').nth(1); + await folderValueEditor.click(); await page.keyboard.type('folder-draft-value-123'); // Create a request in the collection diff --git a/tests/collection/moving-requests/cross-collection-drag-drop-request.spec.ts b/tests/collection/moving-requests/cross-collection-drag-drop-request.spec.ts index 47120acad..d7a8e4cdf 100644 --- a/tests/collection/moving-requests/cross-collection-drag-drop-request.spec.ts +++ b/tests/collection/moving-requests/cross-collection-drag-drop-request.spec.ts @@ -16,7 +16,7 @@ test.describe('Cross-Collection Drag and Drop', () => { // Set the URL await page.locator('#request-url .CodeMirror').click(); - await page.locator('textarea').fill('https://echo.usebruno.com'); + await page.locator('#request-url').locator('textarea').fill('https://echo.usebruno.com'); await page.locator('#send-request').getByTitle('Save Request').click(); await expect(page.locator('.item-name').filter({ hasText: /^Untitled/ })).toBeVisible(); @@ -73,7 +73,7 @@ test.describe('Cross-Collection Drag and Drop', () => { // Set the URL await page.locator('#request-url .CodeMirror').click(); - await page.locator('textarea').fill('https://echo.usebruno.com'); + await page.locator('#request-url').locator('textarea').fill('https://echo.usebruno.com'); await page.locator('#send-request').getByTitle('Save Request').click(); // check if untitled request is created and visible in sidebar @@ -87,7 +87,7 @@ test.describe('Cross-Collection Drag and Drop', () => { // Set the URL await page.locator('#request-url .CodeMirror').click(); - await page.locator('textarea').fill('https://echo.usebruno.com'); + await page.locator('#request-url').locator('textarea').fill('https://echo.usebruno.com'); await page.locator('#send-request').getByTitle('Save Request').click(); // Go back to source collection to drag the request diff --git a/tests/collection/moving-tabs/move-tabs.spec.ts b/tests/collection/moving-tabs/move-tabs.spec.ts index 0f46caa20..aa3938584 100644 --- a/tests/collection/moving-tabs/move-tabs.spec.ts +++ b/tests/collection/moving-tabs/move-tabs.spec.ts @@ -50,7 +50,7 @@ test.describe('Move tabs', () => { await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).click(); await page.getByPlaceholder('Request Name').fill('test-request'); await page.locator('#new-request-url .CodeMirror').click(); - await page.locator('textarea').fill('https://echo.usebruno.com'); + await page.locator('#new-request-url textarea').fill('https://echo.usebruno.com'); await page.getByRole('button', { name: 'Create' }).click(); // Wait for the request to be created @@ -141,7 +141,7 @@ test.describe('Move tabs', () => { await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).click(); await page.getByPlaceholder('Request Name').fill('test-request'); await page.locator('#new-request-url .CodeMirror').click(); - await page.locator('textarea').fill('https://echo.usebruno.com'); + await page.locator('#new-request-url textarea').fill('https://echo.usebruno.com'); await page.getByRole('button', { name: 'Create' }).click(); // Wait for the request to be created diff --git a/tests/request/newlines/newlines-persistence.spec.ts b/tests/request/newlines/newlines-persistence.spec.ts index 4184234c6..e36e5dfbc 100644 --- a/tests/request/newlines/newlines-persistence.spec.ts +++ b/tests/request/newlines/newlines-persistence.spec.ts @@ -28,35 +28,25 @@ test('should persist request with newlines across app restarts', async ({ create await openCollectionAndAcceptSandbox(page, 'newlines-persistence', 'safe'); await page.locator('.collection-item-name').filter({ hasText: 'persistence-test' }).dblclick(); - // Add query param await page.getByRole('tab', { name: 'Params' }).click(); - await page.getByRole('button', { name: /Add.*Param/i }).click(); + const paramRow = page.locator('table tbody tr').first(); + await getTableCell(paramRow, 0).getByRole('textbox').fill('queryParamKey'); - const paramRow = page.locator('table tbody tr').last(); - await getTableCell(paramRow, 0).locator('input[type="text"]').fill('queryParamKey'); - - // Add header with newlines await page.getByRole('tab', { name: 'Headers' }).click(); - await page.getByRole('button', { name: /Add.*Header/i }).click(); - - const headerRow = page.locator('table tbody tr').last(); + const headerRow = page.locator('table tbody tr').first(); await getTableCell(headerRow, 0).locator('.CodeMirror').click(); await getTableCell(headerRow, 0).locator('textarea').fill('headerKey'); await getTableCell(headerRow, 1).locator('.CodeMirror').click(); await getTableCell(headerRow, 1).locator('textarea').fill('header\nValue'); - // Add Pre Request var with newlines await page.getByRole('tab', { name: 'Vars' }).click(); - await page.locator('.btn-add-var').first().click(); const preReqRow = page.locator('table').first().locator('tbody tr').first(); - await getTableCell(preReqRow, 0).locator('input[type="text"]').fill('preRequestVar'); + await getTableCell(preReqRow, 0).getByRole('textbox').fill('preRequestVar'); await getTableCell(preReqRow, 1).locator('.CodeMirror').click(); await getTableCell(preReqRow, 1).locator('textarea').fill('pre\nRequest\nValue'); - // Add Post Response var with newlines - await page.locator('.btn-add-var').last().click(); const postResRow = page.locator('table').nth(1).locator('tbody tr').first(); - await getTableCell(postResRow, 0).locator('input[type="text"]').fill('postResponseVar'); + await getTableCell(postResRow, 0).getByRole('textbox').fill('postResponseVar'); await getTableCell(postResRow, 1).locator('.CodeMirror').click(); await getTableCell(postResRow, 1).locator('textarea').fill('post\nResponse\nValue'); @@ -72,16 +62,16 @@ test('should persist request with newlines across app restarts', async ({ create // Verify params persisted await page2.getByRole('tab', { name: 'Params' }).click(); - await expect(page2.locator('table tbody tr')).toHaveCount(1); + await expect(page2.locator('table tbody tr')).toHaveCount(2); // Verify headers persisted await page2.getByRole('tab', { name: 'Headers' }).click(); - await expect(page2.locator('table tbody tr')).toHaveCount(1); + await expect(page2.locator('table tbody tr')).toHaveCount(2); // Verify vars persisted await page2.getByRole('tab', { name: 'Vars' }).click(); - await expect(page2.locator('table').first().locator('tbody tr')).toHaveCount(1); - await expect(page2.locator('table').nth(1).locator('tbody tr')).toHaveCount(1); + await expect(page2.locator('table').first().locator('tbody tr')).toHaveCount(2); + await expect(page2.locator('table').nth(1).locator('tbody tr')).toHaveCount(2); await app2.close(); }); diff --git a/tests/utils/page/locators.ts b/tests/utils/page/locators.ts index 4101f895b..7329ff256 100644 --- a/tests/utils/page/locators.ts +++ b/tests/utils/page/locators.ts @@ -123,7 +123,7 @@ export const buildWebsocketCommonLocators = (page: Page) => ({ } }); -export const getTableCell = (row, index) => row.locator('td').nth(index); +export const getTableCell = (row, index) => row.locator('td').nth(index + 1); export const buildGrpcCommonLocators = (page: Page) => ({ ...buildCommonLocators(page), diff --git a/tests/variable-tooltip/variable-tooltip.spec.ts b/tests/variable-tooltip/variable-tooltip.spec.ts index c2b39cc6c..6a6c41aaf 100644 --- a/tests/variable-tooltip/variable-tooltip.spec.ts +++ b/tests/variable-tooltip/variable-tooltip.spec.ts @@ -63,18 +63,18 @@ test.describe('Variable Tooltip', () => { }); await test.step('Test secret variable with toggle', async () => { - // Move mouse away to dismiss any active tooltip await page.mouse.move(0, 0); - // Add header with secret await page.getByRole('tab', { name: 'Headers' }).click(); - await page.locator('button.btn-action').filter({ hasText: 'Add Header' }).click(); - const headerNameEditor = page.locator('table tbody tr').first().locator('td').first().locator('.CodeMirror'); + const headerTable = page.locator('table').first(); + const headerRow = headerTable.locator('tbody tr').first(); + + const headerNameEditor = headerRow.locator('.CodeMirror').first(); await headerNameEditor.click(); await page.keyboard.type('Authorization'); - const headerValueEditor = page.locator('table tbody tr').first().locator('td').nth(1).locator('.CodeMirror'); + const headerValueEditor = headerRow.locator('.CodeMirror').nth(1); await headerValueEditor.click(); await page.keyboard.type('Bearer {{secretToken}}'); await page.keyboard.press('Control+s'); @@ -377,12 +377,12 @@ test.describe('Variable Tooltip', () => { await expect(varRow).toBeVisible(); // Check variable name - const varNameInput = varRow.locator('td').first().locator('input[type="text"]'); + const varNameInput = varRow.locator('td').nth(1).getByRole('textbox'); await expect(varNameInput).toBeVisible(); await expect(varNameInput).toHaveValue('myApiKey'); // Check variable value - const varValueTd = varRow.locator('td').nth(1); + const varValueTd = varRow.locator('td').nth(2); const varValue = varValueTd.locator('.CodeMirror'); await expect(varValue).toBeVisible(); const varValueContent = await varValue.locator('.CodeMirror-line').textContent();
Enabled Name Value Secret