Skip to content

Commit

Permalink
feat(query-builder): Add easy multi-select while holding ctrl/cmd (#7…
Browse files Browse the repository at this point in the history
…6610)

This adds the "Hold Ctrl/⌘ to select multiple" text and behavior that
was in the Figma designs. You could already click the checkbox for this
behavior, this makes it possible to do with the keyboard now as well.
  • Loading branch information
malwilley authored Aug 27, 2024
1 parent c233112 commit 3a643a6
Show file tree
Hide file tree
Showing 5 changed files with 187 additions and 9 deletions.
49 changes: 49 additions & 0 deletions static/app/components/searchQueryBuilder/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1524,6 +1524,55 @@ describe('SearchQueryBuilder', function () {
expect(screen.getByRole('combobox', {name: 'Edit filter value'})).toHaveFocus();
});

it('keeps focus inside value when multi-selecting with ctrl+enter', async function () {
render(
<SearchQueryBuilder {...defaultProps} initialQuery="browser.name:firefox" />
);

await userEvent.click(
screen.getByRole('button', {name: 'Edit value for filter: browser.name'})
);

// Arrow down two places to "Chrome" option
await userEvent.keyboard('{ArrowDown}{ArrowDown}');
// Pressing ctrl+enter should toggle the option and keep focus inside the input
await userEvent.keyboard('{Control>}{Enter}');
expect(
await screen.findByRole('row', {name: 'browser.name:[firefox,Chrome]'})
).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByRole('combobox', {name: 'Edit filter value'})).toHaveValue(
'firefox,Chrome,'
);
});
expect(screen.getByRole('combobox', {name: 'Edit filter value'})).toHaveFocus();
});

it('keeps focus inside value when multi-selecting with ctrl+click', async function () {
render(
<SearchQueryBuilder {...defaultProps} initialQuery="browser.name:firefox" />
);

const user = userEvent.setup();

await user.click(
screen.getByRole('button', {name: 'Edit value for filter: browser.name'})
);

// Clicking option while holding Ctrl should toggle the option and keep focus inside the input
await user.keyboard('{Control>}');
await user.click(screen.getByRole('option', {name: 'Chrome'}));
expect(
await screen.findByRole('row', {name: 'browser.name:[firefox,Chrome]'})
).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByRole('combobox', {name: 'Edit filter value'})).toHaveValue(
'firefox,Chrome,'
);
});
expect(screen.getByRole('combobox', {name: 'Edit filter value'})).toHaveFocus();
});

it('collapses many selected options', function () {
render(
<SearchQueryBuilder
Expand Down
2 changes: 2 additions & 0 deletions static/app/components/searchQueryBuilder/tokens/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ type SearchQueryBuilderComboboxProps<T extends SelectOptionOrSectionWithKey<stri
type OverlayProps = ReturnType<typeof useOverlay>['overlayProps'];

export type CustomComboboxMenuProps<T> = {
filterValue: string;
hiddenOptions: Set<SelectKey>;
isOpen: boolean;
listBoxProps: AriaListBoxOptions<T>;
Expand Down Expand Up @@ -277,6 +278,7 @@ function OverlayContent<T extends SelectOptionOrSectionWithKey<string>>({
listBoxProps,
state,
overlayProps,
filterValue,
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import styled from '@emotion/styled';
import {isMac} from '@react-aria/utils';
import {Item, Section} from '@react-stately/collections';
import type {KeyboardEvent} from '@react-types/shared';

Expand All @@ -19,6 +20,7 @@ import {
replaceCommaSeparatedValue,
unescapeTagValue,
} from 'sentry/components/searchQueryBuilder/tokens/filter/utils';
import {ValueListBox} from 'sentry/components/searchQueryBuilder/tokens/filter/valueListBox';
import {getDefaultAbsoluteDateValue} from 'sentry/components/searchQueryBuilder/tokens/filter/valueSuggestions/date';
import type {
SuggestionItem,
Expand Down Expand Up @@ -55,6 +57,7 @@ import {type FieldDefinition, FieldValueType} from 'sentry/utils/fields';
import {isCtrlKeyPressed} from 'sentry/utils/isCtrlKeyPressed';
import {type QueryKey, useQuery} from 'sentry/utils/queryClient';
import {useDebouncedValue} from 'sentry/utils/useDebouncedValue';
import useKeyPress from 'sentry/utils/useKeyPress';
import useOrganization from 'sentry/utils/useOrganization';

type SearchQueryValueBuilderProps = {
Expand Down Expand Up @@ -258,7 +261,9 @@ function useFilterSuggestions({
token,
filterValue,
selectedValues,
ctrlKeyPressed,
}: {
ctrlKeyPressed: boolean;
filterValue: string;
selectedValues: string[];
token: TokenResult<Token.FILTER>;
Expand Down Expand Up @@ -320,12 +325,13 @@ function useFilterSuggestions({
token={token}
disabled={disabled}
value={suggestion.value}
ctrlKeyPressed={ctrlKeyPressed}
/>
);
},
};
},
[canSelectMultipleValues, token]
[canSelectMultipleValues, token, ctrlKeyPressed]
);

const suggestionGroups: SuggestionSection[] = useMemo(() => {
Expand Down Expand Up @@ -379,7 +385,9 @@ function ItemCheckbox({
selected,
disabled,
value,
ctrlKeyPressed,
}: {
ctrlKeyPressed: boolean;
disabled: boolean;
isFocused: boolean;
selected: boolean;
Expand All @@ -394,7 +402,7 @@ function ItemCheckbox({
onMouseUp={e => e.stopPropagation()}
onClick={e => e.stopPropagation()}
>
<CheckWrap visible={isFocused || selected} role="presentation">
<CheckWrap visible={isFocused || selected || ctrlKeyPressed} role="presentation">
<Checkbox
size="sm"
checked={selected}
Expand Down Expand Up @@ -436,8 +444,14 @@ export function SearchQueryBuilderValueCombobox({
const ref = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const organization = useOrganization();
const {getFieldDefinition, filterKeys, dispatch, searchSource, recentSearches} =
useSearchQueryBuilder();
const {
getFieldDefinition,
filterKeys,
dispatch,
searchSource,
recentSearches,
wrapperRef: topLevelWrapperRef,
} = useSearchQueryBuilder();
const keyName = getKeyName(token.key);
const fieldDefinition = getFieldDefinition(keyName);
const canSelectMultipleValues = tokenSupportsMultipleValues(
Expand Down Expand Up @@ -470,6 +484,11 @@ export function SearchQueryBuilderValueCombobox({
[canSelectMultipleValues, inputValue]
);

const ctrlKeyPressed = useKeyPress(
isMac() ? 'Meta' : 'Control',
topLevelWrapperRef.current
);

useEffect(() => {
if (canSelectMultipleValues) {
setInputValue(getMultiSelectInputValue(token));
Expand All @@ -489,6 +508,7 @@ export function SearchQueryBuilderValueCombobox({
token,
filterValue,
selectedValues,
ctrlKeyPressed,
});

const analyticsData = useMemo(
Expand Down Expand Up @@ -533,7 +553,7 @@ export function SearchQueryBuilderValueCombobox({
value: newValue,
});

if (newValue && newValue !== '""') {
if (newValue && newValue !== '""' && !ctrlKeyPressed) {
onCommit();
}

Expand All @@ -548,7 +568,10 @@ export function SearchQueryBuilderValueCombobox({
replaceCommaSeparatedValue(inputValue, selectionIndex, value)
),
});
onCommit();

if (!ctrlKeyPressed) {
onCommit();
}
} else {
dispatch({
type: 'UPDATE_TOKEN_VALUE',
Expand All @@ -570,6 +593,7 @@ export function SearchQueryBuilderValueCombobox({
selectedValues,
selectionIndex,
token,
ctrlKeyPressed,
]
);

Expand Down Expand Up @@ -690,7 +714,16 @@ export function SearchQueryBuilderValueCombobox({

const customMenu: CustomComboboxMenu<SelectOptionWithKey<string>> | undefined =
useMemo(() => {
if (!showDatePicker) return undefined;
if (!showDatePicker)
return function (props) {
return (
<ValueListBox
{...props}
isMultiSelect={canSelectMultipleValues}
items={items}
/>
);
};

return function (props) {
return (
Expand Down Expand Up @@ -722,7 +755,16 @@ export function SearchQueryBuilderValueCombobox({
/>
);
};
}, [analyticsData, dispatch, inputValue, onCommit, showDatePicker, token]);
}, [
showDatePicker,
canSelectMultipleValues,
items,
inputValue,
token,
analyticsData,
dispatch,
onCommit,
]);

return (
<ValueEditing ref={ref} data-test-id="filter-value-editing">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import styled from '@emotion/styled';
import {isMac} from '@react-aria/utils';

import {ListBox} from 'sentry/components/compactSelect/listBox';
import type {SelectOptionOrSectionWithKey} from 'sentry/components/compactSelect/types';
import {Overlay} from 'sentry/components/overlay';
import type {CustomComboboxMenuProps} from 'sentry/components/searchQueryBuilder/tokens/combobox';
import {itemIsSection} from 'sentry/components/searchQueryBuilder/tokens/utils';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';

interface ValueListBoxProps<T> extends CustomComboboxMenuProps<T> {
isMultiSelect: boolean;
items: T[];
}

export function ValueListBox<T extends SelectOptionOrSectionWithKey<string>>({
hiddenOptions,
isOpen,
listBoxProps,
listBoxRef,
popoverRef,
state,
overlayProps,
filterValue,
isMultiSelect,
items,
}: ValueListBoxProps<T>) {
const totalOptions = items.reduce(
(acc, item) => acc + (itemIsSection(item) ? item.options.length : 1),
0
);
const anyItemsShowing = totalOptions > hiddenOptions.size;

if (!isOpen || !anyItemsShowing) {
return null;
}

return (
<StyledPositionWrapper {...overlayProps} visible={isOpen}>
<SectionedOverlay ref={popoverRef}>
<StyledListBox
{...listBoxProps}
ref={listBoxRef}
listState={state}
hasSearch={!!filterValue}
hiddenOptions={hiddenOptions}
keyDownHandler={() => true}
overlayIsOpen={isOpen}
showSectionHeaders={!filterValue}
size="sm"
style={{maxWidth: overlayProps.style.maxWidth}}
/>
{isMultiSelect ? (
<Label>{t('Hold %s to select multiple', isMac() ? '⌘' : 'Ctrl')}</Label>
) : null}
</SectionedOverlay>
</StyledPositionWrapper>
);
}

const SectionedOverlay = styled(Overlay)`
display: grid;
grid-template-rows: 1fr auto;
overflow: hidden;
max-height: 300px;
width: min-content;
`;

const StyledListBox = styled(ListBox)`
width: min-content;
min-width: 200px;
`;

const StyledPositionWrapper = styled('div')<{visible?: boolean}>`
display: ${p => (p.visible ? 'block' : 'none')};
z-index: ${p => p.theme.zIndex.tooltip};
`;

const Label = styled('div')`
padding: ${space(1)} ${space(2)};
color: ${p => p.theme.subText};
border-top: 1px solid ${p => p.theme.innerBorder};
font-size: ${p => p.theme.fontSizeSmall};
`;
2 changes: 1 addition & 1 deletion static/app/utils/useKeyPress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {useEffect, useState} from 'react';
*/
const useKeyPress = (
targetKey: string,
target?: HTMLElement,
target?: HTMLElement | null,
captureAndStop: boolean = false
) => {
const [keyPressed, setKeyPressed] = useState(false);
Expand Down

0 comments on commit 3a643a6

Please sign in to comment.