Browse Source

ref(feedback): Refactor infinite list loading to use issue api (#58209)

The issues api has a timestamp-based cursor, and doesn't support all of
the same sort/start/end params that our newly designed api did. So the
good news means that we can make better use of `useInfiniteQuery` from
'@tanstack/react-query'.

The bad news, for you dear reader, is that everything is rewritten.

Fixes https://github.com/getsentry/team-replay/issues/239
Fixes https://github.com/getsentry/team-replay/issues/234
Ryan Albrecht 1 year ago
parent
commit
bc6cfc987f

+ 12 - 17
static/app/components/feedback/feedbackItem/feedbackItem.tsx

@@ -9,7 +9,6 @@ import Section from 'sentry/components/feedback/feedbackItem/feedbackItemSection
 import FeedbackItemUsername from 'sentry/components/feedback/feedbackItem/feedbackItemUsername';
 import FeedbackViewers from 'sentry/components/feedback/feedbackItem/feedbackViewers';
 import ReplaySection from 'sentry/components/feedback/feedbackItem/replaySection';
-import TagsSection from 'sentry/components/feedback/feedbackItem/tagsSection';
 import useDeleteFeedback from 'sentry/components/feedback/feedbackItem/useDeleteFeedback';
 import ObjectInspector from 'sentry/components/objectInspector';
 import PanelItem from 'sentry/components/panels/panelItem';
@@ -21,7 +20,6 @@ import {space} from 'sentry/styles/space';
 import {getShortEventId} from 'sentry/utils/events';
 import type {HydratedFeedbackItem} from 'sentry/utils/feedback/item/types';
 import useOrganization from 'sentry/utils/useOrganization';
-import useProjects from 'sentry/utils/useProjects';
 
 interface Props {
   feedbackItem: HydratedFeedbackItem;
@@ -29,16 +27,8 @@ interface Props {
 
 export default function FeedbackItem({feedbackItem}: Props) {
   const organization = useOrganization();
-  const {projects} = useProjects();
-  const {onDelete} = useDeleteFeedback({
-    feedbackItem,
-  });
 
-  const project = projects.find(p => p.id === String(feedbackItem.project_id));
-  if (!project) {
-    return null;
-  }
-  const slug = project?.slug;
+  const {onDelete} = useDeleteFeedback({feedbackItem});
 
   return (
     <Fragment>
@@ -47,17 +37,22 @@ export default function FeedbackItem({feedbackItem}: Props) {
           <Flex column>
             <Flex align="center" gap={space(0.5)}>
               <FeedbackItemUsername feedbackItem={feedbackItem} detailDisplay />
-              {feedbackItem.contact_email ? (
+              {feedbackItem.metadata.contact_email ? (
                 <CopyToClipboardButton
                   size="xs"
                   iconSize="xs"
-                  text={feedbackItem.contact_email}
+                  text={feedbackItem.metadata.contact_email}
                 />
               ) : null}
             </Flex>
             <Flex gap={space(1)}>
               <Flex align="center" gap={space(0.5)}>
-                <ProjectAvatar project={project} size={12} title={slug} /> {slug}
+                <ProjectAvatar
+                  project={feedbackItem.project}
+                  size={12}
+                  title={feedbackItem.project.slug}
+                />
+                {feedbackItem.project.slug}
               </Flex>
               <Flex align="center" gap={space(1)}>
                 <IconChevron direction="right" size="xs" />
@@ -126,13 +121,13 @@ export default function FeedbackItem({feedbackItem}: Props) {
       <OverflowPanelItem>
         <Section title={t('Description')}>
           <Blockquote>
-            <pre>{feedbackItem.message}</pre>
+            <pre>{feedbackItem.metadata.message}</pre>
           </Blockquote>
         </Section>
 
         <Section icon={<IconLink size="xs" />} title={t('Url')}>
           <ErrorBoundary mini>
-            <TextCopyInput size="sm">{feedbackItem.url}</TextCopyInput>
+            <TextCopyInput size="sm">{'TODO'}</TextCopyInput>
           </ErrorBoundary>
         </Section>
 
@@ -140,7 +135,7 @@ export default function FeedbackItem({feedbackItem}: Props) {
           <ReplaySection organization={organization} replayId={feedbackItem.replay_id} />
         ) : null}
 
-        <TagsSection tags={feedbackItem.tags} />
+        {/* <TagsSection tags={feedbackItem.tags} /> */}
 
         <Section icon={<IconJson size="xs" />} title={t('Raw')}>
           <ObjectInspector

+ 10 - 12
static/app/components/feedback/feedbackItem/feedbackItemLoader.tsx

@@ -1,31 +1,29 @@
 import FeedbackErrorDetails from 'sentry/components/feedback/details/feedbackErrorDetails';
 import FeedbackItem from 'sentry/components/feedback/feedbackItem/feedbackItem';
-import useFetchFeedbackItem from 'sentry/components/feedback/useFetchFeedbackItem';
+import useFetchFeedbackIssue from 'sentry/components/feedback/useFetchFeedbackIssue';
 import Placeholder from 'sentry/components/placeholder';
 import {t} from 'sentry/locale';
 import useOrganization from 'sentry/utils/useOrganization';
-import useProjectFromSlug from 'sentry/utils/useProjectFromSlug';
 
 interface Props {
   feedbackSlug: string;
 }
 
 export default function FeedbackItemLoader({feedbackSlug}: Props) {
-  const [projectSlug, feedbackId] = feedbackSlug.split(':');
-
   const organization = useOrganization();
-  const project = useProjectFromSlug({organization, projectSlug});
 
-  const {isLoading, isError, data} = useFetchFeedbackItem(
-    {feedbackId, organization, project},
-    {enabled: Boolean(project)}
-  );
+  const [, feedbackId] = feedbackSlug.split(':');
+  const {
+    isLoading: isIssueLoading,
+    isError: isIssueError,
+    data: issue,
+  } = useFetchFeedbackIssue({feedbackId, organization});
 
-  return isLoading || !data ? (
+  return isIssueLoading || !issue ? (
     <Placeholder height="100%" />
-  ) : isError || !project ? (
+  ) : isIssueError ? (
     <FeedbackErrorDetails error={t('Unable to load feedback')} />
   ) : (
-    <FeedbackItem feedbackItem={data} />
+    <FeedbackItem feedbackItem={issue} />
   );
 }

+ 2 - 2
static/app/components/feedback/feedbackItem/feedbackItemUsername.tsx

@@ -10,8 +10,8 @@ interface Props {
 }
 
 export default function FeedbackItemUsername({feedbackItem, detailDisplay}: Props) {
-  const name = feedbackItem.name;
-  const email = feedbackItem.contact_email;
+  const name = ''; // feedbackItem.name;
+  const email = feedbackItem.metadata.contact_email;
 
   if (!email && !name) {
     return <strong>{t('Anonymous User')}</strong>;

+ 33 - 32
static/app/components/feedback/hydrateFeedbackRecord.tsx

@@ -1,44 +1,45 @@
 import {
-  FeedbackItemResponse,
   HydratedFeedbackItem,
+  RawFeedbackItemResponse,
 } from 'sentry/utils/feedback/item/types';
 
 export default function hydrateFeedbackRecord(
-  apiResponse: FeedbackItemResponse
+  apiResponse: RawFeedbackItemResponse
 ): HydratedFeedbackItem {
-  const unorderedTags: HydratedFeedbackItem['tags'] = {
-    ...apiResponse.tags,
-    ...(apiResponse.browser.name ? {'browser.name': apiResponse.browser.name} : {}),
-    ...(apiResponse.browser.version
-      ? {'browser.version': apiResponse.browser.version}
-      : {}),
-    ...(apiResponse.device.brand ? {'device.brand': apiResponse.device.brand} : {}),
-    ...(apiResponse.device.family ? {'device.family': apiResponse.device.family} : {}),
-    ...(apiResponse.device.model ? {'device.model': apiResponse.device.model} : {}),
-    ...(apiResponse.device.name ? {'device.name': apiResponse.device.name} : {}),
-    ...(apiResponse.locale.lang ? {'locale.lang': apiResponse.locale.lang} : {}),
-    ...(apiResponse.locale.timezone
-      ? {'locale.timezone': apiResponse.locale.timezone}
-      : {}),
-    ...(apiResponse.os.name ? {'os.name': apiResponse.os.name} : {}),
-    ...(apiResponse.os.version ? {'os.version': apiResponse.os.version} : {}),
-    ...(apiResponse.platform ? {platform: apiResponse.platform} : {}),
-    ...(apiResponse.sdk.name ? {'sdk.name': apiResponse.sdk.name} : {}),
-    ...(apiResponse.sdk.version ? {'sdk.version': apiResponse.sdk.version} : {}),
-  };
+  // const unorderedTags: HydratedFeedbackItem['tags'] = {
+  //   ...apiResponse.tags,
+  //   ...(apiResponse.browser.name ? {'browser.name': apiResponse.browser.name} : {}),
+  //   ...(apiResponse.browser.version
+  //     ? {'browser.version': apiResponse.browser.version}
+  //     : {}),
+  //   ...(apiResponse.device.brand ? {'device.brand': apiResponse.device.brand} : {}),
+  //   ...(apiResponse.device.family ? {'device.family': apiResponse.device.family} : {}),
+  //   ...(apiResponse.device.model ? {'device.model': apiResponse.device.model} : {}),
+  //   ...(apiResponse.device.name ? {'device.name': apiResponse.device.name} : {}),
+  //   ...(apiResponse.locale.lang ? {'locale.lang': apiResponse.locale.lang} : {}),
+  //   ...(apiResponse.locale.timezone
+  //     ? {'locale.timezone': apiResponse.locale.timezone}
+  //     : {}),
+  //   ...(apiResponse.os.name ? {'os.name': apiResponse.os.name} : {}),
+  //   ...(apiResponse.os.version ? {'os.version': apiResponse.os.version} : {}),
+  //   ...(apiResponse.platform ? {platform: apiResponse.platform} : {}),
+  //   ...(apiResponse.sdk.name ? {'sdk.name': apiResponse.sdk.name} : {}),
+  //   ...(apiResponse.sdk.version ? {'sdk.version': apiResponse.sdk.version} : {}),
+  // };
 
-  // Sort the tags by key
-  const tags = Object.keys(unorderedTags)
-    .sort()
-    .reduce((acc, key) => {
-      acc[key] = unorderedTags[key];
-      return acc;
-    }, {});
+  // // Sort the tags by key
+  // const tags = Object.keys(unorderedTags)
+  //   .sort()
+  //   .reduce((acc, key) => {
+  //     acc[key] = unorderedTags[key];
+  //     return acc;
+  //   }, {});
 
   return {
     ...apiResponse,
-    feedback_id: apiResponse.feedback_id,
-    timestamp: new Date(apiResponse.timestamp),
-    tags,
+    replay_id: undefined,
+    timestamp: new Date(apiResponse.firstSeen ?? ''),
+    feedback_id: apiResponse.id,
+    // tags,
   };
 }

+ 19 - 14
static/app/components/feedback/list/feedbackList.tsx

@@ -29,14 +29,19 @@ const cellMeasurer = {
 
 export default function FeedbackList() {
   const {
-    countLoadedRows,
+    // error,
+    // hasNextPage,
+    // isError,
+    isFetching, // If the network is active
+    isFetchingNextPage,
+    isFetchingPreviousPage,
+    isLoading, // If anything is loaded yet
+    // Below are fields that are shims for react-virtualized
     getRow,
-    isFetchingNext,
-    isFetchingPrev,
     isRowLoaded,
+    issues,
     loadMoreRows,
-    queryView,
-    totalHits,
+    // setFeedback,
   } = useInfiniteFeedbackListData();
 
   const {setParamValue} = useUrlParams('query');
@@ -46,8 +51,8 @@ export default function FeedbackList() {
 
   const listRef = useRef<ReactVirtualizedList>(null);
 
-  const hasRows = totalHits === undefined ? true : totalHits > 0;
-  const deps = useMemo(() => [queryView, hasRows], [queryView, hasRows]);
+  const hasRows = !isLoading;
+  const deps = useMemo(() => [hasRows], [hasRows]);
   const {cache, updateList} = useVirtualizedList({
     cellMeasurer,
     ref: listRef,
@@ -56,7 +61,7 @@ export default function FeedbackList() {
 
   useEffect(() => {
     updateList();
-  }, [updateList, countLoadedRows]);
+  }, [updateList, issues.length]);
 
   const renderRow = ({index, key, style, parent}: ListRowProps) => {
     const item = getRow({index});
@@ -91,7 +96,7 @@ export default function FeedbackList() {
         <InfiniteLoader
           isRowLoaded={isRowLoaded}
           loadMoreRows={loadMoreRows}
-          rowCount={totalHits}
+          rowCount={issues.length}
         >
           {({onRowsRendered, registerChild}) => (
             <AutoSizer onResize={updateList}>
@@ -100,11 +105,11 @@ export default function FeedbackList() {
                   deferredMeasurementCache={cache}
                   height={height}
                   noRowsRenderer={() =>
-                    isFetchingNext || isFetchingPrev ? (
+                    isFetching ? (
                       <LoadingIndicator />
                     ) : (
                       <NoRowRenderer
-                        unfilteredItems={totalHits === undefined ? [undefined] : []}
+                        unfilteredItems={issues}
                         clearSearchTerm={clearSearchTerm}
                       >
                         {t('No feedback received')}
@@ -116,7 +121,7 @@ export default function FeedbackList() {
                   ref={e => {
                     registerChild(e);
                   }}
-                  rowCount={totalHits === undefined ? 1 : totalHits}
+                  rowCount={issues.length}
                   rowHeight={cache.rowHeight}
                   rowRenderer={renderRow}
                   width={width}
@@ -126,14 +131,14 @@ export default function FeedbackList() {
           )}
         </InfiniteLoader>
         <FloatingContainer style={{top: '2px'}}>
-          {isFetchingPrev ? (
+          {isFetchingPreviousPage ? (
             <Tooltip title={t('Loading more feedback...')}>
               <LoadingIndicator mini />
             </Tooltip>
           ) : null}
         </FloatingContainer>
         <FloatingContainer style={{bottom: '2px'}}>
-          {isFetchingNext ? (
+          {isFetchingNextPage ? (
             <Tooltip title={t('Loading more feedback...')}>
               <LoadingIndicator mini />
             </Tooltip>

+ 1 - 1
static/app/components/feedback/list/feedbackListHeader.tsx

@@ -22,7 +22,7 @@ export default function FeedbackListHeader({checked}: Props) {
 
   return (
     <HeaderPanelItem>
-      <Checkbox />
+      <Checkbox onChange={() => {}} />
       {checked.length ? (
         <HasSelection checked={checked} />
       ) : (

+ 11 - 25
static/app/components/feedback/list/feedbackListItem.tsx

@@ -18,7 +18,6 @@ import {HydratedFeedbackItem} from 'sentry/utils/feedback/item/types';
 import {decodeScalar} from 'sentry/utils/queryString';
 import useLocationQuery from 'sentry/utils/url/useLocationQuery';
 import useOrganization from 'sentry/utils/useOrganization';
-import useProjects from 'sentry/utils/useProjects';
 import {normalizeUrl} from 'sentry/utils/withDomainRequired';
 
 interface Props {
@@ -29,17 +28,6 @@ interface Props {
   style?: CSSProperties;
 }
 
-const ReplayBadge = styled(props => (
-  <span {...props}>
-    <IconPlay size="xs" />
-    {t('Replay')}
-  </span>
-))`
-  display: flex;
-  gap: ${space(0.5)};
-  align-items: center;
-`;
-
 function useIsSelectedFeedback({feedbackItem}: {feedbackItem: HydratedFeedbackItem}) {
   const {feedbackSlug} = useLocationQuery({
     fields: {feedbackSlug: decodeScalar},
@@ -51,17 +39,8 @@ function useIsSelectedFeedback({feedbackItem}: {feedbackItem: HydratedFeedbackIt
 const FeedbackListItem = forwardRef<HTMLDivElement, Props>(
   ({className, feedbackItem, isChecked, onChecked, style}: Props, ref) => {
     const organization = useOrganization();
-    const {projects} = useProjects();
-
     const isSelected = useIsSelectedFeedback({feedbackItem});
 
-    const project = projects.find(p => p.id === String(feedbackItem.project_id));
-    if (!project) {
-      // TODO[feedback]: Guard against invalid test data that has no valid project.
-      return null;
-    }
-    const slug = project?.slug;
-
     return (
       <CardSpacing className={className} style={style} ref={ref}>
         <LinkedFeedbackCard
@@ -73,7 +52,7 @@ const FeedbackListItem = forwardRef<HTMLDivElement, Props>(
               query: {
                 ...location.query,
                 referrer: 'feedback_list_page',
-                feedbackSlug: `${project.slug}:${feedbackItem.feedback_id}`,
+                feedbackSlug: `${feedbackItem.project.slug}:${feedbackItem.feedback_id}`,
               },
             };
           }}
@@ -99,13 +78,20 @@ const FeedbackListItem = forwardRef<HTMLDivElement, Props>(
             <TimeSince date={feedbackItem.timestamp} />
           </span>
           <div style={{gridArea: 'message'}}>
-            <TextOverflow>{feedbackItem.message}</TextOverflow>
+            <TextOverflow>{feedbackItem.metadata.message}</TextOverflow>
           </div>
           <Flex style={{gridArea: 'icons'}} gap={space(1)} align="center">
             <Flex align="center" gap={space(0.5)}>
-              <ProjectAvatar project={project} size={12} /> {slug}
+              <ProjectAvatar project={feedbackItem.project} size={12} />
+              {feedbackItem.project.slug}
             </Flex>
-            {feedbackItem.replay_id ? <ReplayBadge /> : null}
+
+            {feedbackItem.replay_id ? (
+              <Flex align="center" gap={space(0.5)}>
+                <IconPlay size="xs" />
+                {t('Replay')}
+              </Flex>
+            ) : null}
           </Flex>
         </LinkedFeedbackCard>
       </CardSpacing>

+ 0 - 24
static/app/components/feedback/useFeedbackListQueryView.tsx

@@ -1,24 +0,0 @@
-import {QueryView} from 'sentry/utils/feedback/list/types';
-import {decodeList, decodeScalar} from 'sentry/utils/queryString';
-import useLocationQuery from 'sentry/utils/url/useLocationQuery';
-
-export default function useFeedbackListQueryView({
-  queryReferrer,
-}: {
-  queryReferrer: string;
-}): QueryView {
-  return useLocationQuery({
-    fields: {
-      queryReferrer,
-      end: decodeScalar,
-      environment: decodeList,
-      field: decodeList,
-      per_page: decodeScalar,
-      project: decodeList,
-      query: decodeScalar,
-      start: decodeScalar,
-      statsPeriod: decodeScalar,
-      utc: decodeScalar,
-    },
-  });
-}

+ 71 - 296
static/app/components/feedback/useFetchFeedbackInfiniteListData.tsx

@@ -1,336 +1,111 @@
-import {useCallback, useEffect, useRef, useState} from 'react';
+import {useCallback, useMemo} from 'react';
 import {Index, IndexRange} from 'react-virtualized';
-import moment from 'moment';
 
-import {ApiResult, Client} from 'sentry/api';
 import hydrateFeedbackRecord from 'sentry/components/feedback/hydrateFeedbackRecord';
-import {Organization} from 'sentry/types';
-import formatDuration from 'sentry/utils/duration/formatDuration';
-import Dispatch from 'sentry/utils/eventDispatcher';
-import {
-  FeedbackItemResponse,
-  HydratedFeedbackItem,
-} from 'sentry/utils/feedback/item/types';
-import {EMPTY_QUERY_VIEW, QueryView} from 'sentry/utils/feedback/list/types';
-import parseLinkHeader from 'sentry/utils/parseLinkHeader';
-import {decodeInteger} from 'sentry/utils/queryString';
-import useApi from 'sentry/utils/useApi';
+import {HydratedFeedbackItem} from 'sentry/utils/feedback/item/types';
+import {RawFeedbackListResponse} from 'sentry/utils/feedback/list/types';
+import {useInfiniteApiQuery} from 'sentry/utils/queryClient';
 import useOrganization from 'sentry/utils/useOrganization';
 
-const PER_PAGE = 10;
-
-type Unsubscribe = () => void;
-
-function startDateFromQueryView({start, statsPeriod}: QueryView): Date {
-  if (start) {
-    return new Date(start);
-  }
-  if (statsPeriod) {
-    const value = parseInt(statsPeriod, 10);
-    const unit = statsPeriod.endsWith('m')
-      ? 'min'
-      : statsPeriod.endsWith('h')
-      ? 'hour'
-      : statsPeriod.endsWith('d')
-      ? 'day'
-      : statsPeriod.endsWith('w')
-      ? 'week'
-      : undefined;
-    if (unit) {
-      const msdifference = formatDuration({
-        precision: 'ms',
-        style: 'count',
-        duration: [value, unit],
-      });
-      return moment.utc().subtract(msdifference, 'ms').toDate();
-    }
-  }
-  throw new Error('Must pass either start or statsPeriod');
-}
-
-function endDateFromQueryView({end}: QueryView): Date {
-  if (end) {
-    return new Date(end);
-  }
-  return new Date();
-}
-
-class InfiniteListLoader {
-  private dispatch = new Dispatch();
-
-  private timestampToFeedback = new Map<number, HydratedFeedbackItem>();
-
-  public hasPrev: undefined | boolean = undefined;
-  public hasMore: undefined | boolean = undefined;
-  public totalHits: undefined | number = undefined;
-
-  private isFetching = false;
-  public isFetchingNext = false;
-  public isFetchingPrev = false;
-
-  constructor(
-    private api: Client,
-    private organization: Organization,
-    private queryView: QueryView,
-    private initialDate: Date
-  ) {
-    if (!queryView.start && !queryView.statsPeriod) {
-      return;
-    }
-    this.fetch({
-      start: startDateFromQueryView(queryView),
-      end: this.initialDate,
-      sort: '-timestamp',
-      perPage: PER_PAGE,
-    }).then(results => {
-      this.totalHits = results.hits;
-      this.didFetchNext(results);
-    });
-  }
-
-  get feedbacks() {
-    const initialDateTime = this.initialDate.getTime();
-    const timestamps = Array.from(this.timestampToFeedback.keys())
-      .filter(timestamp => timestamp < initialDateTime)
-      .sort()
-      .reverse();
-
-    const feedbacks = timestamps.map(timestamp =>
-      this.timestampToFeedback.get(timestamp)
-    );
-    return feedbacks;
-  }
-
-  setFeedback(feedbackId: string, feedback: undefined | HydratedFeedbackItem) {
-    const old = this.feedbacks.find(fb => fb?.feedback_id === feedbackId);
-    if (old) {
-      if (!feedback) {
-        this.timestampToFeedback.delete(old.timestamp.getTime());
-      } else {
-        this.timestampToFeedback.set(old.timestamp.getTime(), feedback);
-      }
-    }
-  }
-
-  onChange(handler: () => void): Unsubscribe {
-    this.dispatch.addEventListener('change', handler);
-    return () => this.dispatch.removeEventListener('change', handler);
-  }
-
-  private get minDatetime() {
-    return (
-      new Date(Math.min(...Array.from(this.timestampToFeedback.keys()))) ?? new Date()
-    );
-  }
-
-  private get maxDatetime() {
-    return (
-      new Date(Math.max(...Array.from(this.timestampToFeedback.keys()))) ?? new Date()
-    );
-  }
-
-  public resetInitialTimestamp() {
-    this.initialDate = new Date(this.maxDatetime);
-  }
-
-  private async fetch({
-    end,
-    perPage,
-    sort,
-    start,
-  }: {
-    end: Date;
-    perPage: number;
-    sort: 'timestamp' | '-timestamp';
-    start: Date;
-  }) {
-    if (this.isFetching) {
-      return {
-        feedbacks: [],
-        hasNextPage: undefined,
-        hits: 0,
-      };
-    }
-
-    this.isFetching = true;
-
-    const [data, , resp]: ApiResult<undefined | FeedbackItemResponse[]> =
-      await this.api.requestPromise(
-        `/organizations/${this.organization.slug}/feedback/`,
-        {
-          includeAllArgs: true,
-          query: {
-            ...this.queryView,
-            statsPeriod: undefined,
-            cursor: `0:0:0`,
-            per_page: perPage,
-            sort,
-            start: start.toISOString(),
-            end: end.toISOString(),
-          },
-        }
-      );
-
-    this.isFetching = false;
-
-    const hits = decodeInteger(resp?.getResponseHeader('X-Hits'), 0);
-    const nextPage = parseLinkHeader(resp?.getResponseHeader('Link') ?? null).cursor;
-    const feedbacks = data?.map(hydrateFeedbackRecord);
-    feedbacks?.forEach(feedback => {
-      this.timestampToFeedback.set(feedback.timestamp.getTime(), feedback);
-    });
-
-    return {
-      feedbacks,
-      hasNextPage: Boolean(nextPage),
-      hits,
-    };
-  }
-
-  public async fetchNext(perPage: number = PER_PAGE) {
-    if (this.hasMore !== true) {
-      // Skip the request if we either:
-      // - Have not yet got the first results back
-      // - or, we know there are no more results to fetch
-      return;
-    }
-
-    this.isFetchingNext = true;
-    const result = await this.fetch({
-      end: this.minDatetime,
-      perPage,
-      sort: '-timestamp',
-      start: startDateFromQueryView(this.queryView),
-    });
-    this.isFetchingNext = false;
-    this.didFetchNext(result);
-  }
-
-  public async fetchPrev(perPage: number = PER_PAGE) {
-    if (this.hasPrev !== false) {
-      // Skip the request if:
-      // - We know there are no more results to fetch
-      return;
-    }
-
-    this.isFetchingPrev = true;
-    const result = await this.fetch({
-      end: endDateFromQueryView(this.queryView),
-      perPage,
-      sort: 'timestamp',
-      start: this.maxDatetime,
-    });
-    this.isFetchingPrev = false;
-    this.didFetchPrev(result);
-  }
-
-  private didFetchNext = ({hasNextPage}) => {
-    const now = Date.now();
-    this.hasMore = hasNextPage || this.minDatetime.getTime() < now;
-    this.dispatch.dispatchEvent(new Event('change'));
-  };
-
-  private didFetchPrev = ({hasNextPage}) => {
-    const now = Date.now();
-    this.hasPrev = hasNextPage || (this.maxDatetime.getTime() ?? now) > now;
-    this.dispatch.dispatchEvent(new Event('change'));
+interface Params {
+  queryView: {
+    collapse: string[];
+    expand: string[];
+    limit: number;
+    queryReferrer: string;
+    shortIdLookup: number;
+    end?: string;
+    environment?: string[];
+    field?: string[];
+    project?: string[];
+    query?: string;
+    start?: string;
+    statsPeriod?: string;
+    utc?: string;
   };
 }
 
 export const EMPTY_INFINITE_LIST_DATA: ReturnType<
   typeof useFetchFeedbackInfiniteListData
 > = {
-  countLoadedRows: 0,
-  getRow: () => undefined,
+  error: null,
+  hasNextPage: false,
   isError: false,
-  isFetchingNext: false,
-  isFetchingPrev: false,
-  isLoading: false,
+  isFetching: false, // If the network is active
+  isFetchingNextPage: false,
+  isFetchingPreviousPage: false,
+  isLoading: false, // If anything is loaded yet
+  // Below are fields that are shims for react-virtualized
+  getRow: () => undefined,
   isRowLoaded: () => false,
+  issues: [],
   loadMoreRows: () => Promise.resolve(),
-  queryView: EMPTY_QUERY_VIEW,
   setFeedback: () => undefined,
-  totalHits: 0,
-};
-
-type State = {
-  isFetchingNext: boolean;
-  isFetchingPrev: boolean;
-  items: (HydratedFeedbackItem | undefined)[];
-  totalHits: undefined | number;
 };
 
-export default function useFetchFeedbackInfiniteListData({
-  queryView,
-  initialDate,
-}: {
-  initialDate: Date;
-  queryView: QueryView;
-}) {
-  const api = useApi();
+export default function useFetchFeedbackInfiniteListData({queryView}: Params) {
   const organization = useOrganization();
 
-  const loaderRef = useRef<InfiniteListLoader>();
-  const [state, setState] = useState<State>({
-    items: [],
-    totalHits: undefined,
-    isFetchingNext: false,
-    isFetchingPrev: false,
-  });
+  const query = useMemo(
+    () => ({
+      ...queryView,
+      query: 'issue.category:feedback ' + queryView.query,
+    }),
+    [queryView]
+  );
 
-  useEffect(() => {
-    const loader = new InfiniteListLoader(api, organization, queryView, initialDate);
-    loaderRef.current = loader;
+  const {
+    data,
+    error,
+    fetchNextPage,
+    hasNextPage,
+    isError,
+    isFetching, // If the network is active
+    isFetchingNextPage,
+    isFetchingPreviousPage,
+    isLoading, // If anything is loaded yet
+  } = useInfiniteApiQuery<RawFeedbackListResponse>({
+    queryKey: [`/organizations/${organization.slug}/issues/`, {query}],
+  });
 
-    return loader.onChange(() => {
-      const {totalHits, isFetchingNext, isFetchingPrev} = loader;
-      setState({
-        items: loader.feedbacks,
-        totalHits,
-        isFetchingNext,
-        isFetchingPrev,
-      });
-    });
-  }, [api, organization, queryView, initialDate]);
+  const issues = useMemo(
+    () => data?.pages.flatMap(([pageData]) => pageData).map(hydrateFeedbackRecord) ?? [],
+    [data]
+  );
 
   const getRow = useCallback(
-    ({index}: Index): HydratedFeedbackItem | undefined => state.items[index] ?? undefined,
-    [state.items]
+    ({index}: Index): HydratedFeedbackItem | undefined => issues?.[index],
+    [issues]
   );
 
-  const isRowLoaded = useCallback(
-    ({index}: Index) => state.items[index] !== undefined,
-    [state.items]
-  );
+  const isRowLoaded = useCallback(({index}: Index) => Boolean(issues?.[index]), [issues]);
 
   const loadMoreRows = useCallback(
-    ({startIndex, stopIndex}: IndexRange) =>
-      loaderRef.current?.fetchNext(stopIndex - startIndex) ?? Promise.resolve(),
-    []
+    ({startIndex: _1, stopIndex: _2}: IndexRange) =>
+      // isFetchingloaderRef.current?.fetchNext(stopIndex - startIndex) ?? Promise.resolve(),
+      hasNextPage && !isFetching ? fetchNextPage() : Promise.resolve(),
+    [hasNextPage, isFetching, fetchNextPage]
   );
 
   const setFeedback = useCallback(
-    (feedbackId: string, feedback: undefined | HydratedFeedbackItem) =>
-      loaderRef.current?.setFeedback(feedbackId, feedback),
+    (_feedbackId: string, _feedback: undefined | HydratedFeedbackItem) => {},
+    // loaderRef.current?.setFeedback(feedbackId, feedback),
     []
   );
 
-  useEffect(() => {
-    loadMoreRows({startIndex: 0, stopIndex: PER_PAGE});
-  }, [loadMoreRows]);
-
-  const {totalHits, isFetchingNext, isFetchingPrev} = state;
   return {
-    countLoadedRows: state.items.length,
+    error,
+    hasNextPage,
+    isError,
+    isFetching, // If the network is active
+    isFetchingNextPage,
+    isFetchingPreviousPage,
+    isLoading, // If anything is loaded yet
+    // Below are fields that are shims for react-virtualized
     getRow,
-    isError: false,
-    isFetchingNext,
-    isFetchingPrev,
-    isLoading: false,
     isRowLoaded,
+    issues,
     loadMoreRows,
-    queryView,
     setFeedback,
-    totalHits,
   };
 }

+ 35 - 0
static/app/components/feedback/useFetchFeedbackIssue.tsx

@@ -0,0 +1,35 @@
+import hydrateFeedbackRecord from 'sentry/components/feedback/hydrateFeedbackRecord';
+import {Organization} from 'sentry/types';
+import {RawFeedbackItemResponse} from 'sentry/utils/feedback/item/types';
+import {useApiQuery, type UseApiQueryOptions} from 'sentry/utils/queryClient';
+
+interface Props {
+  feedbackId: string;
+  organization: Organization;
+}
+
+export default function useFetchFeedbackIssue(
+  {feedbackId, organization}: Props,
+  options: undefined | Partial<UseApiQueryOptions<RawFeedbackItemResponse>> = {}
+) {
+  const {data, ...result} = useApiQuery<RawFeedbackItemResponse>(
+    [
+      `/organizations/${organization.slug}/issues/${feedbackId}/`,
+      {
+        query: {
+          collapse: ['release', 'tags'],
+          expand: ['inbox', 'owners'],
+        },
+      },
+    ],
+    {
+      staleTime: 0,
+      ...options,
+    }
+  );
+
+  return {
+    data: data ? hydrateFeedbackRecord(data) : undefined,
+    ...result,
+  };
+}

Some files were not shown because too many files changed in this diff