Skip to content

Commit 2fbc462

Browse files
authored
feat(bug reports): Optimistically update the feedback items mutation is finished (#58973)
When you're doing a any mutation we're going to optimistically update any feedback items that are in the useApiQuery cache or the useFetchFeedbackInfiniteListData cache. This means the list on the left, and the visible item on the right. Steps to see it in action: 1. Click to view the first feedback in the list to open it on the right side 2. Click the checkboxes for the first 3 feedback items in the list (including the one you have open) 3. Click "Mark all as read" or "unread" bulk operation Notice that the visible feedback on the right side of the screen will update immediately to reflect the new state. Along the way I was able to re-combine the bulk-mutation helper with the single-item-mutation helper, to save some repetition. --- Hooks! Here's a graph of all the hooks and how they depend on each other. Consumers of feedback data (maybe new views, buttons, etc) should only need to interface with either of the `useFetch*` hooks, and the `useMutateFeedback` hook. Hopefully `useFeedbackCache` will just work as we add new fields that can be updated (like assigned to). Also `useMutateFeedback` will need new tiny helpers to support things like `assignTo`. When we do "update all 100+ feedbacks" bulk operation, both these hooks will probably need some changes again. I setup the `FeedbackQueryKeys` context provider & hook. This guarantees that queryKey objects are the exact same ref everywhere. So intead of drilling the keys down through props, we can have the list-header, and item header buttons all reference the same cached objects. <img width="843" alt="SCR-20231031-jeum" src="https://github.com/getsentry/sentry/assets/187460/16cfa993-b07b-4460-a15d-6a158e50f00d"> --- I also created some reusable code for mutations and put it inside `queryClient.tsx` , `fetchMutation` is something that you can just pass in like this, if you wanted, or wrap as i have (composition!): ``` const { ... } = useMutation({ mutationFn: fetchMutation, }); ```
1 parent ba77ac1 commit 2fbc462

21 files changed

+453
-365
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import {decodeScalar} from 'sentry/utils/queryString';
2+
3+
export default function decodeFeedbackId(val: string | string[] | null | undefined) {
4+
const [, feedbackId] = decodeScalar(val, '').split(':');
5+
6+
// TypeScript thinks `feedbackId` is a string, but it could be undefined.
7+
// See `noUncheckedIndexedAccess`
8+
return feedbackId ?? '';
9+
}

static/app/components/feedback/feedbackDataContext.tsx

Lines changed: 0 additions & 29 deletions
This file was deleted.

static/app/components/feedback/feedbackItem/feedbackItem.tsx

Lines changed: 27 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1-
import {Fragment, useEffect} from 'react';
1+
import {Fragment} from 'react';
22
import styled from '@emotion/styled';
33

4+
import {
5+
addErrorMessage,
6+
addLoadingMessage,
7+
addSuccessMessage,
8+
} from 'sentry/actionCreators/indicator';
49
import Button from 'sentry/components/actions/button';
510
import ProjectAvatar from 'sentry/components/avatar/projectAvatar';
611
import {CopyToClipboardButton} from 'sentry/components/copyToClipboardButton';
@@ -18,48 +23,37 @@ import TextCopyInput from 'sentry/components/textCopyInput';
1823
import {IconLink} from 'sentry/icons';
1924
import {t} from 'sentry/locale';
2025
import {space} from 'sentry/styles/space';
21-
import {Event, GroupStatus} from 'sentry/types';
26+
import type {Event} from 'sentry/types';
27+
import {GroupStatus} from 'sentry/types';
2228
import type {FeedbackIssue} from 'sentry/utils/feedback/types';
23-
import useApi from 'sentry/utils/useApi';
2429
import useOrganization from 'sentry/utils/useOrganization';
2530

2631
interface Props {
2732
eventData: Event | undefined;
2833
feedbackItem: FeedbackIssue;
29-
refetchIssue: () => void;
3034
tags: Record<string, string>;
3135
}
3236

33-
export default function FeedbackItem({
34-
feedbackItem,
35-
eventData,
36-
refetchIssue,
37-
tags,
38-
}: Props) {
37+
export default function FeedbackItem({feedbackItem, eventData, tags}: Props) {
3938
const organization = useOrganization();
4039
const hasReplayId = useFeedbackHasReplayId({feedbackId: feedbackItem.id});
4140
const {markAsRead, resolve} = useMutateFeedback({
42-
feedbackId: feedbackItem.id,
41+
feedbackIds: [feedbackItem.id],
4342
organization,
44-
refetchIssue,
4543
});
46-
const api = useApi();
47-
48-
const markReadUrl = `/organizations/${organization.slug}/issues/${feedbackItem.id}/`;
49-
50-
useEffect(() => {
51-
(async () => {
52-
await api.requestPromise(markReadUrl, {
53-
method: 'PUT',
54-
data: {hasSeen: true},
55-
});
56-
refetchIssue();
57-
})();
58-
}, []); // eslint-disable-line
5944

6045
const url = eventData?.tags.find(tag => tag.key === 'url');
6146
const replayId = eventData?.contexts?.feedback?.replay_id;
6247

48+
const mutationOptions = {
49+
onError: () => {
50+
addErrorMessage(t('An error occurred while updating the feedback.'));
51+
},
52+
onSuccess: () => {
53+
addSuccessMessage(t('Updated feedback'));
54+
},
55+
};
56+
6357
return (
6458
<Fragment>
6559
<HeaderPanelItem>
@@ -93,9 +87,12 @@ export default function FeedbackItem({
9387
<ErrorBoundary mini>
9488
<Button
9589
onClick={() => {
96-
feedbackItem.status === 'resolved'
97-
? resolve(GroupStatus.UNRESOLVED)
98-
: resolve(GroupStatus.RESOLVED);
90+
addLoadingMessage(t('Updating feedback...'));
91+
const newStatus =
92+
feedbackItem.status === 'resolved'
93+
? GroupStatus.UNRESOLVED
94+
: GroupStatus.RESOLVED;
95+
resolve(newStatus, mutationOptions);
9996
}}
10097
>
10198
{feedbackItem.status === 'resolved' ? t('Unresolve') : t('Resolve')}
@@ -104,7 +101,8 @@ export default function FeedbackItem({
104101
<ErrorBoundary mini>
105102
<Button
106103
onClick={() => {
107-
feedbackItem.hasSeen ? markAsRead(false) : markAsRead(true);
104+
addLoadingMessage(t('Updating feedback...'));
105+
markAsRead(!feedbackItem.hasSeen, mutationOptions);
108106
}}
109107
>
110108
{feedbackItem.hasSeen ? t('Mark Unread') : t('Mark Read')}
Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,14 @@
11
import FeedbackEmptyDetails from 'sentry/components/feedback/details/feedbackEmptyDetails';
22
import FeedbackErrorDetails from 'sentry/components/feedback/details/feedbackErrorDetails';
33
import FeedbackItem from 'sentry/components/feedback/feedbackItem/feedbackItem';
4-
import useFeedbackItemQueryKey from 'sentry/components/feedback/useFeedbackItemQueryKey';
4+
import useCurrentFeedbackId from 'sentry/components/feedback/useCurrentFeedbackId';
55
import useFetchFeedbackData from 'sentry/components/feedback/useFetchFeedbackData';
66
import Placeholder from 'sentry/components/placeholder';
77
import {t} from 'sentry/locale';
8-
import useOrganization from 'sentry/utils/useOrganization';
98

109
export default function FeedbackItemLoader() {
11-
const organization = useOrganization();
12-
13-
const queryKeys = useFeedbackItemQueryKey({organization});
14-
const {
15-
issueResult,
16-
issueData: feedbackIssue,
17-
tags,
18-
eventData: feedbackEvent,
19-
} = useFetchFeedbackData(queryKeys);
10+
const feedbackId = useCurrentFeedbackId();
11+
const {issueResult, issueData, tags, eventData} = useFetchFeedbackData({feedbackId});
2012

2113
// There is a case where we are done loading, but we're fetching updates
2214
// This happens when the user has seen a feedback, clicks around a bit, then
@@ -29,14 +21,9 @@ export default function FeedbackItemLoader() {
2921
<Placeholder height="100%" />
3022
) : issueResult.isError ? (
3123
<FeedbackErrorDetails error={t('Unable to load feedback')} />
32-
) : !feedbackIssue ? (
24+
) : !issueData ? (
3325
<FeedbackEmptyDetails />
3426
) : (
35-
<FeedbackItem
36-
eventData={feedbackEvent}
37-
feedbackItem={feedbackIssue}
38-
refetchIssue={issueResult.refetch}
39-
tags={tags}
40-
/>
27+
<FeedbackItem eventData={eventData} feedbackItem={issueData} tags={tags} />
4128
);
4229
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type {Organization} from 'sentry/types';
2+
import type {ApiQueryKey} from 'sentry/utils/queryClient';
3+
4+
interface Props {
5+
feedbackId: string;
6+
organization: Organization;
7+
}
8+
9+
export default function getFeedbackItemQueryKey({feedbackId, organization}: Props): {
10+
eventQueryKey: ApiQueryKey | undefined;
11+
issueQueryKey: ApiQueryKey | undefined;
12+
} {
13+
return {
14+
issueQueryKey: feedbackId
15+
? [
16+
`/organizations/${organization.slug}/issues/${feedbackId}/`,
17+
{
18+
query: {
19+
collapse: ['release', 'tags'],
20+
expand: ['inbox', 'owners'],
21+
},
22+
},
23+
]
24+
: undefined,
25+
eventQueryKey: feedbackId
26+
? [`/organizations/${organization.slug}/issues/${feedbackId}/events/latest/`]
27+
: undefined,
28+
};
29+
}

static/app/components/feedback/list/feedbackList.tsx

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ import {
88
} from 'react-virtualized';
99
import styled from '@emotion/styled';
1010

11-
import {useInfiniteFeedbackListData} from 'sentry/components/feedback/feedbackDataContext';
1211
import FeedbackListHeader from 'sentry/components/feedback/list/feedbackListHeader';
1312
import FeedbackListItem from 'sentry/components/feedback/list/feedbackListItem';
1413
import useListItemCheckboxState from 'sentry/components/feedback/list/useListItemCheckboxState';
14+
import useFetchFeedbackInfiniteListData from 'sentry/components/feedback/useFetchFeedbackInfiniteListData';
1515
import LoadingIndicator from 'sentry/components/loadingIndicator';
1616
import PanelItem from 'sentry/components/panels/panelItem';
1717
import {Tooltip} from 'sentry/components/tooltip';
@@ -29,20 +29,16 @@ const cellMeasurer = {
2929

3030
export default function FeedbackList() {
3131
const {
32-
// error,
33-
// hasNextPage,
34-
// isError,
32+
hasNextPage,
3533
isFetching, // If the network is active
3634
isFetchingNextPage,
3735
isFetchingPreviousPage,
3836
isLoading, // If anything is loaded yet
39-
// Below are fields that are shims for react-virtualized
4037
getRow,
4138
isRowLoaded,
4239
issues,
4340
loadMoreRows,
44-
// setFeedback,
45-
} = useInfiniteFeedbackListData();
41+
} = useFetchFeedbackInfiniteListData();
4642

4743
const {setParamValue} = useUrlParams('query');
4844
const clearSearchTerm = () => setParamValue('');
@@ -96,7 +92,7 @@ export default function FeedbackList() {
9692
<InfiniteLoader
9793
isRowLoaded={isRowLoaded}
9894
loadMoreRows={loadMoreRows}
99-
rowCount={issues.length}
95+
rowCount={hasNextPage ? issues.length + 1 : issues.length}
10096
>
10197
{({onRowsRendered, registerChild}) => (
10298
<AutoSizer onResize={updateList}>

static/app/components/feedback/list/feedbackListHeader.tsx

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import styled from '@emotion/styled';
22

3+
import {
4+
addErrorMessage,
5+
addLoadingMessage,
6+
addSuccessMessage,
7+
} from 'sentry/actionCreators/indicator';
38
import Button from 'sentry/components/actions/button';
49
import Checkbox from 'sentry/components/checkbox';
510
import {DropdownMenu} from 'sentry/components/dropdownMenu';
611
import ErrorBoundary from 'sentry/components/errorBoundary';
712
import decodeMailbox from 'sentry/components/feedback/decodeMailbox';
813
import MailboxPicker from 'sentry/components/feedback/list/mailboxPicker';
9-
import useBulkMutateFeedback from 'sentry/components/feedback/useBulkMutateFeedback';
14+
import useMutateFeedback from 'sentry/components/feedback/useMutateFeedback';
1015
import PanelItem from 'sentry/components/panels/panelItem';
1116
import {Flex} from 'sentry/components/profiling/flex';
1217
import {IconEllipsis} from 'sentry/icons/iconEllipsis';
@@ -49,11 +54,20 @@ export default function FeedbackListHeader({checked, toggleChecked}: Props) {
4954

5055
function HasSelection({checked, mailbox}) {
5156
const organization = useOrganization();
52-
const {markAsRead, resolve} = useBulkMutateFeedback({
53-
feedbackList: checked,
57+
const {markAsRead, resolve} = useMutateFeedback({
58+
feedbackIds: checked,
5459
organization,
5560
});
5661

62+
const mutationOptions = {
63+
onError: () => {
64+
addErrorMessage(t('An error occurred while updating the feedback.'));
65+
},
66+
onSuccess: () => {
67+
addSuccessMessage(t('Updated feedback'));
68+
},
69+
};
70+
5771
return (
5872
<Flex gap={space(1)} align="center" justify="space-between" style={{flexGrow: 1}}>
5973
<span>
@@ -62,11 +76,12 @@ function HasSelection({checked, mailbox}) {
6276
<Flex gap={space(1)} justify="flex-end">
6377
<ErrorBoundary mini>
6478
<Button
65-
onClick={() =>
66-
mailbox === 'resolved'
67-
? resolve(GroupStatus.UNRESOLVED)
68-
: resolve(GroupStatus.RESOLVED)
69-
}
79+
onClick={() => {
80+
addLoadingMessage(t('Updating feedback...'));
81+
const newStatus =
82+
mailbox === 'resolved' ? GroupStatus.UNRESOLVED : GroupStatus.RESOLVED;
83+
resolve(newStatus, mutationOptions);
84+
}}
7085
>
7186
{mailbox === 'resolved' ? t('Unresolve') : t('Resolve')}
7287
</Button>
@@ -84,12 +99,18 @@ function HasSelection({checked, mailbox}) {
8499
{
85100
key: 'mark read',
86101
label: t('Mark Read'),
87-
onAction: () => markAsRead(true),
102+
onAction: () => {
103+
addLoadingMessage(t('Updating feedback...'));
104+
markAsRead(true, mutationOptions);
105+
},
88106
},
89107
{
90108
key: 'mark unread',
91109
label: t('Mark Unread'),
92-
onAction: () => markAsRead(false),
110+
onAction: () => {
111+
addLoadingMessage(t('Updating feedback...'));
112+
markAsRead(false, mutationOptions);
113+
},
93114
},
94115
]}
95116
/>

static/app/components/feedback/list/feedbackListItem.tsx

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -81,17 +81,11 @@ const FeedbackListItem = forwardRef<HTMLDivElement, Props>(
8181
<span style={{gridArea: 'time'}}>
8282
<TimeSince date={feedbackItem.firstSeen} />
8383
</span>
84-
{feedbackItem.hasSeen ? null : (
85-
<span
86-
style={{
87-
gridArea: 'unread',
88-
display: 'flex',
89-
justifyContent: 'center',
90-
}}
91-
>
92-
<IconCircleFill size="xs" color="purple300" />
93-
</span>
94-
)}
84+
<Flex justify="center" style={{gridArea: 'unread'}}>
85+
{feedbackItem.hasSeen ? null : (
86+
<IconCircleFill size="xs" color={isSelected ? 'white' : 'purple400'} />
87+
)}
88+
</Flex>
9589
<div style={{gridArea: 'message'}}>
9690
<TextOverflow>{feedbackItem.metadata.message}</TextOverflow>
9791
</div>

0 commit comments

Comments
 (0)