Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1059,6 +1059,79 @@ describe('useStagedCompactSelect', () => {
expect(onChange).toHaveBeenLastCalledWith(['one', 'three']);
});

it('clears anchor after selection becomes empty so next shift+click is a single toggle', async () => {
const onChange = jest.fn();

function TestComponent() {
const [value, setValue] = useState<string[]>([]);
const toggleOptionRef = useRef<((val: string) => void) | undefined>(undefined);
const options = useTestOptions(toggleOptionRef);
const handleChange = (newValue: string[]) => {
onChange(newValue);
setValue(newValue);
};
const stagedSelect = useStagedCompactSelect({
value,
options,
onChange: handleChange,
multiple: true,
});
toggleOptionRef.current = stagedSelect.toggleOption;

return (
<CompactSelect
grid
multiple
search
{...stagedSelect.compactSelectProps}
menuFooter={
xor(stagedSelect.value, value).length > 0 ? (
<Flex>
<MenuComponents.ApplyButton
onClick={() => {
stagedSelect.dispatch({type: 'remove staged'});
handleChange(stagedSelect.value);
}}
/>
</Flex>
) : null
}
/>
);
}

render(<TestComponent />);

// Open the menu
await userEvent.click(screen.getByRole('button', {expanded: false}));

// Shift-click Option One — first shift-click acts as a single toggle, sets anchor to one
await userEvent.keyboard('{Shift>}');
await userEvent.click(screen.getByRole('row', {name: 'Option One'}));
await userEvent.keyboard('{/Shift}');

// Shift-click Option Three — range-selects [one, two, three], anchor now three
await userEvent.keyboard('{Shift>}');
await userEvent.click(screen.getByRole('row', {name: 'Option Three'}));
await userEvent.keyboard('{/Shift}');

// Shift-click Option One — deselects range [one, two, three], staged selection is now empty
await userEvent.keyboard('{Shift>}');
await userEvent.click(screen.getByRole('row', {name: 'Option One'}));
await userEvent.keyboard('{/Shift}');

// Shift-click Option Two — since selection is empty, this must act as a single toggle.
// Without the fix the stale anchor would range-select [one, two].
await userEvent.keyboard('{Shift>}');
await userEvent.click(screen.getByRole('row', {name: 'Option Two'}));
await userEvent.keyboard('{/Shift}');

// Apply the changes
await userEvent.click(screen.getByRole('button', {name: 'Apply'}));

expect(onChange).toHaveBeenLastCalledWith(['two']);
});

it('shift+click range in filtered list only selects visible options', async () => {
const onChange = jest.fn();

Expand Down
27 changes: 23 additions & 4 deletions static/app/components/pageFilters/useStagedCompactSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,23 @@ function stagingReducer<Value extends SelectKey>(
case 'toggle': {
const newSet = new Set(action.currentStaged);
newSet.has(action.val) ? newSet.delete(action.val) : newSet.add(action.val);
return {...state, stagedValue: Array.from(newSet), lastSelected: action.val};
const stagedValue = Array.from(newSet);
return {
...state,
stagedValue,
lastSelected: stagedValue.length === 0 ? null : action.val,
};
}
case 'toggle range': {
if (state.lastSelected === null) {
const newSet = new Set(action.currentStaged);
newSet.has(action.val) ? newSet.delete(action.val) : newSet.add(action.val);
return {...state, stagedValue: Array.from(newSet), lastSelected: action.val};
const stagedValue = Array.from(newSet);
return {
...state,
stagedValue,
lastSelected: stagedValue.length === 0 ? null : action.val,
};
}

// Only include options visible in the current filtered state so that
Expand All @@ -92,7 +102,12 @@ function stagingReducer<Value extends SelectKey>(
// Anchor or clicked item not visible — fall back to single toggle
const newSet = new Set(action.currentStaged);
newSet.has(action.val) ? newSet.delete(action.val) : newSet.add(action.val);
return {...state, stagedValue: Array.from(newSet), lastSelected: action.val};
const stagedValue = Array.from(newSet);
return {
...state,
stagedValue,
lastSelected: stagedValue.length === 0 ? null : action.val,
};
}

const targetState = !action.currentStaged.includes(action.val);
Expand All @@ -112,7 +127,11 @@ function stagingReducer<Value extends SelectKey>(
return aIdx - bIdx;
});

return {...state, stagedValue: sortedValue, lastSelected: action.val};
return {
...state,
stagedValue: sortedValue,
lastSelected: sortedValue.length === 0 ? null : action.val,
};
}
case 'remove staged':
return {...state, stagedValue: null};
Expand Down
Loading