Browse Source

feat(feedback): Show unread count next to each mailbox (#66376)

<img width="432" alt="SCR-20240307-ssof"
src="https://github.com/getsentry/sentry/assets/187460/7aa0da22-12fe-4ac4-9505-ee0d75b78b3b">


it's a query for `is:unassigned` instead of `is:unread` because we don't
have read-status in issue search yet. But it's looking good in the UI

Fixes https://github.com/getsentry/sentry/issues/66332
Closes https://github.com/getsentry/sentry/issues/66695
Ryan Albrecht 1 year ago
parent
commit
1adad11d19

+ 1 - 0
static/app/components/badge.stories.tsx

@@ -21,6 +21,7 @@ export default storyBook(Badge, story => {
       <Badge type="new">New</Badge>
       <Badge type="experimental">Experimental</Badge>
       <Badge type="warning">Warning</Badge>
+      <Badge type="gray">Gray</Badge>
     </SideBySide>
   ));
 });

+ 26 - 5
static/app/components/feedback/list/mailboxPicker.tsx

@@ -1,6 +1,9 @@
+import Badge from 'sentry/components/badge';
 import type decodeMailbox from 'sentry/components/feedback/decodeMailbox';
+import useMailboxCounts from 'sentry/components/feedback/list/useMailboxCounts';
 import {Flex} from 'sentry/components/profiling/flex';
 import {SegmentedControl} from 'sentry/components/segmentedControl';
+import {Tooltip} from 'sentry/components/tooltip';
 import {t} from 'sentry/locale';
 import useOrganization from 'sentry/utils/useOrganization';
 
@@ -11,7 +14,7 @@ interface Props {
   value: Mailbox;
 }
 
-const items = [
+const MAILBOXES = [
   {key: 'unresolved', label: t('Inbox')},
   {key: 'resolved', label: t('Resolved')},
   {key: 'ignored', label: t('Spam')},
@@ -19,8 +22,13 @@ const items = [
 
 export default function MailboxPicker({onChange, value}: Props) {
   const organization = useOrganization();
+  const {data} = useMailboxCounts({organization});
+
   const hasSpamFeature = organization.features.includes('user-feedback-spam-filter-ui');
-  const children = hasSpamFeature ? items : items.filter(i => i.key !== 'ignored');
+  const filteredMailboxes = hasSpamFeature
+    ? MAILBOXES
+    : MAILBOXES.filter(i => i.key !== 'ignored');
+
   return (
     <Flex justify="flex-end" flex="1 0 auto">
       <SegmentedControl
@@ -29,9 +37,22 @@ export default function MailboxPicker({onChange, value}: Props) {
         value={value}
         onChange={onChange}
       >
-        {children.map(c => (
-          <SegmentedControl.Item key={c.key}>{c.label}</SegmentedControl.Item>
-        ))}
+        {filteredMailboxes.map(c => {
+          const count = data?.[c.key];
+          const display = count && count >= 100 ? '99+' : count;
+          const title =
+            count === 1 ? t('1 unassigned item') : t('%s unassigned items', display);
+          return (
+            <SegmentedControl.Item key={c.key}>
+              <Tooltip disabled={!count} title={title}>
+                <Flex align="center">
+                  {c.label}
+                  {display ? <Badge type="gray" text={display} /> : null}
+                </Flex>
+              </Tooltip>
+            </SegmentedControl.Item>
+          );
+        })}
       </SegmentedControl>
     </Flex>
   );

+ 73 - 0
static/app/components/feedback/list/useMailboxCounts.tsx

@@ -0,0 +1,73 @@
+import {useMemo} from 'react';
+
+import type {Organization} from 'sentry/types';
+import {useApiQuery, type UseApiQueryResult} from 'sentry/utils/queryClient';
+import {decodeList, decodeScalar} from 'sentry/utils/queryString';
+import type RequestError from 'sentry/utils/requestError/requestError';
+import useLocationQuery from 'sentry/utils/url/useLocationQuery';
+
+interface Props {
+  organization: Organization;
+}
+
+// The keys here are the different search terms that we're using:
+type ApiReturnType = {
+  'issue.category:feedback is:unassigned is:ignored': number;
+  'issue.category:feedback is:unassigned is:resolved': number;
+  'issue.category:feedback is:unassigned is:unresolved': number;
+};
+
+// This is what the hook consumer gets:
+type HookReturnType = {
+  ignored: number;
+  resolved: number;
+  unresolved: number;
+};
+
+// This is the type to describe the mapping from ApiResponse to hook result:
+const MAILBOX: Record<keyof HookReturnType, keyof ApiReturnType> = {
+  unresolved: 'issue.category:feedback is:unassigned is:unresolved',
+  resolved: 'issue.category:feedback is:unassigned is:resolved',
+  ignored: 'issue.category:feedback is:unassigned is:ignored',
+};
+
+export default function useMailboxCounts({
+  organization,
+}: Props): UseApiQueryResult<HookReturnType, RequestError> {
+  const queryView = useLocationQuery({
+    fields: {
+      end: decodeScalar,
+      environment: decodeList,
+      field: decodeList,
+      project: decodeList,
+      query: Object.values(MAILBOX),
+      queryReferrer: 'feedback_list_page',
+      start: decodeScalar,
+      statsPeriod: decodeScalar,
+      utc: decodeScalar,
+    },
+  });
+
+  const result = useApiQuery<ApiReturnType>(
+    [`/organizations/${organization.slug}/issues-count/`, {query: queryView}],
+    {
+      staleTime: 1_000,
+      refetchInterval: 30_000,
+    }
+  );
+
+  return useMemo(
+    () =>
+      ({
+        ...result,
+        data: result.data
+          ? {
+              unresolved: result.data[MAILBOX.unresolved],
+              resolved: result.data[MAILBOX.resolved],
+              ignored: result.data[MAILBOX.ignored],
+            }
+          : undefined,
+      }) as UseApiQueryResult<HookReturnType, RequestError>,
+    [result]
+  );
+}

+ 5 - 0
static/app/utils/theme.tsx

@@ -506,6 +506,11 @@ const generateBadgeTheme = (colors: BaseColors) => ({
     indicatorColor: colors.yellow300,
     color: colors.gray500,
   },
+  gray: {
+    background: `rgba(43, 34, 51, 0.08)`,
+    indicatorColor: `rgba(43, 34, 51, 0.08)`,
+    color: colors.gray500,
+  },
 });
 
 const generateTagTheme = (colors: BaseColors) => ({