Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/__tests__/feedback/general.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { render, screen } from '@/test-utils';
import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest';
import General from '@/pages/feedback/general';

beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn();
});

const mockQuery: Record<string, string> = {};

vi.mock('next/router', () => ({
useRouter: () => ({
query: mockQuery,
asPath: '/feedback/general',
push: vi.fn(),
replace: vi.fn(),
pathname: '/feedback/general',
}),
}));

vi.mock('react-google-recaptcha-v3', () => ({
useGoogleReCaptcha: () => ({
executeRecaptcha: vi.fn(),
}),
}));

vi.mock('@/api/feedback/feedback', () => ({
useFeedback: () => ({
mutate: vi.fn(),
isLoading: false,
}),
}));

describe('General Feedback Page', () => {
afterEach(() => {
Object.keys(mockQuery).forEach((key) => delete mockQuery[key]);
});

test('shows info notice when error_details query param is present', () => {
mockQuery.error_details = 'Search syntax error near position 10';

render(<General />);

expect(screen.getByText(/error details from your search will be included/i)).toBeInTheDocument();
});

test('does not show info notice when error_details is absent', () => {
render(<General />);

expect(screen.queryByText(/error details from your search will be included/i)).not.toBeInTheDocument();
});
});
1 change: 1 addition & 0 deletions src/api/feedback/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface IGeneralFeedbackParams {
current_page?: string;
current_query?: string;
url?: string;
error_details?: string;
}

export type Relationship = 'errata' | 'addenda' | 'series' | 'arxiv' | 'duplicate' | 'other';
Expand Down
70 changes: 70 additions & 0 deletions src/components/SolrErrorAlert/SolrErrorAlert.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { render, screen } from '@/test-utils';
import { describe, expect, test, vi } from 'vitest';
import { SearchErrorAlert } from './SolrErrorAlert';
import { AxiosError, AxiosHeaders } from 'axios';
import { IADSApiSearchResponse } from '@/api/search/types';

vi.mock('next/router', () => ({
useRouter: () => ({
reload: vi.fn(),
back: vi.fn(),
push: vi.fn(),
query: {},
pathname: '/search',
}),
}));

const makeAxiosError = (msg: string): AxiosError<IADSApiSearchResponse> => {
const error = new AxiosError<IADSApiSearchResponse>(msg);
error.response = {
data: {
error: { code: 400, msg },
} as unknown as IADSApiSearchResponse,
status: 400,
statusText: 'Bad Request',
headers: {},
config: { headers: new AxiosHeaders() },
};
return error;
};

describe('SearchErrorAlert', () => {
test('renders a "Report this issue" link', () => {
const error = makeAxiosError('syntax error: cannot parse query');
render(<SearchErrorAlert error={error} />);

const link = screen.getByRole('link', { name: /report this issue/i });
expect(link).toBeInTheDocument();
});

test('link href contains /feedback/general and error_details=', () => {
const msg = 'syntax error: cannot parse query';
const error = makeAxiosError(msg);
render(<SearchErrorAlert error={error} />);

const link = screen.getByRole('link', { name: /report this issue/i });
expect(link).toHaveAttribute('href');
const href = link.getAttribute('href');
expect(href).toContain('/feedback/general');
const url = new URL(href, 'http://localhost');
expect(url.searchParams.get('error_details')).toBe(msg);
});

test('details section is collapsed by default', () => {
const error = makeAxiosError('syntax error: cannot parse query');
render(<SearchErrorAlert error={error} />);

const toggleBtn = screen.getByLabelText('toggle error details');
expect(toggleBtn).toHaveTextContent('Show Details');
});

test('link always includes from=search param', () => {
const error = new Error('something failed') as AxiosError<IADSApiSearchResponse>;
render(<SearchErrorAlert error={error} />);

const link = screen.getByRole('link', { name: /report this issue/i });
const href = link.getAttribute('href') ?? '';
expect(href).toContain('/feedback/general');
expect(href).toContain('from=search');
});
});
176 changes: 130 additions & 46 deletions src/components/SolrErrorAlert/SolrErrorAlert.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
import {
Alert,
AlertDescription,
AlertIcon,
AlertTitle,
Box,
Button,
Code,
Collapse,
HStack,
Icon,
Link,
Spacer,
Text,
Tooltip,
useClipboard,
useColorModeValue,
useDisclosure,
VStack,
} from '@chakra-ui/react';
import { ChevronDownIcon, ChevronRightIcon, CopyIcon } from '@chakra-ui/icons';
import { CopyIcon, WarningIcon } from '@chakra-ui/icons';
import { AxiosError } from 'axios';
import { IADSApiSearchResponse } from '@/api/search/types';
import { SimpleLink } from '@/components/SimpleLink';
import { ParsedSolrError, SOLR_ERROR, useSolrError } from '@/lib/useSolrError';

interface ISolrErrorAlertProps {
Expand All @@ -26,56 +28,138 @@ interface ISolrErrorAlertProps {

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

const errorMsg = typeof data?.originalMsg === 'string' ? data.originalMsg : String(data?.originalMsg ?? '');
const feedbackUrl = errorMsg
? `/feedback/general?${new URLSearchParams({
from: 'search',
error_details: errorMsg.slice(0, 2000),
}).toString()}`
: '/feedback/general?from=search';

const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('red.100', 'whiteAlpha.200');
const accentBarColor = useColorModeValue('red.500', 'red.400');
const titleColor = useColorModeValue('blue.800', 'white');
const descColor = useColorModeValue('gray.900', 'gray.400');
const shadow = useColorModeValue('md', '2xl');
const codeBg = useColorModeValue('gray.100', 'whiteAlpha.300');
const linkColor = useColorModeValue('gray.800', 'gray.500');
const tryAgainBorderColor = useColorModeValue('red.200', 'red.600');
const tryAgainColor = useColorModeValue('red.600', 'red.300');
const tryAgainBg = useColorModeValue('white', 'gray.700');
const tryAgainHoverBg = useColorModeValue('red.50', 'whiteAlpha.100');
const copyBorderColor = useColorModeValue('gray.300', 'whiteAlpha.300');
const copyHoverBg = useColorModeValue('gray.50', 'whiteAlpha.100');

return (
<Box w="full">
<Alert status="error" variant="subtle" alignItems="flex-start" borderRadius="md">
<VStack align="stretch" spacing={2} w="full">
<HStack align="start" w="full">
<AlertIcon />
<VStack align="start" spacing={1} flex="1">
<AlertTitle>{title}</AlertTitle>
<AlertDescription>{message}</AlertDescription>
</VStack>

<HStack>
{onRetry && (
<Button onClick={onRetry} colorScheme="blue" size="sm" isLoading={isRetrying}>
Try Again
</Button>
)}
<Tooltip label={hasCopied ? 'Copied!' : 'Copy full error'}>
<Button onClick={onCopy} leftIcon={<CopyIcon />} variant="ghost" size="sm">
{hasCopied ? 'Copied' : 'Copy'}
</Button>
</Tooltip>

<Button
rightIcon={isOpen ? <ChevronDownIcon /> : <ChevronRightIcon />}
aria-label="toggle error details"
aria-controls={detailsId}
onClick={onToggle}
variant="ghost"
size="sm"
>
{isOpen ? 'Hide' : 'Show'} Details
</Button>
</HStack>
<Box
w="full"
bg={bgColor}
border="1px solid"
borderColor={borderColor}
borderRadius="md"
shadow={shadow}
overflow="hidden"
position="relative"
role="alert"
>
<Box position="absolute" left={0} top={0} bottom={0} w="4px" bg={accentBarColor} />

<VStack align="start" p={5} spacing={3} pl={8}>
<HStack spacing={3}>
<Icon as={WarningIcon} color={accentBarColor} boxSize={4} />
<Text fontWeight="600" color={titleColor} fontSize="md">
{title}
</Text>
</HStack>

<Text color={descColor} fontSize="sm" lineHeight="1.6">
{message}
</Text>

<HStack w="full" spacing={3} pt={2} wrap="wrap" align="center">
{onRetry && (
<Button
onClick={onRetry}
variant="outline"
size="sm"
borderColor={tryAgainBorderColor}
color={tryAgainColor}
bg={tryAgainBg}
borderRadius="4px"
px={5}
isLoading={isRetrying}
_hover={{ bg: tryAgainHoverBg }}
>
Try Again
</Button>
)}
<Tooltip label={hasCopied ? 'Copied!' : 'Copy full error'}>
<Button
onClick={onCopy}
leftIcon={<CopyIcon />}
variant="outline"
size="sm"
borderColor={copyBorderColor}
color={titleColor}
borderRadius="4px"
px={5}
_hover={{ bg: copyHoverBg }}
>
{hasCopied ? 'Copied' : 'Copy'}
</Button>
</Tooltip>

<Spacer />

<HStack spacing={4}>
<Link
as="button"
fontSize="xs"
color={linkColor}
aria-label="toggle error details"
aria-controls={detailsId}
onClick={onToggle}
_hover={{ color: 'red.400', textDecoration: 'underline' }}
>
{isOpen ? 'Hide' : 'Show'} Details
</Link>
<SimpleLink
href={feedbackUrl}
fontSize="xs"
color={linkColor}
display="inline-flex"
alignItems="center"
gap={1}
_hover={{ color: 'red.400', textDecoration: 'underline' }}
>
Report this issue
</SimpleLink>
</HStack>
</HStack>

<Collapse in={isOpen} animateOpacity>
<Code id={detailsId} p="2" display="block" whiteSpace="pre-wrap" w="full">
{data?.originalMsg}
</Code>
</Collapse>
</VStack>
</Alert>
<Collapse in={isOpen} animateOpacity>
<Code
id={detailsId}
p={3}
display="block"
whiteSpace="pre-wrap"
w="full"
fontSize="xs"
bg={codeBg}
borderRadius="md"
>
{data?.originalMsg}
</Code>
</Collapse>
</VStack>
</Box>
);
};
Expand Down
15 changes: 15 additions & 0 deletions src/pages/feedback/general.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import {
Alert,
AlertDescription,
AlertIcon,
AlertStatus,
Button,
Flex,
Expand Down Expand Up @@ -94,6 +97,8 @@ const General: NextPage = () => {

const router = useRouter();

const errorDetails = typeof router.query.error_details === 'string' ? router.query.error_details : undefined;

const onSubmit = useCallback<SubmitHandler<FormValues>>(
async (params) => {
if (params === null) {
Expand Down Expand Up @@ -130,6 +135,7 @@ const General: NextPage = () => {
current_page: router.query.from ? (router.query.from as string) : undefined,
current_query: makeSearchParams(currentQuery),
url: router.asPath,
error_details: errorDetails,
comments,
},
{
Expand Down Expand Up @@ -176,6 +182,7 @@ const General: NextPage = () => {
currentQuery,
router.query.from,
router.asPath,
errorDetails,
userEmail,
engineName,
engineVersion,
Expand Down Expand Up @@ -223,6 +230,14 @@ const General: NextPage = () => {
</SimpleLink>
. You can also send general comments and questions to <strong>help [at] scixplorer.org</strong>.
</Text>
{errorDetails && (
<Alert status="info" borderRadius="md" my={2}>
<AlertIcon />
<AlertDescription fontSize="sm">
Error details from your search will be included with this submission to help us investigate the issue.
</AlertDescription>
</Alert>
)}
<Flex direction="column" gap={4}>
<Stack direction={{ base: 'column', sm: 'row' }} gap={2}>
<FormControl isRequired isInvalid={!!errors.name}>
Expand Down
Loading