Browse Source

feat(dashboards): Add Releases top level filter (#36411)

Add the top level query filter for releases.
Note that selecting an option currently doesn't
do anything. Implementing some sort of pagination
on scroll will be done in a follow up.
Shruthi 2 years ago
parent
commit
93e596a727

+ 102 - 0
static/app/utils/releases/releasesProvider.tsx

@@ -0,0 +1,102 @@
+import {createContext, useContext, useEffect, useState} from 'react';
+
+import {addErrorMessage} from 'sentry/actionCreators/indicator';
+import {Client} from 'sentry/api';
+import {t} from 'sentry/locale';
+import {Organization, PageFilters, Release} from 'sentry/types';
+import handleXhrErrorResponse from 'sentry/utils/handleXhrErrorResponse';
+
+import useApi from '../useApi';
+
+function fetchReleases(api: Client, orgSlug: string, selection: PageFilters) {
+  const {environments, projects} = selection;
+
+  return api.requestPromise(`/organizations/${orgSlug}/releases/`, {
+    method: 'GET',
+    data: {
+      sort: 'date',
+      project: projects,
+      per_page: 50,
+      environment: environments,
+    },
+  });
+}
+
+type ReleasesProviderProps = {
+  children: React.ReactNode;
+  organization: Organization;
+  selection: PageFilters;
+  skipLoad?: boolean;
+};
+
+function ReleasesProvider({
+  children,
+  organization,
+  selection,
+  skipLoad = false,
+}: ReleasesProviderProps) {
+  const api = useApi();
+  const [releases, setReleases] = useState<Release[]>([]);
+  const [loading, setLoading] = useState(true);
+
+  useEffect(() => {
+    if (skipLoad) {
+      setLoading(false);
+      return undefined;
+    }
+
+    let shouldCancelRequest = false;
+    setLoading(true);
+    fetchReleases(api, organization.slug, selection)
+      .then(response => {
+        if (shouldCancelRequest) {
+          setLoading(false);
+          return;
+        }
+        setLoading(false);
+        setReleases(response);
+      })
+      .catch(e => {
+        if (shouldCancelRequest) {
+          setLoading(false);
+          return;
+        }
+
+        const errorResponse = e?.responseJSON ?? t('Unable to fetch releases');
+        addErrorMessage(errorResponse);
+        setLoading(false);
+        handleXhrErrorResponse(errorResponse)(e);
+      });
+    return () => {
+      shouldCancelRequest = true;
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [skipLoad, api, organization.slug, JSON.stringify(selection)]);
+
+  return (
+    <ReleasesContext.Provider value={{releases, loading}}>
+      {children}
+    </ReleasesContext.Provider>
+  );
+}
+
+interface ReleasesContextValue {
+  loading: boolean;
+  releases: Release[];
+}
+
+const ReleasesContext = createContext<ReleasesContextValue | undefined>(undefined);
+
+function useReleases() {
+  const releasesContext = useContext(ReleasesContext);
+
+  if (!releasesContext) {
+    throw new Error('releasesContext was called outside of ReleasesProvider');
+  }
+
+  return releasesContext;
+}
+
+const ReleasesConsumer = ReleasesContext.Consumer;
+
+export {ReleasesContext, ReleasesConsumer, ReleasesProvider, useReleases};

+ 48 - 10
static/app/views/dashboardsV2/detail.tsx

@@ -16,7 +16,9 @@ import {
   openWidgetViewerModal,
 } from 'sentry/actionCreators/modal';
 import {Client} from 'sentry/api';
+import Feature from 'sentry/components/acl/feature';
 import Breadcrumbs from 'sentry/components/breadcrumbs';
+import ButtonBar from 'sentry/components/buttonBar';
 import DatePageFilter from 'sentry/components/datePageFilter';
 import EnvironmentPageFilter from 'sentry/components/environmentPageFilter';
 import HookOrDefault from 'sentry/components/hookOrDefault';
@@ -33,12 +35,14 @@ import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import {t} from 'sentry/locale';
 import {PageContent} from 'sentry/styles/organization';
 import space from 'sentry/styles/space';
-import {Organization} from 'sentry/types';
+import {Organization, PageFilters} from 'sentry/types';
 import {defined} from 'sentry/utils';
 import {trackAnalyticsEvent} from 'sentry/utils/analytics';
 import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
+import {ReleasesProvider} from 'sentry/utils/releases/releasesProvider';
 import withApi from 'sentry/utils/withApi';
 import withOrganization from 'sentry/utils/withOrganization';
+import withPageFilters from 'sentry/utils/withPageFilters';
 
 import {
   WidgetViewerContext,
@@ -52,6 +56,7 @@ import {
   calculateColumnDepths,
   getDashboardLayout,
 } from './layoutUtils';
+import ReleasesSelectControl from './releasesSelectControl';
 import DashboardTitle from './title';
 import {
   DashboardDetails,
@@ -82,6 +87,7 @@ type Props = RouteComponentProps<RouteParams, {}> & {
   initialState: DashboardState;
   organization: Organization;
   route: PlainRoute;
+  selection: PageFilters;
   newWidget?: Widget;
   onDashboardUpdate?: (updatedDashboard: DashboardDetails) => void;
   onSetNewWidget?: () => void;
@@ -628,11 +634,11 @@ class DashboardDetail extends Component<Props, State> {
                 widgetLimitReached={widgetLimitReached}
               />
             </StyledPageHeader>
-            <DashboardPageFilterBar condensed>
+            <PageFilterBar condensed>
               <ProjectPageFilter />
               <EnvironmentPageFilter />
               <DatePageFilter alignDropdown="left" />
-            </DashboardPageFilterBar>
+            </PageFilterBar>
             <HookHeader organization={organization} />
             <Dashboard
               paramDashboardId={dashboardId}
@@ -674,6 +680,7 @@ class DashboardDetail extends Component<Props, State> {
       router,
       location,
       newWidget,
+      selection,
       onSetNewWidget,
     } = this.props;
     const {modifiedDashboard, dashboardState, widgetLimitReached, seriesData, setData} =
@@ -731,11 +738,23 @@ class DashboardDetail extends Component<Props, State> {
               </Layout.Header>
               <Layout.Body>
                 <Layout.Main fullWidth>
-                  <DashboardPageFilterBar condensed>
-                    <ProjectPageFilter />
-                    <EnvironmentPageFilter />
-                    <DatePageFilter alignDropdown="left" />
-                  </DashboardPageFilterBar>
+                  <Wrapper>
+                    <PageFilterBar condensed>
+                      <ProjectPageFilter />
+                      <EnvironmentPageFilter />
+                      <DatePageFilter alignDropdown="left" />
+                    </PageFilterBar>
+                    <Feature features={['dashboards-top-level-filter']}>
+                      <FilterButtons>
+                        <ReleasesProvider
+                          organization={organization}
+                          selection={selection}
+                        >
+                          <ReleasesSelectControl />
+                        </ReleasesProvider>
+                      </FilterButtons>
+                    </Feature>
+                  </Wrapper>
                   <WidgetViewerContext.Provider value={{seriesData, setData}}>
                     <Dashboard
                       paramDashboardId={dashboardId}
@@ -799,8 +818,27 @@ const StyledPageContent = styled(PageContent)`
   padding: 0;
 `;
 
-const DashboardPageFilterBar = styled(PageFilterBar)`
+const Wrapper = styled('div')`
+  display: grid;
+  gap: ${space(1.5)};
   margin-bottom: ${space(2)};
+
+  @media (min-width: ${p => p.theme.breakpoints.small}) {
+    grid-template-columns: min-content 1fr;
+  }
+`;
+
+const FilterButtons = styled(ButtonBar)`
+  @media (max-width: ${p => p.theme.breakpoints.small}) {
+    display: flex;
+    align-items: flex-start;
+    gap: ${space(1.5)};
+  }
+
+  @media (min-width: ${p => p.theme.breakpoints.small}) {
+    display: grid;
+    grid-auto-columns: minmax(auto, 300px);
+  }
 `;
 
-export default withApi(withOrganization(DashboardDetail));
+export default withApi(withOrganization(withPageFilters(DashboardDetail)));

+ 58 - 0
static/app/views/dashboardsV2/releasesSelectControl.tsx

@@ -0,0 +1,58 @@
+import {Fragment, useState} from 'react';
+import styled from '@emotion/styled';
+
+import Badge from 'sentry/components/badge';
+import CompactSelect from 'sentry/components/forms/compactSelect';
+import TextOverflow from 'sentry/components/textOverflow';
+import {IconReleases} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {Release} from 'sentry/types';
+import {useReleases} from 'sentry/utils/releases/releasesProvider';
+
+function ReleasesSelectControl() {
+  const {releases, loading} = useReleases();
+  const [selectedReleases, setSelectedReleases] = useState<Release[]>([]);
+
+  const triggerLabel = selectedReleases.length ? (
+    <TextOverflow>{selectedReleases[0]}</TextOverflow>
+  ) : (
+    t('All Releases')
+  );
+
+  return (
+    <CompactSelect
+      multiple
+      isClearable
+      isSearchable
+      isLoading={loading}
+      menuTitle={t('Filter Releases')}
+      options={
+        releases.length
+          ? releases.map(release => {
+              return {
+                label: release.shortVersion ?? release.version,
+                value: release.version,
+              };
+            })
+          : []
+      }
+      onChange={opts => setSelectedReleases(opts.map(opt => opt.value))}
+      value={selectedReleases}
+      triggerLabel={
+        <Fragment>
+          {triggerLabel}
+          {selectedReleases.length > 1 && (
+            <StyledBadge text={`+${selectedReleases.length - 1}`} />
+          )}
+        </Fragment>
+      }
+      triggerProps={{icon: <IconReleases />}}
+    />
+  );
+}
+
+export default ReleasesSelectControl;
+
+const StyledBadge = styled(Badge)`
+  flex-shrink: 0;
+`;

+ 1 - 1
static/app/views/dashboardsV2/widgetCard/releaseWidgetQueries.tsx

@@ -178,7 +178,7 @@ class ReleaseWidgetQueries extends Component<Props, State> {
             sort: 'date',
             project: projects,
             per_page: 50,
-            environments,
+            environment: environments,
           },
         }
       );

+ 66 - 0
tests/js/spec/utils/releases/useReleases.spec.tsx

@@ -0,0 +1,66 @@
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import {PageFilters} from 'sentry/types';
+import {ReleasesProvider, useReleases} from 'sentry/utils/releases/releasesProvider';
+
+function TestComponent({other}: {other: string}) {
+  const {releases, loading} = useReleases();
+  return (
+    <div>
+      <span>{other}</span>
+      {releases &&
+        releases.map(release => <em key={release.version}>{release.version}</em>)}
+      {`loading: ${loading}`}
+    </div>
+  );
+}
+
+describe('useReleases', function () {
+  const {organization} = initializeOrg();
+  const selection = {
+    projects: [1],
+    environments: ['prod'],
+    datetime: {
+      period: '14d',
+      start: null,
+      end: null,
+      utc: false,
+    },
+  } as PageFilters;
+
+  it("fetches releases and save values in the context's state", async function () {
+    const mockReleases = MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/releases/',
+      body: [
+        TestStubs.Release({
+          shortVersion: 'sentry-android-shop@1.2.0',
+          version: 'sentry-android-shop@1.2.0',
+        }),
+        TestStubs.Release({
+          shortVersion: 'sentry-android-shop@1.3.0',
+          version: 'sentry-android-shop@1.3.0',
+        }),
+        TestStubs.Release({
+          shortVersion: 'sentry-android-shop@1.4.0',
+          version: 'sentry-android-shop@1.4.0',
+        }),
+      ],
+    });
+
+    render(
+      <ReleasesProvider organization={organization} selection={selection}>
+        <TestComponent other="value" />
+      </ReleasesProvider>
+    );
+
+    // Should forward prop
+    expect(screen.getByText('value')).toBeInTheDocument();
+
+    expect(mockReleases).toHaveBeenCalledTimes(1);
+
+    expect(await screen.findByText('loading: false')).toBeInTheDocument();
+    expect(screen.getByText('sentry-android-shop@1.2.0')).toBeInTheDocument();
+    expect(screen.getByText('sentry-android-shop@1.3.0')).toBeInTheDocument();
+  });
+});

+ 38 - 0
tests/js/spec/views/dashboardsV2/gridLayout/detail.spec.jsx

@@ -442,6 +442,44 @@ describe('Dashboards > Detail', function () {
       expect(openEditModal).toHaveBeenCalledTimes(1);
     });
 
+    it('shows top level release filter', async function () {
+      const mockReleases = MockApiClient.addMockResponse({
+        url: '/organizations/org-slug/releases/',
+        body: [TestStubs.Release()],
+      });
+
+      initialData = initializeOrg({
+        organization: TestStubs.Organization({
+          features: [
+            'global-views',
+            'dashboards-basic',
+            'dashboards-edit',
+            'discover-query',
+            'dashboards-top-level-filter',
+          ],
+          projects: [TestStubs.Project()],
+        }),
+      });
+
+      wrapper = mountWithTheme(
+        <OrganizationContext.Provider value={initialData.organization}>
+          <ViewEditDashboard
+            organization={initialData.organization}
+            params={{orgId: 'org-slug', dashboardId: '1'}}
+            router={initialData.router}
+            location={initialData.router.location}
+          />
+        </OrganizationContext.Provider>,
+        initialData.routerContext
+      );
+      await act(async () => {
+        await tick();
+        wrapper.update();
+      });
+      expect(wrapper.find('ReleasesSelectControl').exists()).toBe(true);
+      expect(mockReleases).toHaveBeenCalledTimes(1);
+    });
+
     it('opens widget library when add widget option is clicked', async function () {
       initialData = initializeOrg({
         organization: TestStubs.Organization({

+ 1 - 1
tests/js/spec/views/dashboardsV2/releaseWidgetQueries.spec.tsx

@@ -150,7 +150,7 @@ describe('Dashboards > ReleaseWidgetQueries', function () {
       '/organizations/org-slug/releases/',
       expect.objectContaining({
         data: {
-          environments: ['prod'],
+          environment: ['prod'],
           per_page: 50,
           project: [1],
           sort: 'date',

+ 64 - 0
tests/js/spec/views/dashboardsV2/releasesSelectControl.spec.tsx

@@ -0,0 +1,64 @@
+import selectEvent from 'react-select-event';
+
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import {ReleasesContext} from 'sentry/utils/releases/releasesProvider';
+import ReleasesSelectControl from 'sentry/views/dashboardsV2/releasesSelectControl';
+
+function renderReleasesSelect() {
+  render(
+    <ReleasesContext.Provider
+      value={{
+        releases: [
+          TestStubs.Release({
+            shortVersion: 'sentry-android-shop@1.2.0',
+            version: 'sentry-android-shop@1.2.0',
+          }),
+          TestStubs.Release({
+            shortVersion: 'sentry-android-shop@1.3.0',
+            version: 'sentry-android-shop@1.3.0',
+          }),
+          TestStubs.Release({
+            shortVersion: 'sentry-android-shop@1.4.0',
+            version: 'sentry-android-shop@1.4.0',
+          }),
+        ],
+        loading: false,
+      }}
+    >
+      <ReleasesSelectControl />
+    </ReleasesContext.Provider>
+  );
+}
+
+describe('Dashboards > ReleasesSelectControl', function () {
+  it('updates menu title with selection', async function () {
+    renderReleasesSelect();
+
+    expect(screen.getByText('All Releases')).toBeInTheDocument();
+    await selectEvent.select(
+      screen.getByText('All Releases'),
+      'sentry-android-shop@1.2.0'
+    );
+
+    userEvent.click(document.body);
+
+    expect(screen.getByText('sentry-android-shop@1.2.0')).toBeInTheDocument();
+    expect(screen.queryByText('+1')).not.toBeInTheDocument();
+  });
+
+  it('updates menu title with multiple selections', function () {
+    renderReleasesSelect();
+
+    expect(screen.getByText('All Releases')).toBeInTheDocument();
+
+    userEvent.click(screen.getByText('All Releases'));
+    userEvent.click(screen.getByText('sentry-android-shop@1.2.0'));
+    userEvent.click(screen.getByText('sentry-android-shop@1.4.0'));
+
+    userEvent.click(document.body);
+
+    expect(screen.getByText('sentry-android-shop@1.2.0')).toBeInTheDocument();
+    expect(screen.getByText('+1')).toBeInTheDocument();
+  });
+});