Skip to content

Commit

Permalink
feat(feedback): Implement a feedback list page table (#56046)
Browse files Browse the repository at this point in the history
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 #55809
  • Loading branch information
ryan953 authored Sep 12, 2023
1 parent 36121c1 commit 5043cdf
Show file tree
Hide file tree
Showing 9 changed files with 372 additions and 49 deletions.
153 changes: 153 additions & 0 deletions static/app/components/feedback/table/feedbackTable.tsx
Original file line number Diff line number Diff line change
@@ -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];
}
Original file line number Diff line number Diff line change
@@ -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 changes: 36 additions & 0 deletions static/app/components/feedback/table/renderSortableHeaderCell.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 45 additions & 0 deletions static/app/components/feedback/table/useQueryBasedColumnResize.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 24 additions & 0 deletions static/app/components/feedback/table/useQueryBasedSorting.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 4 additions & 0 deletions static/app/components/feedback/useFetchFeedbackList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type MockState = {
data: undefined | FeedbackListResponse;
isError: false;
isLoading: boolean;
pageLinks: null;
};

export default function useFetchFeedbackList(
Expand All @@ -24,6 +25,7 @@ export default function useFetchFeedbackList(
isLoading: true,
isError: false,
data: undefined,
pageLinks: null,
});

useEffect(() => {
Expand All @@ -32,6 +34,7 @@ export default function useFetchFeedbackList(
isLoading: false,
isError: false,
data: exampleListResponse,
pageLinks: null,
});
}, Math.random() * 1000);
return () => clearTimeout(timeout);
Expand All @@ -40,5 +43,6 @@ export default function useFetchFeedbackList(
return {
...state,
data: state.data?.map(hydrateFeedbackRecord),
pageLinks: null,
};
}
4 changes: 3 additions & 1 deletion static/app/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'))}
Expand Down
Loading

0 comments on commit 5043cdf

Please sign in to comment.