Browse Source

feat(bug reports): Create a infinite scrolling list of feedback items (#57432)

This sets up an infinite loading list of feedback items along the left
side of the page, replacing the single-page of feedback items we had
before. Also improves some rendering issues by telling the measurement
cache to update whenever new data has arrived.

<img width="1327" alt="SCR-20231003-owss"
src="https://github.com/getsentry/sentry/assets/187460/2fc603be-2a21-4903-9d00-be2b7762059b">

The network requests, after some scrolling, look like this:
<img width="733" alt="SCR-20231003-oxec"
src="https://github.com/getsentry/sentry/assets/187460/ca0e34ef-b595-46d6-911b-8a8673abc3e0">

Each page is a max of 10 records right now, which is not optimal, but
that's something to change in a followup.

The way this works is:
- We've got `FeedbackDataContext` which is a simple Provider that allows
us to hoist the data-fetching hook up to the top of the page, and share
the data with everything below.
- The data-fetching hook is `useFetchFeedbackInfiniteListData` which
uses `class InfiniteListLoader` under the hood.
- the hook part is basically an adapter that returns callbacks for
`react-virtualized` to call, and in turn will call into `class
InfiniteListLoader` to load more data, either at the top or bottom of
the list.

`class InfiniteListLoader` is kind of simple, but has some features for
later:
- It contains an EventDispatcher (event emitter?) so we can easily
reason about when updates will be flushed, basically controlling when
react will re-render. I found this easier than trying to carefully
manage object identity.
- The class will be able to fetch 'newer' items, but won't render them
right away, doing that would cause `react-virtualized` to get confused.
The pattern that we will build will be to: a) fetch data in the
background, or on demand, whatever, b) show a floating pull on the list
(like a "jump to bottom" style) that loads new data at the top of the
list. the user it taken to the top of the list for this to work.
- This would be useful when the user is deep-linked to a specific
feedback item. In that case we can a) load the specific feedback item
first b) get the timestamp of that feedback item c) set the list to
start from that timestamp, loading items 'older' and older. This means
that the currently selected item naturally be at the top of the list,
visible to the user. d) in the background we can load 'newer' feedbacks,
and when the user is ready they can click to render those 'newer' items
above the currently select item that was deep-linked.

We've also got a new helper in here called `useLocationQuery()` which is
something i hope can help to reduce re-renders whenever location
changes. This can be something to experiment with inside of replays as
well.

---------

Co-authored-by: Billy Vong <billyvg@users.noreply.github.com>
Co-authored-by: Michelle Zhang <56095982+michellewzhang@users.noreply.github.com>
Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
Ryan Albrecht 1 year ago
parent
commit
1fe99d443d

+ 26 - 0
static/app/components/feedback/feedbackDataContext.tsx

@@ -0,0 +1,26 @@
+import {createContext, ReactNode, useContext} from 'react';
+
+import useFetchFeedbackInfiniteListData, {
+  EMPTY_INFINITE_LIST_DATA,
+} from 'sentry/components/feedback/useFetchFeedbackInfiniteListData';
+
+type ListDataParams = Parameters<typeof useFetchFeedbackInfiniteListData>[0];
+interface ProviderProps extends ListDataParams {
+  children: ReactNode;
+}
+
+const FeedbackListDataContext = createContext<
+  ReturnType<typeof useFetchFeedbackInfiniteListData>
+>(EMPTY_INFINITE_LIST_DATA);
+
+export function FeedbackDataContext({children, ...listDataParams}: ProviderProps) {
+  const contextValue = useFetchFeedbackInfiniteListData(listDataParams);
+
+  return (
+    <FeedbackListDataContext.Provider value={contextValue}>
+      {children}
+    </FeedbackListDataContext.Provider>
+  );
+}
+
+export const useInfiniteFeedbackListData = () => useContext(FeedbackListDataContext);

+ 0 - 37
static/app/components/feedback/index/feedbackIndexLoader.tsx

@@ -1,37 +0,0 @@
-import {CSSProperties} from 'react';
-
-import FeedbackErrorDetails from 'sentry/components/feedback/details/feedbackErrorDetails';
-import FeedbackList from 'sentry/components/feedback/list/feedbackList';
-import FeedbackListWrapper from 'sentry/components/feedback/list/feedbackListWrapper';
-import useFetchFeedbackList from 'sentry/components/feedback/useFetchFeedbackList';
-import Placeholder from 'sentry/components/placeholder';
-import {t} from 'sentry/locale';
-
-interface Props {
-  query: Record<string, string | string[] | undefined>;
-  className?: string;
-  style?: CSSProperties;
-}
-
-export default function FeedbackIndexLoader(props: Props) {
-  const {
-    isLoading,
-    isError,
-    data,
-    pageLinks: _,
-  } = useFetchFeedbackList({query: props.query}, {});
-
-  return isLoading || !data ? (
-    <FeedbackListWrapper>
-      <Placeholder height="100%" />
-    </FeedbackListWrapper>
-  ) : isError ? (
-    <FeedbackListWrapper>
-      <FeedbackErrorDetails error={t('Unable to load feedback list')} />
-    </FeedbackListWrapper>
-  ) : (
-    <FeedbackListWrapper>
-      <FeedbackList items={data} />
-    </FeedbackListWrapper>
-  );
-}

+ 73 - 27
static/app/components/feedback/list/feedbackList.tsx

@@ -1,14 +1,19 @@
-import {useMemo, useRef} from 'react';
+import {Fragment, useEffect, useMemo, useRef} from 'react';
 import {
   AutoSizer,
   CellMeasurer,
+  InfiniteLoader,
   List as ReactVirtualizedList,
   ListRowProps,
 } from 'react-virtualized';
+import styled from '@emotion/styled';
 
+import {useInfiniteFeedbackListData} from 'sentry/components/feedback/feedbackDataContext';
 import FeedbackListItem from 'sentry/components/feedback/list/feedbackListItem';
+import PanelItem from 'sentry/components/panels/panelItem';
 import {t} from 'sentry/locale';
-import {HydratedFeedbackItem} from 'sentry/utils/feedback/item/types';
+import {space} from 'sentry/styles/space';
+import useUrlParams from 'sentry/utils/useUrlParams';
 import NoRowRenderer from 'sentry/views/replays/detail/noRowRenderer';
 import useVirtualizedList from 'sentry/views/replays/detail/useVirtualizedList';
 
@@ -19,24 +24,32 @@ const cellMeasurer = {
   minHeight: 24,
 };
 
-interface Props {
-  items: HydratedFeedbackItem[];
-}
+export default function FeedbackList() {
+  const {getRow, isRowLoaded, loadMoreRows, totalHits, countLoadedRows, queryView} =
+    useInfiniteFeedbackListData();
 
-export default function FeedbackList({items}: Props) {
-  const clearSearchTerm = () => {}; // setSearchTerm('');
+  const {setParamValue} = useUrlParams('query');
+  const clearSearchTerm = () => setParamValue('');
 
   const listRef = useRef<ReactVirtualizedList>(null);
 
-  const deps = useMemo(() => [items], [items]);
+  const hasRows = totalHits === undefined ? true : totalHits > 0;
+  const deps = useMemo(() => [queryView, hasRows], [queryView, hasRows]);
   const {cache, updateList} = useVirtualizedList({
     cellMeasurer,
     ref: listRef,
     deps,
   });
 
+  useEffect(() => {
+    updateList();
+  }, [updateList, countLoadedRows]);
+
   const renderRow = ({index, key, style, parent}: ListRowProps) => {
-    const item = items[index];
+    const item = getRow({index});
+    if (!item) {
+      return null;
+    }
 
     return (
       <CellMeasurer
@@ -52,24 +65,57 @@ export default function FeedbackList({items}: Props) {
   };
 
   return (
-    <AutoSizer onResize={updateList}>
-      {({width, height}) => (
-        <ReactVirtualizedList
-          deferredMeasurementCache={cache}
-          height={height}
-          noRowsRenderer={() => (
-            <NoRowRenderer unfilteredItems={items} clearSearchTerm={clearSearchTerm}>
-              {t('No feedback received')}
-            </NoRowRenderer>
+    <Fragment>
+      <HeaderPanelItem> </HeaderPanelItem>
+      <OverflowPanelItem noPadding>
+        <InfiniteLoader
+          isRowLoaded={isRowLoaded}
+          loadMoreRows={loadMoreRows}
+          rowCount={totalHits}
+        >
+          {({onRowsRendered, registerChild}) => (
+            <AutoSizer onResize={updateList}>
+              {({width, height}) => (
+                <ReactVirtualizedList
+                  deferredMeasurementCache={cache}
+                  height={height}
+                  noRowsRenderer={() => (
+                    <NoRowRenderer
+                      unfilteredItems={totalHits === undefined ? [undefined] : []}
+                      clearSearchTerm={clearSearchTerm}
+                    >
+                      {t('No feedback received')}
+                    </NoRowRenderer>
+                  )}
+                  onRowsRendered={onRowsRendered}
+                  overscanRowCount={5}
+                  ref={e => {
+                    registerChild(e);
+                  }}
+                  rowCount={totalHits === undefined ? 1 : totalHits}
+                  rowHeight={cache.rowHeight}
+                  rowRenderer={renderRow}
+                  width={width}
+                />
+              )}
+            </AutoSizer>
           )}
-          overscanRowCount={5}
-          ref={listRef}
-          rowCount={items.length}
-          rowHeight={cache.rowHeight}
-          rowRenderer={renderRow}
-          width={width}
-        />
-      )}
-    </AutoSizer>
+        </InfiniteLoader>
+      </OverflowPanelItem>
+    </Fragment>
   );
 }
+
+const HeaderPanelItem = styled(PanelItem)`
+  display: grid;
+  padding: ${space(1)} ${space(2)};
+`;
+
+const OverflowPanelItem = styled(PanelItem)`
+  overflow: scroll;
+  padding: ${space(0.5)};
+
+  flex-direction: column;
+  flex-grow: 1;
+  gap: ${space(1)};
+`;

+ 64 - 49
static/app/components/feedback/list/feedbackListItem.tsx

@@ -1,4 +1,5 @@
-import {CSSProperties} from 'react';
+import {CSSProperties, forwardRef} from 'react';
+import {browserHistory} from 'react-router';
 import styled from '@emotion/styled';
 
 import FeatureBadge from 'sentry/components/featureBadge';
@@ -17,7 +18,7 @@ import {
   HydratedFeedbackItem,
 } from 'sentry/utils/feedback/item/types';
 import {decodeScalar} from 'sentry/utils/queryString';
-import {useLocation} from 'sentry/utils/useLocation';
+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';
@@ -43,57 +44,69 @@ function UnreadBadge() {
   return <FeatureBadge type="new" variant="indicator" />;
 }
 
-export default function FeedbackListItem({className, feedbackItem, style}: Props) {
-  const organization = useOrganization();
-  const {projects} = useProjects();
-  const location = useLocation<FeedbackItemLoaderQueryParams>();
-  const feedbackSlug = decodeScalar(location.query.feedbackSlug);
+function useIsSelectedFeedback({feedbackItem}: {feedbackItem: HydratedFeedbackItem}) {
+  const {feedbackSlug} = useLocationQuery<FeedbackItemLoaderQueryParams>({
+    fields: {feedbackSlug: decodeScalar},
+  });
   const [, feedbackId] = feedbackSlug?.split(':') ?? [];
+  return feedbackId === feedbackItem.feedback_id;
+}
 
-  const isSelected = feedbackId === feedbackItem.feedback_id;
+const FeedbackListItem = forwardRef<HTMLAnchorElement, Props>(
+  ({className, feedbackItem, style}: Props, ref) => {
+    const organization = useOrganization();
+    const {projects} = useProjects();
 
-  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 isSelected = useIsSelectedFeedback({feedbackItem});
 
-  return (
-    <Wrapper
-      className={className}
-      style={style}
-      data-selected={isSelected}
-      to={{
-        pathname: normalizeUrl(`/organizations/${organization.slug}/feedback/`),
-        query: {
-          referrer: 'feedback_list_page',
-          feedbackSlug: `${project.slug}:${feedbackItem.feedback_id}`,
-        },
-      }}
-      onClick={() => {
-        trackAnalytics('feedback_list.details_link.click', {organization});
-      }}
-    >
-      <InteractionStateLayer />
-      <Flex column style={{gridArea: 'right'}}>
-        <input type="checkbox" />
-        <UnreadBadge />
-      </Flex>
-      <strong style={{gridArea: 'user'}}>
-        <FeedbackItemUsername feedbackItem={feedbackItem} />
-      </strong>
-      <span style={{gridArea: 'time'}}>
-        <TimeSince date={feedbackItem.timestamp} />
-      </span>
-      <div style={{gridArea: 'message'}}>
-        <TextOverflow>{feedbackItem.message}</TextOverflow>
-      </div>
-      <Flex style={{gridArea: 'icons'}}>
-        {feedbackItem.replay_id ? <ReplayBadge /> : null}
-      </Flex>
-    </Wrapper>
-  );
-}
+    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;
+    }
+
+    return (
+      <Wrapper
+        ref={ref}
+        className={className}
+        style={style}
+        data-selected={isSelected}
+        to={() => {
+          const location = browserHistory.getCurrentLocation();
+          return {
+            pathname: normalizeUrl(`/organizations/${organization.slug}/feedback/`),
+            query: {
+              ...location.query,
+              referrer: 'feedback_list_page',
+              feedbackSlug: `${project.slug}:${feedbackItem.feedback_id}`,
+            },
+          };
+        }}
+        onClick={() => {
+          trackAnalytics('feedback_list.details_link.click', {organization});
+        }}
+      >
+        <InteractionStateLayer />
+        <Flex column style={{gridArea: 'right'}}>
+          <input type="checkbox" />
+          <UnreadBadge />
+        </Flex>
+        <strong style={{gridArea: 'user'}}>
+          <FeedbackItemUsername feedbackItem={feedbackItem} />
+        </strong>
+        <span style={{gridArea: 'time'}}>
+          <TimeSince date={feedbackItem.timestamp} />
+        </span>
+        <div style={{gridArea: 'message'}}>
+          <TextOverflow>{feedbackItem.message}</TextOverflow>
+        </div>
+        <Flex style={{gridArea: 'icons'}}>
+          {feedbackItem.replay_id ? <ReplayBadge /> : null}
+        </Flex>
+      </Wrapper>
+    );
+  }
+);
 
 const Wrapper = styled(Link)`
   border-radius: ${p => p.theme.borderRadius};
@@ -118,3 +131,5 @@ const Wrapper = styled(Link)`
   gap: ${space(1)};
   place-items: stretch;
 `;
+
+export default FeedbackListItem;

+ 0 - 72
static/app/components/feedback/useFeedbackListQueryParams.tsx

@@ -1,72 +0,0 @@
-// import {useMemo} from 'react';
-import {Location} from 'history';
-
-import {decodeList, decodeScalar} from 'sentry/utils/queryString';
-
-export default function useFeedbackListQueryParams({
-  location,
-  queryReferrer,
-}: {
-  location: Location;
-  queryReferrer: string;
-}): Record<string, string | string[] | undefined> {
-  const {
-    cursor,
-    end,
-    environment,
-    field,
-    offset,
-    per_page,
-    project,
-    query,
-    sort,
-    start,
-    statsPeriod,
-  } = location.query;
-
-  return {
-    cursor: decodeScalar(cursor),
-    end: decodeScalar(end),
-    environment: decodeList(environment),
-    field: decodeList(field),
-    offset: decodeScalar(offset),
-    per_page: decodeScalar(per_page),
-    project: decodeList(project),
-    query: decodeScalar(query),
-    sort: decodeScalar(sort),
-    start: decodeScalar(start),
-    statsPeriod: decodeScalar(statsPeriod),
-    queryReferrer,
-  };
-  // Tried alone, didn't stop the list page from re-rendering
-  // return useMemo(
-  //   () => ({
-  //     cursor: decodeScalar(cursor),
-  //     end: decodeScalar(end),
-  //     environment: decodeList(environment),
-  //     field: decodeList(field),
-  //     offset: decodeScalar(offset),
-  //     per_page: decodeScalar(per_page),
-  //     project: decodeList(project),
-  //     query: decodeScalar(query),
-  //     sort: decodeScalar(sort),
-  //     start: decodeScalar(start),
-  //     statsPeriod: decodeScalar(statsPeriod),
-  //     queryReferrer,
-  //   }),
-  //   [
-  //     cursor,
-  //     end,
-  //     environment,
-  //     field,
-  //     offset,
-  //     per_page,
-  //     project,
-  //     query,
-  //     sort,
-  //     start,
-  //     statsPeriod,
-  //     queryReferrer,
-  //   ]
-  // );
-}

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

@@ -0,0 +1,24 @@
+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<QueryView>({
+    fields: {
+      queryReferrer,
+      end: decodeScalar,
+      environment: decodeList,
+      field: decodeList,
+      per_page: decodeScalar,
+      project: decodeList,
+      query: decodeScalar,
+      start: decodeScalar,
+      statsPeriod: decodeScalar,
+      utc: decodeScalar,
+    },
+  });
+}

+ 305 - 0
static/app/components/feedback/useFetchFeedbackInfiniteListData.tsx

@@ -0,0 +1,305 @@
+import {useCallback, useEffect, useRef, useState} 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 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',
+        timespan: [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;
+
+  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 feedbacks: HydratedFeedbackItem[] = [];
+    const initialDateTime = this.initialDate.getTime();
+    for (const [timestamp, feedback] of this.timestampToFeedback.entries()) {
+      if (timestamp < initialDateTime) {
+        feedbacks.push(feedback);
+      }
+    }
+    return feedbacks;
+  }
+
+  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() {
+    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;
+    }
+
+    const result = await this.fetch({
+      end: this.minDatetime,
+      perPage: PER_PAGE,
+      sort: '-timestamp',
+      start: startDateFromQueryView(this.queryView),
+    });
+    this.didFetchNext(result);
+  }
+
+  public async fetchPrev() {
+    if (this.hasPrev !== false) {
+      // Skip the request if:
+      // - We know there are no more results to fetch
+      return;
+    }
+
+    const result = await this.fetch({
+      end: endDateFromQueryView(this.queryView),
+      perPage: PER_PAGE,
+      sort: 'timestamp',
+      start: this.maxDatetime,
+    });
+    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'));
+  };
+}
+
+export const EMPTY_INFINITE_LIST_DATA: ReturnType<
+  typeof useFetchFeedbackInfiniteListData
+> = {
+  countLoadedRows: 0,
+  getRow: () => undefined,
+  isError: false,
+  isLoading: false,
+  isRowLoaded: () => false,
+  loadMoreRows: () => Promise.resolve(),
+  queryView: EMPTY_QUERY_VIEW,
+  totalHits: 0,
+  updateFeedback: () => undefined,
+};
+
+type State = {
+  items: HydratedFeedbackItem[];
+  totalHits: undefined | number;
+};
+
+export default function useFetchFeedbackInfiniteListData({
+  queryView,
+  initialDate,
+}: {
+  initialDate: Date;
+  queryView: QueryView;
+}) {
+  const api = useApi();
+  const organization = useOrganization();
+
+  const loaderRef = useRef<InfiniteListLoader>();
+  const [state, setState] = useState<State>({
+    items: [],
+    totalHits: undefined,
+  });
+
+  useEffect(() => {
+    const loader = new InfiniteListLoader(api, organization, queryView, initialDate);
+    loaderRef.current = loader;
+
+    return loader.onChange(() => {
+      setState({
+        items: loader.feedbacks,
+        totalHits: loader.totalHits,
+      });
+    });
+  }, [api, organization, queryView, initialDate]);
+
+  const getRow = useCallback(
+    ({index}: Index): HydratedFeedbackItem | undefined => {
+      return state.items[index] ?? undefined;
+    },
+    [state.items]
+  );
+
+  const isRowLoaded = useCallback(
+    ({index}: Index) => {
+      return state.items[index] !== undefined;
+    },
+    [state.items]
+  );
+
+  const loadMoreRows = useCallback(({startIndex: _1, stopIndex: _2}: IndexRange) => {
+    return loaderRef.current?.fetchNext() ?? Promise.resolve();
+  }, []);
+
+  const updateFeedback = useCallback(({feedbackId: _}: {feedbackId: string}) => {
+    // TODO
+  }, []);
+
+  useEffect(() => {
+    loadMoreRows({startIndex: 0, stopIndex: PER_PAGE});
+  }, [loadMoreRows]);
+
+  return {
+    countLoadedRows: state.items.length,
+    getRow,
+    isError: false,
+    isLoading: false,
+    isRowLoaded,
+    loadMoreRows,
+    queryView,
+    totalHits: state.totalHits,
+    updateFeedback,
+  };
+}

+ 0 - 32
static/app/components/feedback/useFetchFeedbackList.tsx

@@ -1,32 +0,0 @@
-import hydrateFeedbackRecord from 'sentry/components/feedback/hydrateFeedbackRecord';
-import {HydratedFeedbackItem} from 'sentry/utils/feedback/item/types';
-import {FeedbackListResponse} from 'sentry/utils/feedback/list/types';
-import {useApiQuery, type UseApiQueryOptions} from 'sentry/utils/queryClient';
-import useOrganization from 'sentry/utils/useOrganization';
-
-type Response = {
-  data: HydratedFeedbackItem[] | undefined;
-  isError: boolean;
-  isLoading: boolean;
-  pageLinks: string | string[] | undefined;
-};
-
-export default function useFetchFeedbackList(
-  params: {query: Record<string, string | string[] | undefined>} = {
-    query: {},
-  },
-  options: undefined | Partial<UseApiQueryOptions<FeedbackListResponse>> = {}
-): Response {
-  const organization = useOrganization();
-  const {data, isError, isLoading, getResponseHeader} = useApiQuery<FeedbackListResponse>(
-    [`/organizations/${organization.slug}/feedback/`, params],
-    {staleTime: 0, ...options}
-  );
-
-  return {
-    data: data?.filter(Boolean).map(hydrateFeedbackRecord),
-    isError,
-    isLoading,
-    pageLinks: getResponseHeader?.('Link') ?? undefined,
-  };
-}

+ 34 - 0
static/app/utils/url/useLocationQuery.tsx

@@ -0,0 +1,34 @@
+import {useMemo} from 'react';
+
+import {decodeInteger, decodeList, decodeScalar} from 'sentry/utils/queryString';
+import {useLocation} from 'sentry/utils/useLocation';
+
+type Scalar = string | boolean | number | undefined;
+type Decoder = typeof decodeList | typeof decodeScalar | typeof decodeInteger;
+
+export default function useLocationQuery<
+  Fields extends Record<string, Scalar | Scalar[]>,
+>({fields}: {fields: Record<keyof Fields, Fields[string] | Decoder>}): Fields {
+  const location = useLocation();
+
+  const locationFields = {};
+  const staticFields = {};
+  Object.entries(fields).forEach(([field, decoderOrValue]) => {
+    if (typeof decoderOrValue === 'function') {
+      locationFields[field] = decoderOrValue(location.query[field]);
+    } else {
+      staticFields[field] = decoderOrValue;
+    }
+  }, {});
+
+  const stringyFields = JSON.stringify(locationFields);
+  const objFields = useMemo(() => JSON.parse(stringyFields), [stringyFields]);
+
+  return useMemo(
+    () => ({
+      ...objFields,
+      ...staticFields,
+    }),
+    [objFields] // eslint-disable-line react-hooks/exhaustive-deps
+  );
+}

+ 34 - 41
static/app/views/feedback/feedbackListPage.tsx

@@ -2,18 +2,19 @@ import {RouteComponentProps} from 'react-router';
 import styled from '@emotion/styled';
 
 import FeedbackEmptyDetails from 'sentry/components/feedback/details/feedbackEmptyDetails';
+import {FeedbackDataContext} from 'sentry/components/feedback/feedbackDataContext';
 import FeedbackFilters from 'sentry/components/feedback/feedbackFilters';
 import FeedbackItemLoader from 'sentry/components/feedback/feedbackItem/feedbackItemLoader';
 import FeedbackSearch from 'sentry/components/feedback/feedbackSearch';
-import FeedbackIndexLoader from 'sentry/components/feedback/index/feedbackIndexLoader';
-import useFeedbackListQueryParams from 'sentry/components/feedback/useFeedbackListQueryParams';
+import FeedbackList from 'sentry/components/feedback/list/feedbackList';
+import useFeedbackListQueryView from 'sentry/components/feedback/useFeedbackListQueryView';
 import FullViewport from 'sentry/components/layouts/fullViewport';
 import * as Layout from 'sentry/components/layouts/thirds';
 import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
-import {PageHeadingQuestionTooltip} from 'sentry/components/pageHeadingQuestionTooltip';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
+import {FeedbackItemLoaderQueryParams} from 'sentry/utils/feedback/item/types';
 import {decodeScalar} from 'sentry/utils/queryString';
 import {useLocation} from 'sentry/utils/useLocation';
 import useOrganization from 'sentry/utils/useOrganization';
@@ -22,50 +23,42 @@ import FluidHeight from 'sentry/views/replays/detail/layout/fluidHeight';
 interface Props extends RouteComponentProps<{}, {}, {}> {}
 
 export default function FeedbackListPage({}: Props) {
-  const location = useLocation();
-  const query = useFeedbackListQueryParams({
-    location,
-    queryReferrer: 'feedback_list_page',
-  });
-
   const organization = useOrganization();
+  const location = useLocation<FeedbackItemLoaderQueryParams>();
 
+  const queryView = useFeedbackListQueryView({
+    queryReferrer: 'feedback_list_page',
+  });
   const feedbackSlug = decodeScalar(location.query.feedbackSlug);
 
   return (
-    <SentryDocumentTitle title={t(`Bug Reports`)} orgSlug={organization.slug}>
-      <FullViewport>
-        <Layout.Header>
-          <Layout.HeaderContent>
-            <Layout.Title>
-              {t('Bug Reports')}
-              <PageHeadingQuestionTooltip
-                title={t(
-                  'Feedback submitted by users who experienced an error while using your application, including their name, email address, and any additional comments.'
+    <FeedbackDataContext queryView={queryView} initialDate={new Date()}>
+      <SentryDocumentTitle title={t(`Bug Reports`)} orgSlug={organization.slug}>
+        <FullViewport>
+          <Layout.Header>
+            <Layout.HeaderContent>
+              <Layout.Title>{t('Bug Reports')}</Layout.Title>
+            </Layout.HeaderContent>
+          </Layout.Header>
+          <PageFiltersContainer>
+            <LayoutGrid>
+              <FeedbackFilters style={{gridArea: 'filters'}} />
+              <FeedbackSearch style={{gridArea: 'search'}} />
+              <Container style={{gridArea: 'list'}}>
+                <FeedbackList />
+              </Container>
+              <Container style={{gridArea: 'details'}}>
+                {feedbackSlug ? (
+                  <FeedbackItemLoader feedbackSlug={feedbackSlug} />
+                ) : (
+                  <FeedbackEmptyDetails />
                 )}
-                docsUrl="https://docs.sentry.io/product/user-feedback/"
-              />
-            </Layout.Title>
-          </Layout.HeaderContent>
-        </Layout.Header>
-        <PageFiltersContainer>
-          <LayoutGrid>
-            <FeedbackFilters style={{gridArea: 'filters'}} />
-            <FeedbackSearch style={{gridArea: 'search'}} />
-            <Container style={{gridArea: 'list'}}>
-              <FeedbackIndexLoader query={query} />
-            </Container>
-            <Container style={{gridArea: 'details'}}>
-              {feedbackSlug ? (
-                <FeedbackItemLoader feedbackSlug={feedbackSlug} />
-              ) : (
-                <FeedbackEmptyDetails />
-              )}
-            </Container>
-          </LayoutGrid>
-        </PageFiltersContainer>
-      </FullViewport>
-    </SentryDocumentTitle>
+              </Container>
+            </LayoutGrid>
+          </PageFiltersContainer>
+        </FullViewport>
+      </SentryDocumentTitle>
+    </FeedbackDataContext>
   );
 }