Browse Source

feat(ddm): Add url filter persisting (#56900)

Matej Minar 1 year ago
parent
commit
d747fe2541
3 changed files with 128 additions and 140 deletions
  1. 25 16
      static/app/utils/metrics.tsx
  2. 82 107
      static/app/views/ddm/metricsExplorer.tsx
  3. 21 17
      static/app/views/ddm/summaryTable.tsx

+ 25 - 16
static/app/utils/metrics.tsx

@@ -1,4 +1,5 @@
 import {useMemo} from 'react';
+import {InjectedRouter} from 'react-router';
 import moment from 'moment';
 
 import {getInterval} from 'sentry/components/charts/utils';
@@ -78,13 +79,16 @@ export function useMetricsTagValues(mri: string, tag: string) {
   );
 }
 
-export type MetricsDataProps = {
-  datetime: PageFilters['datetime'];
+export type MetricsQuery = {
   mri: string;
   groupBy?: string[];
   op?: string;
-  projects?: string[];
-  queryString?: string;
+  query?: string;
+};
+
+export type MetricsDataProps = MetricsQuery & {
+  datetime: PageFilters['datetime'];
+  projects: PageFilters['projects'];
 };
 
 // TODO(ddm): reuse from types/metrics.tsx
@@ -109,23 +113,22 @@ export function useMetricsData({
   op,
   datetime,
   projects,
-  queryString,
+  query,
   groupBy,
 }: MetricsDataProps) {
   const {slug} = useOrganization();
   const useCase = getUseCaseFromMri(mri);
   const field = op ? `${op}(${mri})` : mri;
 
-  const query = getQueryString({projects, queryString});
-
   const interval = getInterval(datetime, 'metrics');
 
   const queryToSend = {
     ...getDateTimeParams(datetime),
+    query,
+    project: projects,
     field,
     useCase,
     interval,
-    query,
     groupBy,
 
     // max result groups
@@ -157,14 +160,6 @@ function getDateTimeParams({start, end, period}: PageFilters['datetime']) {
     : {start: moment(start).toISOString(), end: moment(end).toISOString()};
 }
 
-function getQueryString({
-  projects = [],
-  queryString = '',
-}: Pick<MetricsDataProps, 'projects' | 'queryString'>): string {
-  const projectQuery = projects.length ? `project:[${projects}]` : '';
-  return [projectQuery, queryString].join(' ');
-}
-
 type UseCase = 'sessions' | 'transactions' | 'custom';
 
 export function getUseCaseFromMri(mri?: string): UseCase {
@@ -275,3 +270,17 @@ export function formatMetricsUsingUnitAndOp(
   }
   return formatMetricUsingUnit(value, unit);
 }
+
+export function isAllowedOp(op: string) {
+  return !['max_timestamp', 'min_timestamp', 'histogram'].includes(op);
+}
+
+export function updateQuery(router: InjectedRouter, partialQuery: Record<string, any>) {
+  router.push({
+    ...router.location,
+    query: {
+      ...router.location.query,
+      ...partialQuery,
+    },
+  });
+}

+ 82 - 107
static/app/views/ddm/metricsExplorer.tsx

@@ -1,4 +1,4 @@
-import {Fragment, useCallback, useEffect, useMemo, useReducer, useState} from 'react';
+import {Fragment, useCallback, useEffect, useMemo, useState} from 'react';
 import styled from '@emotion/styled';
 import moment from 'moment';
 
@@ -27,93 +27,61 @@ import {
   getReadableMetricType,
   getUnitFromMRI,
   getUseCaseFromMri,
+  isAllowedOp,
   MetricDisplayType,
   MetricsData,
   MetricsDataProps,
+  MetricsQuery,
+  updateQuery,
   useMetricsData,
   useMetricsMeta,
   useMetricsTags,
 } from 'sentry/utils/metrics';
+import {decodeList} from 'sentry/utils/queryString';
 import theme from 'sentry/utils/theme';
 import useApi from 'sentry/utils/useApi';
 import useKeyPress from 'sentry/utils/useKeyPress';
 import useOrganization from 'sentry/utils/useOrganization';
 import usePageFilters from 'sentry/utils/usePageFilters';
-import useProjects from 'sentry/utils/useProjects';
 import useRouter from 'sentry/utils/useRouter';
 import {SummaryTable} from 'sentry/views/ddm/summaryTable';
 
-const useProjectSelectionSlugs = () => {
-  const {selection} = usePageFilters();
-  const {projects} = useProjects();
-
-  return useMemo(
-    () =>
-      selection.projects
-        .map(id => projects.find(p => p.id === id.toString())?.slug)
-        .filter(Boolean) as string[],
-    [projects, selection.projects]
-  );
-};
-
 function MetricsExplorer() {
   const {selection} = usePageFilters();
 
-  const slugs = useProjectSelectionSlugs();
   const router = useRouter();
 
-  const [query, setQuery] = useState<QueryBuilderState>();
+  const metricsQuery: MetricsQuery = {
+    mri: router.location.query.mri,
+    op: router.location.query.op,
+    query: router.location.query.query,
+    groupBy: decodeList(router.location.query.groupBy),
+  };
 
   return (
     <MetricsExplorerPanel>
       <PanelBody>
-        <QueryBuilder setQuery={setQuery} />
-        {query && (
-          <MetricsExplorerDisplayOuter
-            displayType={router.location.query.display ?? defaultMetricDisplayType}
-            datetime={selection.datetime}
-            projects={slugs}
-            {...query}
-          />
-        )}
+        <QueryBuilder metricsQuery={metricsQuery} />
+        <MetricsExplorerDisplayOuter
+          displayType={router.location.query.display ?? defaultMetricDisplayType}
+          datetime={selection.datetime}
+          projects={selection.projects}
+          {...metricsQuery}
+        />
       </PanelBody>
     </MetricsExplorerPanel>
   );
 }
 
 type QueryBuilderProps = {
-  setQuery: (query: QueryBuilderState) => void;
-};
-
-type QueryBuilderState = {
-  groupBy: string[];
-  mri: string;
-  op: string;
-  queryString: string;
+  metricsQuery: MetricsQuery;
 };
 
-type QueryBuilderAction =
-  | {
-      type: 'mri';
-      value: string;
-    }
-  | {
-      type: 'op';
-      value: string;
-    }
-  | {
-      type: 'groupBy';
-      value: string[];
-    }
-  | {
-      type: 'queryString';
-      value: string;
-    };
-
-function QueryBuilder({setQuery}: QueryBuilderProps) {
+function QueryBuilder({metricsQuery}: QueryBuilderProps) {
+  const router = useRouter();
   const meta = useMetricsMeta();
   const mriModeKeyPressed = useKeyPress('`', undefined, true);
-  const [mriMode, setMriMode] = useState(false);
+  const [mriMode, setMriMode] = useState(false); // power user mode that shows raw MRI instead of metrics names
 
   useEffect(() => {
     if (mriModeKeyPressed) {
@@ -122,41 +90,12 @@ function QueryBuilder({setQuery}: QueryBuilderProps) {
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [mriModeKeyPressed]);
 
-  const isAllowedOp = (op: string) =>
-    !['max_timestamp', 'min_timestamp', 'histogram'].includes(op);
-
-  const reducer = (state: QueryBuilderState, action: QueryBuilderAction) => {
-    if (action.type === 'mri') {
-      const availableOps = meta[`${action.value}`]?.operations.filter(isAllowedOp);
-      const selectedOp = availableOps.includes(state.op) ? state.op : availableOps[0];
-      return {...state, mri: action.value, op: selectedOp};
-    }
-    if (['op', 'groupBy', 'queryString'].includes(action.type)) {
-      return {...state, [action.type]: action.value};
-    }
-
-    return state;
-  };
-
-  const [state, dispatch] = useReducer(reducer, {
-    mri: '',
-    op: '',
-    queryString: '',
-    groupBy: [],
-  });
-
-  const {data: tags = []} = useMetricsTags(state.mri);
-
-  useEffect(() => {
-    setQuery(state);
-  }, [state, setQuery]);
+  const {data: tags = []} = useMetricsTags(metricsQuery.mri);
 
   if (!meta) {
     return null;
   }
 
-  const selectedMetric = meta[state.mri] || {operations: []};
-
   return (
     <QueryBuilderWrapper>
       <QueryBuilderRow>
@@ -165,7 +104,11 @@ function QueryBuilder({setQuery}: QueryBuilderProps) {
             searchable
             triggerProps={{prefix: t('Metric'), size: 'sm'}}
             options={Object.values(meta)
-              .filter(metric => (mriMode ? true : metric.mri.includes(':custom/')))
+              .filter(metric =>
+                mriMode
+                  ? true
+                  : metric.mri.includes(':custom/') || metric.mri === metricsQuery.mri
+              )
               .map(metric => ({
                 label: mriMode ? metric.mri : metric.name,
                 value: metric.mri,
@@ -178,19 +121,34 @@ function QueryBuilder({setQuery}: QueryBuilderProps) {
                   </Fragment>
                 ),
               }))}
-            value={state.mri}
+            value={metricsQuery.mri}
             onChange={option => {
-              dispatch({type: 'mri', value: option.value});
+              const availableOps = meta[option.value]?.operations.filter(isAllowedOp);
+              const selectedOp = availableOps.includes(metricsQuery.op ?? '')
+                ? metricsQuery.op
+                : availableOps[0];
+              updateQuery(router, {
+                mri: option.value,
+                op: selectedOp,
+                groupBy: undefined,
+              });
             }}
           />
           <CompactSelect
             triggerProps={{prefix: t('Operation'), size: 'sm'}}
-            options={selectedMetric.operations.filter(isAllowedOp).map(op => ({
-              label: op,
-              value: op,
-            }))}
-            value={state.op}
-            onChange={option => dispatch({type: 'op', value: option.value})}
+            options={
+              meta[metricsQuery.mri]?.operations.filter(isAllowedOp).map(op => ({
+                label: op,
+                value: op,
+              })) ?? []
+            }
+            disabled={!metricsQuery.mri}
+            value={metricsQuery.op}
+            onChange={option =>
+              updateQuery(router, {
+                op: option.value,
+              })
+            }
           />
           <CompactSelect
             multiple
@@ -199,21 +157,23 @@ function QueryBuilder({setQuery}: QueryBuilderProps) {
               label: tag.key,
               value: tag.key,
             }))}
-            value={state.groupBy}
-            onChange={options => {
-              dispatch({type: 'groupBy', value: options.map(o => o.value)});
-            }}
+            disabled={!metricsQuery.mri}
+            value={metricsQuery.groupBy}
+            onChange={options =>
+              updateQuery(router, {
+                groupBy: options.map(o => o.value),
+              })
+            }
           />
         </PageFilterBar>
       </QueryBuilderRow>
       <QueryBuilderRow>
         <MetricSearchBar
           tags={tags}
-          mri={state.mri}
-          disabled={!state.mri}
-          onChange={data => {
-            dispatch({type: 'queryString', value: data});
-          }}
+          mri={metricsQuery.mri}
+          disabled={!metricsQuery.mri}
+          onChange={query => updateQuery(router, {query})}
+          query={metricsQuery.query}
         />
       </QueryBuilderRow>
     </QueryBuilderWrapper>
@@ -225,9 +185,10 @@ type MetricSearchBarProps = {
   onChange: (value: string) => void;
   tags: MetricsTag[];
   disabled?: boolean;
+  query?: string;
 };
 
-function MetricSearchBar({tags, mri, disabled, onChange}: MetricSearchBarProps) {
+function MetricSearchBar({tags, mri, disabled, onChange, query}: MetricSearchBarProps) {
   const org = useOrganization();
   const api = useApi();
 
@@ -268,6 +229,7 @@ function MetricSearchBar({tags, mri, disabled, onChange}: MetricSearchBarProps)
       onClose={handleChange}
       onSearch={handleChange}
       placeholder={t('Filter by tags')}
+      defaultQuery={query}
     />
   );
 }
@@ -287,6 +249,7 @@ const WideSearchBar = styled(SearchBar)`
   opacity: ${p => (p.disabled ? '0.6' : '1')};
 `;
 
+// TODO(ddm): reuse from types/metrics.tsx
 type Group = {
   by: Record<string, unknown>;
   series: Record<string, number[]>;
@@ -313,15 +276,27 @@ function MetricsExplorerDisplayOuter(props?: DisplayProps) {
 }
 
 function MetricsExplorerDisplay({displayType, ...metricsDataProps}: DisplayProps) {
+  const router = useRouter();
   const {data, isLoading, isError} = useMetricsData(metricsDataProps);
-  // TODO(ddm): maybe it is nicer to use a set here, or to keep state of shown series instead
-  const [hiddenSeries, setHiddenSeries] = useState<string[]>([]);
+  const hiddenSeries = decodeList(router.location.query.hiddenSeries);
 
   const toggleSeriesVisibility = (seriesName: string) => {
     if (hiddenSeries.includes(seriesName)) {
-      setHiddenSeries(hiddenSeries.filter(s => s !== seriesName));
+      router.push({
+        ...router.location,
+        query: {
+          ...router.location.query,
+          hiddenSeries: hiddenSeries.filter(s => s !== seriesName),
+        },
+      });
     } else {
-      setHiddenSeries([...hiddenSeries, seriesName]);
+      router.push({
+        ...router.location,
+        query: {
+          ...router.location.query,
+          hiddenSeries: [...hiddenSeries, seriesName],
+        },
+      });
     }
   };
 

+ 21 - 17
static/app/views/ddm/summaryTable.tsx

@@ -24,23 +24,27 @@ export function SummaryTable({
       <HeaderCell>{t('Max')}</HeaderCell>
       <HeaderCell>{t('Sum')}</HeaderCell>
 
-      {series.map(({seriesName, color, hidden, unit, data}) => {
-        const {avg, min, max, sum} = getValues(data);
-
-        return (
-          <Fragment key={seriesName}>
-            <FlexCell onClick={() => onClick(seriesName)} hidden={hidden}>
-              <ColorDot color={color} />
-            </FlexCell>
-            <Cell onClick={() => onClick(seriesName)}>{getNameFromMRI(seriesName)}</Cell>
-            {/* TODO(ddm): Add a tooltip with the full value, don't add on click in case users want to copy the value */}
-            <Cell>{formatMetricsUsingUnitAndOp(avg, unit, operation)}</Cell>
-            <Cell>{formatMetricsUsingUnitAndOp(min, unit, operation)}</Cell>
-            <Cell>{formatMetricsUsingUnitAndOp(max, unit, operation)}</Cell>
-            <Cell>{formatMetricsUsingUnitAndOp(sum, unit, operation)}</Cell>
-          </Fragment>
-        );
-      })}
+      {series
+        .sort((a, b) => a.seriesName.localeCompare(b.seriesName))
+        .map(({seriesName, color, hidden, unit, data}) => {
+          const {avg, min, max, sum} = getValues(data);
+
+          return (
+            <Fragment key={seriesName}>
+              <FlexCell onClick={() => onClick(seriesName)} hidden={hidden}>
+                <ColorDot color={color} />
+              </FlexCell>
+              <Cell onClick={() => onClick(seriesName)}>
+                {getNameFromMRI(seriesName)}
+              </Cell>
+              {/* TODO(ddm): Add a tooltip with the full value, don't add on click in case users want to copy the value */}
+              <Cell>{formatMetricsUsingUnitAndOp(avg, unit, operation)}</Cell>
+              <Cell>{formatMetricsUsingUnitAndOp(min, unit, operation)}</Cell>
+              <Cell>{formatMetricsUsingUnitAndOp(max, unit, operation)}</Cell>
+              <Cell>{formatMetricsUsingUnitAndOp(sum, unit, operation)}</Cell>
+            </Fragment>
+          );
+        })}
     </SummaryTableWrapper>
   );
 }