diff --git a/frontend/src/authschemes/webauthn/helpers.ts b/frontend/src/authschemes/webauthn/helpers.ts index c564a0cab..38485af8b 100644 --- a/frontend/src/authschemes/webauthn/helpers.ts +++ b/frontend/src/authschemes/webauthn/helpers.ts @@ -1,4 +1,7 @@ -import { type ProvidedCredentialCreationOptions, type ProvidedCredentialRequestOptions } from './types' +import { + type ProvidedCredentialCreationOptions, + type ProvidedCredentialRequestOptions, +} from './types' export const encodeAsB64 = (ab: ArrayBuffer) => { return base64UrlEncode(arrayBufferToString(ab)) diff --git a/frontend/src/components/bullet_chooser/evidence_type_chooser.tsx b/frontend/src/components/bullet_chooser/evidence_type_chooser.tsx index c6c3bdaa1..f9207a361 100644 --- a/frontend/src/components/bullet_chooser/evidence_type_chooser.tsx +++ b/frontend/src/components/bullet_chooser/evidence_type_chooser.tsx @@ -1,4 +1,3 @@ - import BulletChooser, { type BulletProps } from 'src/components/bullet_chooser' import { type SupportedEvidenceType } from 'src/global_types' diff --git a/frontend/src/components/bullet_chooser/index.tsx b/frontend/src/components/bullet_chooser/index.tsx index 50bed0a1a..69c26e4c5 100644 --- a/frontend/src/components/bullet_chooser/index.tsx +++ b/frontend/src/components/bullet_chooser/index.tsx @@ -169,9 +169,7 @@ export type BulletRendererProps = { selected?: boolean } -export type BulletRenderer = ( - props: BulletRendererProps, -) => ReactNode +export type BulletRenderer = (props: BulletRendererProps) => ReactNode function StandardBulletRenderer(props: BulletRendererProps) { return ( diff --git a/frontend/src/components/bullet_chooser/text_chooser.tsx b/frontend/src/components/bullet_chooser/text_chooser.tsx index 90fd2e316..f180a1f6a 100644 --- a/frontend/src/components/bullet_chooser/text_chooser.tsx +++ b/frontend/src/components/bullet_chooser/text_chooser.tsx @@ -1,4 +1,3 @@ - import BulletChooser, { type BulletProps } from 'src/components/bullet_chooser' import Tag from 'src/components/tag' diff --git a/frontend/src/components/chooser/index.tsx b/frontend/src/components/chooser/index.tsx index fc3a689db..6512fa6cf 100644 --- a/frontend/src/components/chooser/index.tsx +++ b/frontend/src/components/chooser/index.tsx @@ -84,11 +84,7 @@ export default function (props: { ) } -const Row = (props: { - selected: boolean - onChange: (v: boolean) => void - children: ReactNode -}) => ( +const Row = (props: { selected: boolean; onChange: (v: boolean) => void; children: ReactNode }) => (
props.onChange(!props.selected)} diff --git a/frontend/src/components/code_block/ace_editor/index.tsx b/frontend/src/components/code_block/ace_editor/index.tsx index 15b033969..7e0e77aa1 100644 --- a/frontend/src/components/code_block/ace_editor/index.tsx +++ b/frontend/src/components/code_block/ace_editor/index.tsx @@ -47,7 +47,9 @@ function useLoadAceModeWithWebpack(requestedMode: string): string { } import(`ace-builds/src-noconflict/mode-${requestedMode}`) .then(() => setLoadedMode(requestedMode)) - .catch(() => { /* mode load failed, editor will use plain_text fallback */ }) + .catch(() => { + /* mode load failed, editor will use plain_text fallback */ + }) }, [requestedMode]) return loadedMode @@ -84,9 +86,7 @@ function useSizeOfParentContainer(parentRef: MutableRefObject, -) { +function useStopPropagationOfSearchKeydowns(parentRef: MutableRefObject) { const onKeyDown = (e: KeyboardEvent) => { if (e.target && (e.target as HTMLElement).className === 'ace_search_field') { e.stopPropagation() diff --git a/frontend/src/components/date_range_picker/index.tsx b/frontend/src/components/date_range_picker/index.tsx index 4e2e53c94..ea02a3022 100644 --- a/frontend/src/components/date_range_picker/index.tsx +++ b/frontend/src/components/date_range_picker/index.tsx @@ -55,7 +55,10 @@ const DropDown = (props: {
) -export default function DateRangePicker(props: { range: MaybeDateRange; onSelectRange: (r: MaybeDateRange) => void }) { +export default function DateRangePicker(props: { + range: MaybeDateRange + onSelectRange: (r: MaybeDateRange) => void +}) { const [isOpen, setIsOpen] = useState(false) return ( diff --git a/frontend/src/components/error_boundary/index.tsx b/frontend/src/components/error_boundary/index.tsx new file mode 100644 index 000000000..9ad3119ea --- /dev/null +++ b/frontend/src/components/error_boundary/index.tsx @@ -0,0 +1,24 @@ +import { Component, type ReactNode, type ErrorInfo } from 'react' +import ErrorDisplay from 'src/components/error_display' + +type Props = { children: ReactNode } +type State = { error: Error | null } + +export default class ErrorBoundary extends Component { + state: State = { error: null } + + static getDerivedStateFromError(error: Error): State { + return { error } + } + + componentDidCatch(_error: Error, _info: ErrorInfo) { + // Errors are surfaced via the render method + } + + render() { + if (this.state.error) { + return + } + return this.props.children + } +} diff --git a/frontend/src/components/form/index.test.tsx b/frontend/src/components/form/index.test.tsx new file mode 100644 index 000000000..69fa873eb --- /dev/null +++ b/frontend/src/components/form/index.test.tsx @@ -0,0 +1,112 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import { userEvent } from '@testing-library/user-event' +import Form from './index' + +describe('Form', () => { + it('renders children', () => { + render( +
{}}> + +
, + ) + expect(screen.getByPlaceholderText('Name')).toBeInTheDocument() + }) + + it('renders submit button when submitText is provided', () => { + render(
{}} submitText="Save" />) + expect(screen.getByText('Save')).toBeInTheDocument() + }) + + it('does not render submit button when submitText is absent', () => { + render( {}} />) + expect(screen.queryByRole('button')).toBeNull() + }) + + it('renders cancel button when onCancel is provided', () => { + render( + {}} + onCancel={() => {}} + cancelText="Cancel" + />, + ) + expect(screen.getByText('Cancel')).toBeInTheDocument() + }) + + it('calls onSubmit when form is submitted', async () => { + const onSubmit = vi.fn((e) => e.preventDefault()) + const user = userEvent.setup() + render( + + +
, + ) + await user.click(screen.getByText('Go')) + expect(onSubmit).toHaveBeenCalled() + }) + + it('calls onCancel when cancel button is clicked', async () => { + const onCancel = vi.fn() + const user = userEvent.setup() + render( +
{}} + onCancel={onCancel} + cancelText="Cancel" + />, + ) + await user.click(screen.getByText('Cancel')) + expect(onCancel).toHaveBeenCalled() + }) + + it('displays an error result message', () => { + render( + {}} + />, + ) + expect(screen.getByText('Something went wrong')).toBeInTheDocument() + }) + + it('displays a success result message', () => { + render( {}} />) + expect(screen.getByText('Saved!')).toBeInTheDocument() + }) + + it('shows loading state on submit button', () => { + render( {}} submitText="Save" />) + // Button renders a spinner when loading; the text may be hidden or replaced + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + }) + + it('disables cancel when loading', () => { + render( + {}} + onCancel={() => {}} + cancelText="Cancel" + />, + ) + // Cancel button is disabled when loading + const buttons = screen.getAllByRole('button') + const cancelButton = buttons.find((b) => b.textContent === 'Cancel') + expect(cancelButton).toBeDisabled() + }) + + it('disables submit when disableSubmit is true', () => { + render( + {}} submitText="Save" disableSubmit />, + ) + expect(screen.getByRole('button')).toBeDisabled() + }) +}) diff --git a/frontend/src/components/help/index.tsx b/frontend/src/components/help/index.tsx index f951435d3..cbf33855f 100644 --- a/frontend/src/components/help/index.tsx +++ b/frontend/src/components/help/index.tsx @@ -142,6 +142,4 @@ const KeyboardShortcutKey = (props: { shortcut: KeyboardShortcut }) => { ) } -const Key = (props: { children: ReactNode }) => ( -
{props.children}
-) +const Key = (props: { children: ReactNode }) =>
{props.children}
diff --git a/frontend/src/components/http_cycle_viewer/index.tsx b/frontend/src/components/http_cycle_viewer/index.tsx index 332516c54..8ec66af26 100644 --- a/frontend/src/components/http_cycle_viewer/index.tsx +++ b/frontend/src/components/http_cycle_viewer/index.tsx @@ -1,6 +1,14 @@ import { useState, useRef, type MouseEvent } from 'react' import classnames from 'classnames/bind' -import { type Har, type Entry, type Log, type Header, type PostData, type Response, type Request } from 'har-format' +import { + type Har, + type Entry, + type Log, + type Header, + type PostData, + type Response, + type Request, +} from 'har-format' import { type EvidenceViewHint } from 'src/global_types' import { trimURL, clamp } from 'src/helpers' import { mimetypeToAceLang } from './helpers' @@ -200,10 +208,9 @@ const RequestTable = (props: { } } - const onRowSelected = - (index: number) => (e: MouseEvent) => { - props.setSelectedRow(props.selectedRow == index ? -1 : index) - } + const onRowSelected = (index: number) => (e: MouseEvent) => { + props.setSelectedRow(props.selectedRow == index ? -1 : index) + } return (
( diff --git a/frontend/src/components/modal/index.test.tsx b/frontend/src/components/modal/index.test.tsx new file mode 100644 index 000000000..b44596827 --- /dev/null +++ b/frontend/src/components/modal/index.test.tsx @@ -0,0 +1,80 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, within } from '@testing-library/react' +import { userEvent } from '@testing-library/user-event' +import Modal from './index' + +describe('Modal', () => { + it('renders the title', () => { + render( + {}}> + {'.'} + , + ) + expect(screen.getByText('My Modal')).toBeInTheDocument() + }) + + it('renders children', () => { + render( + {}}> +

Modal content here

+
, + ) + expect(screen.getByText('Modal content here')).toBeInTheDocument() + }) + + it('renders via a portal into document.body', () => { + const { baseElement } = render( + {}}> + portal child + , + ) + expect(within(baseElement).getByText('Portal Test')).toBeInTheDocument() + }) + + it('calls onRequestClose when the backdrop is clicked', async () => { + const user = userEvent.setup() + const onRequestClose = vi.fn() + render( + + + , + ) + const backdrop = document.body.querySelector('[aria-modal]')?.parentElement + if (backdrop) { + await user.pointer({ target: backdrop, keys: '[MouseLeft]' }) + } + expect(onRequestClose).toHaveBeenCalled() + }) + + it('does not call onRequestClose when the inner modal is clicked', async () => { + const user = userEvent.setup() + const onRequestClose = vi.fn() + render( + + + , + ) + await user.click(screen.getByText('No close')) + expect(onRequestClose).not.toHaveBeenCalled() + }) + + it('has role="dialog" and aria-modal on the dialog element', () => { + render( + {}}> + {'.'} + , + ) + const dialog = screen.getByRole('dialog') + expect(dialog).toHaveAttribute('aria-modal', 'true') + }) + + it('dialog is labelled by the title', () => { + render( + {}}> + {'.'} + , + ) + const dialog = screen.getByRole('dialog', { name: 'Labelled Modal' }) + expect(dialog).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/modal/index.tsx b/frontend/src/components/modal/index.tsx index af954bff8..bade71735 100644 --- a/frontend/src/components/modal/index.tsx +++ b/frontend/src/components/modal/index.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useRef, useEffect } from 'react' +import { type ReactNode, useId, useRef, useEffect } from 'react' import classnames from 'classnames/bind' import { createPortal } from 'react-dom' import { useFocusFirstFocusableChild } from 'src/helpers' @@ -10,7 +10,8 @@ export default function Modal(props: { title: string smallerWidth?: boolean }) { - const rootRef = useRef(null) + const titleId = useId() + const rootRef = useRef(null) useFocusFirstFocusableChild(rootRef) useEffect(() => { @@ -20,15 +21,20 @@ export default function Modal(props: { return () => { main.style.removeProperty('filter') } - }) + }, []) return createPortal(
e.stopPropagation()} > -

{props.title}

+

+ {props.title} +

{props.children}
, diff --git a/frontend/src/components/operation_badges/index.tsx b/frontend/src/components/operation_badges/index.tsx index d59197bd3..ad0215f5b 100644 --- a/frontend/src/components/operation_badges/index.tsx +++ b/frontend/src/components/operation_badges/index.tsx @@ -1,7 +1,11 @@ import classnames from 'classnames/bind' const cx = classnames.bind(require('./stylesheet')) -const OperationBadges = (props: { className?: string; numUsers: number; showDetailsModal: () => void }) => ( +const OperationBadges = (props: { + className?: string + numUsers: number + showDetailsModal: () => void +}) => (