import {Fragment} from 'react';
import styled from '@emotion/styled';
import Button from 'sentry/components/button';
import ButtonBar from 'sentry/components/buttonBar';
import HotkeysLabel from 'sentry/components/hotkeysLabel';
import LoadingIndicator from 'sentry/components/loadingIndicator';
import {Overlay} from 'sentry/components/overlay';
import {parseSearch, SearchConfig} from 'sentry/components/searchSyntax/parser';
import HighlightQuery from 'sentry/components/searchSyntax/renderer';
import Tag from 'sentry/components/tag';
import {IconOpen} from 'sentry/icons';
import {t, tct} from 'sentry/locale';
import space from 'sentry/styles/space';
import {TagCollection} from 'sentry/types';
import {CustomMeasurementCollection} from 'sentry/utils/customMeasurements/customMeasurements';
import {FieldKind} from 'sentry/utils/fields';
import {ItemType, SearchGroup, SearchItem, Shortcut} from './types';
import {getSearchConfigFromCustomPerformanceMetrics} from './utils';
const getDropdownItemKey = (item: SearchItem) =>
`${item.value || item.desc || item.title}-${
item.children && item.children.length > 0 ? getDropdownItemKey(item.children[0]) : ''
}`;
type Props = {
items: SearchGroup[];
loading: boolean;
onClick: (value: string, item: SearchItem) => void;
searchSubstring: string;
className?: string;
customPerformanceMetrics?: CustomMeasurementCollection;
maxMenuHeight?: number;
onIconClick?: (value: string) => void;
runShortcut?: (shortcut: Shortcut) => void;
supportedTags?: TagCollection;
visibleShortcuts?: Shortcut[];
};
const SearchDropdown = ({
className,
loading,
items,
runShortcut,
visibleShortcuts,
maxMenuHeight,
onIconClick,
searchSubstring = '',
onClick = () => {},
customPerformanceMetrics,
supportedTags,
}: Props) => (
{loading ? (
) : (
{items.map(item => {
const isEmpty = item.children && !item.children.length;
// Hide header if `item.children` is defined, an array, and is empty
return (
{item.type === 'header' && }
{item.children &&
item.children.map(child => (
))}
{isEmpty && {t('No items found')}}
);
})}
)}
{runShortcut &&
visibleShortcuts?.map(shortcut => (
))}
);
export default SearchDropdown;
type HeaderItemProps = {
group: SearchGroup;
};
const HeaderItem = ({group}: HeaderItemProps) => (
{group.icon}
{group.title && group.title}
{group.desc && {group.desc}}
);
type HighlightedRestOfWordsProps = {
combinedRestWords: string;
firstWord: string;
searchSubstring: string;
hasSplit?: boolean;
isFirstWordHidden?: boolean;
};
const HighlightedRestOfWords = ({
combinedRestWords,
searchSubstring,
firstWord,
isFirstWordHidden,
hasSplit,
}: HighlightedRestOfWordsProps) => {
const remainingSubstr =
searchSubstring.indexOf(firstWord) === -1
? searchSubstring
: searchSubstring.slice(firstWord.length + 1);
const descIdx = combinedRestWords.indexOf(remainingSubstr);
if (descIdx > -1) {
return (
.{combinedRestWords.slice(0, descIdx)}
{combinedRestWords.slice(descIdx, descIdx + remainingSubstr.length)}
{combinedRestWords.slice(descIdx + remainingSubstr.length)}
);
}
return (
.{combinedRestWords}
);
};
type ItemTitleProps = {
item: SearchItem;
searchSubstring: string;
isChild?: boolean;
};
const ItemTitle = ({item, searchSubstring, isChild}: ItemTitleProps) => {
if (!item.title) {
return null;
}
const fullWord = item.title;
const words = item.kind !== FieldKind.FUNCTION ? fullWord.split('.') : [fullWord];
const [firstWord, ...restWords] = words;
const isFirstWordHidden = isChild;
const combinedRestWords = restWords.length > 0 ? restWords.join('.') : null;
const hasSingleField = item.type === ItemType.LINK;
if (searchSubstring) {
const idx =
restWords.length === 0
? fullWord.toLowerCase().indexOf(searchSubstring.split('.')[0])
: fullWord.toLowerCase().indexOf(searchSubstring);
// Below is the logic to make the current query bold inside the result.
if (idx !== -1) {
return (
{!isFirstWordHidden && (
{firstWord.slice(0, idx)}
{firstWord.slice(idx, idx + searchSubstring.length)}
{firstWord.slice(idx + searchSubstring.length)}
)}
{combinedRestWords && (
1}
/>
)}
);
}
}
return (
{!isFirstWordHidden && {firstWord}}
{combinedRestWords && (
1}
>
.{combinedRestWords}
)}
);
};
type KindTagProps = {
kind: FieldKind;
deprecated?: boolean;
};
const KindTag = ({kind, deprecated}: KindTagProps) => {
if (deprecated) {
return deprecated;
}
switch (kind) {
case FieldKind.FUNCTION:
case FieldKind.NUMERIC_METRICS:
return f(x);
case FieldKind.MEASUREMENT:
case FieldKind.BREAKDOWN:
return field;
case FieldKind.TAG:
return {kind};
default:
return {kind};
}
};
type DropdownItemProps = {
item: SearchItem;
onClick: (value: string, item: SearchItem) => void;
searchSubstring: string;
additionalSearchConfig?: Partial;
isChild?: boolean;
onIconClick?: any;
};
const DropdownItem = ({
item,
isChild,
searchSubstring,
onClick,
onIconClick,
additionalSearchConfig,
}: DropdownItemProps) => {
const isDisabled = item.value === null;
let children: React.ReactNode;
if (item.type === ItemType.RECENT_SEARCH) {
children = ;
} else if (item.type === ItemType.INVALID_TAG) {
children = (
{tct("The field [field] isn't supported here. ", {
field: {item.desc},
})}
{tct('[highlight:See all searchable properties in the docs.]', {
highlight: ,
})}
);
} else if (item.type === ItemType.LINK) {
children = (
{onIconClick && (
{
// stop propagation so the item-level onClick doesn't get called
e.stopPropagation();
onIconClick(item.value);
}}
/>
)}
);
} else {
children = (
{item.desc && {item.desc}}
{item.kind && !isChild && (
)}
);
}
return (
item.active && element?.scrollIntoView?.({block: 'nearest'})}
isGrouped={isChild}
isDisabled={isDisabled}
>
{children}
{!isChild &&
item.children?.map(child => (
))}
);
};
type DropdownDocumentationProps = {
searchSubstring: string;
documentation?: React.ReactNode;
};
const DropdownDocumentation = ({
documentation,
searchSubstring,
}: DropdownDocumentationProps) => {
if (documentation && typeof documentation === 'string') {
const startIndex =
documentation.toLocaleLowerCase().indexOf(searchSubstring.toLocaleLowerCase()) ??
-1;
if (startIndex !== -1) {
const endIndex = startIndex + searchSubstring.length;
return (
{documentation.slice(0, startIndex)}
{documentation.slice(startIndex, endIndex)}
{documentation.slice(endIndex)}
);
}
}
return {documentation};
};
type QueryItemProps = {
item: SearchItem;
additionalSearchConfig?: Partial;
};
const QueryItem = ({item, additionalSearchConfig}: QueryItemProps) => {
if (!item.value) {
return null;
}
const parsedQuery = parseSearch(item.value, additionalSearchConfig);
if (!parsedQuery) {
return null;
}
return (
);
};
const SearchDropdownOverlay = styled(Overlay)`
position: absolute;
top: 100%;
left: -1px;
right: -1px;
overflow: hidden;
margin-top: ${space(1)};
`;
const LoadingWrapper = styled('div')`
display: flex;
justify-content: center;
padding: ${space(1)};
`;
const Info = styled('div')`
display: flex;
padding: ${space(1)} ${space(2)};
font-size: ${p => p.theme.fontSizeLarge};
color: ${p => p.theme.gray300};
&:not(:last-child) {
border-bottom: 1px solid ${p => p.theme.innerBorder};
}
`;
const ListItem = styled('li')`
&:not(:first-child):not(.group-child) {
border-top: 1px solid ${p => p.theme.innerBorder};
}
`;
const SearchDropdownGroup = styled(ListItem)``;
const SearchDropdownGroupTitle = styled('header')`
display: flex;
align-items: center;
background-color: ${p => p.theme.backgroundSecondary};
color: ${p => p.theme.gray300};
font-weight: normal;
font-size: ${p => p.theme.fontSizeMedium};
margin: 0;
padding: ${space(1)} ${space(2)};
& > svg {
margin-right: ${space(1)};
}
`;
const SearchItemsList = styled('ul')<{maxMenuHeight?: number}>`
padding-left: 0;
list-style: none;
margin-bottom: 0;
${p => {
if (p.maxMenuHeight !== undefined) {
return `
max-height: ${p.maxMenuHeight}px;
overflow-y: scroll;
`;
}
return `
height: auto;
`;
}}
`;
const SearchListItem = styled(ListItem)<{isDisabled?: boolean; isGrouped?: boolean}>`
scroll-margin: 40px 0;
font-size: ${p => p.theme.fontSizeLarge};
padding: 4px ${space(2)};
min-height: ${p => (p.isGrouped ? '30px' : '36px')};
${p => {
if (!p.isDisabled) {
return `
cursor: pointer;
&:hover,
&.active {
background: ${p.theme.hover};
}
`;
}
return '';
}}
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
width: 100%;
`;
const SearchItemTitleWrapper = styled('div')<{hasSingleField?: boolean}>`
display: flex;
flex-grow: 1;
flex-shrink: 0;
max-width: ${p => (p.hasSingleField ? '75%' : 'min(280px, 50%)')};
color: ${p => p.theme.textColor};
font-weight: normal;
font-size: ${p => p.theme.fontSizeMedium};
margin: 0;
line-height: ${p => p.theme.text.lineHeightHeading};
${p => p.theme.overflowEllipsis};
`;
const RestOfWordsContainer = styled('span')<{
hasSplit?: boolean;
isFirstWordHidden?: boolean;
}>`
color: ${p => (p.hasSplit ? p.theme.blue400 : p.theme.textColor)};
margin-left: ${p => (p.isFirstWordHidden ? space(1) : '0px')};
`;
const FirstWordWrapper = styled('span')`
font-weight: medium;
`;
const TagWrapper = styled('span')`
flex-shrink: 0;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
`;
const Documentation = styled('span')`
display: flex;
flex: 2;
padding: 0 ${space(1)};
min-width: 0;
${p => p.theme.overflowEllipsis}
font-size: ${p => p.theme.fontSizeMedium};
font-family: ${p => p.theme.text.family};
color: ${p => p.theme.subText};
white-space: pre;
`;
const DropdownFooter = styled(`div`)`
width: 100%;
min-height: 45px;
background-color: ${p => p.theme.backgroundSecondary};
border-top: 1px solid ${p => p.theme.innerBorder};
flex-direction: row;
display: flex;
align-items: center;
justify-content: space-between;
padding: ${space(1)};
flex-wrap: wrap;
gap: ${space(1)};
`;
const HotkeyGlyphWrapper = styled('span')`
color: ${p => p.theme.gray300};
margin-right: ${space(0.5)};
@media (max-width: ${p => p.theme.breakpoints.small}) {
display: none;
}
`;
const IconWrapper = styled('span')`
display: none;
@media (max-width: ${p => p.theme.breakpoints.small}) {
display: flex;
margin-right: ${space(0.5)};
align-items: center;
justify-content: center;
}
`;
const Invalid = styled(`span`)`
font-size: ${p => p.theme.fontSizeSmall};
font-family: ${p => p.theme.text.family};
color: ${p => p.theme.gray400};
display: flex;
flex-direction: row;
flex-wrap: wrap;
span {
white-space: pre;
}
`;
const Highlight = styled(`strong`)`
color: ${p => p.theme.linkColor};
`;
const QueryItemWrapper = styled('span')`
font-size: ${p => p.theme.fontSizeSmall};
width: 100%;
gap: ${space(1)};
display: flex;
white-space: nowrap;
word-break: normal;
font-family: ${p => p.theme.text.familyMono};
`;
const Value = styled('span')<{hasDocs?: boolean}>`
font-family: ${p => p.theme.text.familyMono};
font-size: ${p => p.theme.fontSizeSmall};
max-width: ${p => (p.hasDocs ? '280px' : 'none')};
${p => p.theme.overflowEllipsis};
`;