Browse Source

feat(feedback): Show the short-id of feedbacks in the list & details (#61227)

Added in the feedback short-code to the UI. It basically replaces the
Project name on the list and details sections of the page:

| Where | Pic | 
| --- | --- |
| List Item | <img width="457" alt="SCR-20231205-ppbk"
src="https://github.com/getsentry/sentry/assets/187460/6acdb741-0fb2-41cc-9bd7-f3e23f8dd105">
|
| Details header | <img width="322" alt="SCR-20231205-ppda"
src="https://github.com/getsentry/sentry/assets/187460/991c57e8-c8cb-4e70-97a3-8d2eec44213d">
|

Along the way I also trimmed some extra dom nodes and css/styled
components away in the `FeedbackListItem` component. It turns out that I
was able to make project-name overflow work better, because that bottom
row of project + icons is a single grid now. Now a project name will
expand to take as much space as possible, but always leave space for any
icons that are there. This means the project name can expand under the
timestamp, making the grid a little more fluid than before.
<img width="454" alt="SCR-20231205-pqzb"
src="https://github.com/getsentry/sentry/assets/187460/091320a6-c27d-42dc-bf86-e9bee9b94305">
Ryan Albrecht 1 year ago
parent
commit
19db0ad31f

+ 39 - 16
static/app/components/feedback/feedbackItem/feedbackItem.tsx

@@ -8,6 +8,7 @@ import {
 } from 'sentry/actionCreators/indicator';
 import Button from 'sentry/components/actions/button';
 import ProjectAvatar from 'sentry/components/avatar/projectAvatar';
+import {DropdownMenu} from 'sentry/components/dropdownMenu';
 import ErrorBoundary from 'sentry/components/errorBoundary';
 import CrashReportSection from 'sentry/components/feedback/feedbackItem/crashReportSection';
 import FeedbackAssignedTo from 'sentry/components/feedback/feedbackItem/feedbackAssignedTo';
@@ -23,7 +24,7 @@ import PanelItem from 'sentry/components/panels/panelItem';
 import {Flex} from 'sentry/components/profiling/flex';
 import TextCopyInput from 'sentry/components/textCopyInput';
 import TextOverflow from 'sentry/components/textOverflow';
-import {IconLink} from 'sentry/icons';
+import {IconChevron, IconLink} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import type {Event, Group} from 'sentry/types';
@@ -61,15 +62,21 @@ export default function FeedbackItem({feedbackItem, eventData, tags}: Props) {
 
   const crashReportId = eventData?.contexts?.feedback?.associated_event_id;
 
-  const {onClick: copyLink} = useCopyToClipboard({
-    successMessage: t('Feedback URL copied to clipboard'),
-    text:
-      window.location.origin +
-      normalizeUrl(
-        `/organizations/${organization.slug}/feedback/?feedbackSlug=${feedbackItem.project.slug}:${feedbackItem.id}&project=${feedbackItem.project.id}`
-      ),
+  const feedbackUrl =
+    window.location.origin +
+    normalizeUrl(
+      `/organizations/${organization.slug}/feedback/?feedbackSlug=${feedbackItem.project.slug}:${feedbackItem.id}&project=${feedbackItem.project.id}`
+    );
+
+  const {onClick: handleCopyUrl} = useCopyToClipboard({
+    successMessage: t('Copied Feedback URL to clipboard'),
+    text: feedbackUrl,
   });
 
+  const {onClick: handleCopyShortId} = useCopyToClipboard({
+    successMessage: t('Copied Short-ID to clipboard'),
+    text: feedbackItem.shortId,
+  });
   return (
     <Fragment>
       <HeaderPanelItem>
@@ -84,17 +91,33 @@ export default function FeedbackItem({feedbackItem, eventData, tags}: Props) {
                 size={12}
                 title={feedbackItem.project.slug}
               />
-              <TextOverflow>{feedbackItem.project.slug}</TextOverflow>
+              <TextOverflow>{feedbackItem.shortId}</TextOverflow>
+              <DropdownMenu
+                triggerProps={{
+                  'aria-label': t('Short-ID copy actions'),
+                  icon: <IconChevron direction="down" size="xs" />,
+                  size: 'zero',
+                  borderless: true,
+                  showChevron: false,
+                }}
+                position="bottom"
+                size="xs"
+                items={[
+                  {
+                    key: 'copy-url',
+                    label: t('Copy Feedback URL'),
+                    onAction: handleCopyUrl,
+                  },
+                  {
+                    key: 'copy-short-id',
+                    label: t('Copy Short-ID'),
+                    onAction: handleCopyShortId,
+                  },
+                ]}
+              />
             </Flex>
           </Flex>
           <Flex gap={space(1)} align="center" wrap="wrap">
-            <Button
-              title={t('Copy link to this feedback')}
-              size="xs"
-              onClick={copyLink}
-              aria-label={t('Copy Link')}
-              icon={<IconLink />}
-            />
             <ErrorBoundary mini>
               <FeedbackAssignedTo
                 feedbackIssue={feedbackItem}

+ 94 - 128
static/app/components/feedback/list/feedbackListItem.tsx

@@ -1,4 +1,4 @@
-import {CSSProperties, forwardRef, ReactNode} from 'react';
+import {CSSProperties, forwardRef} from 'react';
 import {browserHistory} from 'react-router';
 import {ThemeProvider} from '@emotion/react';
 import styled from '@emotion/styled';
@@ -37,22 +37,6 @@ interface Props {
   style?: CSSProperties;
 }
 
-export function FeedbackIcon({
-  tooltipText,
-  icon,
-}: {
-  icon: ReactNode;
-  tooltipText: string;
-}) {
-  return (
-    <StyledTooltip
-      title={<span style={{textTransform: 'capitalize'}}>{tooltipText}</span>}
-    >
-      {icon}
-    </StyledTooltip>
-  );
-}
-
 function useIsSelectedFeedback({feedbackItem}: {feedbackItem: FeedbackIssue}) {
   const {feedbackSlug} = useLocationQuery({
     fields: {feedbackSlug: decodeScalar},
@@ -61,130 +45,106 @@ function useIsSelectedFeedback({feedbackItem}: {feedbackItem: FeedbackIssue}) {
   return feedbackId === feedbackItem.id;
 }
 
-function MutedText({children, isOpen}: {children: ReactNode; isOpen: boolean}) {
-  const config = useLegacyStore(ConfigStore);
-
-  return (
-    <ThemeProvider theme={isOpen || config.theme === 'dark' ? lightTheme : darkTheme}>
-      <StyledText>{children}</StyledText>
-    </ThemeProvider>
-  );
-}
-
 const FeedbackListItem = forwardRef<HTMLDivElement, Props>(
   ({className, feedbackItem, isSelected, onSelect, style}: Props, ref) => {
+    const config = useLegacyStore(ConfigStore);
     const organization = useOrganization();
     const isOpen = useIsSelectedFeedback({feedbackItem});
     const hasReplayId = useFeedbackHasReplayId({feedbackId: feedbackItem.id});
+
     const isCrashReport = feedbackItem.metadata.source === 'crash_report_embed_form';
+    const theme = isOpen || config.theme === 'dark' ? darkTheme : lightTheme;
 
     return (
       <CardSpacing className={className} style={style} ref={ref}>
-        <LinkedFeedbackCard
-          data-selected={isOpen}
-          to={() => {
-            const location = browserHistory.getCurrentLocation();
-            return {
-              pathname: normalizeUrl(`/organizations/${organization.slug}/feedback/`),
-              query: {
-                ...location.query,
-                referrer: 'feedback_list_page',
-                feedbackSlug: `${feedbackItem.project.slug}:${feedbackItem.id}`,
-              },
-            };
-          }}
-          onClick={() => {
-            trackAnalytics('feedback.list-item-selected', {organization});
-          }}
-        >
-          <InteractionStateLayer />
-          <Flex column style={{gridArea: 'checkbox'}}>
-            <Checkbox
-              disabled={isSelected === 'all-selected'}
-              checked={isSelected !== false}
-              onChange={e => onSelect(e.target.checked)}
-              onClick={e => e.stopPropagation()}
-              invertColors={isOpen}
-            />
-          </Flex>
-          <TextOverflow>
-            <span style={{gridArea: 'user'}}>
-              <FeedbackItemUsername feedbackIssue={feedbackItem} detailDisplay={false} />
-            </span>
-          </TextOverflow>
-          <span style={{gridArea: 'time'}}>
-            <StyledTimeSince date={feedbackItem.firstSeen} />
-          </span>
-          <Flex justify="center" style={{gridArea: 'unread'}}>
-            {feedbackItem.hasSeen ? null : (
-              <IconCircleFill size="xs" color={isOpen ? 'white' : 'purple400'} />
-            )}
-          </Flex>
-          <div style={{gridArea: 'message'}}>
-            <MutedText isOpen={isOpen}>
-              <TextOverflow>{feedbackItem.metadata.message}</TextOverflow>
-            </MutedText>
-          </div>
-          <RightAlignedIcons
-            style={{
-              gridArea: 'icons',
+        <ThemeProvider theme={theme}>
+          <LinkedFeedbackCard
+            data-selected={isOpen}
+            to={() => {
+              const location = browserHistory.getCurrentLocation();
+              return {
+                pathname: normalizeUrl(`/organizations/${organization.slug}/feedback/`),
+                query: {
+                  ...location.query,
+                  referrer: 'feedback_list_page',
+                  feedbackSlug: `${feedbackItem.project.slug}:${feedbackItem.id}`,
+                },
+              };
+            }}
+            onClick={() => {
+              trackAnalytics('feedback.list-item-selected', {organization});
             }}
           >
-            <IssueTrackingSignals group={feedbackItem as unknown as Group} />
-            {isCrashReport && (
-              <FeedbackIcon
-                tooltipText={t('Linked Issue')}
-                icon={<IconIssues size="xs" />}
-              />
-            )}
-            {hasReplayId && (
-              <FeedbackIcon
-                tooltipText={t('Linked Replay')}
-                icon={<IconPlay size="xs" />}
+            <InteractionStateLayer />
+
+            <Row style={{gridArea: 'checkbox'}}>
+              <Checkbox
+                style={{gridArea: 'checkbox'}}
+                disabled={isSelected === 'all-selected'}
+                checked={isSelected !== false}
+                onChange={e => onSelect(e.target.checked)}
+                onClick={e => e.stopPropagation()}
+                invertColors={isOpen}
               />
+            </Row>
+
+            <TextOverflow style={{gridArea: 'user'}}>
+              <FeedbackItemUsername feedbackIssue={feedbackItem} detailDisplay={false} />
+            </TextOverflow>
+
+            <TimeSince date={feedbackItem.firstSeen} style={{gridArea: 'time'}} />
+
+            {feedbackItem.hasSeen ? null : (
+              <Row style={{gridArea: 'unread'}}>
+                <IconCircleFill size="xs" color={isOpen ? 'white' : 'purple400'} />
+              </Row>
             )}
-            {feedbackItem.assignedTo && (
-              <StyledAvatar actor={feedbackItem.assignedTo} size={16} />
-            )}
-          </RightAlignedIcons>
-          <Flex style={{gridArea: 'proj'}} gap={space(1)} align="center">
-            <ProjectAvatar project={feedbackItem.project} size={12} />
-            <MutedText isOpen={isOpen}>
-              <ProjectOverflow>{feedbackItem.project.slug}</ProjectOverflow>
-            </MutedText>
-          </Flex>
-        </LinkedFeedbackCard>
+
+            <Row align="flex-start" justify="flex-start" style={{gridArea: 'message'}}>
+              <TextOverflow>{feedbackItem.metadata.message}</TextOverflow>
+            </Row>
+
+            <BottomGrid style={{gridArea: 'bottom'}}>
+              <Row justify="flex-start" gap={space(0.75)}>
+                <ProjectAvatar
+                  project={feedbackItem.project}
+                  size={12}
+                  title={feedbackItem.project.slug}
+                />
+                <TextOverflow>{feedbackItem.shortId}</TextOverflow>
+              </Row>
+
+              <Row justify="flex-end" gap={space(1)}>
+                <IssueTrackingSignals group={feedbackItem as unknown as Group} />
+
+                {isCrashReport && (
+                  <Tooltip title={t('Linked Issue')} containerDisplayMode="flex">
+                    <IconIssues size="xs" />
+                  </Tooltip>
+                )}
+
+                {hasReplayId && (
+                  <Tooltip title={t('Linked Replay')} containerDisplayMode="flex">
+                    {<IconPlay size="xs" />}
+                  </Tooltip>
+                )}
+
+                {feedbackItem.assignedTo && (
+                  <ActorAvatar
+                    actor={feedbackItem.assignedTo}
+                    size={16}
+                    tooltipOptions={{containerDisplayMode: 'flex'}}
+                  />
+                )}
+              </Row>
+            </BottomGrid>
+          </LinkedFeedbackCard>
+        </ThemeProvider>
       </CardSpacing>
     );
   }
 );
 
-const StyledText = styled('div')`
-  color: ${p => p.theme.gray200};
-`;
-
-const StyledTooltip = styled(Tooltip)`
-  display: flex;
-  align-items: center;
-`;
-
-const StyledAvatar = styled(ActorAvatar)`
-  display: flex;
-  align-items: center;
-`;
-
-const StyledTimeSince = styled(TimeSince)`
-  display: flex;
-  justify-content: end;
-`;
-
-const RightAlignedIcons = styled('div')`
-  display: flex;
-  justify-content: end;
-  gap: ${space(0.75)};
-  align-items: center;
-`;
-
 const CardSpacing = styled('div')`
   padding: ${space(0.25)} ${space(0.5)};
 `;
@@ -209,17 +169,23 @@ const LinkedFeedbackCard = styled(Link)`
   grid-template-areas:
     'checkbox user time'
     'unread message message'
-    '. proj icons';
+    '. bottom bottom';
   gap: ${space(1)};
   place-items: stretch;
   align-items: center;
 `;
 
-const ProjectOverflow = styled('span')`
-  text-overflow: ellipsis;
+const Row = styled(Flex)`
+  place-items: center;
+  overflow: hidden;
+`;
+
+const BottomGrid = styled('div')`
+  display: grid;
+  grid-template-columns: auto max-content;
+  gap: ${space(1)};
+
   overflow: hidden;
-  white-space: nowrap;
-  max-width: 150px;
 `;
 
 export default FeedbackListItem;

+ 32 - 17
static/app/components/feedback/list/issueTrackingSignals.tsx

@@ -1,6 +1,12 @@
-import {FeedbackIcon} from 'sentry/components/feedback/list/feedbackListItem';
-import {ExternalIssueComponent} from 'sentry/components/group/externalIssuesList/types';
+import {
+  ExternalIssueComponent,
+  IntegrationComponent,
+  PluginActionComponent,
+  PluginIssueComponent,
+  SentryAppIssueComponent,
+} from 'sentry/components/group/externalIssuesList/types';
 import useExternalIssueData from 'sentry/components/group/externalIssuesList/useExternalIssueData';
+import {Tooltip} from 'sentry/components/tooltip';
 import {IconLink} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {Event, Group} from 'sentry/types';
@@ -27,14 +33,14 @@ function filterLinkedPlugins(actions: ExternalIssueComponent[]) {
   return plugins.concat(nonPlugins);
 }
 
-function getPluginNames(pluginIssue) {
+function getPluginNames(pluginIssue: PluginIssueComponent | PluginActionComponent) {
   return {
     name: pluginIssue.props.plugin.name ?? '',
     icon: pluginIssue.props.plugin.slug ?? '',
   };
 }
 
-function getIntegrationNames(integrationIssue) {
+function getIntegrationNames(integrationIssue: IntegrationComponent) {
   if (!integrationIssue.props.configurations.length) {
     return {name: '', icon: ''};
   }
@@ -45,6 +51,13 @@ function getIntegrationNames(integrationIssue) {
   };
 }
 
+function getAppIntegrationNames(integrationIssue: SentryAppIssueComponent) {
+  return {
+    name: integrationIssue.props.sentryApp.name,
+    icon: integrationIssue.key ?? '',
+  };
+}
+
 export default function IssueTrackingSignals({group}: Props) {
   const {actions} = useExternalIssueData({
     group,
@@ -60,24 +73,26 @@ export default function IssueTrackingSignals({group}: Props) {
 
   if (linkedIssues.length > 1) {
     return (
-      <FeedbackIcon
-        tooltipText={t('Linked Tickets: %d', linkedIssues.length)}
-        icon={<IconLink size="xs" />}
-      />
+      <Tooltip
+        title={t('Linked Tickets: %d', linkedIssues.length)}
+        containerDisplayMode="flex"
+      >
+        <IconLink size="xs" />
+      </Tooltip>
     );
   }
 
   const issue = linkedIssues[0];
-
-  const {name, icon} =
-    issue.type === 'plugin-issue' || issue.type === 'plugin-action'
-      ? getPluginNames(issue)
-      : getIntegrationNames(issue);
+  const {name, icon} = {
+    'plugin-issue': getPluginNames,
+    'plugin-actions': getPluginNames,
+    'integration-issue': getIntegrationNames,
+    'sentry-app-issue': getAppIntegrationNames,
+  }[issue.type](issue) ?? {name: '', icon: undefined};
 
   return (
-    <FeedbackIcon
-      tooltipText={t('Linked %s Issue', name)}
-      icon={getIntegrationIcon(icon, 'xs')}
-    />
+    <Tooltip title={t('Linked %s Issue', name)} containerDisplayMode="flex">
+      {getIntegrationIcon(icon, 'xs')}
+    </Tooltip>
   );
 }

+ 5 - 2
static/app/components/textOverflow.tsx

@@ -1,3 +1,4 @@
+import {CSSProperties} from 'react';
 import styled from '@emotion/styled';
 
 type Props = {
@@ -18,6 +19,7 @@ type Props = {
    */
   ellipsisDirection?: 'left' | 'right';
   isParagraph?: boolean;
+  style?: CSSProperties;
 };
 
 const TextOverflow = styled(
@@ -27,17 +29,18 @@ const TextOverflow = styled(
     ellipsisDirection,
     isParagraph,
     ['data-test-id']: dataTestId,
+    style,
   }: Props) => {
     const Component = isParagraph ? 'p' : 'div';
     if (ellipsisDirection === 'left') {
       return (
-        <Component className={className} data-test-id={dataTestId}>
+        <Component className={className} style={style} data-test-id={dataTestId}>
           <bdi>{children}</bdi>
         </Component>
       );
     }
     return (
-      <Component className={className} data-test-id={dataTestId}>
+      <Component className={className} style={style} data-test-id={dataTestId}>
         {children}
       </Component>
     );