diff --git a/static/app/components/pageFilters/useStagedCompactSelect.spec.tsx b/static/app/components/pageFilters/useStagedCompactSelect.spec.tsx index 87af98fef000..0cf5d97721f8 100644 --- a/static/app/components/pageFilters/useStagedCompactSelect.spec.tsx +++ b/static/app/components/pageFilters/useStagedCompactSelect.spec.tsx @@ -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([]); + 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 ( + 0 ? ( + + { + stagedSelect.dispatch({type: 'remove staged'}); + handleChange(stagedSelect.value); + }} + /> + + ) : null + } + /> + ); + } + + render(); + + // 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(); diff --git a/static/app/components/pageFilters/useStagedCompactSelect.tsx b/static/app/components/pageFilters/useStagedCompactSelect.tsx index 2db01ba90c83..a3ebd6fba4d9 100644 --- a/static/app/components/pageFilters/useStagedCompactSelect.tsx +++ b/static/app/components/pageFilters/useStagedCompactSelect.tsx @@ -66,13 +66,23 @@ function stagingReducer( 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 @@ -92,7 +102,12 @@ function stagingReducer( // 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); @@ -112,7 +127,11 @@ function stagingReducer( 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};