Skip to content
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import styled from 'styled-components';

const StyledWrapper = styled.div`
background-color: ${(props) => props.theme.sidebar.bg};
background-color: ${(props) => props.theme.bg};
height: 100%;
display: flex;
flex-direction: column;
Expand All @@ -12,13 +12,14 @@ const StyledWrapper = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid ${(props) => props.theme.sidebar.dragbar};
margin-bottom: 0.5rem;
padding: 0.75rem 1rem;
background-color: ${(props) => props.theme.background.mantle};
border-bottom: 1px solid ${(props) => props.theme.border.border0};

.counter {
font-size: ${(props) => props.theme.font.size.base};
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 500;
color: ${(props) => props.theme.colors.text.subtext0};
}

.actions {
Expand Down Expand Up @@ -66,11 +67,12 @@ const StyledWrapper = styled.div`
position: relative;
height: 2.5rem;
border: 1px solid transparent;
background-color: ${(props) => props.theme.sidebar.bg};
background-color: ${(props) => props.theme.bg};
transition: transform 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;

&.is-selected {
background-color: ${(props) => props.theme.background.surface0};
background-color: ${(props) => props.theme.background.mantle};
border-color: ${(props) => props.theme.border.border0};

.checkbox {
background-color: ${(props) => props.theme.primary.solid};
Expand All @@ -82,9 +84,32 @@ const StyledWrapper = styled.div`
}
}

&.is-disabled {
opacity: 0.4;
pointer-events: none;
user-select: none;

.drag-handle {
visibility: hidden;
}

.checkbox-container {
cursor: default;

.checkbox {
border-color: ${(props) => props.theme.border.border2};
background-color: ${(props) => props.theme.background.surface0};

&:hover {
border-color: ${(props) => props.theme.border.border2};
}
}
}
}

&.is-dragging {
opacity: 0.5;
background-color: ${(props) => props.theme.sidebar.bg};
background-color: ${(props) => props.theme.bg};
border: 1px dashed ${(props) => props.theme.sidebar.dragbar};
transform: scale(0.98);
box-shadow: ${(props) => props.theme.shadow.md};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,31 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { useDrag, useDrop } from 'react-dnd';
import { getEmptyImage } from 'react-dnd-html5-backend';
import { IconGripVertical, IconCheck, IconAdjustmentsAlt } from '@tabler/icons';
import { IconGripVertical, IconCheck } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { updateRunnerConfiguration } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { isItemARequest } from 'utils/collections';
import path from 'utils/common/path';
import { cloneDeep, get } from 'lodash';
import Button from 'ui/Button/index';
import { isRequestTagsIncluded } from '@usebruno/common';

const isRequestDisabled = (item, tags) => {
// WS and gRPC are not supported by the collection runner
if (item.type === 'ws-request' || item.type === 'grpc-request') return true;

// Check tag filtering
const requestTags = item.draft?.tags || item.tags || [];
const includeTags = tags?.include || [];
const excludeTags = tags?.exclude || [];

if (includeTags.length > 0 || excludeTags.length > 0) {
return !isRequestTagsIncluded(requestTags, includeTags, excludeTags);
}

return false;
};

const ItemTypes = {
REQUEST_ITEM: 'request-item'
Expand Down Expand Up @@ -40,7 +57,7 @@ const getMethodInfo = (item) => {
return { methodText, methodClass };
};

const RequestItem = ({ item, index, moveItem, isSelected, onSelect, onDrop }) => {
const RequestItem = ({ item, index, moveItem, isSelected, onSelect, onDrop, isDisabled }) => {
const ref = useRef(null);
const [dropType, setDropType] = useState(null);

Expand All @@ -58,6 +75,7 @@ const RequestItem = ({ item, index, moveItem, isSelected, onSelect, onDrop }) =>
const [{ isDragging }, drag, preview] = useDrag({
type: ItemTypes.REQUEST_ITEM,
item: { uid: item.uid, name: item.name, request: item.request, index },
canDrag: !isDisabled,
collect: (monitor) => ({ isDragging: monitor.isDragging() }),
options: {
dropEffect: 'move'
Expand Down Expand Up @@ -117,28 +135,30 @@ const RequestItem = ({ item, index, moveItem, isSelected, onSelect, onDrop }) =>

drag(drop(ref));

const methodInfo = getMethodInfo(item);
const itemClasses = [
'request-item',
isDragging ? 'is-dragging' : '',
isSelected ? 'is-selected' : '',
isDisabled ? 'is-disabled' : '',
isOver && canDrop && dropType === 'above' ? 'drop-target-above' : '',
isOver && canDrop && dropType === 'below' ? 'drop-target-below' : ''
].filter(Boolean).join(' ');

return (
<div ref={ref} className={itemClasses}>
<div ref={ref} className={itemClasses} data-testid="runner-request-item">
<div className="drag-handle">
<IconGripVertical size={16} strokeWidth={1.5} />
</div>

<div className="checkbox-container" onClick={() => onSelect(item)}>
<div className="checkbox-container" onClick={() => !isDisabled && onSelect(item)}>
<div className="checkbox">
{isSelected && <IconCheck className="checkbox-icon" size={12} strokeWidth={3} />}
{isSelected && !isDisabled && <IconCheck className="checkbox-icon" size={12} strokeWidth={3} />}
</div>
</div>

<div className={`method ${getMethodInfo(item).methodClass}`}>
{getMethodInfo(item).methodText}
<div className={`method ${methodInfo.methodClass}`}>
{methodInfo.methodText}
</div>

<div className="request-name">
Expand All @@ -151,11 +171,15 @@ const RequestItem = ({ item, index, moveItem, isSelected, onSelect, onDrop }) =>
);
};

const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems }) => {
const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems, tags }) => {
const dispatch = useDispatch();
const [flattenedRequests, setFlattenedRequests] = useState([]);
const [originalRequests, setOriginalRequests] = useState([]);
const [isLoading, setIsLoading] = useState(true);
// On first mount, ignore any stale saved config and auto-select all items
const isInitialMountRef = useRef(true);
// Track items that were auto-deselected due to tag filters, so we can re-select them when tags change back
const pendingReselectRef = useRef(new Set());

const flattenRequests = useCallback((collection) => {
const result = [];
Expand Down Expand Up @@ -192,6 +216,7 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems })
const requests = flattenRequests(structureCopy);

const savedConfiguration = get(collection, 'runnerConfiguration', null);
let finalRequests;
if (savedConfiguration?.requestItemsOrder?.length > 0) {
const orderedRequests = [];
const requestMap = new Map(requests.map((req) => [req.uid, req]));
Expand All @@ -208,19 +233,66 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems })
orderedRequests.push(request);
});

setFlattenedRequests(orderedRequests);
finalRequests = orderedRequests;
} else {
setFlattenedRequests(requests);
finalRequests = requests;
}

setFlattenedRequests(finalRequests);
setOriginalRequests(cloneDeep(requests));

if (!savedConfiguration || isInitialMountRef.current) {
isInitialMountRef.current = false;
const enabledUids = finalRequests
.filter((item) => !isRequestDisabled(item, tags))
.map((item) => item.uid);
setSelectedItems(enabledUids);
}
} catch (error) {
console.error('Error loading collection structure:', error);
} finally {
setIsLoading(false);
}
}, [collection, flattenRequests]);

// When tags change: disable newly-filtered items, re-select previously-filtered items that are now enabled again
useEffect(() => {
if (flattenedRequests.length === 0) return;

let newSelected = [...selectedItems];
let changed = false;

flattenedRequests.forEach((item) => {
const disabled = isRequestDisabled(item, tags);
const isCurrentlySelected = selectedItems.includes(item.uid);
const isPendingReselect = pendingReselectRef.current.has(item.uid);

if (disabled && isCurrentlySelected) {
pendingReselectRef.current.add(item.uid);
newSelected = newSelected.filter((uid) => uid !== item.uid);
changed = true;
} else if (!disabled && isPendingReselect) {
pendingReselectRef.current.delete(item.uid);
if (!newSelected.includes(item.uid)) {
newSelected.push(item.uid);
changed = true;
}
}
});

if (changed) {
const ordered = flattenedRequests
.filter((r) => newSelected.includes(r.uid))
.map((r) => r.uid);
setSelectedItems(ordered);
const allRequestUidsOrder = flattenedRequests.map((item) => item.uid);
dispatch(updateRunnerConfiguration(collection.uid, ordered, allRequestUidsOrder));
Comment on lines +283 to +289
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

These config updates drop the saved delay.

updateRunnerConfiguration() replaces the whole runnerConfiguration object. The new tag-sync/select-all/reset dispatches only send selection and order, so any previously saved delay is cleared as soon as one of these handlers runs. Please thread the live delay through here, or merge config fields in the reducer instead of replacing them.

Also applies to: 359-367, 376-384

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/bruno-app/src/components/RunnerResults/RunConfigurationPanel/index.jsx`
around lines 283 - 289, The dispatch calls that invoke updateRunnerConfiguration
(seen near setSelectedItems and where selection/order changes) replace the
entire runnerConfiguration and thus drop any saved delay; fix by threading the
current delay through these calls or updating the reducer to merge fields
instead of replacing: when calling updateRunnerConfiguration(collection.uid,
ordered, allRequestUidsOrder) include the current delay value (e.g., pull
runnerConfiguration.delay from props/state) so the action carries delay along
with selection/order, or change the reducer handling updateRunnerConfiguration
to merge incoming selection/order into existing runnerConfiguration (preserving
delay) rather than overwriting the whole object.

}
}, [tags, flattenedRequests]);

const enabledRequests = flattenedRequests.filter((item) => !isRequestDisabled(item, tags));
const enabledCount = enabledRequests.length;

const moveItem = useCallback((draggedItemUid, hoverIndex) => {
setFlattenedRequests((prevRequests) => {
const dragIndex = prevRequests.findIndex((item) => item.uid === draggedItemUid);
Expand Down Expand Up @@ -255,6 +327,8 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems })
}, [selectedItems, collection.uid, dispatch, setSelectedItems]);

const handleRequestSelect = useCallback((item) => {
if (isRequestDisabled(item, tags)) return;

try {
if (selectedItems.includes(item.uid)) {
const newSelectedUids = selectedItems.filter((uid) => uid !== item.uid);
Expand All @@ -277,51 +351,61 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems })
} catch (error) {
console.error('Error selecting item:', error);
}
}, [selectedItems, setSelectedItems, flattenedRequests, dispatch, collection.uid]);
}, [selectedItems, setSelectedItems, flattenedRequests, dispatch, collection.uid, tags]);

const handleSelectAll = useCallback(() => {
try {
const allRequestUidsOrder = flattenedRequests.map((item) => item.uid);
const enabledUids = enabledRequests.map((item) => item.uid);

if (selectedItems.length === flattenedRequests.length) {
if (selectedItems.length === enabledCount) {
pendingReselectRef.current.clear();
setSelectedItems([]);
dispatch(updateRunnerConfiguration(collection.uid, [], allRequestUidsOrder));
} else {
setSelectedItems(allRequestUidsOrder);
dispatch(updateRunnerConfiguration(collection.uid, allRequestUidsOrder, allRequestUidsOrder));
setSelectedItems(enabledUids);
dispatch(updateRunnerConfiguration(collection.uid, enabledUids, allRequestUidsOrder));
}
} catch (error) {
console.error('Error selecting/deselecting all items:', error);
}
}, [flattenedRequests, selectedItems, setSelectedItems, dispatch, collection.uid]);
}, [flattenedRequests, enabledRequests, enabledCount, selectedItems, setSelectedItems, dispatch, collection.uid]);

const handleReset = useCallback(() => {
try {
setFlattenedRequests(cloneDeep(originalRequests));
setSelectedItems([]);
dispatch(updateRunnerConfiguration(collection.uid, [], []));
pendingReselectRef.current.clear();
const resetRequests = cloneDeep(originalRequests);
setFlattenedRequests(resetRequests);
const enabledUids = resetRequests
.filter((item) => !isRequestDisabled(item, tags))
.map((item) => item.uid);
setSelectedItems(enabledUids);
const allUidsOrder = resetRequests.map((item) => item.uid);
dispatch(updateRunnerConfiguration(collection.uid, enabledUids, allUidsOrder));
} catch (error) {
console.error('Error resetting configuration:', error);
}
}, [originalRequests, setSelectedItems, collection.uid, dispatch]);
}, [originalRequests, setSelectedItems, collection.uid, dispatch, tags]);

return (
<StyledWrapper>
<StyledWrapper data-testid="runner-config-panel">
<div className="header">
<div className="counter">
{selectedItems.length} of {flattenedRequests.length} selected
<div className="counter" data-testid="runner-config-counter">
{selectedItems.length} of {enabledCount} selected
</div>
<div className="actions">
<Button
variant="ghost"
onClick={handleSelectAll}
data-testid="runner-select-all"
>
{selectedItems.length === flattenedRequests.length ? 'Deselect All' : 'Select All'}
{selectedItems.length === enabledCount ? 'Deselect All' : 'Select All'}
</Button>
<Button
variant="ghost"
onClick={handleReset}
title="Reset selection and order"
data-testid="runner-config-reset"
>
Reset
</Button>
Expand All @@ -337,13 +421,15 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems })
<div className="requests-container">
{flattenedRequests.map((item, idx) => {
const isSelected = selectedItems.includes(item.uid);
const disabled = isRequestDisabled(item, tags);

return (
<RequestItem
key={item.uid}
item={item}
index={idx}
isSelected={isSelected}
isDisabled={disabled}
onSelect={() => handleRequestSelect(item)}
moveItem={moveItem}
onDrop={handleDrop}
Expand Down
Loading
Loading