Browse Source

ref(whats-new): Revamp "Whats New" (#76818)

Priscila Oliveira 6 months ago
parent
commit
396d0441ee

+ 100 - 0
static/app/components/sidebar/broadcastPanelItem.tsx

@@ -0,0 +1,100 @@
+import {useCallback} from 'react';
+import styled from '@emotion/styled';
+
+import Badge from 'sentry/components/badge/badge';
+import Tag from 'sentry/components/badge/tag';
+import ExternalLink from 'sentry/components/links/externalLink';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {Broadcast} from 'sentry/types/system';
+import {trackAnalytics} from 'sentry/utils/analytics';
+import useOrganization from 'sentry/utils/useOrganization';
+
+export const BROADCAST_CATEGORIES: Record<NonNullable<Broadcast['category']>, string> = {
+  announcement: t('Announcement'),
+  feature: t('New Feature'),
+  blog: t('Blog Post'),
+  event: t('Event'),
+  video: t('Video'),
+};
+
+interface BroadcastPanelItemProps
+  extends Pick<
+    Broadcast,
+    'hasSeen' | 'category' | 'title' | 'message' | 'link' | 'mediaUrl'
+  > {}
+
+export function BroadcastPanelItem({
+  hasSeen,
+  title,
+  message,
+  link,
+  mediaUrl,
+  category,
+}: BroadcastPanelItemProps) {
+  const organization = useOrganization();
+
+  const handlePanelClicked = useCallback(() => {
+    trackAnalytics('whats_new.link_clicked', {organization, title, category});
+  }, [organization, title, category]);
+
+  return (
+    <SidebarPanelItemRoot>
+      <TextBlock>
+        {category &&
+          (hasSeen ? (
+            <CategoryTag>{BROADCAST_CATEGORIES[category]}</CategoryTag>
+          ) : (
+            <CategoryBadge type="new">{BROADCAST_CATEGORIES[category]}</CategoryBadge>
+          ))}
+        <Title hasSeen={hasSeen} href={link} onClick={handlePanelClicked}>
+          {title}
+        </Title>
+        <Message>{message}</Message>
+      </TextBlock>
+      {mediaUrl && <Media src={mediaUrl} alt={title} />}
+    </SidebarPanelItemRoot>
+  );
+}
+
+const SidebarPanelItemRoot = styled('div')`
+  line-height: 1.5;
+  background: ${p => p.theme.background};
+  margin: 0 ${space(3)};
+  padding: ${space(2)} 0;
+
+  :not(:first-child) {
+    border-top: 1px solid ${p => p.theme.border};
+  }
+`;
+
+const Title = styled(ExternalLink)<Pick<BroadcastPanelItemProps, 'hasSeen'>>`
+  font-size: ${p => p.theme.fontSizeLarge};
+  color: ${p => p.theme.blue400};
+  ${p => !p.hasSeen && `font-weight: ${p.theme.fontWeightBold}`};
+`;
+
+const Message = styled('div')`
+  color: ${p => p.theme.subText};
+`;
+
+const TextBlock = styled('div')`
+  margin-bottom: ${space(1.5)};
+  display: flex;
+  flex-direction: column;
+`;
+
+const Media = styled('img')`
+  border-radius: ${p => p.theme.borderRadius};
+  border: 1px solid ${p => p.theme.translucentGray200};
+  max-width: 100%;
+`;
+
+const CategoryTag = styled(Tag)`
+  margin-bottom: ${space(1)};
+`;
+
+const CategoryBadge = styled(Badge)`
+  margin-left: 0;
+  margin-bottom: ${space(1)};
+`;

+ 110 - 0
static/app/components/sidebar/broadcasts.spec.tsx

@@ -0,0 +1,110 @@
+import {BroadcastFixture} from 'sentry-fixture/broadcast';
+import {OrganizationFixture} from 'sentry-fixture/organization';
+
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import {BROADCAST_CATEGORIES} from 'sentry/components/sidebar/broadcastPanelItem';
+import Broadcasts from 'sentry/components/sidebar/broadcasts';
+import {SidebarPanelKey} from 'sentry/components/sidebar/types';
+import type {Broadcast} from 'sentry/types/system';
+import {trackAnalytics} from 'sentry/utils/analytics';
+
+jest.mock('sentry/utils/analytics');
+
+function renderMockRequests({
+  orgSlug,
+  broadcastsResponse,
+}: {
+  orgSlug: string;
+  broadcastsResponse?: Broadcast[];
+}) {
+  MockApiClient.addMockResponse({
+    url: '/broadcasts/',
+    method: 'PUT',
+  });
+  MockApiClient.addMockResponse({
+    url: `/organizations/${orgSlug}/broadcasts/`,
+    body: broadcastsResponse ?? [],
+  });
+}
+
+describe('Broadcasts', function () {
+  const category = 'blog';
+
+  it('renders empty state', async function () {
+    const organization = OrganizationFixture();
+
+    renderMockRequests({orgSlug: organization.slug});
+
+    render(
+      <Broadcasts
+        orientation="left"
+        collapsed={false}
+        currentPanel={SidebarPanelKey.BROADCASTS}
+        onShowPanel={() => jest.fn()}
+        hidePanel={jest.fn()}
+        organization={organization}
+      />
+    );
+
+    expect(await screen.findByText(/No recent updates/)).toBeInTheDocument();
+  });
+
+  it('renders a broadcast item with media content correctly', async function () {
+    const organization = OrganizationFixture({features: ['what-is-new-revamp']});
+    const broadcast = BroadcastFixture({
+      mediaUrl:
+        'https://images.ctfassets.net/em6l9zw4tzag/2vWdw7ZaApWxygugalbyOC/285525e5b7c9fbfa8fb814a69ab214cd/PerformancePageSketches_hero.jpg?w=2520&h=945&q=50&fm=webp',
+      category,
+    });
+
+    renderMockRequests({orgSlug: organization.slug, broadcastsResponse: [broadcast]});
+
+    render(
+      <Broadcasts
+        orientation="left"
+        collapsed={false}
+        currentPanel={SidebarPanelKey.BROADCASTS}
+        onShowPanel={() => jest.fn()}
+        hidePanel={jest.fn()}
+        organization={organization}
+      />
+    );
+
+    // Verify that the broadcast content is rendered correctly
+    expect(await screen.findByText(BROADCAST_CATEGORIES[category])).toBeInTheDocument();
+    const titleLink = screen.getByRole('link', {name: broadcast.title});
+    expect(titleLink).toHaveAttribute('href', broadcast.link);
+    expect(screen.getByText(/Source maps are JSON/)).toBeInTheDocument();
+
+    // Simulate click and check if analytics tracking is called
+    await userEvent.click(titleLink);
+    expect(trackAnalytics).toHaveBeenCalledWith(
+      'whats_new.link_clicked',
+      expect.objectContaining({
+        title: broadcast.title,
+        category,
+      })
+    );
+  });
+
+  it('renders deprecated broadcast experience', async function () {
+    const organization = OrganizationFixture();
+    const broadcast = BroadcastFixture();
+
+    renderMockRequests({orgSlug: organization.slug, broadcastsResponse: [broadcast]});
+
+    render(
+      <Broadcasts
+        orientation="left"
+        collapsed={false}
+        currentPanel={SidebarPanelKey.BROADCASTS}
+        onShowPanel={() => jest.fn()}
+        hidePanel={jest.fn()}
+        organization={organization}
+      />
+    );
+
+    expect(await screen.findByRole('link', {name: broadcast.cta})).toBeInTheDocument();
+  });
+});

+ 15 - 1
static/app/components/sidebar/broadcasts.tsx

@@ -4,6 +4,7 @@ import {getAllBroadcasts, markBroadcastsAsSeen} from 'sentry/actionCreators/broa
 import type {Client} from 'sentry/api';
 import DemoModeGate from 'sentry/components/acl/demoModeGate';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
+import {BroadcastPanelItem} from 'sentry/components/sidebar/broadcastPanelItem';
 import SidebarItem from 'sentry/components/sidebar/sidebarItem';
 import SidebarPanel from 'sentry/components/sidebar/sidebarPanel';
 import SidebarPanelEmpty from 'sentry/components/sidebar/sidebarPanelEmpty';
@@ -115,10 +116,11 @@ class Broadcasts extends Component<Props, State> {
   }
 
   render() {
-    const {orientation, collapsed, currentPanel, hidePanel} = this.props;
+    const {orientation, collapsed, currentPanel, hidePanel, organization} = this.props;
     const {broadcasts, loading} = this.state;
 
     const unseenPosts = this.unseenIds;
+    const whatIsNewRevampFeature = organization.features.includes('what-is-new-revamp');
 
     return (
       <DemoModeGate>
@@ -149,6 +151,18 @@ class Broadcasts extends Component<Props, State> {
                 <SidebarPanelEmpty>
                   {t('No recent updates from the Sentry team.')}
                 </SidebarPanelEmpty>
+              ) : whatIsNewRevampFeature ? (
+                broadcasts.map(item => (
+                  <BroadcastPanelItem
+                    key={item.id}
+                    hasSeen={item.hasSeen}
+                    title={item.title}
+                    message={item.message}
+                    link={item.link}
+                    mediaUrl={item.mediaUrl}
+                    category={item.category}
+                  />
+                ))
               ) : (
                 broadcasts.map(item => (
                   <SidebarPanelItem

+ 6 - 3
static/app/components/sidebar/sidebarPanelItem.tsx

@@ -63,9 +63,12 @@ function SidebarPanelItem({
         <Text>
           <ExternalLink
             href={link}
-            onClick={() =>
-              trackAnalytics('whats_new.link_clicked', {organization, title})
-            }
+            onClick={() => {
+              if (!title) {
+                return;
+              }
+              trackAnalytics('whats_new.link_clicked', {organization, title});
+            }}
           >
             {cta || t('Read More')}
           </ExternalLink>

+ 24 - 3
static/app/types/system.tsx

@@ -228,17 +228,38 @@ export type PipelineInitialData = {
   props: Record<string, any>;
 };
 
-export type Broadcast = {
-  cta: string;
+export interface Broadcast {
   dateCreated: string;
   dateExpires: string;
+  /**
+   * Has the item been seen? affects the styling of the panel item
+   */
   hasSeen: boolean;
   id: string;
   isActive: boolean;
+  /**
+   * The URL to use for the CTA
+   */
   link: string;
+  /**
+   * A message with muted styling which appears above the children content
+   */
   message: string;
   title: string;
-};
+  /**
+   * Category of the broadcast.
+   * Synced with https://github.com/getsentry/sentry/blob/master/src/sentry/models/broadcast.py#L14
+   */
+  category?: 'announcement' | 'feature' | 'blog' | 'event' | 'video';
+  /**
+   * The text for the CTA link at the bottom of the panel item
+   */
+  cta?: string;
+  /**
+   * Image url
+   */
+  mediaUrl?: string;
+}
 
 // XXX(epurkhiser): The components list can be generated using jq
 //

+ 3 - 1
static/app/utils/analytics/issueAnalyticsEvents.tsx

@@ -2,6 +2,7 @@ import type {SourceMapProcessingIssueType} from 'sentry/components/events/interf
 import type {FieldValue} from 'sentry/components/forms/model';
 import type {PriorityLevel} from 'sentry/types/group';
 import type {IntegrationType} from 'sentry/types/integrations';
+import type {Broadcast} from 'sentry/types/system';
 import type {BaseEventAnalyticsParams} from 'sentry/utils/analytics/workflowAnalyticsEvents';
 import type {CommonGroupAnalyticsData} from 'sentry/utils/events';
 
@@ -296,7 +297,8 @@ export type IssueEventParameters = {
   'tag.clicked': {
     is_clickable: boolean;
   };
-  'whats_new.link_clicked': {title?: string};
+  'whats_new.link_clicked': Pick<Broadcast, 'title'> &
+    Partial<Pick<Broadcast, 'category'>>;
 };
 
 export type IssueEventKey = keyof IssueEventParameters;