diff --git a/frontend/src/components/AttackResults/attackAttemptFormatting.test.ts b/frontend/src/components/AttackResults/attackAttemptFormatting.test.ts index 7d7e20833d..e40824e624 100644 --- a/frontend/src/components/AttackResults/attackAttemptFormatting.test.ts +++ b/frontend/src/components/AttackResults/attackAttemptFormatting.test.ts @@ -1,4 +1,4 @@ -import { objectivePreview } from './attackAttemptFormatting' +import { formatAttemptTiming, formatRetries, formatTimestamp, objectivePreview } from './attackAttemptFormatting' const LONE_SURROGATE_REGEX = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { expect(objectivePreview('', 'seed-456')).toBe('Objective unavailable (seed-456)') }) }) + +describe('formatRetries', () => { + it('says when there were no retries and pluralizes the rest', () => { + expect(formatRetries(0)).toBe('No retries') + expect(formatRetries(1)).toBe('1 retry') + expect(formatRetries(3)).toBe('3 retries') + }) +}) + +describe('formatAttemptTiming', () => { + it('joins duration, retries, and timestamp on one line', () => { + const timestamp = '2026-01-01T00:04:30Z' + expect(formatAttemptTiming({ execution_time_ms: 270_000, total_retries: 0, timestamp })) + .toBe(`4m 30s · No retries · ${formatTimestamp(timestamp)}`) + expect(formatAttemptTiming({ execution_time_ms: 5_000, total_retries: 2, timestamp })) + .toBe(`5s · 2 retries · ${formatTimestamp(timestamp)}`) + }) +}) diff --git a/frontend/src/components/AttackResults/attackAttemptFormatting.ts b/frontend/src/components/AttackResults/attackAttemptFormatting.ts index 65862007f9..f57a083b62 100644 --- a/frontend/src/components/AttackResults/attackAttemptFormatting.ts +++ b/frontend/src/components/AttackResults/attackAttemptFormatting.ts @@ -76,6 +76,22 @@ export function formatDuration(milliseconds: number): string { return `${seconds}s` } +export function formatRetries(retries: number): string { + if (retries === 0) return 'No retries' + return `${retries} ${retries === 1 ? 'retry' : 'retries'}` +} + +/** One line of attempt timing, such as "4m 30s · No retries · Jan 1, 2026, 12:00:05 AM". */ +export function formatAttemptTiming( + attempt: Pick, +): string { + return [ + formatDuration(attempt.execution_time_ms), + formatRetries(attempt.total_retries), + formatTimestamp(attempt.timestamp), + ].join(' · ') +} + export function objectivePreview(objective: string | null, fallbackId: string): string { if (!objective) { return `Objective unavailable (${fallbackId})` diff --git a/frontend/src/components/Chat/AttachToFindingsButton.styles.ts b/frontend/src/components/Chat/AttachToFindingsButton.styles.ts new file mode 100644 index 0000000000..76db3b84dd --- /dev/null +++ b/frontend/src/components/Chat/AttachToFindingsButton.styles.ts @@ -0,0 +1,9 @@ +import { makeStyles } from '@fluentui/react-components' + +import { mobileTouchTargetHeight } from '@/styles/touchTargets' + +export const useAttachToFindingsButtonStyles = makeStyles({ + button: { + ...mobileTouchTargetHeight, + }, +}) diff --git a/frontend/src/components/Chat/AttachToFindingsButton.test.tsx b/frontend/src/components/Chat/AttachToFindingsButton.test.tsx new file mode 100644 index 0000000000..e40b4677d1 --- /dev/null +++ b/frontend/src/components/Chat/AttachToFindingsButton.test.tsx @@ -0,0 +1,75 @@ +import { FluentProvider, webLightTheme } from '@fluentui/react-components' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router' + +import { attacksApi, operationsApi } from '@/services/api' + +import AttachToFindingsButton from './AttachToFindingsButton' + +jest.mock('@/services/api', () => ({ + attacksApi: { getAttack: jest.fn() }, + operationsApi: { + get: jest.fn(), list: jest.fn(), listFindings: jest.fn(), attachFindingEvidence: jest.fn(), createFinding: jest.fn(), + getFindingOptions: jest.fn(), + }, +})) + +const OPERATION = { id: 'scan-operation', name: 'Scan operation', created_at: '2026-10-08T12:00:00Z' } +const FINDING = { + id: 'finding', operation_id: OPERATION.id, title: 'Proof', severity: 'low', description: '', + created_at: '2026-10-08T12:00:00Z', evidence_count: 0, +} + +function TestWrapper({ children }: { readonly children: React.ReactNode }) { + return {children} +} + +describe('AttachToFindingsButton', () => { + beforeEach(() => { + jest.clearAllMocks() + jest.mocked(attacksApi.getAttack).mockResolvedValue({ operation_id: null } as never) + jest.mocked(operationsApi.get).mockResolvedValue(OPERATION as never) + jest.mocked(operationsApi.list).mockResolvedValue({ items: [{ ...OPERATION, finding_counts: {} }] } as never) + jest.mocked(operationsApi.listFindings).mockResolvedValue({ items: [FINDING], has_more: false, next_offset: null } as never) + jest.mocked(operationsApi.attachFindingEvidence).mockResolvedValue({ item: { id: 'evidence' }, created: true } as never) + }) + + it('should attach the attack to a finding in the default operation and report it', async () => { + const user = userEvent.setup() + const onAttached = jest.fn() + render( + + ) + + const button = screen.getByRole('button', { name: 'Attach to findings' }) + await user.click(button) + expect(await screen.findByRole('dialog')).toBeInTheDocument() + expect(operationsApi.get).toHaveBeenCalledWith(OPERATION.id) + await user.click(await screen.findByRole('radio', { name: /Proof/ })) + await user.click(screen.getByRole('button', { name: 'Attach attack' })) + + await waitFor(() => { expect(onAttached).toHaveBeenCalledTimes(1) }) + expect(operationsApi.attachFindingEvidence).toHaveBeenCalledWith(OPERATION.id, FINDING.id, { attack_result_id: 'attack-1' }) + await waitFor(() => { expect(button).toHaveFocus() }) + }) + + it('should not report an attachment when the dialog is cancelled', async () => { + const user = userEvent.setup() + const onAttached = jest.fn() + render() + + await user.click(screen.getByRole('button', { name: 'Attach to findings' })) + await user.click(await screen.findByRole('button', { name: 'Cancel' })) + + await waitFor(() => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) + expect(onAttached).not.toHaveBeenCalled() + }) + + it('should explain why a running attack cannot be attached yet', () => { + render() + const button = screen.getByRole('button', { name: 'Attach to findings' }) + expect(button).toBeDisabled() + expect(button).toHaveAttribute('title', 'Available after the attack finishes') + }) +}) diff --git a/frontend/src/components/Chat/AttachToFindingsButton.tsx b/frontend/src/components/Chat/AttachToFindingsButton.tsx new file mode 100644 index 0000000000..8f5e06688a --- /dev/null +++ b/frontend/src/components/Chat/AttachToFindingsButton.tsx @@ -0,0 +1,37 @@ +import { useRef, useState } from 'react' + +import { Button } from '@fluentui/react-components' +import { AttachRegular } from '@fluentui/react-icons' + +import { useAttachToFindingsButtonStyles } from './AttachToFindingsButton.styles' +import FindingEvidenceDialog from './FindingEvidenceDialog' + +interface AttachToFindingsButtonProps { + readonly attackResultId: string + /** The Operation offered first when the attack has no Operation of its own. */ + readonly defaultOperationId?: string | null + /** Running attacks cannot be attached until they are saved. */ + readonly disabled?: boolean + /** Called after the attack is attached, such as to refresh `useAttackFindings`. */ + readonly onAttached?: () => void +} + +/** Opens the finding picker to attach one attack as evidence, returning focus to the button. */ +export default function AttachToFindingsButton({ + attackResultId, defaultOperationId = null, disabled = false, onAttached, +}: AttachToFindingsButtonProps) { + const styles = useAttachToFindingsButtonStyles() + const [open, setOpen] = useState(false) + const trigger = useRef(null) + return ( + <> + + + + ) +} diff --git a/frontend/src/components/Chat/FindingEvidenceDialog.tsx b/frontend/src/components/Chat/FindingEvidenceDialog.tsx index d38aa2e7eb..4162ee49c4 100644 --- a/frontend/src/components/Chat/FindingEvidenceDialog.tsx +++ b/frontend/src/components/Chat/FindingEvidenceDialog.tsx @@ -28,6 +28,8 @@ interface FindingEvidenceDialogProps { onOpenChange: (open: boolean) => void /** Receives focus when the dialog closes, such as the control that opened it. */ returnFocusRef?: React.RefObject + /** Called after the attack is linked to a finding, new or existing. */ + onAttached?: () => void } interface AttachmentSource { @@ -36,7 +38,7 @@ interface AttachmentSource { } export default function FindingEvidenceDialog({ - attackResultId, disabled, defaultOperationId = null, open, onOpenChange, returnFocusRef, + attackResultId, disabled, defaultOperationId = null, open, onOpenChange, returnFocusRef, onAttached, }: FindingEvidenceDialogProps) { const styles = useFindingEvidenceDialogStyles() const viewKey = attackResultId @@ -144,6 +146,7 @@ export default function FindingEvidenceDialog({ setCreating(false) onOpenChange(false) setNotice({ key: viewKey, text: `Finding created and attack attached: ${finding.title}` }) + onAttached?.() } const attach = async (): Promise => { @@ -158,6 +161,7 @@ export default function FindingEvidenceDialog({ }) setNotice({ key: viewKey, text: response.created ? 'Attached' : 'Already attached' }) onOpenChange(false) + onAttached?.() } catch (cause: unknown) { setAttachError(toApiError(cause).detail) } finally { diff --git a/frontend/src/components/Chat/MessageList.styles.ts b/frontend/src/components/Chat/MessageList.styles.ts index 5499dadbbc..3a8b59b384 100644 --- a/frontend/src/components/Chat/MessageList.styles.ts +++ b/frontend/src/components/Chat/MessageList.styles.ts @@ -235,50 +235,6 @@ export const useMessageListStyles = makeStyles({ textOverflow: 'ellipsis', whiteSpace: 'nowrap', }, - scoreSurface: { - display: 'flex', - flexDirection: 'column', - rowGap: tokens.spacingVerticalXS, - width: 'min(360px, calc(100vw - 64px))', - minWidth: 0, - maxWidth: '100%', - }, - scoreRow: { - display: 'flex', - alignItems: 'flex-start', - columnGap: tokens.spacingHorizontalS, - width: '100%', - minWidth: 0, - }, - scoreLabel: { - flex: '0 0 72px', - color: tokens.colorNeutralForeground2, - }, - scoreValue: { - display: 'block', - flex: '0 1 auto', - minWidth: 0, - maxWidth: '100%', - height: 'auto', - whiteSpace: 'normal', - overflowWrap: 'anywhere', - wordBreak: 'break-word', - }, - scoreRationale: { - display: 'flex', - flexDirection: 'column', - rowGap: tokens.spacingVerticalXXS, - marginTop: tokens.spacingVerticalXS, - paddingTop: tokens.spacingVerticalXS, - borderTop: `1px solid ${tokens.colorNeutralStroke2}`, - }, - scoreRationaleText: { - color: tokens.colorNeutralForeground2, - whiteSpace: 'pre-wrap', - wordBreak: 'break-word', - maxHeight: '30vh', - overflowY: 'auto', - }, loadingEllipsis: { fontSize: tokens.fontSizeBase500, animationName: { diff --git a/frontend/src/components/Chat/MessageList.tsx b/frontend/src/components/Chat/MessageList.tsx index 2d0c1f3650..82a29abbc3 100644 --- a/frontend/src/components/Chat/MessageList.tsx +++ b/frontend/src/components/Chat/MessageList.tsx @@ -31,9 +31,11 @@ import MarkdownContent from '@/components/Markdown/MarkdownContent' import MediaPreview from '@/components/Media/MediaPreview' import type { DisplayScore, Message, MessageAttachment } from '@/types' +import { scoreSummaryFromBackendScore, scoreSummaryValue } from '@/utils/scoreSummary' import { useMessageListStyles } from './MessageList.styles' import { formatStructuredMessage, getRenderMessagePieces } from './messageViewModel' +import ScoreDetails from './ScoreDetails' interface MessageListProps { onEditConversation?: () => void @@ -60,51 +62,13 @@ interface MessageListProps { } function scoreDisplayValue(score: DisplayScore): string { - // An undetermined score has no value, so name its status instead. - return score.score_value ?? score.status ?? '' + return scoreSummaryValue(scoreSummaryFromBackendScore(score)) } function scoreDisplayLabel(score: DisplayScore): string { return `${score.is_objective_score ? 'Final score' : 'Score'}: ${scoreDisplayValue(score)}` } -function ScoreDetails({ score, testId }: { score: DisplayScore; testId: string }) { - const styles = useMessageListStyles() - const categories = score.score_category?.filter(Boolean) ?? [] - - return ( -
- Score details -
- Score - {scoreDisplayValue(score)} -
-
- Scorer - {score.scorer_type} -
-
- Result role - - {score.is_objective_score ? 'Final score' : 'Supporting score'} - -
- {categories.length > 0 && ( -
- Category - {categories.join(', ')} -
- )} - {score.score_rationale && ( -
- Rationale - {score.score_rationale} -
- )} -
- ) -} - function MessageScore({ score, groupId, scoreIndex }: { score: DisplayScore; groupId: string | number; scoreIndex: number }) { const styles = useMessageListStyles() const displayValue = scoreDisplayValue(score) @@ -128,7 +92,7 @@ function MessageScore({ score, groupId, scoreIndex }: { score: DisplayScore; gro - + ) @@ -427,7 +391,7 @@ function MessageScores({ scores, groupId }: { scores: DisplayScore[]; groupId: s id={`message-score-panel-${groupId}`} aria-labelledby={`message-score-tab-${groupId}-${selectedIndex}`} > - + diff --git a/frontend/src/components/Chat/ScoreDetails.styles.ts b/frontend/src/components/Chat/ScoreDetails.styles.ts new file mode 100644 index 0000000000..c47dccc28d --- /dev/null +++ b/frontend/src/components/Chat/ScoreDetails.styles.ts @@ -0,0 +1,48 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +export const useScoreDetailsStyles = makeStyles({ + surface: { + display: 'flex', + flexDirection: 'column', + rowGap: tokens.spacingVerticalXS, + width: 'min(360px, calc(100vw - 64px))', + minWidth: 0, + maxWidth: '100%', + }, + row: { + display: 'flex', + alignItems: 'flex-start', + columnGap: tokens.spacingHorizontalS, + width: '100%', + minWidth: 0, + }, + label: { + flex: '0 0 72px', + color: tokens.colorNeutralForeground2, + }, + value: { + display: 'block', + flex: '0 1 auto', + minWidth: 0, + maxWidth: '100%', + height: 'auto', + whiteSpace: 'normal', + overflowWrap: 'anywhere', + wordBreak: 'break-word', + }, + rationale: { + display: 'flex', + flexDirection: 'column', + rowGap: tokens.spacingVerticalXXS, + marginTop: tokens.spacingVerticalXS, + paddingTop: tokens.spacingVerticalXS, + borderTop: `1px solid ${tokens.colorNeutralStroke2}`, + }, + rationaleText: { + color: tokens.colorNeutralForeground2, + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + maxHeight: '30vh', + overflowY: 'auto', + }, +}) diff --git a/frontend/src/components/Chat/ScoreDetails.test.tsx b/frontend/src/components/Chat/ScoreDetails.test.tsx new file mode 100644 index 0000000000..e162cc3bd0 --- /dev/null +++ b/frontend/src/components/Chat/ScoreDetails.test.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from 'react' + +import { FluentProvider, webLightTheme } from '@fluentui/react-components' +import { render, screen } from '@testing-library/react' + +import ScoreDetails from './ScoreDetails' + +function TestWrapper({ children }: { readonly children: ReactNode }) { + return {children} +} + +describe('ScoreDetails', () => { + it('should show the value, scorer, role, categories, and rationale', () => { + render() + + const details = screen.getByTestId('details') + expect(details).toHaveTextContent('Score details') + expect(screen.getByText('true')).toBeInTheDocument() + expect(screen.getByText('SelfAskRefusalScorer')).toBeInTheDocument() + expect(screen.getByText('Final score')).toBeInTheDocument() + expect(screen.getByText('jailbreak, harm')).toBeInTheDocument() + expect(screen.getByText('The target complied.')).toBeInTheDocument() + }) + + it('should name an undetermined score by its status and omit empty sections', () => { + render() + + expect(screen.getByText('undetermined')).toBeInTheDocument() + expect(screen.getByText('Supporting score')).toBeInTheDocument() + expect(screen.queryByText('Category')).not.toBeInTheDocument() + expect(screen.queryByText('Rationale')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/Chat/ScoreDetails.tsx b/frontend/src/components/Chat/ScoreDetails.tsx new file mode 100644 index 0000000000..49a721e5d1 --- /dev/null +++ b/frontend/src/components/Chat/ScoreDetails.tsx @@ -0,0 +1,49 @@ +import { Badge, Text } from '@fluentui/react-components' + +import type { ScoreSummary } from '@/types' +import { scoreSummaryValue } from '@/utils/scoreSummary' + +import { useScoreDetailsStyles } from './ScoreDetails.styles' + +interface ScoreDetailsProps { + readonly score: ScoreSummary + readonly testId?: string +} + +/** One score's value, scorer, role, categories, and rationale, as shown in a score popover. */ +export default function ScoreDetails({ score, testId }: ScoreDetailsProps) { + const styles = useScoreDetailsStyles() + const categories = score.categories?.filter(Boolean) ?? [] + + return ( +
+ Score details +
+ Score + {scoreSummaryValue(score)} +
+
+ Scorer + {score.scorer} +
+
+ Result role + + {score.isObjective ? 'Final score' : 'Supporting score'} + +
+ {categories.length > 0 && ( +
+ Category + {categories.join(', ')} +
+ )} + {score.rationale && ( +
+ Rationale + {score.rationale} +
+ )} +
+ ) +} diff --git a/frontend/src/components/DetailFacts.styles.ts b/frontend/src/components/DetailFacts.styles.ts new file mode 100644 index 0000000000..a9af12e1ec --- /dev/null +++ b/frontend/src/components/DetailFacts.styles.ts @@ -0,0 +1,39 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +import { NARROW_VIEWPORT_QUERY } from '@/styles/touchTargets' + +export const useDetailFactsStyles = makeStyles({ + facts: { + display: 'grid', + gridTemplateColumns: '9rem minmax(0, 1fr)', + alignItems: 'baseline', + gap: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalXL}`, + margin: 0, + fontSize: tokens.fontSizeBase300, + lineHeight: tokens.lineHeightBase300, + [NARROW_VIEWPORT_QUERY]: { + gridTemplateColumns: 'minmax(0, 1fr)', + rowGap: tokens.spacingVerticalXS, + }, + }, + primaryLabel: { + margin: 0, + color: tokens.colorNeutralForeground1, + }, + secondaryLabel: { + margin: 0, + color: tokens.colorNeutralForeground3, + }, + value: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: tokens.spacingVerticalS, + minWidth: 0, + margin: 0, + overflowWrap: 'anywhere', + [NARROW_VIEWPORT_QUERY]: { + paddingBottom: tokens.spacingVerticalM, + }, + }, +}) diff --git a/frontend/src/components/DetailFacts.test.tsx b/frontend/src/components/DetailFacts.test.tsx new file mode 100644 index 0000000000..e03d2d14b9 --- /dev/null +++ b/frontend/src/components/DetailFacts.test.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react' + +import { FluentProvider, Text, webLightTheme } from '@fluentui/react-components' +import { render, screen, within } from '@testing-library/react' + +import DetailFacts, { DetailFact } from './DetailFacts' + +function TestWrapper({ children }: { readonly children: ReactNode }) { + return {children} +} + +describe('DetailFacts', () => { + it('should pair each label with its value in a named description list', () => { + render( + + Extract the system prompt + Crescendo + + ) + + const list = screen.getByLabelText('Attack outcome') + expect(list.tagName).toBe('DL') + expect(within(list).getAllByRole('term').map((term) => term.textContent)).toEqual(['Objective:', 'Technique']) + expect(within(list).getAllByRole('definition').map((value) => value.textContent)).toEqual([ + 'Extract the system prompt', 'Crescendo', + ]) + }) +}) diff --git a/frontend/src/components/DetailFacts.tsx b/frontend/src/components/DetailFacts.tsx new file mode 100644 index 0000000000..91bb5c2b9d --- /dev/null +++ b/frontend/src/components/DetailFacts.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from 'react' + +import { Text, mergeClasses } from '@fluentui/react-components' + +import { useDetailFactsStyles } from './DetailFacts.styles' + +interface DetailFactsProps { + /** Accessible name of the list, when its heading is not visible. */ + readonly label?: string + readonly className?: string + readonly children: ReactNode +} + +/** An aligned label/value list that stacks labels above values on narrow viewports. */ +export default function DetailFacts({ label, className, children }: DetailFactsProps) { + const styles = useDetailFactsStyles() + return
{children}
+} + +interface DetailFactProps { + readonly label: string + /** Primary facts are the subject of the list; secondary facts describe it. */ + readonly tone?: 'primary' | 'secondary' + readonly valueClassName?: string + readonly children: ReactNode +} + +// Fluent Text's `as` does not accept dt/dd, so the native elements wrap Text. +export function DetailFact({ label, tone = 'secondary', valueClassName, children }: DetailFactProps) { + const styles = useDetailFactsStyles() + return ( + <> +
+ {label} +
+
{children}
+ + ) +} diff --git a/frontend/src/components/Home/Home.tsx b/frontend/src/components/Home/Home.tsx index 3b7b9daf0c..a645952097 100644 --- a/frontend/src/components/Home/Home.tsx +++ b/frontend/src/components/Home/Home.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react' import type { ReactNode } from 'react' import { useNavigate } from 'react-router' import { - Badge, Button, MessageBar, MessageBarBody, @@ -11,7 +10,6 @@ import { TabList, Text, } from '@fluentui/react-components' -import type { BadgeProps } from '@fluentui/react-components' import { AddRegular, ArrowRightRegular, @@ -24,26 +22,19 @@ import { } from '@fluentui/react-icons' import OutcomeBadge from '@/components/OutcomeBadge' +import ScanStatusBadge from '@/components/ScanStatusBadge' import type { ViewName } from '@/components/Sidebar/Navigation' import { useRuntime } from '@/hooks/useRuntime' import { useAutoRefresh } from '@/hooks/useAutoRefresh' import { attacksApi, operationsApi, scenariosApi } from '@/services/api' import { toApiError } from '@/services/errors' -import type { AttackSummary, Operation, OperationListItem, ScenarioRunListItem, ScenarioRunState } from '@/types' +import type { AttackSummary, Operation, OperationListItem, ScenarioRunListItem } from '@/types' import { scenarioRunRoutePath } from '@/utils/routeParams' import { useHomeStyles } from './Home.styles' const RECENT_WORK_LIMIT = 5 const VISIBLE_OPERATIONS_LIMIT = 3 -const SCAN_STATUS_LABELS: Record = { - CREATED: 'Created', QUEUED: 'Queued', IN_PROGRESS: 'Executing', - COMPLETED: 'Completed', FAILED: 'Failed', CANCELLED: 'Cancelled', -} -const SCAN_STATUS_COLORS: Record = { - CREATED: 'informative', QUEUED: 'informative', IN_PROGRESS: 'brand', - COMPLETED: 'success', FAILED: 'danger', CANCELLED: 'warning', -} interface HomeProps { readonly selectedOperation: Operation | null @@ -165,7 +156,7 @@ export default function Home({ title: scan.scenario_registry_name || scan.scenario_name, operationName: scan.operation_name, updatedAt: scan.updated_at, - badge: {SCAN_STATUS_LABELS[scan.status]}, + badge: , open: () => { void navigate(scenarioRunRoutePath(scan.scenario_result_id)) }, })), ].filter((item: RecentItem) => filter === 'all' || (filter === 'attacks' ? item.kind === 'Attack' : item.kind === 'Scan')) diff --git a/frontend/src/components/Operations/OperationParentLink.styles.ts b/frontend/src/components/Operations/OperationParentLink.styles.ts new file mode 100644 index 0000000000..dcc2326b29 --- /dev/null +++ b/frontend/src/components/Operations/OperationParentLink.styles.ts @@ -0,0 +1,32 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +import { mobileTouchTargetHeight } from '@/styles/touchTargets' + +export const useOperationParentLinkStyles = makeStyles({ + link: { + ...mobileTouchTargetHeight, + display: 'inline-flex', + alignItems: 'center', + alignSelf: 'flex-start', + gap: tokens.spacingHorizontalXS, + maxWidth: '100%', + color: tokens.colorNeutralForeground2, + fontSize: tokens.fontSizeBase200, + lineHeight: tokens.lineHeightBase200, + fontWeight: tokens.fontWeightSemibold, + textDecorationLine: 'none', + overflowWrap: 'anywhere', + ':hover': { + color: tokens.colorBrandForegroundLinkHover, + textDecorationLine: 'underline', + }, + ':focus-visible': { + outline: `${tokens.strokeWidthThick} solid ${tokens.colorStrokeFocus2}`, + borderRadius: tokens.borderRadiusSmall, + }, + }, + icon: { + flexShrink: 0, + fontSize: tokens.fontSizeBase400, + }, +}) diff --git a/frontend/src/components/Operations/OperationParentLink.test.tsx b/frontend/src/components/Operations/OperationParentLink.test.tsx new file mode 100644 index 0000000000..ff88704f72 --- /dev/null +++ b/frontend/src/components/Operations/OperationParentLink.test.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react' + +import { FluentProvider, webLightTheme } from '@fluentui/react-components' +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router' + +import OperationParentLink from './OperationParentLink' + +function TestWrapper({ children }: { readonly children: ReactNode }) { + return {children} +} + +describe('OperationParentLink', () => { + it('should link to the operation by name', () => { + render() + const link = screen.getByRole('link', { name: 'Operation: Spring review' }) + expect(link).toHaveAttribute('href', '/operations/operation%2F1') + expect(link).toHaveTextContent('Spring review') + }) + + it('should still link when the operation name is unknown', () => { + render() + expect(screen.getByRole('link', { name: 'Operation: View operation' })).toHaveAttribute('href', '/operations/operation-1') + }) + + it.each([undefined, null, ''])('should render nothing without an operation id (%p)', (id: string | null | undefined) => { + render() + expect(screen.queryByRole('link')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/Operations/OperationParentLink.tsx b/frontend/src/components/Operations/OperationParentLink.tsx new file mode 100644 index 0000000000..a70d8958d7 --- /dev/null +++ b/frontend/src/components/Operations/OperationParentLink.tsx @@ -0,0 +1,22 @@ +import { BriefcaseRegular } from '@fluentui/react-icons' +import { Link } from 'react-router' + +import { useOperationParentLinkStyles } from './OperationParentLink.styles' + +interface OperationParentLinkProps { + readonly id?: string | null + readonly name?: string | null +} + +/** The Operation an item belongs to, shown as small parent context above its title. */ +export default function OperationParentLink({ id, name }: OperationParentLinkProps) { + const styles = useOperationParentLinkStyles() + if (!id) return null + const label = name || 'View operation' + return ( + +