@sentry/junior-dashboard 0.270.0 → 0.272.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +44 -3
- package/dist/client.js +77 -77
- package/dist/index.js +40 -3
- package/dist/tailwind.css +1 -1
- package/package.json +4 -4
- package/src/app.ts +2 -0
- package/src/client/App.tsx +89 -61
- package/src/client/components/ProfileMenu.tsx +0 -24
- package/src/client/components/UserInputForm.tsx +38 -7
- package/src/client/components/layout/PageHeader.tsx +25 -10
- package/src/client/conversations/ConversationPage.tsx +55 -8
- package/src/client/conversations/ConversationTranscript.tsx +74 -5
- package/src/client/conversations/TranscriptRows.tsx +23 -0
- package/src/client/conversations/TranscriptView.tsx +22 -0
- package/src/client/conversations/conversationRoutes.ts +30 -3
- package/src/client/conversations/queries.ts +30 -5
- package/src/client/conversations/transcriptBottomPinning.ts +84 -13
- package/src/client/conversations/transcriptReadPosition.ts +267 -0
- package/src/client/conversations/transcriptRenderModel.ts +16 -2
- package/src/client/pages/automations/AutomationActions.tsx +2 -3
- package/src/client/pages/automations/AutomationEditor.tsx +18 -30
- package/src/client/pages/automations/AutomationOverview.tsx +2 -2
- package/src/client/pages/automations/AutomationVersionsTab.tsx +1 -1
- package/src/client/pages/settings/ConnectionsPage.tsx +198 -0
- package/src/client/pages/settings/McpSettingsPage.tsx +77 -0
- package/src/client/pages/settings/PersonalTokensPage.tsx +198 -0
- package/src/client/pages/{SettingsPage.tsx → settings/SettingsPage.tsx} +115 -61
- package/src/client/pages/settings/SettingsPageLayout.tsx +45 -0
- package/src/client/primaryNav.ts +1 -1
- package/src/client/review-desk/ReviewDeskPage.tsx +185 -242
- package/src/client/review-desk/ReviewDeskSidebar.tsx +259 -0
- package/src/client/review-desk/ReviewItemActions.tsx +1 -10
- package/src/client/review-desk/ReviewItemContext.tsx +108 -0
- package/src/client/review-desk/ReviewItemDetails.tsx +153 -0
- package/src/client/review-desk/ReviewItemView.tsx +159 -0
- package/src/client/review-desk/queries.ts +3 -3
- package/src/client/review-desk/reviewItemFormat.ts +52 -0
- package/src/mock-reporting/read-positions.ts +39 -0
- package/src/mock-reporting/routes.ts +26 -1
- package/src/tailwind.css +1 -1
- package/src/client/components/AGENTS.md +0 -65
- package/src/client/components/charts/AGENTS.md +0 -15
- package/src/client/conversations/AGENTS.md +0 -38
- package/src/client/pages/ConnectionsPage.tsx +0 -207
- package/src/client/pages/PersonalTokensPage.tsx +0 -205
- package/src/client/pages/automations/AGENTS.md +0 -34
- package/src/client/pages/system/AGENTS.md +0 -12
- package/src/client/review-desk/ReviewItemDetail.tsx +0 -123
|
@@ -1,297 +1,240 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import type {
|
|
5
|
-
AutomationSummary,
|
|
6
|
-
ReviewItemPayload,
|
|
7
|
-
} from "@sentry/junior/api/schema";
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { useSearchParams } from "react-router";
|
|
3
|
+
import type { ReviewItemPayload } from "@sentry/junior/api/schema";
|
|
8
4
|
|
|
9
5
|
import { InlineError } from "../components/InlineError";
|
|
10
6
|
import { LoadMorePagination } from "../components/Pagination";
|
|
11
|
-
import {
|
|
12
|
-
import { StatusChip } from "../components/StatusChip";
|
|
13
|
-
import { Card } from "../components/layout/Card";
|
|
14
|
-
import { PageHeader } from "../components/layout/PageHeader";
|
|
15
|
-
import { PageLayout } from "../components/layout/PageLayout";
|
|
7
|
+
import { useDetailsPanel } from "../components/DetailsPanel";
|
|
16
8
|
import { Skeleton } from "../components/ui/skeleton";
|
|
17
|
-
import {
|
|
9
|
+
import { useDocumentTitle } from "../documentTitle";
|
|
18
10
|
import { pathWithSearch, useDebouncedSearchParam } from "../searchParams";
|
|
19
11
|
import { cn } from "../styles";
|
|
20
|
-
import {
|
|
12
|
+
import { ReviewDeskSidebar, type InboxGroupKey } from "./ReviewDeskSidebar";
|
|
13
|
+
import { ReviewItemDetails } from "./ReviewItemDetails";
|
|
14
|
+
import { ReviewItemView } from "./ReviewItemView";
|
|
21
15
|
import {
|
|
22
16
|
useBlockedAutomationsData,
|
|
17
|
+
useLinkedConversationTitles,
|
|
23
18
|
useReviewItemData,
|
|
24
19
|
useReviewItemsData,
|
|
25
20
|
} from "./queries";
|
|
26
|
-
import {
|
|
27
|
-
assigneeLabel,
|
|
28
|
-
reviewItemPath,
|
|
29
|
-
reviewItemStatusLabel,
|
|
30
|
-
} from "./reviewItemFormat";
|
|
31
|
-
|
|
32
|
-
const rowClass =
|
|
33
|
-
"grid gap-0.5 rounded-md px-3 py-2 text-inherit no-underline transition-colors";
|
|
21
|
+
import { inboxGroups, nextInboxItem, reviewItemPath } from "./reviewItemFormat";
|
|
34
22
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
function EmptyMessage(props: { children: ReactNode }) {
|
|
38
|
-
return (
|
|
39
|
-
<p className="m-0 px-4 py-8 text-center text-sm text-dashboard-text-muted">
|
|
40
|
-
{props.children}
|
|
41
|
-
</p>
|
|
42
|
-
);
|
|
43
|
-
}
|
|
23
|
+
const shellClass =
|
|
24
|
+
"grid h-full min-h-0 w-full overflow-hidden bg-dashboard-bg md:grid-cols-[20rem_minmax(0,1fr)]";
|
|
44
25
|
|
|
45
|
-
function
|
|
46
|
-
children: ReactNode;
|
|
47
|
-
className?: string;
|
|
48
|
-
icon?: ReactNode;
|
|
49
|
-
title: string;
|
|
50
|
-
}) {
|
|
51
|
-
const headingId = useId();
|
|
26
|
+
function InboxEmpty(props: { className?: string }) {
|
|
52
27
|
return (
|
|
53
|
-
<
|
|
54
|
-
|
|
55
|
-
|
|
28
|
+
<div
|
|
29
|
+
className={cn(
|
|
30
|
+
"grid content-center justify-items-center gap-3 px-4 py-12 text-center",
|
|
31
|
+
props.className,
|
|
32
|
+
)}
|
|
56
33
|
>
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
{props.icon}
|
|
62
|
-
{props.title}
|
|
63
|
-
</h2>
|
|
64
|
-
<ul className="m-0 grid list-none gap-0.5 p-0">{props.children}</ul>
|
|
65
|
-
</section>
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function ItemRow(props: {
|
|
70
|
-
item: ReviewItemPayload;
|
|
71
|
-
searchParams: URLSearchParams;
|
|
72
|
-
selected: boolean;
|
|
73
|
-
}) {
|
|
74
|
-
const { item } = props;
|
|
75
|
-
const closed = item.status !== "open";
|
|
76
|
-
const unassigned = !item.assignedToEveryone && item.assignees.length === 0;
|
|
77
|
-
return (
|
|
78
|
-
<li>
|
|
79
|
-
<Link
|
|
80
|
-
aria-current={props.selected ? "true" : undefined}
|
|
81
|
-
className={cn(
|
|
82
|
-
rowClass,
|
|
83
|
-
props.selected
|
|
84
|
-
? "bg-dashboard-accent/[0.08]"
|
|
85
|
-
: "hover:bg-dashboard-fill-soft",
|
|
86
|
-
)}
|
|
87
|
-
replace
|
|
88
|
-
to={pathWithSearch(reviewItemPath(item.id), props.searchParams)}
|
|
89
|
-
>
|
|
90
|
-
<span
|
|
91
|
-
className={cn(
|
|
92
|
-
"truncate text-sm font-medium",
|
|
93
|
-
closed && "text-dashboard-text-muted",
|
|
94
|
-
)}
|
|
95
|
-
>
|
|
96
|
-
{item.title}
|
|
97
|
-
</span>
|
|
98
|
-
<span className="flex min-w-0 items-center gap-2 text-xs text-dashboard-text-muted">
|
|
99
|
-
{closed ? (
|
|
100
|
-
<StatusChip className="shrink-0" size="compact">
|
|
101
|
-
{reviewItemStatusLabel(item)}
|
|
102
|
-
</StatusChip>
|
|
103
|
-
) : unassigned ? (
|
|
104
|
-
<StatusChip size="compact" tone="warning">
|
|
105
|
-
Unassigned
|
|
106
|
-
</StatusChip>
|
|
107
|
-
) : (
|
|
108
|
-
<span className="truncate">{assigneeLabel(item)}</span>
|
|
109
|
-
)}
|
|
110
|
-
<span className="shrink-0">
|
|
111
|
-
{formatRelativeTime(item.closedAt ?? item.updatedAt)}
|
|
112
|
-
</span>
|
|
113
|
-
</span>
|
|
114
|
-
</Link>
|
|
115
|
-
</li>
|
|
34
|
+
<p className="m-0 font-display text-lg font-medium text-dashboard-text">
|
|
35
|
+
Nothing needs you
|
|
36
|
+
</p>
|
|
37
|
+
</div>
|
|
116
38
|
);
|
|
117
39
|
}
|
|
118
40
|
|
|
119
|
-
|
|
41
|
+
/** Keep the inbox frame while the dashboard shell loads. */
|
|
42
|
+
export function ReviewDeskLoading() {
|
|
120
43
|
return (
|
|
121
|
-
<
|
|
122
|
-
<
|
|
123
|
-
|
|
124
|
-
|
|
44
|
+
<div className={shellClass}>
|
|
45
|
+
<aside
|
|
46
|
+
aria-busy="true"
|
|
47
|
+
aria-label="Loading review items"
|
|
48
|
+
className="grid content-start gap-2 border-r border-dashboard-border bg-dashboard-surface-panel px-5 py-3"
|
|
125
49
|
>
|
|
126
|
-
<
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function ReviewListLoading() {
|
|
138
|
-
return (
|
|
139
|
-
<div
|
|
140
|
-
aria-busy="true"
|
|
141
|
-
aria-label="Loading review items"
|
|
142
|
-
className="grid gap-2"
|
|
143
|
-
>
|
|
144
|
-
{[0, 1, 2, 3].map((row) => (
|
|
145
|
-
<div className="grid gap-1.5 px-3 py-2" key={row}>
|
|
146
|
-
<Skeleton className="h-4 w-3/4" />
|
|
147
|
-
<Skeleton className="h-3 w-1/3" />
|
|
148
|
-
</div>
|
|
149
|
-
))}
|
|
50
|
+
<Skeleton className="h-5 w-16" />
|
|
51
|
+
<Skeleton className="h-8 w-full" />
|
|
52
|
+
{[0, 1, 2, 3].map((row) => (
|
|
53
|
+
<div className="grid gap-1 py-2" key={row}>
|
|
54
|
+
<Skeleton className="h-4 w-4/5" />
|
|
55
|
+
<Skeleton className="h-3 w-2/5 opacity-70" />
|
|
56
|
+
</div>
|
|
57
|
+
))}
|
|
58
|
+
</aside>
|
|
150
59
|
</div>
|
|
151
60
|
);
|
|
152
61
|
}
|
|
153
62
|
|
|
154
63
|
/**
|
|
155
|
-
* Render the
|
|
156
|
-
*
|
|
157
|
-
*
|
|
64
|
+
* Render the inbox: Review items that the viewer can see in a sidebar, and
|
|
65
|
+
* one selected item with the end of its Conversation, where the viewer
|
|
66
|
+
* decides it. Details show beside the item, or in a sheet on small screens.
|
|
158
67
|
*/
|
|
159
68
|
export function ReviewDeskPage() {
|
|
69
|
+
useDocumentTitle("Inbox");
|
|
160
70
|
const [searchParams] = useSearchParams();
|
|
161
71
|
const [search, setSearch, q] = useDebouncedSearchParam();
|
|
162
72
|
const { items: list, moreArchived, query: items } = useReviewItemsData({ q });
|
|
163
73
|
const showBlocked = !q;
|
|
164
74
|
const blocked = useBlockedAutomationsData(showBlocked);
|
|
165
|
-
const
|
|
75
|
+
const blockedAutomations = showBlocked
|
|
76
|
+
? (blocked.data?.automations ?? [])
|
|
77
|
+
: [];
|
|
78
|
+
const groups = useMemo(() => inboxGroups(list), [list]);
|
|
79
|
+
// A search lists every match in one list. Later pages add open items, so
|
|
80
|
+
// keep open matches before archived ones.
|
|
81
|
+
const results = useMemo(
|
|
82
|
+
() =>
|
|
83
|
+
q
|
|
84
|
+
? [
|
|
85
|
+
...list.filter((item) => item.status === "open"),
|
|
86
|
+
...list.filter((item) => item.status !== "open"),
|
|
87
|
+
]
|
|
88
|
+
: undefined,
|
|
89
|
+
[list, q],
|
|
90
|
+
);
|
|
91
|
+
const open = [...groups.needsYou, ...groups.fyi];
|
|
92
|
+
|
|
93
|
+
const [collapsed, setCollapsed] = useState<ReadonlySet<InboxGroupKey>>(
|
|
94
|
+
() => new Set(),
|
|
95
|
+
);
|
|
96
|
+
const toggle = (key: InboxGroupKey) =>
|
|
97
|
+
setCollapsed((current) => {
|
|
98
|
+
const next = new Set(current);
|
|
99
|
+
if (next.has(key)) next.delete(key);
|
|
100
|
+
else next.add(key);
|
|
101
|
+
return next;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const itemParam = searchParams.get("item") ?? undefined;
|
|
105
|
+
// Wide screens select the item that waited longest. Small screens show
|
|
106
|
+
// the list until the reader picks an item.
|
|
107
|
+
const selectedId = itemParam ?? open[0]?.id;
|
|
166
108
|
const listed = list.find((item) => item.id === selectedId);
|
|
167
109
|
// A link can select an item that is not in the current view.
|
|
168
110
|
const direct = useReviewItemData(listed ? undefined : selectedId);
|
|
169
111
|
const selected = listed ?? direct.data;
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
112
|
+
const nextItem = selected ? nextInboxItem(groups, selected) : undefined;
|
|
113
|
+
|
|
114
|
+
// Read source titles only for the rows on screen and the selected item.
|
|
115
|
+
const sourceIds = useMemo(() => {
|
|
116
|
+
const rows = results ?? [
|
|
117
|
+
...(collapsed.has("needs-you") ? [] : groups.needsYou),
|
|
118
|
+
...(collapsed.has("fyi") ? [] : groups.fyi),
|
|
119
|
+
];
|
|
120
|
+
const shown = selected ? [...rows, selected] : rows;
|
|
121
|
+
return [
|
|
122
|
+
...new Set(
|
|
123
|
+
shown
|
|
124
|
+
.filter((item) => !item.automationId)
|
|
125
|
+
.map((item) => item.addedByConversationId),
|
|
126
|
+
),
|
|
127
|
+
];
|
|
128
|
+
}, [collapsed, groups, results, selected]);
|
|
129
|
+
const sourceTitles = useLinkedConversationTitles(sourceIds);
|
|
130
|
+
const sourceTitle = (item: ReviewItemPayload) =>
|
|
131
|
+
sourceTitles[sourceIds.indexOf(item.addedByConversationId)];
|
|
132
|
+
|
|
133
|
+
const panelFits = useDetailsPanel();
|
|
134
|
+
const [detailsChoice, setDetailsChoice] = useState<boolean>();
|
|
135
|
+
const detailsOpen = Boolean(selected) && (detailsChoice ?? panelFits);
|
|
136
|
+
|
|
137
|
+
const listParams = new URLSearchParams(searchParams);
|
|
138
|
+
listParams.delete("item");
|
|
139
|
+
const loaded = Boolean(items.data);
|
|
177
140
|
const empty =
|
|
178
|
-
|
|
141
|
+
loaded && !q && open.length === 0 && blockedAutomations.length === 0;
|
|
179
142
|
|
|
180
143
|
return (
|
|
181
|
-
<
|
|
182
|
-
<
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
<Card
|
|
194
|
-
className="grid content-start gap-4 py-3 lg:self-start"
|
|
195
|
-
padding="none"
|
|
196
|
-
>
|
|
197
|
-
{items.error && !items.data ? (
|
|
198
|
-
<InlineError className="px-3">
|
|
199
|
-
Could not load review items.
|
|
200
|
-
</InlineError>
|
|
201
|
-
) : !items.data ? (
|
|
202
|
-
<ReviewListLoading />
|
|
203
|
-
) : empty ? (
|
|
204
|
-
<EmptyMessage>
|
|
205
|
-
{q ? `No items match "${q}".` : "No open items."}
|
|
206
|
-
</EmptyMessage>
|
|
207
|
-
) : (
|
|
144
|
+
<div className={shellClass}>
|
|
145
|
+
<div
|
|
146
|
+
className={cn(
|
|
147
|
+
"h-full min-h-0 overflow-hidden",
|
|
148
|
+
itemParam && "max-md:hidden",
|
|
149
|
+
)}
|
|
150
|
+
>
|
|
151
|
+
<ReviewDeskSidebar
|
|
152
|
+
blocked={blockedAutomations}
|
|
153
|
+
collapsed={collapsed}
|
|
154
|
+
error={Boolean(items.error && !items.data)}
|
|
155
|
+
footer={
|
|
208
156
|
<>
|
|
209
|
-
{actions.length > 0 ? (
|
|
210
|
-
<ListGroup title="Needs an answer">
|
|
211
|
-
{actions.map((item) => (
|
|
212
|
-
<ItemRow
|
|
213
|
-
item={item}
|
|
214
|
-
key={item.id}
|
|
215
|
-
searchParams={searchParams}
|
|
216
|
-
selected={item.id === selected?.id}
|
|
217
|
-
/>
|
|
218
|
-
))}
|
|
219
|
-
</ListGroup>
|
|
220
|
-
) : null}
|
|
221
|
-
{notices.length > 0 ? (
|
|
222
|
-
<ListGroup title="Notices">
|
|
223
|
-
{notices.map((item) => (
|
|
224
|
-
<ItemRow
|
|
225
|
-
item={item}
|
|
226
|
-
key={item.id}
|
|
227
|
-
searchParams={searchParams}
|
|
228
|
-
selected={item.id === selected?.id}
|
|
229
|
-
/>
|
|
230
|
-
))}
|
|
231
|
-
</ListGroup>
|
|
232
|
-
) : null}
|
|
233
157
|
<LoadMorePagination
|
|
234
|
-
className="
|
|
158
|
+
className="mx-3 mt-2"
|
|
235
159
|
hasMore={!items.isPlaceholderData && Boolean(items.hasNextPage)}
|
|
236
160
|
loading={items.isFetchingNextPage}
|
|
237
161
|
onLoadMore={() => void items.fetchNextPage()}
|
|
238
162
|
/>
|
|
239
163
|
{items.isFetchNextPageError ? (
|
|
240
|
-
<InlineError className="px-3
|
|
164
|
+
<InlineError className="px-3 py-2">
|
|
241
165
|
Could not load more items. Try again.
|
|
242
166
|
</InlineError>
|
|
243
167
|
) : null}
|
|
244
|
-
{
|
|
245
|
-
<
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
? "border-t border-dashboard-border-subtle pt-3"
|
|
249
|
-
: undefined
|
|
250
|
-
}
|
|
251
|
-
icon={<Archive aria-hidden="true" size={13} />}
|
|
252
|
-
title="Archived"
|
|
253
|
-
>
|
|
254
|
-
{archived.map((item) => (
|
|
255
|
-
<ItemRow
|
|
256
|
-
item={item}
|
|
257
|
-
key={item.id}
|
|
258
|
-
searchParams={searchParams}
|
|
259
|
-
selected={item.id === selected?.id}
|
|
260
|
-
/>
|
|
261
|
-
))}
|
|
262
|
-
{moreArchived ? (
|
|
263
|
-
<li className="px-3 py-1 text-xs text-dashboard-text-muted">
|
|
264
|
-
Only the newest archived matches show. Add words to the
|
|
265
|
-
search to find older items.
|
|
266
|
-
</li>
|
|
267
|
-
) : null}
|
|
268
|
-
</ListGroup>
|
|
168
|
+
{moreArchived ? (
|
|
169
|
+
<p className="m-0 px-3 py-2 text-xs text-dashboard-text-muted">
|
|
170
|
+
Narrow the search to find older archived items.
|
|
171
|
+
</p>
|
|
269
172
|
) : null}
|
|
270
|
-
{
|
|
271
|
-
<
|
|
272
|
-
{
|
|
273
|
-
|
|
274
|
-
automation={automation}
|
|
275
|
-
key={automation.id}
|
|
276
|
-
/>
|
|
277
|
-
))}
|
|
278
|
-
</ListGroup>
|
|
173
|
+
{loaded && q && list.length === 0 ? (
|
|
174
|
+
<p className="m-0 px-3 py-8 text-center text-sm text-dashboard-text-muted">
|
|
175
|
+
No items match "{q}".
|
|
176
|
+
</p>
|
|
279
177
|
) : null}
|
|
178
|
+
{empty ? <InboxEmpty className="md:hidden" /> : null}
|
|
280
179
|
</>
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
{
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
180
|
+
}
|
|
181
|
+
fyi={groups.fyi}
|
|
182
|
+
loading={!items.data && !items.error}
|
|
183
|
+
needsYou={groups.needsYou}
|
|
184
|
+
onSearchChange={setSearch}
|
|
185
|
+
onToggle={toggle}
|
|
186
|
+
results={results}
|
|
187
|
+
search={search}
|
|
188
|
+
searchParams={listParams}
|
|
189
|
+
selectedId={selected?.id}
|
|
190
|
+
selectedShown={Boolean(itemParam)}
|
|
191
|
+
sourceTitle={sourceTitle}
|
|
192
|
+
/>
|
|
294
193
|
</div>
|
|
295
|
-
|
|
194
|
+
<section
|
|
195
|
+
aria-label="Selected review item"
|
|
196
|
+
className={cn(
|
|
197
|
+
"grid min-h-0 grid-rows-[minmax(0,1fr)] overflow-hidden",
|
|
198
|
+
!itemParam && "max-md:hidden",
|
|
199
|
+
detailsOpen && "xl:grid-cols-[minmax(0,1fr)_25rem]",
|
|
200
|
+
)}
|
|
201
|
+
>
|
|
202
|
+
{selected ? (
|
|
203
|
+
<>
|
|
204
|
+
<ReviewItemView
|
|
205
|
+
backTo={pathWithSearch("/review", listParams)}
|
|
206
|
+
detailsOpen={detailsOpen}
|
|
207
|
+
item={selected}
|
|
208
|
+
// A key per item resets the answer form and its send lock, so
|
|
209
|
+
// one item's answer can never reach another item.
|
|
210
|
+
key={selected.id}
|
|
211
|
+
next={
|
|
212
|
+
nextItem
|
|
213
|
+
? {
|
|
214
|
+
title: nextItem.title,
|
|
215
|
+
to: pathWithSearch(
|
|
216
|
+
reviewItemPath(nextItem.id),
|
|
217
|
+
listParams,
|
|
218
|
+
),
|
|
219
|
+
}
|
|
220
|
+
: undefined
|
|
221
|
+
}
|
|
222
|
+
onDetailsClick={() => setDetailsChoice(!detailsOpen)}
|
|
223
|
+
sourceTitle={sourceTitle(selected)}
|
|
224
|
+
/>
|
|
225
|
+
{detailsOpen ? (
|
|
226
|
+
<ReviewItemDetails
|
|
227
|
+
item={selected}
|
|
228
|
+
onClose={() => setDetailsChoice(false)}
|
|
229
|
+
/>
|
|
230
|
+
) : null}
|
|
231
|
+
</>
|
|
232
|
+
) : direct.error ? (
|
|
233
|
+
<InlineError className="p-7">Review item not found.</InlineError>
|
|
234
|
+
) : empty ? (
|
|
235
|
+
<InboxEmpty />
|
|
236
|
+
) : null}
|
|
237
|
+
</section>
|
|
238
|
+
</div>
|
|
296
239
|
);
|
|
297
240
|
}
|