Browse Source

feat(highlights): UI bug fix, add replay context, change text (#70988)

Resolves https://github.com/getsentry/sentry/issues/70249

This PR does 3 small things:
- Adds the replay context as a known context in the same style as the
profile context (+ a test)
![Screenshot 2024-05-15 at 6 29
33 PM](https://github.com/getsentry/sentry/assets/35509934/a2da90ff-17a4-4318-afdb-ef7916a629f2)
![Screenshot 2024-05-15 at 6 34
08 PM](https://github.com/getsentry/sentry/assets/35509934/7dc219ef-3575-4190-a4bf-a3e2be9eec61)

- Changes the section title from 'Context' to 'Contexts'
- Fixes a UI bug where release text wouldn't wrap
![Screenshot 2024-05-15 at 5 52
48 PM](https://github.com/getsentry/sentry/assets/35509934/c4e0c946-c116-475b-aa66-16760ca60301)
![Screenshot 2024-05-15 at 5 52
13 PM](https://github.com/getsentry/sentry/assets/35509934/350fa1fd-8ba8-431e-9ff7-4fdd886bd375)
Leander Rodrigues 10 months ago
parent
commit
3408a86593

+ 1 - 1
static/app/components/events/contexts/contextDataSection.tsx

@@ -52,7 +52,7 @@ export default function ContextDataSection(props: ContextDataSectionProps) {
     <EventDataSection
       key={'context'}
       type={'context'}
-      title={t('Context')}
+      title={t('Contexts')}
       help={tct(
         'The structured context items attached to this event. [link:Learn more]',
         {

+ 13 - 20
static/app/components/events/contexts/index.tsx

@@ -43,8 +43,14 @@ export function getOrderedContextItems(event): ContextItem[] {
     ['user', user],
     ...Object.entries(otherContexts),
   ];
-  // For these context keys, use 'key' as 'type' rather than 'value.type'
-  const overrideTypesWithKeys = new Set(['response', 'feedback', 'user']);
+  // For these context aliases, use the alias as 'type' rather than 'value.type'
+  const overrideTypesWithAliases = new Set([
+    'response',
+    'feedback',
+    'user',
+    'profile',
+    'replay',
+  ]);
 
   const items = orderedContext
     .filter(([_k, ctxValue]) => {
@@ -56,24 +62,11 @@ export function getOrderedContextItems(event): ContextItem[] {
         (contextKeys.length === 1 && contextKeys[0] === 'type');
       return !isInvalid;
     })
-    .map<ContextItem>(([alias, ctx]) => {
-      const contextKey = Object.keys(ctx ?? {}).filter(key => key !== 'type')[0];
-
-      // We get the type of profile context as 'default' but it's possible to route to the profile
-      // page, so we need to set the type to 'profile' in this case, so that we treat it as a known context.
-      const type =
-        alias === 'profile'
-          ? alias
-          : overrideTypesWithKeys.has(contextKey)
-            ? contextKey
-            : ctx?.type;
-
-      return {
-        alias,
-        type,
-        value: ctx,
-      };
-    });
+    .map<ContextItem>(([alias, ctx]) => ({
+      alias: alias,
+      type: overrideTypesWithAliases.has(alias) ? alias : ctx?.type,
+      value: ctx,
+    }));
 
   return items;
 }

+ 24 - 0
static/app/components/events/contexts/replay/index.spec.tsx

@@ -0,0 +1,24 @@
+import {EventFixture} from 'sentry-fixture/event';
+import {OrganizationFixture} from 'sentry-fixture/organization';
+
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import {ReplayEventContext} from 'sentry/components/events/contexts/replay';
+
+describe('replay event context', function () {
+  const organization = OrganizationFixture();
+  const event = EventFixture();
+  const replayId = '61d2d7c5acf448ffa8e2f8f973e2cd36';
+  const replayContext = {
+    type: 'default',
+    replay_id: replayId,
+  };
+
+  it('renders replay id with button', function () {
+    render(<ReplayEventContext data={replayContext} event={event} />, {organization});
+
+    expect(screen.getByText('Replay ID')).toBeInTheDocument();
+    expect(screen.getByText(replayId)).toBeInTheDocument();
+    expect(screen.getByRole('button', {name: 'View Replay'})).toBeInTheDocument();
+  });
+});

+ 110 - 0
static/app/components/events/contexts/replay/index.tsx

@@ -0,0 +1,110 @@
+import {LinkButton} from 'sentry/components/button';
+import ErrorBoundary from 'sentry/components/errorBoundary';
+import KeyValueList from 'sentry/components/events/interfaces/keyValueList';
+import {t} from 'sentry/locale';
+import {type Event, type ReplayContext, ReplayContextKey} from 'sentry/types/event';
+import type {Organization} from 'sentry/types/organization';
+import type {Project} from 'sentry/types/project';
+import useOrganization from 'sentry/utils/useOrganization';
+
+import {
+  getContextMeta,
+  getKnownData,
+  getKnownStructuredData,
+  getUnknownData,
+  type KnownDataDetails,
+} from '../utils';
+
+const REPLAY_KNOWN_DATA_VALUES = ['replay_id'];
+
+interface ReplayContextProps {
+  data: ReplayContext & Record<string, any>;
+  event: Event;
+  meta?: Record<string, any>;
+}
+
+export function getKnownReplayContextData({
+  data,
+  meta,
+  organization,
+}: Pick<ReplayContextProps, 'data' | 'meta'> & {
+  organization: Organization;
+}) {
+  return getKnownData<ReplayContext, ReplayContextKey>({
+    data,
+    meta,
+    knownDataTypes: REPLAY_KNOWN_DATA_VALUES,
+    onGetKnownDataDetails: v => getReplayKnownDataDetails({...v, organization}),
+  }).map(v => ({
+    ...v,
+    subjectDataTestId: `replay-context-${v.key.toLowerCase()}-value`,
+  }));
+}
+
+export function getUnknownReplayContextData({
+  data,
+  meta,
+}: Pick<ReplayContextProps, 'data' | 'meta'>) {
+  return getUnknownData({
+    allData: data,
+    knownKeys: REPLAY_KNOWN_DATA_VALUES,
+    meta,
+  });
+}
+
+export function ReplayEventContext({event, data, meta: propsMeta}: ReplayContextProps) {
+  const organization = useOrganization();
+  const meta = propsMeta ?? getContextMeta(event, 'replay');
+
+  const knownData = getKnownReplayContextData({data, meta, organization});
+  const knownStructuredData = getKnownStructuredData(knownData, meta);
+  const unknownData = getUnknownReplayContextData({data, meta});
+
+  return (
+    <ErrorBoundary mini>
+      <KeyValueList
+        data={knownStructuredData}
+        shouldSort={false}
+        raw={false}
+        isContextData
+      />
+      <KeyValueList data={unknownData} shouldSort={false} raw={false} isContextData />
+    </ErrorBoundary>
+  );
+}
+
+function getReplayKnownDataDetails({
+  data,
+  organization,
+  type,
+}: {
+  data: ReplayContext;
+  organization: Organization;
+  type: ReplayContextKey;
+  project?: Project;
+}): KnownDataDetails {
+  switch (type) {
+    case ReplayContextKey.REPLAY_ID: {
+      const replayId = data.replay_id || '';
+      if (!replayId) {
+        return undefined;
+      }
+      const link = `/organizations/${organization.slug}/replays/${encodeURIComponent(replayId)}/`;
+
+      return {
+        subject: t('Replay ID'),
+        value: replayId,
+        action: {
+          link,
+        },
+        actionButton: link && (
+          <LinkButton size="xs" href={link}>
+            {t('View Replay')}
+          </LinkButton>
+        ),
+      };
+    }
+    default:
+      return undefined;
+  }
+}

+ 11 - 1
static/app/components/events/contexts/utils.tsx

@@ -45,6 +45,11 @@ import {
   ProfileEventContext,
 } from './profile';
 import {getReduxContextData, ReduxContext} from './redux';
+import {
+  getKnownReplayContextData,
+  getUnknownReplayContextData,
+  ReplayEventContext,
+} from './replay';
 import {
   getKnownRuntimeContextData,
   getUnknownRuntimeContextData,
@@ -91,7 +96,7 @@ const CONTEXT_TYPES = {
   threadpool_info: ThreadPoolInfoEventContext,
   state: StateEventContext,
   profile: ProfileEventContext,
-
+  replay: ReplayEventContext,
   // 'redux.state' will be replaced with more generic context called 'state'
   'redux.state': ReduxContext,
   // 'ThreadPool Info' will be replaced with 'threadpool_info' but
@@ -383,6 +388,11 @@ export function getFormattedContextData({
         ...getKnownProfileContextData({data: contextValue, meta, organization, project}),
         ...getUnknownProfileContextData({data: contextValue, meta}),
       ];
+    case 'replay':
+      return [
+        ...getKnownReplayContextData({data: contextValue, meta, organization}),
+        ...getUnknownReplayContextData({data: contextValue, meta}),
+      ];
     default:
       return getDefaultContextData(contextValue);
   }

+ 1 - 1
static/app/components/events/eventTags/eventTagsTreeRow.tsx

@@ -272,7 +272,7 @@ function EventTagsTreeValue({
           showUnderline
           underlineColor="linkUnderline"
         >
-          <Version version={content.value} truncate />
+          <Version version={content.value} truncate shouldWrapText />
         </VersionHoverCard>
       );
       break;

+ 24 - 9
static/app/components/version.tsx

@@ -34,6 +34,10 @@ type Props = {
    * If not provided and user does not have global-views enabled, it will try to take it from current url query.
    */
   projectId?: string;
+  /**
+   * Should the release text break and wrap onto the next line
+   */
+  shouldWrapText?: boolean;
   /**
    * Should there be a tooltip with raw version on hover
    */
@@ -57,6 +61,7 @@ function Version({
   withPackage,
   projectId,
   truncate,
+  shouldWrapText = false,
   className,
 }: Props) {
   const location = useLocation();
@@ -85,19 +90,27 @@ function Version({
       if (preservePageFilters) {
         return (
           <GlobalSelectionLink {...props}>
-            <VersionText truncate={truncate}>{versionToDisplay}</VersionText>
+            <VersionText truncate={truncate} shouldWrapText={shouldWrapText}>
+              {versionToDisplay}
+            </VersionText>
           </GlobalSelectionLink>
         );
       }
       return (
         <Link {...props}>
-          <VersionText truncate={truncate}>{versionToDisplay}</VersionText>
+          <VersionText truncate={truncate} shouldWrapText={shouldWrapText}>
+            {versionToDisplay}
+          </VersionText>
         </Link>
       );
     }
 
     return (
-      <VersionText className={className} truncate={truncate}>
+      <VersionText
+        className={className}
+        truncate={truncate}
+        shouldWrapText={shouldWrapText}
+      >
         {versionToDisplay}
       </VersionText>
     );
@@ -149,15 +162,17 @@ function Version({
 //   display: inline-block;
 // `;
 
-const VersionText = styled('span')<{truncate?: boolean}>`
-  ${p =>
-    p.truncate &&
-    `max-width: 100%;
-    display: block;
+const truncateStyles = css`
+  max-width: 100%;
+  display: block;
   overflow: hidden;
   font-variant-numeric: tabular-nums;
   text-overflow: ellipsis;
-  white-space: nowrap;`}
+`;
+
+const VersionText = styled('span')<{shouldWrapText?: boolean; truncate?: boolean}>`
+  ${p => p.truncate && truncateStyles}
+  white-space: ${p => (p.shouldWrapText ? 'normal' : 'nowrap')};
 `;
 
 const TooltipContent = styled('span')`

+ 5 - 1
static/app/types/event.tsx

@@ -638,8 +638,12 @@ export interface ProfileContext {
   [ProfileContextKey.PROFILE_ID]?: string;
 }
 
+export enum ReplayContextKey {
+  REPLAY_ID = 'replay_id',
+}
+
 export interface ReplayContext {
-  replay_id: string;
+  [ReplayContextKey.REPLAY_ID]: string;
   type: string;
 }
 export interface BrowserContext {