diff --git a/static/app/components/feedback/table/feedbackTable.tsx b/static/app/components/feedback/table/feedbackTable.tsx new file mode 100644 index 0000000000000..77b53ba0ec98f --- /dev/null +++ b/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; +} + +const BASE_COLUMNS: GridColumnOrder[] = [ + {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 ; + case 'status': + return {value}; + case 'message': + return {value}; + case 'replay_id': { + const referrer = getRouteStringFromRoutes(routes); + return ( + + + {getShortEventId(value)} + + + ); + } + default: + return renderSimpleBodyCell(column, dataRow); + } + }, + [organization, routes] + ); + + return ( + } + /> + ); +} + +function FeedbackDetailsLink({ + organization, + value, +}: { + organization: Organization; + value: string; +}) { + return ( + { + trackAnalytics('feedback_list.details_link.click', {organization}); + }} + > + {getShortEventId(value)} + + ); +} + +function renderSimpleBodyCell(column: GridColumnOrder, dataRow: T) { + const value = dataRow[column.key]; + if (value instanceof Date) { + return ; + } + return dataRow[column.key]; +} diff --git a/static/app/components/feedback/table/queryBasedSortLinkGenerator.tsx b/static/app/components/feedback/table/queryBasedSortLinkGenerator.tsx new file mode 100644 index 0000000000000..29303e1f9a1ba --- /dev/null +++ b/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( + location: Location, + column: GridColumnOrder, + 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}`, + }, + }); +} diff --git a/static/app/components/feedback/table/renderSortableHeaderCell.tsx b/static/app/components/feedback/table/renderSortableHeaderCell.tsx new file mode 100644 index 0000000000000..c4b4516982691 --- /dev/null +++ b/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 { + currentSort: Sort; + makeSortLinkGenerator: (column: GridColumnOrder) => () => LocationDescriptorObject; + onClick(column: GridColumnOrder, e: MouseEvent): void; + rightAlignedColumns: GridColumnOrder[]; + sortableColumns: GridColumnOrder[]; +} + +export default function renderSortableHeaderCell({ + currentSort, + onClick, + rightAlignedColumns, + sortableColumns, + makeSortLinkGenerator, +}: Props) { + return function (column: GridColumnOrder, _columnIndex: number) { + return ( + 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 + /> + ); + }; +} diff --git a/static/app/components/feedback/table/useQueryBasedColumnResize.tsx b/static/app/components/feedback/table/useQueryBasedColumnResize.tsx new file mode 100644 index 0000000000000..9dcd15e9c1bc0 --- /dev/null +++ b/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[]; + 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, + }; +} diff --git a/static/app/components/feedback/table/useQueryBasedSorting.tsx b/static/app/components/feedback/table/useQueryBasedSorting.tsx new file mode 100644 index 0000000000000..0eb7ba1c4c444 --- /dev/null +++ b/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, + }; +} diff --git a/static/app/components/feedback/useFetchFeedbackList.tsx b/static/app/components/feedback/useFetchFeedbackList.tsx index 079a47e7baace..31c014d69fb2d 100644 --- a/static/app/components/feedback/useFetchFeedbackList.tsx +++ b/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, }; } diff --git a/static/app/routes.tsx b/static/app/routes.tsx index 672de63da2162..8323608b88ed0 100644 --- a/static/app/routes.tsx +++ b/static/app/routes.tsx @@ -1784,7 +1784,9 @@ function buildRoutes() { const feedbackChildRoutes = ( - import('sentry/views/feedback/list'))} /> + import('sentry/views/feedback/feedbackListPage'))} + /> import('sentry/views/feedback/details'))} diff --git a/static/app/views/feedback/feedbackListPage.tsx b/static/app/views/feedback/feedbackListPage.tsx new file mode 100644 index 0000000000000..d26a06a5f321d --- /dev/null +++ b/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 ( + + + + + {t('Feedback v2')} + + + + + + + + + + + + + + + + { + browserHistory.push({ + pathname: path, + query: {...searchQuery, cursor}, + }); + }} + /> + + + + + ); +} + +const LayoutGap = styled('div')` + display: grid; + gap: ${space(2)}; +`; + +const PaginationNoMargin = styled(Pagination)` + margin: 0; +`; diff --git a/static/app/views/feedback/list.tsx b/static/app/views/feedback/list.tsx deleted file mode 100644 index 6d87a40ecdeac..0000000000000 --- a/static/app/views/feedback/list.tsx +++ /dev/null @@ -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 ( - - - - - {t('Feedback v2')} - - - - - - - - {isLoading ? ( - - ) : isError ? ( - - {t('An error occurred')} - - ) : ( -
{JSON.stringify(data, null, '\t')}
- )} -
-
-
-
- ); -}