import {Fragment, useCallback, useEffect, useMemo, useState} from 'react'; import styled from '@emotion/styled'; import {CompactSelect} from 'sentry/components/compactSelect'; import PageFilterBar from 'sentry/components/organizations/pageFilterBar'; import {BooleanOperator} from 'sentry/components/searchSyntax/parser'; import SmartSearchBar, {SmartSearchBarProps} from 'sentry/components/smartSearchBar'; import Tag from 'sentry/components/tag'; import {IconLightning, IconReleases} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import {MetricMeta, MRI, SavedSearchType, TagCollection} from 'sentry/types'; import { defaultMetricDisplayType, getReadableMetricType, isAllowedOp, isCustomMetric, isMeasurement, isTransactionDuration, MetricDisplayType, MetricsQuery, MetricWidgetQueryParams, } from 'sentry/utils/metrics'; import {formatMRI, getUseCaseFromMRI} from 'sentry/utils/metrics/mri'; import {useMetricsMeta} from 'sentry/utils/metrics/useMetricsMeta'; import {useMetricsTags} from 'sentry/utils/metrics/useMetricsTags'; import useApi from 'sentry/utils/useApi'; import useKeyPress from 'sentry/utils/useKeyPress'; import useOrganization from 'sentry/utils/useOrganization'; import usePageFilters from 'sentry/utils/usePageFilters'; type QueryBuilderProps = { displayType: MetricDisplayType; // TODO(ddm): move display type out of the query builder metricsQuery: Pick; onChange: (data: Partial) => void; projects: number[]; powerUserMode?: boolean; }; const isShownByDefault = (metric: MetricMeta) => isMeasurement(metric) || isCustomMetric(metric) || isTransactionDuration(metric); function stopPropagation(e: React.MouseEvent) { e.stopPropagation(); } export function QueryBuilder({ metricsQuery, projects, displayType, powerUserMode, onChange, }: QueryBuilderProps) { const {data: meta, isLoading: isMetaLoading} = useMetricsMeta(projects); const mriModeKeyPressed = useKeyPress('`', undefined, true); const [mriMode, setMriMode] = useState(powerUserMode); // power user mode that shows raw MRI instead of metrics names useEffect(() => { if (mriModeKeyPressed && !powerUserMode) { setMriMode(!mriMode); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [mriModeKeyPressed, powerUserMode]); const {data: tags = []} = useMetricsTags(metricsQuery.mri, projects); const displayedMetrics = useMemo(() => { if (mriMode) { return meta; } const isSelected = (metric: MetricMeta) => metric.mri === metricsQuery.mri; return meta.filter(metric => isShownByDefault(metric) || isSelected(metric)); }, [meta, metricsQuery.mri, mriMode]); const selectedMeta = useMemo(() => { return meta.find(metric => metric.mri === metricsQuery.mri); }, [meta, metricsQuery.mri]); // Reset the query data if the selected metric is no longer available useEffect(() => { if ( metricsQuery.mri && !isMetaLoading && !displayedMetrics.find(metric => metric.mri === metricsQuery.mri) ) { onChange({mri: '' as MRI, op: '', groupBy: []}); } }, [isMetaLoading, displayedMetrics, metricsQuery.mri, onChange]); return ( ({ label: mriMode ? metric.mri : formatMRI(metric.mri), value: metric.mri, trailingItems: mriMode ? undefined : ( {getReadableMetricType(metric.type)} {metric.unit} ), }))} value={metricsQuery.mri} onChange={option => { const availableOps = meta .find(metric => metric.mri === option.value) ?.operations.filter(isAllowedOp) ?? []; // @ts-expect-error .op is an operation const selectedOp = availableOps.includes(metricsQuery.op ?? '') ? metricsQuery.op : availableOps?.[0]; onChange({ mri: option.value, op: selectedOp, groupBy: undefined, focusedSeries: undefined, displayType: getWidgetDisplayType(option.value, selectedOp), }); }} /> ({ label: op, value: op, })) ?? [] } disabled={!metricsQuery.mri} value={metricsQuery.op} onChange={option => onChange({ op: option.value, }) } /> ({ label: tag.key, value: tag.key, trailingItems: ( {tag.key === 'release' && } {tag.key === 'transaction' && } ), }))} disabled={!metricsQuery.mri} value={metricsQuery.groupBy} onChange={options => onChange({ groupBy: options.map(o => o.value), focusedSeries: undefined, }) } /> { onChange({displayType: value}); }} /> {/* Stop propagation so widget does not get selected immediately */} id.toString())} mri={metricsQuery.mri} disabled={!metricsQuery.mri} onChange={query => onChange({query})} query={metricsQuery.query} /> ); } interface MetricSearchBarProps extends Partial { onChange: (value: string) => void; projectIds: string[]; disabled?: boolean; mri?: MRI; query?: string; } const EMPTY_ARRAY = []; const EMPTY_SET = new Set(); const DISSALLOWED_LOGICAL_OPERATORS = new Set([BooleanOperator.OR]); export function MetricSearchBar({ mri, disabled, onChange, query, projectIds, ...props }: MetricSearchBarProps) { const org = useOrganization(); const api = useApi(); const {selection} = usePageFilters(); const projectIdNumbers = useMemo( () => projectIds.map(id => parseInt(id, 10)), [projectIds] ); const {data: tags = EMPTY_ARRAY, isLoading} = useMetricsTags(mri, projectIdNumbers); const supportedTags: TagCollection = useMemo( () => tags.reduce((acc, tag) => ({...acc, [tag.key]: tag}), {}), [tags] ); // TODO(ddm): try to use useApiQuery here const getTagValues = useCallback( async tag => { const useCase = getUseCaseFromMRI(mri); const tagsValues = await api.requestPromise( `/organizations/${org.slug}/metrics/tags/${tag.key}/`, { query: { metric: mri, useCase, project: selection.projects, }, } ); return tagsValues.filter(tv => tv.value !== '').map(tv => tv.value); }, [api, mri, org.slug, selection.projects] ); const handleChange = useCallback( (value: string, {validSearch} = {validSearch: true}) => { if (validSearch) { onChange(value); } }, [onChange] ); return ( ); } function getWidgetDisplayType( mri: MetricsQuery['mri'], op: MetricsQuery['op'] ): MetricDisplayType { if (mri?.startsWith('c') || op === 'count') { return MetricDisplayType.BAR; } return MetricDisplayType.LINE; } const QueryBuilderWrapper = styled('div')` display: flex; flex-grow: 1; flex-direction: column; `; const QueryBuilderRow = styled('div')` padding: ${space(1)}; padding-bottom: 0; `; const WideSearchBar = styled(SmartSearchBar)` width: 100%; opacity: ${p => (p.disabled ? '0.6' : '1')}; `; const WrapPageFilterBar = styled(PageFilterBar)` max-width: max-content; height: auto; flex-wrap: wrap; `;