Skip to content
Open
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
5 changes: 4 additions & 1 deletion frontend/src/authschemes/webauthn/helpers.ts
Original file line number Diff line number Diff line change
@@ -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))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

import BulletChooser, { type BulletProps } from 'src/components/bullet_chooser'
import { type SupportedEvidenceType } from 'src/global_types'

Expand Down
4 changes: 1 addition & 3 deletions frontend/src/components/bullet_chooser/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,7 @@ export type BulletRendererProps<T extends BulletProps> = {
selected?: boolean
}

export type BulletRenderer<T extends BulletProps> = (
props: BulletRendererProps<T>,
) => ReactNode
export type BulletRenderer<T extends BulletProps> = (props: BulletRendererProps<T>) => ReactNode

function StandardBulletRenderer<T extends BulletProps>(props: BulletRendererProps<T>) {
return (
Expand Down
1 change: 0 additions & 1 deletion frontend/src/components/bullet_chooser/text_chooser.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

import BulletChooser, { type BulletProps } from 'src/components/bullet_chooser'
import Tag from 'src/components/tag'

Expand Down
6 changes: 1 addition & 5 deletions frontend/src/components/chooser/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,7 @@ export default function <T extends { uuid: string }>(props: {
)
}

const Row = (props: {
selected: boolean
onChange: (v: boolean) => void
children: ReactNode
}) => (
const Row = (props: { selected: boolean; onChange: (v: boolean) => void; children: ReactNode }) => (
<div
className={cx('row', { selected: props.selected })}
onClick={() => props.onChange(!props.selected)}
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/components/code_block/ace_editor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -84,9 +86,7 @@ function useSizeOfParentContainer(parentRef: MutableRefObject<HTMLDivElement | n
// are a few places where we listen to keydown events higher up in the dom e.g. timeline.
// Since search field is self-contained within the ace-editor it seems reasonable that
// a user typing in the search field shouldn't emit keydown events up the dom.
function useStopPropagationOfSearchKeydowns(
parentRef: MutableRefObject<HTMLDivElement | null>,
) {
function useStopPropagationOfSearchKeydowns(parentRef: MutableRefObject<HTMLDivElement | null>) {
const onKeyDown = (e: KeyboardEvent) => {
if (e.target && (e.target as HTMLElement).className === 'ace_search_field') {
e.stopPropagation()
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/components/date_range_picker/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ const DropDown = (props: {
</div>
)

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 (
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/components/error_boundary/index.tsx
Original file line number Diff line number Diff line change
@@ -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<Props, State> {
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 <ErrorDisplay err={this.state.error} />
}
return this.props.children
}
}
Comment on lines +7 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 ErrorBoundary has no recovery mechanism

The ErrorBoundary component sets state.error when an error is caught via getDerivedStateFromError, but provides no way to reset the state back to null. Once triggered, the user is permanently stuck on the <ErrorDisplay> with no navigation or retry button. A common pattern is to reset on location change (via componentDidUpdate checking a key prop or location from the router) or to provide a "Try Again" button that calls this.setState({ error: null }). Since this boundary wraps the entire app at the root, recovery is especially important.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

112 changes: 112 additions & 0 deletions frontend/src/components/form/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Form result={null} loading={false} onSubmit={() => {}}>
<input placeholder="Name" />
</Form>,
)
expect(screen.getByPlaceholderText('Name')).toBeInTheDocument()
})

it('renders submit button when submitText is provided', () => {
render(<Form result={null} loading={false} onSubmit={() => {}} submitText="Save" />)
expect(screen.getByText('Save')).toBeInTheDocument()
})

it('does not render submit button when submitText is absent', () => {
render(<Form result={null} loading={false} onSubmit={() => {}} />)
expect(screen.queryByRole('button')).toBeNull()
})

it('renders cancel button when onCancel is provided', () => {
render(
<Form
result={null}
loading={false}
onSubmit={() => {}}
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(
<Form result={null} loading={false} onSubmit={onSubmit} submitText="Go">
<input />
</Form>,
)
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(
<Form
result={null}
loading={false}
onSubmit={() => {}}
onCancel={onCancel}
cancelText="Cancel"
/>,
)
await user.click(screen.getByText('Cancel'))
expect(onCancel).toHaveBeenCalled()
})

it('displays an error result message', () => {
render(
<Form
result={{ err: new Error('Something went wrong') }}
loading={false}
onSubmit={() => {}}
/>,
)
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
})

it('displays a success result message', () => {
render(<Form result={{ success: 'Saved!' }} loading={false} onSubmit={() => {}} />)
expect(screen.getByText('Saved!')).toBeInTheDocument()
})

it('shows loading state on submit button', () => {
render(<Form result={null} loading={true} onSubmit={() => {}} 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(
<Form
result={null}
loading={true}
onSubmit={() => {}}
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(
<Form result={null} loading={false} onSubmit={() => {}} submitText="Save" disableSubmit />,
)
expect(screen.getByRole('button')).toBeDisabled()
})
})
4 changes: 1 addition & 3 deletions frontend/src/components/help/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,4 @@ const KeyboardShortcutKey = (props: { shortcut: KeyboardShortcut }) => {
)
}

const Key = (props: { children: ReactNode }) => (
<div className={cx('key')}>{props.children}</div>
)
const Key = (props: { children: ReactNode }) => <div className={cx('key')}>{props.children}</div>
17 changes: 12 additions & 5 deletions frontend/src/components/http_cycle_viewer/index.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -200,10 +208,9 @@ const RequestTable = (props: {
}
}

const onRowSelected =
(index: number) => (e: MouseEvent<HTMLTableRowElement>) => {
props.setSelectedRow(props.selectedRow == index ? -1 : index)
}
const onRowSelected = (index: number) => (e: MouseEvent<HTMLTableRowElement>) => {
props.setSelectedRow(props.selectedRow == index ? -1 : index)
}

return (
<div
Expand Down
1 change: 0 additions & 1 deletion frontend/src/components/markdown_renderer/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

import SyncMarkdownRenderer from './sync_renderer'

const MarkdownRenderer = (props: { className?: string; children: string }) => (
Expand Down
80 changes: 80 additions & 0 deletions frontend/src/components/modal/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Modal title="My Modal" onRequestClose={() => {}}>
{'.'}
</Modal>,
)
expect(screen.getByText('My Modal')).toBeInTheDocument()
})

it('renders children', () => {
render(
<Modal title="Test" onRequestClose={() => {}}>
<p>Modal content here</p>
</Modal>,
)
expect(screen.getByText('Modal content here')).toBeInTheDocument()
})

it('renders via a portal into document.body', () => {
const { baseElement } = render(
<Modal title="Portal Test" onRequestClose={() => {}}>
<span>portal child</span>
</Modal>,
)
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(
<Modal title="Close test" onRequestClose={onRequestClose}>
<button>inside</button>
</Modal>,
)
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(
<Modal title="No close" onRequestClose={onRequestClose}>
<button>inside modal</button>
</Modal>,
)
await user.click(screen.getByText('No close'))
expect(onRequestClose).not.toHaveBeenCalled()
})

it('has role="dialog" and aria-modal on the dialog element', () => {
render(
<Modal title="ARIA test" onRequestClose={() => {}}>
{'.'}
</Modal>,
)
const dialog = screen.getByRole('dialog')
expect(dialog).toHaveAttribute('aria-modal', 'true')
})

it('dialog is labelled by the title', () => {
render(
<Modal title="Labelled Modal" onRequestClose={() => {}}>
{'.'}
</Modal>,
)
const dialog = screen.getByRole('dialog', { name: 'Labelled Modal' })
expect(dialog).toBeInTheDocument()
})
})
14 changes: 10 additions & 4 deletions frontend/src/components/modal/index.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -10,7 +10,8 @@ export default function Modal(props: {
title: string
smallerWidth?: boolean
}) {
const rootRef = useRef(null)
const titleId = useId()
const rootRef = useRef<HTMLDivElement | null>(null)
useFocusFirstFocusableChild(rootRef)

useEffect(() => {
Expand All @@ -20,15 +21,20 @@ export default function Modal(props: {
return () => {
main.style.removeProperty('filter')
}
})
}, [])

return createPortal(
<div className={cx('root')} onMouseDown={props.onRequestClose} ref={rootRef}>
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className={cx('modal', props.smallerWidth ? 'smaller-width' : '')}
onMouseDown={(e) => e.stopPropagation()}
>
<h1 className={cx('title')}>{props.title}</h1>
<h1 id={titleId} className={cx('title')}>
{props.title}
</h1>
<div className={cx('content')}>{props.children}</div>
</div>
</div>,
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/components/operation_badges/index.tsx
Original file line number Diff line number Diff line change
@@ -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
}) => (
<button className={cx('root', props.className)} onClick={() => props.showDetailsModal()}>
<div
className={cx('icon', 'users')}
Expand Down
Loading
Loading