Browse Source

feat(toolbar): add latest release panel (#74922)

- barebones release panel showing the latest release for one specific
project.
- closes [#74453](https://github.com/getsentry/sentry/issues/74453)
- can follow up with adding crash free rate, issues related to the
release, adoption chart, etc!
- matches the release card on the releases page somewhat:

<img width="880" alt="SCR-20240724-oirj"
src="https://github.com/user-attachments/assets/c76379cb-b8be-44ea-8b69-c036ebb93b6c">
Michelle Zhang 7 months ago
parent
commit
ca8f7a5469

+ 9 - 1
static/app/components/devtoolbar/components/navigation.tsx

@@ -2,7 +2,14 @@ import type {ReactNode} from 'react';
 import {css} from '@emotion/react';
 
 import InteractionStateLayer from 'sentry/components/interactionStateLayer';
-import {IconClose, IconFlag, IconIssues, IconMegaphone, IconSiren} from 'sentry/icons';
+import {
+  IconClose,
+  IconFlag,
+  IconIssues,
+  IconMegaphone,
+  IconReleases,
+  IconSiren,
+} from 'sentry/icons';
 
 import useConfiguration from '../hooks/useConfiguration';
 import usePlacementCss from '../hooks/usePlacementCss';
@@ -36,6 +43,7 @@ export default function Navigation({
         <AlertCountBadge />
       </NavButton>
       <NavButton panelName="featureFlags" label="Feature Flags" icon={<IconFlag />} />
+      <NavButton panelName="releases" label="Releases" icon={<IconReleases />} />
       <HideButton
         onClick={() => {
           setIsDisabled(true);

+ 3 - 0
static/app/components/devtoolbar/components/panelRouter.tsx

@@ -6,6 +6,7 @@ const PanelAlerts = lazy(() => import('./alerts/alertsPanel'));
 const PanelFeedback = lazy(() => import('./feedback/feedbackPanel'));
 const PanelIssues = lazy(() => import('./issues/issuesPanel'));
 const PanelFeatureFlags = lazy(() => import('./featureFlags/featureFlagsPanel'));
+const PanelReleases = lazy(() => import('./releases/releasesPanel'));
 
 export default function PanelRouter() {
   const {state} = useToolbarRoute();
@@ -19,6 +20,8 @@ export default function PanelRouter() {
       return <PanelIssues />;
     case 'featureFlags':
       return <PanelFeatureFlags />;
+    case 'releases':
+      return <PanelReleases />;
     default:
       return null;
   }

+ 134 - 0
static/app/components/devtoolbar/components/releases/releasesPanel.tsx

@@ -0,0 +1,134 @@
+import {Fragment} from 'react';
+import {css} from '@emotion/react';
+
+import useToolbarRelease from 'sentry/components/devtoolbar/components/releases/useToolbarRelease';
+import SentryAppLink from 'sentry/components/devtoolbar/components/sentryAppLink';
+import {listItemPlaceholderWrapperCss} from 'sentry/components/devtoolbar/styles/listItem';
+import {
+  resetFlexColumnCss,
+  resetFlexRowCss,
+} from 'sentry/components/devtoolbar/styles/reset';
+import ProjectBadge from 'sentry/components/idBadge/projectBadge';
+import Placeholder from 'sentry/components/placeholder';
+import TextOverflow from 'sentry/components/textOverflow';
+import TimeSince from 'sentry/components/timeSince';
+import type {PlatformKey} from 'sentry/types/project';
+import type {Release} from 'sentry/types/release';
+import {formatVersion} from 'sentry/utils/versions/formatVersion';
+import {
+  PackageName,
+  ReleaseInfoHeader,
+  ReleaseInfoSubheader,
+  VersionWrapper,
+} from 'sentry/views/releases/list/releaseCard';
+import ReleaseCardCommits from 'sentry/views/releases/list/releaseCard/releaseCardCommits';
+
+import useConfiguration from '../../hooks/useConfiguration';
+import {panelInsetContentCss, panelSectionCss} from '../../styles/panel';
+import {smallCss} from '../../styles/typography';
+import PanelLayout from '../panelLayout';
+
+function ReleaseHeader({release, orgSlug}: {orgSlug: string; release: Release}) {
+  return (
+    <div style={{padding: '12px'}}>
+      <ReleaseInfoHeader>
+        <SentryAppLink
+          to={{
+            url: `/organizations/${orgSlug}/releases/${encodeURIComponent(release.version)}/`,
+            query: {project: release.projects[0].id},
+          }}
+        >
+          <VersionWrapper>{formatVersion(release.version)}</VersionWrapper>
+        </SentryAppLink>
+        {release.commitCount > 0 && (
+          <ReleaseCardCommits release={release} withHeading={false} />
+        )}
+      </ReleaseInfoHeader>
+      <ReleaseInfoSubheader
+        style={{display: 'flex', flexDirection: 'column', alignItems: 'flex-start'}}
+      >
+        {release.versionInfo?.package && (
+          <PackageName>
+            <TextOverflow ellipsisDirection="left">
+              {release.versionInfo.package}
+            </TextOverflow>
+          </PackageName>
+        )}
+        <span style={{display: 'flex', flexDirection: 'row', gap: '3px'}}>
+          <TimeSince date={release.lastDeploy?.dateFinished || release.dateCreated} />
+          {release.lastDeploy?.dateFinished &&
+            ` \u007C ${release.lastDeploy.environment}`}
+        </span>
+      </ReleaseInfoSubheader>
+    </div>
+  );
+}
+
+export default function ReleasesPanel() {
+  const {data, isLoading, isError} = useToolbarRelease();
+  const {organizationSlug, projectSlug, projectId, projectPlatform, trackAnalytics} =
+    useConfiguration();
+
+  const estimateSize = 515;
+  const placeholderHeight = `${estimateSize - 8}px`; // The real height of the items, minus the padding-block value
+
+  return (
+    <PanelLayout title="Latest Release">
+      {isLoading || isError ? (
+        <div
+          css={[
+            resetFlexColumnCss,
+            panelSectionCss,
+            panelInsetContentCss,
+            listItemPlaceholderWrapperCss,
+          ]}
+        >
+          <Placeholder height={placeholderHeight} />
+        </div>
+      ) : (
+        <Fragment>
+          <div css={[smallCss, panelSectionCss, panelInsetContentCss]}>
+            <span css={[resetFlexRowCss, {gap: 'var(--space50)'}]}>
+              Latest release for{' '}
+              <SentryAppLink
+                to={{
+                  url: `/releases/`,
+                  query: {project: projectId},
+                }}
+                onClick={() => {
+                  trackAnalytics?.({
+                    eventKey: `devtoolbar.releases-list.header.click`,
+                    eventName: `devtoolbar: Click releases-list header`,
+                  });
+                }}
+              >
+                <div
+                  css={[
+                    resetFlexRowCss,
+                    {display: 'inline-flex', gap: 'var(--space50)', alignItems: 'center'},
+                  ]}
+                >
+                  <ProjectBadge
+                    css={css({'&& img': {boxShadow: 'none'}})}
+                    project={{
+                      slug: projectSlug,
+                      id: projectId,
+                      platform: projectPlatform as PlatformKey,
+                    }}
+                    avatarSize={16}
+                    hideName
+                    avatarProps={{hasTooltip: false}}
+                  />
+                  {projectSlug}
+                </div>
+              </SentryAppLink>
+            </span>
+          </div>
+          <div style={{alignItems: 'start'}}>
+            <ReleaseHeader release={data.json[0]} orgSlug={organizationSlug} />
+          </div>
+        </Fragment>
+      )}
+    </PanelLayout>
+  );
+}

+ 28 - 0
static/app/components/devtoolbar/components/releases/useToolbarRelease.tsx

@@ -0,0 +1,28 @@
+import {useMemo} from 'react';
+
+import type {ApiEndpointQueryKey} from 'sentry/components/devtoolbar/types';
+import type {Release} from 'sentry/types/release';
+
+import useConfiguration from '../../hooks/useConfiguration';
+import useFetchApiData from '../../hooks/useFetchApiData';
+
+export default function useToolbarRelease() {
+  const {organizationSlug, projectSlug} = useConfiguration();
+
+  return useFetchApiData<Release[]>({
+    queryKey: useMemo(
+      (): ApiEndpointQueryKey => [
+        'io.sentry.toolbar',
+        `/organizations/${organizationSlug}/releases/`,
+        {
+          query: {
+            queryReferrer: 'devtoolbar',
+            projectSlug,
+          },
+        },
+      ],
+      [organizationSlug, projectSlug]
+    ),
+    cacheTime: Infinity,
+  });
+}

+ 1 - 1
static/app/components/devtoolbar/hooks/useToolbarRoute.tsx

@@ -1,7 +1,7 @@
 import {createContext, useCallback, useContext, useState} from 'react';
 
 type State = {
-  activePanel: null | 'alerts' | 'feedback' | 'issues' | 'featureFlags';
+  activePanel: null | 'alerts' | 'feedback' | 'issues' | 'featureFlags' | 'releases';
 };
 
 const context = createContext<{

+ 4 - 4
static/app/views/releases/list/releaseCard/index.tsx

@@ -214,7 +214,7 @@ function ReleaseCard({
   );
 }
 
-const VersionWrapper = styled('div')`
+export const VersionWrapper = styled('div')`
   display: flex;
   align-items: center;
 `;
@@ -244,12 +244,12 @@ const ReleaseInfo = styled('div')`
   }
 `;
 
-const ReleaseInfoSubheader = styled('div')`
+export const ReleaseInfoSubheader = styled('div')`
   font-size: ${p => p.theme.fontSizeSmall};
   color: ${p => p.theme.gray400};
 `;
 
-const PackageName = styled('div')`
+export const PackageName = styled('div')`
   font-size: ${p => p.theme.fontSizeMedium};
   color: ${p => p.theme.textColor};
   display: flex;
@@ -267,7 +267,7 @@ const ReleaseProjects = styled('div')`
   }
 `;
 
-const ReleaseInfoHeader = styled('div')`
+export const ReleaseInfoHeader = styled('div')`
   font-size: ${p => p.theme.fontSizeExtraLarge};
   display: grid;
   grid-template-columns: minmax(0, 1fr) max-content;