diff --git a/src/__tests__/feedback/general.test.tsx b/src/__tests__/feedback/general.test.tsx new file mode 100644 index 000000000..36046f86c --- /dev/null +++ b/src/__tests__/feedback/general.test.tsx @@ -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 = {}; + +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(); + + 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(); + + expect(screen.queryByText(/error details from your search will be included/i)).not.toBeInTheDocument(); + }); +}); diff --git a/src/api/feedback/types.ts b/src/api/feedback/types.ts index 641940975..a7c25daa4 100644 --- a/src/api/feedback/types.ts +++ b/src/api/feedback/types.ts @@ -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'; diff --git a/src/components/SolrErrorAlert/SolrErrorAlert.test.tsx b/src/components/SolrErrorAlert/SolrErrorAlert.test.tsx new file mode 100644 index 000000000..ec625aceb --- /dev/null +++ b/src/components/SolrErrorAlert/SolrErrorAlert.test.tsx @@ -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 => { + const error = new AxiosError(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(); + + 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(); + + 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(); + + 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; + render(); + + 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'); + }); +}); diff --git a/src/components/SolrErrorAlert/SolrErrorAlert.tsx b/src/components/SolrErrorAlert/SolrErrorAlert.tsx index 4ce6a3dda..a0b2c5b15 100644 --- a/src/components/SolrErrorAlert/SolrErrorAlert.tsx +++ b/src/components/SolrErrorAlert/SolrErrorAlert.tsx @@ -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 { @@ -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 ( - - - - - - - {title} - {message} - - - - {onRetry && ( - - )} - - - - - - + + + + + + + + {title} + + + + + {message} + + + + {onRetry && ( + + )} + + + + + + + + + {isOpen ? 'Hide' : 'Show'} Details + + + Report this issue + + - - - {data?.originalMsg} - - - - + + + {data?.originalMsg} + + + ); }; diff --git a/src/pages/feedback/general.tsx b/src/pages/feedback/general.tsx index 71839fca5..8cc89429e 100644 --- a/src/pages/feedback/general.tsx +++ b/src/pages/feedback/general.tsx @@ -1,4 +1,7 @@ import { + Alert, + AlertDescription, + AlertIcon, AlertStatus, Button, Flex, @@ -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>( async (params) => { if (params === null) { @@ -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, }, { @@ -176,6 +182,7 @@ const General: NextPage = () => { currentQuery, router.query.from, router.asPath, + errorDetails, userEmail, engineName, engineVersion, @@ -223,6 +230,14 @@ const General: NextPage = () => { . You can also send general comments and questions to help [at] scixplorer.org. + {errorDetails && ( + + + + Error details from your search will be included with this submission to help us investigate the issue. + + + )}