Browse Source

feat(feedback): Implement a feedback list page table (#56046)

Wire up a list of feedbacks and lightly style it.


![SCR-20230912-jpdj](https://github.com/getsentry/sentry/assets/187460/671064dc-fec0-4788-83b8-d2600a810267)



Related to https://github.com/getsentry/sentry/issues/55809
Ryan Albrecht 1 year ago
parent
commit
5043cdf281

+ 153 - 0
static/app/components/feedback/table/feedbackTable.tsx

@@ -0,0 +1,153 @@
+import {useCallback, useMemo} from 'react';
+import type {Location} from 'history';
+
+import renderSortableHeaderCell from 'sentry/components/feedback/table/renderSortableHeaderCell';
+import useQueryBasedColumnResize from 'sentry/components/feedback/table/useQueryBasedColumnResize';
+import useQueryBasedSorting from 'sentry/components/feedback/table/useQueryBasedSorting';
+import GridEditable, {GridColumnOrder} from 'sentry/components/gridEditable';
+import Link from 'sentry/components/links/link';
+import Tag from 'sentry/components/tag';
+import TextOverflow from 'sentry/components/textOverflow';
+import TimeSince from 'sentry/components/timeSince';
+import {Tooltip} from 'sentry/components/tooltip';
+import {t} from 'sentry/locale';
+import {Organization} from 'sentry/types';
+import {trackAnalytics} from 'sentry/utils/analytics';
+import {getShortEventId} from 'sentry/utils/events';
+import type {
+  FeedbackListQueryParams,
+  HydratedFeedbackItem,
+  HydratedFeedbackList,
+} from 'sentry/utils/feedback/types';
+import getRouteStringFromRoutes from 'sentry/utils/getRouteStringFromRoutes';
+import useOrganization from 'sentry/utils/useOrganization';
+import {useRoutes} from 'sentry/utils/useRoutes';
+import {normalizeUrl} from 'sentry/utils/withDomainRequired';
+
+interface UrlState {
+  widths: string[];
+}
+
+interface Props {
+  data: HydratedFeedbackList;
+  isError: boolean;
+  isLoading: boolean;
+  location: Location<FeedbackListQueryParams & UrlState>;
+}
+
+const BASE_COLUMNS: GridColumnOrder<string>[] = [
+  {key: 'id', name: 'id'},
+  {key: 'status', name: 'status'},
+  {key: 'contact_email', name: 'contact_email'},
+  {key: 'message', name: 'message'},
+  {key: 'replay_id', name: 'Replay'},
+  {key: 'timestamp', name: 'timestamp'},
+];
+
+export default function FeedbackTable({isError, isLoading, data, location}: Props) {
+  const routes = useRoutes();
+  const organization = useOrganization();
+
+  const {currentSort, makeSortLinkGenerator} = useQueryBasedSorting({
+    defaultSort: {field: 'status', kind: 'desc'},
+    location,
+  });
+
+  const {columns, handleResizeColumn} = useQueryBasedColumnResize({
+    columns: BASE_COLUMNS,
+    location,
+  });
+
+  const renderHeadCell = useMemo(
+    () =>
+      renderSortableHeaderCell({
+        currentSort,
+        makeSortLinkGenerator,
+        onClick: () => {},
+        rightAlignedColumns: [],
+        sortableColumns: columns,
+      }),
+    [columns, currentSort, makeSortLinkGenerator]
+  );
+
+  const renderBodyCell = useCallback(
+    (column, dataRow) => {
+      const value = dataRow[column.key];
+      switch (column.key) {
+        case 'id':
+          return <FeedbackDetailsLink organization={organization} value={value} />;
+        case 'status':
+          return <Tag type={value === 'resolved' ? 'default' : 'warning'}>{value}</Tag>;
+        case 'message':
+          return <TextOverflow>{value}</TextOverflow>;
+        case 'replay_id': {
+          const referrer = getRouteStringFromRoutes(routes);
+          return (
+            <Tooltip title={t('View Replay')}>
+              <Link
+                to={{
+                  pathname: normalizeUrl(
+                    `/organizations/${organization.slug}/replays/${value}/`
+                  ),
+                  query: {referrer},
+                }}
+              >
+                {getShortEventId(value)}
+              </Link>
+            </Tooltip>
+          );
+        }
+        default:
+          return renderSimpleBodyCell<HydratedFeedbackItem>(column, dataRow);
+      }
+    },
+    [organization, routes]
+  );
+
+  return (
+    <GridEditable
+      error={isError}
+      isLoading={isLoading}
+      data={data ?? []}
+      columnOrder={columns}
+      columnSortBy={[]}
+      stickyHeader
+      grid={{
+        onResizeColumn: handleResizeColumn,
+        renderHeadCell,
+        renderBodyCell,
+      }}
+      location={location as Location<any>}
+    />
+  );
+}
+
+function FeedbackDetailsLink({
+  organization,
+  value,
+}: {
+  organization: Organization;
+  value: string;
+}) {
+  return (
+    <Link
+      to={{
+        pathname: normalizeUrl(`/organizations/${organization.slug}/feedback/${value}/`),
+        query: {referrer: 'feedback_list_page'},
+      }}
+      onClick={() => {
+        trackAnalytics('feedback_list.details_link.click', {organization});
+      }}
+    >
+      {getShortEventId(value)}
+    </Link>
+  );
+}
+
+function renderSimpleBodyCell<T>(column: GridColumnOrder<string>, dataRow: T) {
+  const value = dataRow[column.key];
+  if (value instanceof Date) {
+    return <TimeSince date={value} />;
+  }
+  return dataRow[column.key];
+}

+ 26 - 0
static/app/components/feedback/table/queryBasedSortLinkGenerator.tsx

@@ -0,0 +1,26 @@
+import type {ReactText} from 'react';
+import type {Location, LocationDescriptorObject} from 'history';
+
+import type {GridColumnOrder} from 'sentry/components/gridEditable';
+import {Sort} from 'sentry/utils/discover/fields';
+
+export default function queryBasedSortLinkGenerator<Key extends ReactText>(
+  location: Location,
+  column: GridColumnOrder<Key>,
+  currentSort: Sort
+): () => LocationDescriptorObject {
+  const direction =
+    currentSort.field !== column.key
+      ? 'desc'
+      : currentSort.kind === 'desc'
+      ? 'asc'
+      : 'desc';
+
+  return () => ({
+    ...location,
+    query: {
+      ...location.query,
+      sort: `${direction === 'desc' ? '-' : ''}${column.key}`,
+    },
+  });
+}

+ 36 - 0
static/app/components/feedback/table/renderSortableHeaderCell.tsx

@@ -0,0 +1,36 @@
+import type {MouseEvent} from 'react';
+import type {LocationDescriptorObject} from 'history';
+
+import type {GridColumnOrder} from 'sentry/components/gridEditable';
+import SortLink from 'sentry/components/gridEditable/sortLink';
+import type {Sort} from 'sentry/utils/discover/fields';
+
+interface Props<Key extends string> {
+  currentSort: Sort;
+  makeSortLinkGenerator: (column: GridColumnOrder<Key>) => () => LocationDescriptorObject;
+  onClick(column: GridColumnOrder<Key>, e: MouseEvent<HTMLAnchorElement>): void;
+  rightAlignedColumns: GridColumnOrder<string>[];
+  sortableColumns: GridColumnOrder<string>[];
+}
+
+export default function renderSortableHeaderCell<Key extends string>({
+  currentSort,
+  onClick,
+  rightAlignedColumns,
+  sortableColumns,
+  makeSortLinkGenerator,
+}: Props<Key>) {
+  return function (column: GridColumnOrder<Key>, _columnIndex: number) {
+    return (
+      <SortLink
+        onClick={e => onClick(column, e)}
+        align={rightAlignedColumns.includes(column) ? 'right' : 'left'}
+        title={column.name}
+        direction={currentSort?.field === column.key ? currentSort?.kind : undefined}
+        canSort={sortableColumns.includes(column)}
+        generateSortLink={makeSortLinkGenerator(column)}
+        replace
+      />
+    );
+  };
+}

+ 45 - 0
static/app/components/feedback/table/useQueryBasedColumnResize.tsx

@@ -0,0 +1,45 @@
+import {useCallback, useMemo} from 'react';
+import {browserHistory} from 'react-router';
+import type {Location} from 'history';
+import dropRightWhile from 'lodash/dropRightWhile';
+
+import {COL_WIDTH_UNDEFINED, GridColumnOrder} from 'sentry/components/gridEditable';
+import {decodeInteger, decodeList} from 'sentry/utils/queryString';
+
+interface Props {
+  columns: GridColumnOrder<string>[];
+  location: Location<{widths: string[]}>;
+}
+
+export default function useQueryBasedColumnResize({columns, location}: Props) {
+  const columnsWidthWidths = useMemo(() => {
+    const widths = decodeList(location.query.widths);
+
+    return columns.map((column, i) => {
+      column.width = decodeInteger(widths[i], COL_WIDTH_UNDEFINED);
+      return column;
+    });
+  }, [columns, location.query.widths]);
+
+  const handleResizeColumn = useCallback(
+    (columnIndex, resizedColumn) => {
+      const widths = columns.map(
+        (column, i) =>
+          (i === columnIndex ? resizedColumn.width : column.width) ?? COL_WIDTH_UNDEFINED
+      );
+      browserHistory.push({
+        pathname: location.pathname,
+        query: {
+          ...location.query,
+          widths: dropRightWhile(widths, width => width === COL_WIDTH_UNDEFINED),
+        },
+      });
+    },
+    [columns, location.pathname, location.query]
+  );
+
+  return {
+    columns: columnsWidthWidths,
+    handleResizeColumn,
+  };
+}

+ 24 - 0
static/app/components/feedback/table/useQueryBasedSorting.tsx

@@ -0,0 +1,24 @@
+import {useMemo} from 'react';
+import type {Location} from 'history';
+import first from 'lodash/first';
+
+import queryBasedSortLinkGenerator from 'sentry/components/feedback/table/queryBasedSortLinkGenerator';
+import {GridColumnOrder} from 'sentry/components/gridEditable';
+import {fromSorts} from 'sentry/utils/discover/eventView';
+import type {Sort} from 'sentry/utils/discover/fields';
+
+interface Props {
+  defaultSort: Sort;
+  location: Location<{sort?: undefined | string}>;
+}
+
+export default function useQueryBasedSorting({location, defaultSort}: Props) {
+  const sorts = useMemo(() => fromSorts(location.query.sort), [location.query.sort]);
+  const currentSort = useMemo(() => first(sorts) ?? defaultSort, [defaultSort, sorts]);
+
+  return {
+    makeSortLinkGenerator: (column: GridColumnOrder) =>
+      queryBasedSortLinkGenerator(location, column, currentSort),
+    currentSort,
+  };
+}

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

@@ -13,6 +13,7 @@ type MockState = {
   data: undefined | FeedbackListResponse;
   isError: false;
   isLoading: boolean;
+  pageLinks: null;
 };
 
 export default function useFetchFeedbackList(
@@ -24,6 +25,7 @@ export default function useFetchFeedbackList(
     isLoading: true,
     isError: false,
     data: undefined,
+    pageLinks: null,
   });
 
   useEffect(() => {
@@ -32,6 +34,7 @@ export default function useFetchFeedbackList(
         isLoading: false,
         isError: false,
         data: exampleListResponse,
+        pageLinks: null,
       });
     }, Math.random() * 1000);
     return () => clearTimeout(timeout);
@@ -40,5 +43,6 @@ export default function useFetchFeedbackList(
   return {
     ...state,
     data: state.data?.map(hydrateFeedbackRecord),
+    pageLinks: null,
   };
 }

+ 3 - 1
static/app/routes.tsx

@@ -1784,7 +1784,9 @@ function buildRoutes() {
 
   const feedbackChildRoutes = (
     <Fragment>
-      <IndexRoute component={make(() => import('sentry/views/feedback/list'))} />
+      <IndexRoute
+        component={make(() => import('sentry/views/feedback/feedbackListPage'))}
+      />
       <Route
         path=":feedbackId/"
         component={make(() => import('sentry/views/feedback/details'))}

+ 81 - 0
static/app/views/feedback/feedbackListPage.tsx

@@ -0,0 +1,81 @@
+import {browserHistory, RouteComponentProps} from 'react-router';
+import styled from '@emotion/styled';
+
+import DatePageFilter from 'sentry/components/datePageFilter';
+import EnvironmentPageFilter from 'sentry/components/environmentPageFilter';
+import FeedbackTable from 'sentry/components/feedback/table/feedbackTable';
+import useFetchFeedbackList from 'sentry/components/feedback/useFetchFeedbackList';
+import * as Layout from 'sentry/components/layouts/thirds';
+import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
+import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
+import {PageHeadingQuestionTooltip} from 'sentry/components/pageHeadingQuestionTooltip';
+import Pagination from 'sentry/components/pagination';
+import ProjectPageFilter from 'sentry/components/projectPageFilter';
+import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {FeedbackListQueryParams} from 'sentry/utils/feedback/types';
+import useOrganization from 'sentry/utils/useOrganization';
+
+interface Props extends RouteComponentProps<{}, {}, FeedbackListQueryParams> {}
+
+export default function FeedbackListPage({location}: Props) {
+  const organization = useOrganization();
+
+  const {isLoading, isError, data, pageLinks} = useFetchFeedbackList({}, {});
+
+  return (
+    <SentryDocumentTitle title={`Feedback v2 — ${organization.slug}`}>
+      <Layout.Header>
+        <Layout.HeaderContent>
+          <Layout.Title>
+            {t('Feedback v2')}
+            <PageHeadingQuestionTooltip
+              title={t(
+                'Feedback submitted by users who experienced an error while using your application, including their name, email address, and any additional comments.'
+              )}
+              docsUrl="https://docs.sentry.io/product/user-feedback/"
+            />
+          </Layout.Title>
+        </Layout.HeaderContent>
+      </Layout.Header>
+      <PageFiltersContainer>
+        <Layout.Body>
+          <Layout.Main fullWidth>
+            <LayoutGap>
+              <PageFilterBar condensed>
+                <ProjectPageFilter resetParamsOnChange={['cursor']} />
+                <EnvironmentPageFilter resetParamsOnChange={['cursor']} />
+                <DatePageFilter alignDropdown="left" resetParamsOnChange={['cursor']} />
+              </PageFilterBar>
+              <FeedbackTable
+                data={data ?? []}
+                isError={isError}
+                isLoading={isLoading}
+                location={location}
+              />
+            </LayoutGap>
+            <PaginationNoMargin
+              pageLinks={pageLinks}
+              onCursor={(cursor, path, searchQuery) => {
+                browserHistory.push({
+                  pathname: path,
+                  query: {...searchQuery, cursor},
+                });
+              }}
+            />
+          </Layout.Main>
+        </Layout.Body>
+      </PageFiltersContainer>
+    </SentryDocumentTitle>
+  );
+}
+
+const LayoutGap = styled('div')`
+  display: grid;
+  gap: ${space(2)};
+`;
+
+const PaginationNoMargin = styled(Pagination)`
+  margin: 0;
+`;

+ 0 - 48
static/app/views/feedback/list.tsx

@@ -1,48 +0,0 @@
-import Alert from 'sentry/components/alert';
-import useFetchFeedbackList from 'sentry/components/feedback/useFetchFeedbackList';
-import * as Layout from 'sentry/components/layouts/thirds';
-import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
-import {PageHeadingQuestionTooltip} from 'sentry/components/pageHeadingQuestionTooltip';
-import Placeholder from 'sentry/components/placeholder';
-import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
-import {t} from 'sentry/locale';
-import useOrganization from 'sentry/utils/useOrganization';
-
-export default function List() {
-  const organization = useOrganization();
-
-  const {isLoading, isError, data} = useFetchFeedbackList({}, {});
-
-  return (
-    <SentryDocumentTitle title={`Feedback v2 — ${organization.slug}`}>
-      <Layout.Header>
-        <Layout.HeaderContent>
-          <Layout.Title>
-            {t('Feedback v2')}
-            <PageHeadingQuestionTooltip
-              title={t(
-                'Feedback submitted by users who experienced an error while using your application, including their name, email address, and any additional comments.'
-              )}
-              docsUrl="https://docs.sentry.io/product/user-feedback/"
-            />
-          </Layout.Title>
-        </Layout.HeaderContent>
-      </Layout.Header>
-      <PageFiltersContainer>
-        <Layout.Body>
-          <Layout.Main fullWidth>
-            {isLoading ? (
-              <Placeholder />
-            ) : isError ? (
-              <Alert type="error" showIcon>
-                {t('An error occurred')}
-              </Alert>
-            ) : (
-              <pre>{JSON.stringify(data, null, '\t')}</pre>
-            )}
-          </Layout.Main>
-        </Layout.Body>
-      </PageFiltersContainer>
-    </SentryDocumentTitle>
-  );
-}