Browse Source

feat(widget-builder): Support dataset specific search bars (#82088)

The filter bars should now suggest tags and offer
auto-complete.
Nar Saynorath 2 months ago
parent
commit
03dce6eab4

+ 6 - 1
static/app/views/dashboards/widgetBuilder/components/newWidgetBuilder.spec.tsx

@@ -84,6 +84,10 @@ describe('NewWidgetBuiler', function () {
       url: '/organizations/org-slug/measurements-meta/',
       url: '/organizations/org-slug/measurements-meta/',
       body: [],
       body: [],
     });
     });
+
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/recent-searches/',
+    });
   });
   });
 
 
   afterEach(() => PageFiltersStore.reset());
   afterEach(() => PageFiltersStore.reset());
@@ -126,7 +130,8 @@ describe('NewWidgetBuiler', function () {
     // ensure the dropdown input has the default value 'table'
     // ensure the dropdown input has the default value 'table'
     expect(screen.getByDisplayValue('table')).toBeInTheDocument();
     expect(screen.getByDisplayValue('table')).toBeInTheDocument();
 
 
-    expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
+    expect(screen.getByText('Filter')).toBeInTheDocument();
+    expect(screen.getByLabelText('Create a search query')).toBeInTheDocument();
 
 
     // Test sort by selector for table display type
     // Test sort by selector for table display type
     expect(screen.getByText('Sort by')).toBeInTheDocument();
     expect(screen.getByText('Sort by')).toBeInTheDocument();

+ 8 - 1
static/app/views/dashboards/widgetBuilder/components/newWidgetBuilder.tsx

@@ -1,4 +1,4 @@
-import {Fragment, useEffect} from 'react';
+import {Fragment, useEffect, useState} from 'react';
 import {css} from '@emotion/react';
 import {css} from '@emotion/react';
 import styled from '@emotion/styled';
 import styled from '@emotion/styled';
 import {AnimatePresence, motion} from 'framer-motion';
 import {AnimatePresence, motion} from 'framer-motion';
@@ -47,6 +47,8 @@ function WidgetBuilderV2({
   const organization = useOrganization();
   const organization = useOrganization();
   const {selection} = usePageFilters();
   const {selection} = usePageFilters();
 
 
+  const [queryConditionsValid, setQueryConditionsValid] = useState<boolean>(true);
+
   useEffect(() => {
   useEffect(() => {
     if (escapeKeyPressed) {
     if (escapeKeyPressed) {
       if (isOpen) {
       if (isOpen) {
@@ -72,10 +74,12 @@ function WidgetBuilderV2({
                       isOpen={isOpen}
                       isOpen={isOpen}
                       onClose={onClose}
                       onClose={onClose}
                       onSave={onSave}
                       onSave={onSave}
+                      onQueryConditionChange={setQueryConditionsValid}
                     />
                     />
                     <WidgetPreviewContainer
                     <WidgetPreviewContainer
                       dashboardFilters={dashboardFilters}
                       dashboardFilters={dashboardFilters}
                       dashboard={dashboard}
                       dashboard={dashboard}
+                      isWidgetInvalid={!queryConditionsValid}
                     />
                     />
                   </WidgetBuilderContainer>
                   </WidgetBuilderContainer>
                 </ContainerWithoutSidebar>
                 </ContainerWithoutSidebar>
@@ -93,9 +97,11 @@ export default WidgetBuilderV2;
 function WidgetPreviewContainer({
 function WidgetPreviewContainer({
   dashboardFilters,
   dashboardFilters,
   dashboard,
   dashboard,
+  isWidgetInvalid,
 }: {
 }: {
   dashboard: DashboardDetails;
   dashboard: DashboardDetails;
   dashboardFilters: DashboardFilters;
   dashboardFilters: DashboardFilters;
+  isWidgetInvalid: boolean;
 }) {
 }) {
   const {state} = useWidgetBuilderContext();
   const {state} = useWidgetBuilderContext();
   const organization = useOrganization();
   const organization = useOrganization();
@@ -129,6 +135,7 @@ function WidgetPreviewContainer({
                 <WidgetPreview
                 <WidgetPreview
                   dashboardFilters={dashboardFilters}
                   dashboardFilters={dashboardFilters}
                   dashboard={dashboard}
                   dashboard={dashboard}
+                  isWidgetInvalid={isWidgetInvalid}
                 />
                 />
               </SampleWidgetCard>
               </SampleWidgetCard>
             </MEPSettingProvider>
             </MEPSettingProvider>

+ 54 - 0
static/app/views/dashboards/widgetBuilder/components/queryFilterBuilder.spec.tsx

@@ -0,0 +1,54 @@
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import useCustomMeasurements from 'sentry/utils/useCustomMeasurements';
+import {WidgetType} from 'sentry/views/dashboards/types';
+import WidgetBuilderQueryFilterBuilder from 'sentry/views/dashboards/widgetBuilder/components/queryFilterBuilder';
+import {WidgetBuilderProvider} from 'sentry/views/dashboards/widgetBuilder/contexts/widgetBuilderContext';
+import useWidgetBuilderState from 'sentry/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState';
+import {useSpanTags} from 'sentry/views/explore/contexts/spanTagsContext';
+
+jest.mock('sentry/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState');
+jest.mock('sentry/utils/useCustomMeasurements');
+jest.mock('sentry/views/explore/contexts/spanTagsContext');
+
+describe('QueryFilterBuilder', () => {
+  beforeEach(() => {
+    jest.mocked(useWidgetBuilderState).mockReturnValue({
+      dispatch: jest.fn(),
+      state: {dataset: WidgetType.ERRORS},
+    });
+    jest.mocked(useCustomMeasurements).mockReturnValue({
+      customMeasurements: {},
+    });
+    jest.mocked(useSpanTags).mockReturnValue({});
+
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/recent-searches/',
+    });
+  });
+
+  it('renders a dataset-specific query filter bar', async () => {
+    const {rerender} = render(
+      <WidgetBuilderProvider>
+        <WidgetBuilderQueryFilterBuilder onQueryConditionChange={() => {}} />
+      </WidgetBuilderProvider>
+    );
+    expect(
+      await screen.findByPlaceholderText('Search for events, users, tags, and more')
+    ).toBeInTheDocument();
+
+    jest.mocked(useWidgetBuilderState).mockReturnValue({
+      dispatch: jest.fn(),
+      state: {dataset: WidgetType.SPANS},
+    });
+
+    rerender(
+      <WidgetBuilderProvider>
+        <WidgetBuilderQueryFilterBuilder onQueryConditionChange={() => {}} />
+      </WidgetBuilderProvider>
+    );
+    expect(
+      await screen.findByPlaceholderText('Search for spans, users, tags, and more')
+    ).toBeInTheDocument();
+  });
+});

+ 101 - 39
static/app/views/dashboards/widgetBuilder/components/queryFilterBuilder.tsx

@@ -1,19 +1,46 @@
-import {Fragment} from 'react';
+import {Fragment, useCallback, useState} from 'react';
 import styled from '@emotion/styled';
 import styled from '@emotion/styled';
+import cloneDeep from 'lodash/cloneDeep';
 
 
 import {Button} from 'sentry/components/button';
 import {Button} from 'sentry/components/button';
 import Input from 'sentry/components/input';
 import Input from 'sentry/components/input';
-import {SearchQueryBuilder} from 'sentry/components/searchQueryBuilder';
 import {IconAdd, IconDelete} from 'sentry/icons';
 import {IconAdd, IconDelete} from 'sentry/icons';
-import {t} from 'sentry/locale';
+import {t, tct} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import {space} from 'sentry/styles/space';
-import {DisplayType} from 'sentry/views/dashboards/types';
+import {
+  createOnDemandFilterWarning,
+  shouldDisplayOnDemandWidgetWarning,
+} from 'sentry/utils/onDemandMetrics';
+import useOrganization from 'sentry/utils/useOrganization';
+import usePageFilters from 'sentry/utils/usePageFilters';
+import {getDatasetConfig} from 'sentry/views/dashboards/datasetConfig/base';
+import {DisplayType, WidgetType} from 'sentry/views/dashboards/types';
 import {SectionHeader} from 'sentry/views/dashboards/widgetBuilder/components/common/sectionHeader';
 import {SectionHeader} from 'sentry/views/dashboards/widgetBuilder/components/common/sectionHeader';
 import {useWidgetBuilderContext} from 'sentry/views/dashboards/widgetBuilder/contexts/widgetBuilderContext';
 import {useWidgetBuilderContext} from 'sentry/views/dashboards/widgetBuilder/contexts/widgetBuilderContext';
 import {BuilderStateAction} from 'sentry/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState';
 import {BuilderStateAction} from 'sentry/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState';
+import {getDiscoverDatasetFromWidgetType} from 'sentry/views/dashboards/widgetBuilder/utils';
+import {convertBuilderStateToWidget} from 'sentry/views/dashboards/widgetBuilder/utils/convertBuilderStateToWidget';
 
 
-function WidgetBuilderQueryFilterBuilder() {
+interface WidgetBuilderQueryFilterBuilderProps {
+  onQueryConditionChange: (valid: boolean) => void;
+}
+
+function WidgetBuilderQueryFilterBuilder({
+  onQueryConditionChange,
+}: WidgetBuilderQueryFilterBuilderProps) {
   const {state, dispatch} = useWidgetBuilderContext();
   const {state, dispatch} = useWidgetBuilderContext();
+  const {selection} = usePageFilters();
+  const organization = useOrganization();
+
+  const [queryConditionValidity, setQueryConditionValidity] = useState<boolean[]>(() => {
+    // Make a validity entry for each query condition initially
+    return state.query?.map(() => true) ?? [];
+  });
+
+  const widgetType = state.dataset ?? WidgetType.ERRORS;
+  const datasetConfig = getDatasetConfig(state.dataset);
+
+  const widget = convertBuilderStateToWidget(state);
 
 
   const canAddSearchConditions =
   const canAddSearchConditions =
     state.displayType !== DisplayType.TABLE &&
     state.displayType !== DisplayType.TABLE &&
@@ -27,6 +54,45 @@ function WidgetBuilderQueryFilterBuilder() {
     });
     });
   };
   };
 
 
+  const handleClose = useCallback(
+    (queryIndex: number) => {
+      return (field: string, props: any) => {
+        const {validSearch} = props;
+        const nextQueryConditionValidity = cloneDeep(queryConditionValidity);
+        nextQueryConditionValidity[queryIndex] = validSearch;
+        setQueryConditionValidity(nextQueryConditionValidity);
+        onQueryConditionChange(nextQueryConditionValidity.every(validity => validity));
+        dispatch({
+          type: BuilderStateAction.SET_QUERY,
+          payload: state.query?.map((q, i) => (i === queryIndex ? field : q)) ?? [],
+        });
+      };
+    },
+    [dispatch, queryConditionValidity, state.query, onQueryConditionChange]
+  );
+
+  const handleRemove = useCallback(
+    (queryIndex: number) => () => {
+      queryConditionValidity.splice(queryIndex, 1);
+      setQueryConditionValidity(queryConditionValidity);
+      onQueryConditionChange(queryConditionValidity.every(validity => validity));
+      dispatch({
+        type: BuilderStateAction.SET_QUERY,
+        payload: state.query?.filter((_, i) => i !== queryIndex) ?? [],
+      });
+    },
+    [dispatch, queryConditionValidity, state.query, onQueryConditionChange]
+  );
+
+  const getOnDemandFilterWarning = createOnDemandFilterWarning(
+    tct(
+      'We don’t routinely collect metrics from this property. However, we’ll do so [strong:once this widget has been saved.]',
+      {
+        strong: <strong />,
+      }
+    )
+  );
+
   return (
   return (
     <Fragment>
     <Fragment>
       <SectionHeader
       <SectionHeader
@@ -42,14 +108,26 @@ function WidgetBuilderQueryFilterBuilder() {
       />
       />
       {!state.query?.length ? (
       {!state.query?.length ? (
         <QueryFieldRowWrapper key={0}>
         <QueryFieldRowWrapper key={0}>
-          <QueryField
-            query={''}
+          <datasetConfig.SearchBar
+            getFilterWarning={
+              shouldDisplayOnDemandWidgetWarning(
+                widget.queries[0],
+                widgetType,
+                organization
+              )
+                ? getOnDemandFilterWarning
+                : undefined
+            }
+            pageFilters={selection}
+            onClose={handleClose(0)}
             onSearch={queryString => {
             onSearch={queryString => {
               dispatch({
               dispatch({
                 type: BuilderStateAction.SET_QUERY,
                 type: BuilderStateAction.SET_QUERY,
                 payload: [queryString],
                 payload: [queryString],
               });
               });
             }}
             }}
+            widgetQuery={widget.queries[0]}
+            dataset={getDiscoverDatasetFromWidgetType(widgetType)}
           />
           />
           {canAddSearchConditions && (
           {canAddSearchConditions && (
             // TODO: Hook up alias to query hook when it's implemented
             // TODO: Hook up alias to query hook when it's implemented
@@ -62,10 +140,20 @@ function WidgetBuilderQueryFilterBuilder() {
           )}
           )}
         </QueryFieldRowWrapper>
         </QueryFieldRowWrapper>
       ) : (
       ) : (
-        state.query?.map((query, index) => (
+        state.query?.map((_, index) => (
           <QueryFieldRowWrapper key={index}>
           <QueryFieldRowWrapper key={index}>
-            <QueryField
-              query={query}
+            <datasetConfig.SearchBar
+              getFilterWarning={
+                shouldDisplayOnDemandWidgetWarning(
+                  widget.queries[index],
+                  widgetType,
+                  organization
+                )
+                  ? getOnDemandFilterWarning
+                  : undefined
+              }
+              pageFilters={selection}
+              onClose={handleClose(index)}
               onSearch={queryString => {
               onSearch={queryString => {
                 dispatch({
                 dispatch({
                   type: BuilderStateAction.SET_QUERY,
                   type: BuilderStateAction.SET_QUERY,
@@ -73,6 +161,8 @@ function WidgetBuilderQueryFilterBuilder() {
                     state.query?.map((q, i) => (i === index ? queryString : q)) ?? [],
                     state.query?.map((q, i) => (i === index ? queryString : q)) ?? [],
                 });
                 });
               }}
               }}
+              widgetQuery={widget.queries[index]}
+              dataset={getDiscoverDatasetFromWidgetType(widgetType)}
             />
             />
             {canAddSearchConditions && (
             {canAddSearchConditions && (
               // TODO: Hook up alias to query hook when it's implemented
               // TODO: Hook up alias to query hook when it's implemented
@@ -84,14 +174,7 @@ function WidgetBuilderQueryFilterBuilder() {
               />
               />
             )}
             )}
             {state.query && state.query?.length > 1 && canAddSearchConditions && (
             {state.query && state.query?.length > 1 && canAddSearchConditions && (
-              <DeleteButton
-                onDelete={() =>
-                  dispatch({
-                    type: BuilderStateAction.SET_QUERY,
-                    payload: state.query?.filter((_, i) => i !== index) ?? [],
-                  })
-                }
-              />
+              <DeleteButton onDelete={handleRemove(index)} />
             )}
             )}
           </QueryFieldRowWrapper>
           </QueryFieldRowWrapper>
         ))
         ))
@@ -107,27 +190,6 @@ function WidgetBuilderQueryFilterBuilder() {
 
 
 export default WidgetBuilderQueryFilterBuilder;
 export default WidgetBuilderQueryFilterBuilder;
 
 
-function QueryField({
-  query,
-  onSearch,
-}: {
-  onSearch: (query: string) => void;
-  query: string;
-}) {
-  return (
-    <SearchQueryBuilder
-      placeholder={t('Search')}
-      filterKeys={{}}
-      initialQuery={query ?? ''}
-      onSearch={onSearch}
-      searchSource={'widget_builder'}
-      filterKeySections={[]}
-      getTagValues={() => Promise.resolve([])}
-      showUnsubmittedIndicator
-    />
-  );
-}
-
 export function DeleteButton({onDelete}: {onDelete: () => void}) {
 export function DeleteButton({onDelete}: {onDelete: () => void}) {
   return (
   return (
     <Button
     <Button

+ 10 - 2
static/app/views/dashboards/widgetBuilder/components/widgetBuilderSlideout.tsx

@@ -21,10 +21,16 @@ import {useWidgetBuilderContext} from 'sentry/views/dashboards/widgetBuilder/con
 type WidgetBuilderSlideoutProps = {
 type WidgetBuilderSlideoutProps = {
   isOpen: boolean;
   isOpen: boolean;
   onClose: () => void;
   onClose: () => void;
+  onQueryConditionChange: (valid: boolean) => void;
   onSave: ({index, widget}: {index: number; widget: Widget}) => void;
   onSave: ({index, widget}: {index: number; widget: Widget}) => void;
 };
 };
 
 
-function WidgetBuilderSlideout({isOpen, onClose, onSave}: WidgetBuilderSlideoutProps) {
+function WidgetBuilderSlideout({
+  isOpen,
+  onClose,
+  onSave,
+  onQueryConditionChange,
+}: WidgetBuilderSlideoutProps) {
   const {state} = useWidgetBuilderContext();
   const {state} = useWidgetBuilderContext();
   const {widgetIndex} = useParams();
   const {widgetIndex} = useParams();
   const isEditing = widgetIndex !== undefined;
   const isEditing = widgetIndex !== undefined;
@@ -68,7 +74,9 @@ function WidgetBuilderSlideout({isOpen, onClose, onSave}: WidgetBuilderSlideoutP
           <Visualize />
           <Visualize />
         </Section>
         </Section>
         <Section>
         <Section>
-          <WidgetBuilderQueryFilterBuilder />
+          <WidgetBuilderQueryFilterBuilder
+            onQueryConditionChange={onQueryConditionChange}
+          />
         </Section>
         </Section>
         {isChartWidget && (
         {isChartWidget && (
           <Section>
           <Section>

+ 7 - 5
static/app/views/dashboards/widgetBuilder/components/widgetPreview.tsx

@@ -17,9 +17,14 @@ import WidgetLegendSelectionState from 'sentry/views/dashboards/widgetLegendSele
 interface WidgetPreviewProps {
 interface WidgetPreviewProps {
   dashboard: DashboardDetails;
   dashboard: DashboardDetails;
   dashboardFilters: DashboardFilters;
   dashboardFilters: DashboardFilters;
+  isWidgetInvalid?: boolean;
 }
 }
 
 
-function WidgetPreview({dashboard, dashboardFilters}: WidgetPreviewProps) {
+function WidgetPreview({
+  dashboard,
+  dashboardFilters,
+  isWidgetInvalid,
+}: WidgetPreviewProps) {
   const organization = useOrganization();
   const organization = useOrganization();
   const location = useLocation();
   const location = useLocation();
   const router = useRouter();
   const router = useRouter();
@@ -46,6 +51,7 @@ function WidgetPreview({dashboard, dashboardFilters}: WidgetPreviewProps) {
   return (
   return (
     <WidgetCard
     <WidgetCard
       disableFullscreen
       disableFullscreen
+      isWidgetInvalid={isWidgetInvalid}
       shouldResize={state.displayType !== DisplayType.TABLE}
       shouldResize={state.displayType !== DisplayType.TABLE}
       organization={organization}
       organization={organization}
       selection={pageFilters.selection}
       selection={pageFilters.selection}
@@ -66,10 +72,6 @@ function WidgetPreview({dashboard, dashboardFilters}: WidgetPreviewProps) {
           : undefined
           : undefined
       }
       }
       widgetLegendState={widgetLegendState}
       widgetLegendState={widgetLegendState}
-      // TODO: This can only be set once we have the filter bar in place
-      isWidgetInvalid={false}
-      // isWidgetInvalid={!state.queryConditionsValid}
-
       // TODO: This will be filled in once we start supporting thresholds
       // TODO: This will be filled in once we start supporting thresholds
       onDataFetched={() => {}}
       onDataFetched={() => {}}
       // onDataFetched={onDataFetched}
       // onDataFetched={onDataFetched}