Skip to content

Commit 649eb77

Browse files
committed
SCIX-834 fix(search): improve search error alert UX
Redesign the search error card and add a feedback reporting flow.
1 parent ea6f244 commit 649eb77

File tree

5 files changed

+268
-46
lines changed

5 files changed

+268
-46
lines changed

src/api/feedback/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export interface IGeneralFeedbackParams {
2121
current_page?: string;
2222
current_query?: string;
2323
url?: string;
24+
error_details?: string;
2425
}
2526

2627
export type Relationship = 'errata' | 'addenda' | 'series' | 'arxiv' | 'duplicate' | 'other';
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { render, screen } from '@/test-utils';
2+
import { describe, expect, test, vi } from 'vitest';
3+
import { SearchErrorAlert } from './SolrErrorAlert';
4+
import { AxiosError, AxiosHeaders } from 'axios';
5+
import { IADSApiSearchResponse } from '@/api/search/types';
6+
7+
vi.mock('next/router', () => ({
8+
useRouter: () => ({
9+
reload: vi.fn(),
10+
back: vi.fn(),
11+
push: vi.fn(),
12+
query: {},
13+
pathname: '/search',
14+
}),
15+
}));
16+
17+
const makeAxiosError = (msg: string): AxiosError<IADSApiSearchResponse> => {
18+
const error = new AxiosError<IADSApiSearchResponse>(msg);
19+
error.response = {
20+
data: {
21+
error: { code: 400, msg },
22+
} as unknown as IADSApiSearchResponse,
23+
status: 400,
24+
statusText: 'Bad Request',
25+
headers: {},
26+
config: { headers: new AxiosHeaders() },
27+
};
28+
return error;
29+
};
30+
31+
describe('SearchErrorAlert', () => {
32+
test('renders a "Report this issue" link', () => {
33+
const error = makeAxiosError('syntax error: cannot parse query');
34+
render(<SearchErrorAlert error={error} />);
35+
36+
const link = screen.getByRole('link', { name: /report this issue/i });
37+
expect(link).toBeInTheDocument();
38+
});
39+
40+
test('link href contains /feedback/general and error_details=', () => {
41+
const msg = 'syntax error: cannot parse query';
42+
const error = makeAxiosError(msg);
43+
render(<SearchErrorAlert error={error} />);
44+
45+
const link = screen.getByRole('link', { name: /report this issue/i });
46+
expect(link).toHaveAttribute('href');
47+
const href = link.getAttribute('href');
48+
expect(href).toContain('/feedback/general');
49+
const url = new URL(href, 'http://localhost');
50+
expect(url.searchParams.get('error_details')).toBe(msg);
51+
});
52+
53+
test('details section is collapsed by default', () => {
54+
const error = makeAxiosError('syntax error: cannot parse query');
55+
render(<SearchErrorAlert error={error} />);
56+
57+
const toggleBtn = screen.getByLabelText('toggle error details');
58+
expect(toggleBtn).toHaveTextContent('Show Details');
59+
});
60+
61+
test('link always includes from=search param', () => {
62+
const error = new Error('something failed') as AxiosError<IADSApiSearchResponse>;
63+
render(<SearchErrorAlert error={error} />);
64+
65+
const link = screen.getByRole('link', { name: /report this issue/i });
66+
const href = link.getAttribute('href') ?? '';
67+
expect(href).toContain('/feedback/general');
68+
expect(href).toContain('from=search');
69+
});
70+
});

src/components/SolrErrorAlert/SolrErrorAlert.tsx

Lines changed: 130 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,23 @@
11
import {
2-
Alert,
3-
AlertDescription,
4-
AlertIcon,
5-
AlertTitle,
62
Box,
73
Button,
84
Code,
95
Collapse,
106
HStack,
7+
Icon,
8+
Link,
9+
Spacer,
10+
Text,
1111
Tooltip,
1212
useClipboard,
13+
useColorModeValue,
1314
useDisclosure,
1415
VStack,
1516
} from '@chakra-ui/react';
16-
import { ChevronDownIcon, ChevronRightIcon, CopyIcon } from '@chakra-ui/icons';
17+
import { CopyIcon, WarningIcon } from '@chakra-ui/icons';
1718
import { AxiosError } from 'axios';
1819
import { IADSApiSearchResponse } from '@/api/search/types';
20+
import { SimpleLink } from '@/components/SimpleLink';
1921
import { ParsedSolrError, SOLR_ERROR, useSolrError } from '@/lib/useSolrError';
2022

2123
interface ISolrErrorAlertProps {
@@ -26,56 +28,138 @@ interface ISolrErrorAlertProps {
2628

2729
export const SearchErrorAlert = ({ error, onRetry, isRetrying = false }: ISolrErrorAlertProps) => {
2830
const data = useSolrError(error);
29-
const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: true });
31+
const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: false });
3032
const detailsId = 'search-error-details';
3133
const { onCopy, hasCopied } = useClipboard(
3234
typeof data?.originalMsg === 'string' ? data.originalMsg : String(data?.originalMsg ?? ''),
3335
);
3436
const { title, message } = solrErrorToCopy(data, { includeFieldName: !!data.field });
3537

38+
const errorMsg = typeof data?.originalMsg === 'string' ? data.originalMsg : String(data?.originalMsg ?? '');
39+
const feedbackUrl = errorMsg
40+
? `/feedback/general?${new URLSearchParams({
41+
from: 'search',
42+
error_details: errorMsg.slice(0, 2000),
43+
}).toString()}`
44+
: '/feedback/general?from=search';
45+
46+
const bgColor = useColorModeValue('white', 'gray.800');
47+
const borderColor = useColorModeValue('red.100', 'whiteAlpha.200');
48+
const accentBarColor = useColorModeValue('red.500', 'red.400');
49+
const titleColor = useColorModeValue('blue.800', 'white');
50+
const descColor = useColorModeValue('gray.900', 'gray.400');
51+
const shadow = useColorModeValue('md', '2xl');
52+
const codeBg = useColorModeValue('gray.100', 'whiteAlpha.300');
53+
const linkColor = useColorModeValue('gray.800', 'gray.500');
54+
const tryAgainBorderColor = useColorModeValue('red.200', 'red.600');
55+
const tryAgainColor = useColorModeValue('red.600', 'red.300');
56+
const tryAgainBg = useColorModeValue('white', 'gray.700');
57+
const tryAgainHoverBg = useColorModeValue('red.50', 'whiteAlpha.100');
58+
const copyBorderColor = useColorModeValue('gray.300', 'whiteAlpha.300');
59+
const copyHoverBg = useColorModeValue('gray.50', 'whiteAlpha.100');
60+
3661
return (
37-
<Box w="full">
38-
<Alert status="error" variant="subtle" alignItems="flex-start" borderRadius="md">
39-
<VStack align="stretch" spacing={2} w="full">
40-
<HStack align="start" w="full">
41-
<AlertIcon />
42-
<VStack align="start" spacing={1} flex="1">
43-
<AlertTitle>{title}</AlertTitle>
44-
<AlertDescription>{message}</AlertDescription>
45-
</VStack>
46-
47-
<HStack>
48-
{onRetry && (
49-
<Button onClick={onRetry} colorScheme="blue" size="sm" isLoading={isRetrying}>
50-
Try Again
51-
</Button>
52-
)}
53-
<Tooltip label={hasCopied ? 'Copied!' : 'Copy full error'}>
54-
<Button onClick={onCopy} leftIcon={<CopyIcon />} variant="ghost" size="sm">
55-
{hasCopied ? 'Copied' : 'Copy'}
56-
</Button>
57-
</Tooltip>
58-
59-
<Button
60-
rightIcon={isOpen ? <ChevronDownIcon /> : <ChevronRightIcon />}
61-
aria-label="toggle error details"
62-
aria-controls={detailsId}
63-
onClick={onToggle}
64-
variant="ghost"
65-
size="sm"
66-
>
67-
{isOpen ? 'Hide' : 'Show'} Details
68-
</Button>
69-
</HStack>
62+
<Box
63+
w="full"
64+
bg={bgColor}
65+
border="1px solid"
66+
borderColor={borderColor}
67+
borderRadius="md"
68+
shadow={shadow}
69+
overflow="hidden"
70+
position="relative"
71+
role="alert"
72+
>
73+
<Box position="absolute" left={0} top={0} bottom={0} w="4px" bg={accentBarColor} />
74+
75+
<VStack align="start" p={5} spacing={3} pl={8}>
76+
<HStack spacing={3}>
77+
<Icon as={WarningIcon} color={accentBarColor} boxSize={4} />
78+
<Text fontWeight="600" color={titleColor} fontSize="md">
79+
{title}
80+
</Text>
81+
</HStack>
82+
83+
<Text color={descColor} fontSize="sm" lineHeight="1.6">
84+
{message}
85+
</Text>
86+
87+
<HStack w="full" spacing={3} pt={2} wrap="wrap" align="center">
88+
{onRetry && (
89+
<Button
90+
onClick={onRetry}
91+
variant="outline"
92+
size="sm"
93+
borderColor={tryAgainBorderColor}
94+
color={tryAgainColor}
95+
bg={tryAgainBg}
96+
borderRadius="4px"
97+
px={5}
98+
isLoading={isRetrying}
99+
_hover={{ bg: tryAgainHoverBg }}
100+
>
101+
Try Again
102+
</Button>
103+
)}
104+
<Tooltip label={hasCopied ? 'Copied!' : 'Copy full error'}>
105+
<Button
106+
onClick={onCopy}
107+
leftIcon={<CopyIcon />}
108+
variant="outline"
109+
size="sm"
110+
borderColor={copyBorderColor}
111+
color={titleColor}
112+
borderRadius="4px"
113+
px={5}
114+
_hover={{ bg: copyHoverBg }}
115+
>
116+
{hasCopied ? 'Copied' : 'Copy'}
117+
</Button>
118+
</Tooltip>
119+
120+
<Spacer />
121+
122+
<HStack spacing={4}>
123+
<Link
124+
as="button"
125+
fontSize="xs"
126+
color={linkColor}
127+
aria-label="toggle error details"
128+
aria-controls={detailsId}
129+
onClick={onToggle}
130+
_hover={{ color: 'red.400', textDecoration: 'underline' }}
131+
>
132+
{isOpen ? 'Hide' : 'Show'} Details
133+
</Link>
134+
<SimpleLink
135+
href={feedbackUrl}
136+
fontSize="xs"
137+
color={linkColor}
138+
display="inline-flex"
139+
alignItems="center"
140+
gap={1}
141+
_hover={{ color: 'red.400', textDecoration: 'underline' }}
142+
>
143+
Report this issue
144+
</SimpleLink>
70145
</HStack>
146+
</HStack>
71147

72-
<Collapse in={isOpen} animateOpacity>
73-
<Code id={detailsId} p="2" display="block" whiteSpace="pre-wrap" w="full">
74-
{data?.originalMsg}
75-
</Code>
76-
</Collapse>
77-
</VStack>
78-
</Alert>
148+
<Collapse in={isOpen} animateOpacity>
149+
<Code
150+
id={detailsId}
151+
p={3}
152+
display="block"
153+
whiteSpace="pre-wrap"
154+
w="full"
155+
fontSize="xs"
156+
bg={codeBg}
157+
borderRadius="md"
158+
>
159+
{data?.originalMsg}
160+
</Code>
161+
</Collapse>
162+
</VStack>
79163
</Box>
80164
);
81165
};
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { render, screen } from '@/test-utils';
2+
import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest';
3+
import General from '../general';
4+
5+
beforeAll(() => {
6+
Element.prototype.scrollIntoView = vi.fn();
7+
});
8+
9+
const mockQuery: Record<string, string> = {};
10+
11+
vi.mock('next/router', () => ({
12+
useRouter: () => ({
13+
query: mockQuery,
14+
asPath: '/feedback/general',
15+
push: vi.fn(),
16+
replace: vi.fn(),
17+
pathname: '/feedback/general',
18+
}),
19+
}));
20+
21+
vi.mock('react-google-recaptcha-v3', () => ({
22+
useGoogleReCaptcha: () => ({
23+
executeRecaptcha: vi.fn(),
24+
}),
25+
}));
26+
27+
vi.mock('@/api/feedback/feedback', () => ({
28+
useFeedback: () => ({
29+
mutate: vi.fn(),
30+
isLoading: false,
31+
}),
32+
}));
33+
34+
describe('General Feedback Page', () => {
35+
afterEach(() => {
36+
Object.keys(mockQuery).forEach((key) => delete mockQuery[key]);
37+
});
38+
39+
test('shows info notice when error_details query param is present', () => {
40+
mockQuery.error_details = 'Search syntax error near position 10';
41+
42+
render(<General />);
43+
44+
expect(screen.getByText(/error details from your search will be included/i)).toBeInTheDocument();
45+
});
46+
47+
test('does not show info notice when error_details is absent', () => {
48+
render(<General />);
49+
50+
expect(screen.queryByText(/error details from your search will be included/i)).not.toBeInTheDocument();
51+
});
52+
});

src/pages/feedback/general.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import {
2+
Alert,
3+
AlertDescription,
4+
AlertIcon,
25
AlertStatus,
36
Button,
47
Flex,
@@ -94,6 +97,8 @@ const General: NextPage = () => {
9497

9598
const router = useRouter();
9699

100+
const errorDetails = typeof router.query.error_details === 'string' ? router.query.error_details : undefined;
101+
97102
const onSubmit = useCallback<SubmitHandler<FormValues>>(
98103
async (params) => {
99104
if (params === null) {
@@ -130,6 +135,7 @@ const General: NextPage = () => {
130135
current_page: router.query.from ? (router.query.from as string) : undefined,
131136
current_query: makeSearchParams(currentQuery),
132137
url: router.asPath,
138+
error_details: errorDetails,
133139
comments,
134140
},
135141
{
@@ -176,6 +182,7 @@ const General: NextPage = () => {
176182
currentQuery,
177183
router.query.from,
178184
router.asPath,
185+
errorDetails,
179186
userEmail,
180187
engineName,
181188
engineVersion,
@@ -223,6 +230,14 @@ const General: NextPage = () => {
223230
</SimpleLink>
224231
. You can also send general comments and questions to <strong>help [at] scixplorer.org</strong>.
225232
</Text>
233+
{errorDetails && (
234+
<Alert status="info" borderRadius="md" my={2}>
235+
<AlertIcon />
236+
<AlertDescription fontSize="sm">
237+
Error details from your search will be included with this submission to help us investigate the issue.
238+
</AlertDescription>
239+
</Alert>
240+
)}
226241
<Flex direction="column" gap={4}>
227242
<Stack direction={{ base: 'column', sm: 'row' }} gap={2}>
228243
<FormControl isRequired isInvalid={!!errors.name}>

0 commit comments

Comments
 (0)