diff --git a/frontend/README.md b/frontend/README.md index ead30c2f00..690cce7374 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -21,6 +21,14 @@ refreshes do not override your open/closed choice. Creating, copying, or repeati conversations still opens the desktop drawer. On narrow screens the drawer opens only when requested and closes after selecting a conversation. +## Dataset catalog + +The **Datasets** page shows summaries of datasets already loaded into the backend's +memory. When memory is empty, **Learn how to load datasets into memory** opens the +[dataset-loading guide](https://microsoft.github.io/PyRIT/latest/code/datasets/loading-datasets/). +Use the same memory as the backend, then select **Refresh** in the catalog. +Provider-loading controls and individual-example browsing are not available yet. + ## Resuming a scenario run Failed runs offer **Resume run** on the run page and **Resume** in Scanner History. diff --git a/frontend/e2e/datasets.spec.ts b/frontend/e2e/datasets.spec.ts new file mode 100644 index 0000000000..2471155c54 --- /dev/null +++ b/frontend/e2e/datasets.spec.ts @@ -0,0 +1,142 @@ +import { expect, test, type Page } from "./_fixtures"; +import { mockVersion } from "./_compatibility"; +import { READY_RUNTIME } from "./_runtime"; + +const LONG_HARM_CATEGORY = "cybersecurity_credentials_and_privileged_information_disclosure"; + +const DATASET = { + name: "harmbench", + selection_key: "dataset:named:harmbench", + loaded: true, + provider_available: true, + logical_examples: 12, + seed_pieces: 12, + objectives: 1, + modalities: ["text"], + harm_categories: [LONG_HARM_CATEGORY], + has_unlabeled_harm_categories: false, +}; + +const NEIGHBOR_DATASET = { + ...DATASET, + name: "neighbor", + selection_key: "dataset:named:neighbor", +}; + +async function mockDatasetCatalog(page: Page): Promise { + await page.route("**/api/**", async (route) => { + const apiPath = new URL(route.request().url()).pathname.replace(/^\/api/, ""); + const body = (payload: unknown) => ({ + status: 200, + contentType: "application/json", + body: JSON.stringify(payload), + }); + + if (apiPath === "/datasets") { + await route.fulfill(body({ items: [DATASET, NEIGHBOR_DATASET] })); + return; + } + if (apiPath === "/runtime") { + await route.fulfill(body(READY_RUNTIME)); + return; + } + if (apiPath === "/version") { + await route.fulfill(body(mockVersion())); + return; + } + if (apiPath === "/auth/config") { + await route.fulfill(body({ clientId: "", tenantId: "", allowedGroupIds: "" })); + return; + } + if (apiPath === "/auth/access") { + await route.fulfill(body({ isAdmin: false })); + return; + } + if (apiPath === "/health") { + await route.fulfill(body({ status: "healthy" })); + return; + } + if (apiPath === "/targets" || apiPath === "/converters" || apiPath === "/converters/types") { + await route.fulfill(body({ items: [] })); + return; + } + if (apiPath === "/targets/types") { + await route.fulfill(body({ items: [] })); + return; + } + if (apiPath === "/labels") { + await route.fulfill(body({ source: "attacks", labels: {} })); + return; + } + + throw new Error(`Unhandled dataset catalog request: ${route.request().method()} ${apiPath}`); + }); +} + +async function expectLongLabelStaysInsideCard(page: Page): Promise { + const card = page.getByRole("article", { name: "harmbench" }); + await expect(card).toBeVisible(); + await expect(card.getByText(LONG_HARM_CATEGORY)).toBeVisible(); + + const metrics = await card.evaluate((article, label) => { + const badge = Array.from(article.querySelectorAll("*")).find((element) => element.textContent === label); + const textHost = badge ?? article; + const range = document.createRange(); + range.selectNodeContents(textHost); + const text = range.getBoundingClientRect(); + const cardBox = article.getBoundingClientRect(); + const badgeBox = textHost.getBoundingClientRect(); + return { + cardWidth: cardBox.width, + cardRight: cardBox.right, + cardLeft: cardBox.left, + cardTop: cardBox.top, + cardBottom: cardBox.bottom, + textLeft: text.left, + textRight: text.right, + textTop: text.top, + textBottom: text.bottom, + badgeHeight: badgeBox.height, + scrollWidth: textHost.scrollWidth, + clientWidth: textHost.clientWidth, + }; + }, LONG_HARM_CATEGORY); + + expect(metrics.cardWidth).toBeLessThanOrEqual(360); + expect(metrics.textLeft).toBeGreaterThanOrEqual(metrics.cardLeft - 1); + expect(metrics.textRight).toBeLessThanOrEqual(metrics.cardRight + 1); + expect(metrics.textTop).toBeGreaterThanOrEqual(metrics.cardTop - 1); + expect(metrics.textBottom).toBeLessThanOrEqual(metrics.cardBottom + 1); + expect(metrics.badgeHeight).toBeGreaterThan(20); + expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth + 1); +} + +test.describe("dataset catalog layout", () => { + test.beforeEach(async ({ page }) => { + await mockDatasetCatalog(page); + }); + + test("wraps a long harm category inside a multi-column desktop catalog", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 1050 }); + await page.goto("/datasets"); + await expectLongLabelStaysInsideCard(page); + + const cards = page.getByRole("article"); + await expect(cards).toHaveCount(2); + const [first, second] = await cards.evaluateAll((articles: Element[]) => + articles.map((article: Element) => { + const { left, right, top } = article.getBoundingClientRect(); + return { left, right, top }; + }), + ); + expect(first.top).toBe(second.top); + expect(second.left).toBeGreaterThan(first.right); + }); + + test("wraps a long harm category inside the card on a 390px mobile viewport", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/datasets"); + await expectLongLabelStaysInsideCard(page); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); + }); +}); diff --git a/frontend/e2e/scenario-history.spec.ts b/frontend/e2e/scenario-history.spec.ts index 1abb526004..0cfc087e49 100644 --- a/frontend/e2e/scenario-history.spec.ts +++ b/frontend/e2e/scenario-history.spec.ts @@ -535,13 +535,14 @@ test.describe("Scenario catalog, history, and live run routing", () => { const primaryNavigation = page.getByRole("navigation", { name: "Primary" }); const primaryButtons = primaryNavigation.getByRole("button"); await expect(primaryNavigation.getByRole("button", { name: "Configuration" })).toBeVisible(); - await expect(primaryButtons).toHaveCount(6); + await expect(primaryButtons).toHaveCount(7); expect(await primaryButtons.evaluateAll((buttons) => buttons.map((button) => button.getAttribute("aria-label")))).toEqual([ "Home", "Chat", "History", "Scanner", + "Datasets", "Registry", "Configuration", ]); diff --git a/frontend/e2e/touch-targets.spec.ts b/frontend/e2e/touch-targets.spec.ts index 6185c1251b..d75603ed57 100644 --- a/frontend/e2e/touch-targets.spec.ts +++ b/frontend/e2e/touch-targets.spec.ts @@ -374,6 +374,27 @@ async function installTouchTargetMocks(page: Page): Promise { await route.fulfill(jsonResponse({ converter_types: [] })); return; } + if (apiPath === "/datasets") { + await route.fulfill( + jsonResponse({ + items: [ + { + name: "harmbench", + selection_key: "dataset:named:harmbench", + loaded: true, + provider_available: true, + logical_examples: 12, + seed_pieces: 12, + objectives: 1, + modalities: ["text"], + harm_categories: ["hate"], + has_unlabeled_harm_categories: false, + }, + ], + }) + ); + return; + } if (apiPath === "/scenarios/catalog" || apiPath === "/scenarios/runs") { await route.fulfill( jsonResponse({ @@ -751,6 +772,16 @@ test.describe("Mobile touch targets", () => { await expectNoDocumentOverflow(page); }); + test("keeps closed dataset filters at least 44px", async ({ page }) => { + await page.goto("/datasets"); + await expect(page.getByRole("heading", { name: "Datasets" })).toBeVisible(); + + await expectMinimumTouchTarget(page.getByRole("combobox", { name: "Modalities" })); + await expectMinimumTouchTarget(page.getByRole("combobox", { name: "Harm categories" })); + await expectMinimumTouchTarget(page.getByRole("combobox", { name: "Sort datasets" })); + await expectNoDocumentOverflow(page); + }); + test("keeps every tour action at least 44px without changing containment", async ({ page, }) => { @@ -886,4 +917,10 @@ test("preserves compact desktop controls and existing sidebar dimensions", async await openAttack.scrollIntoViewIfNeeded(); await expectCompactDesktopTarget(openAttack); await expectCompactDesktopTarget(page.getByTestId("next-page-btn")); + + await page.goto("/datasets"); + await expect(page.getByRole("heading", { name: "Datasets" })).toBeVisible(); + await expectCompactDesktopTarget(page.getByRole("combobox", { name: "Modalities" })); + await expectCompactDesktopTarget(page.getByRole("combobox", { name: "Harm categories" })); + await expectCompactDesktopTarget(page.getByRole("combobox", { name: "Sort datasets" })); }); diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index f86bddcfbf..e856f9e0db 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -160,6 +160,9 @@ jest.mock("./components/Layout/MainLayout", () => { + {children} ); @@ -386,6 +389,33 @@ jest.mock("./components/Home/Home", () => { }; }); +jest.mock("./components/Datasets/DatasetCatalog", () => { + const MockDatasetCatalog = () =>
; + MockDatasetCatalog.displayName = "MockDatasetCatalog"; + return { + __esModule: true, + default: MockDatasetCatalog, + }; +}); + +jest.mock("./components/Datasets/DatasetSelection", () => { + const { useLocation } = jest.requireActual("react-router"); + const MockDatasetSelection = () => { + const location = useLocation(); + return ( +
+ ); + }; + MockDatasetSelection.displayName = "MockDatasetSelection"; + return { + __esModule: true, + default: MockDatasetSelection, + }; +}); + jest.mock("./components/Scenarios/ScenarioCatalog", () => { const MockScenarioCatalog = () =>
; MockScenarioCatalog.displayName = "MockScenarioCatalog"; @@ -637,6 +667,41 @@ describe("App", () => { expect(screen.getByTestId("attack-history")).toBeInTheDocument(); }); + it("renders the dataset catalog when deep-linked to /datasets", () => { + renderApp("/datasets"); + + expect(screen.getByTestId("main-layout")).toHaveAttribute( + "data-current-view", + "datasets" + ); + expect(screen.getByTestId("dataset-catalog")).toBeInTheDocument(); + }); + + it("keeps the dataset selection query on the datasets view", () => { + renderApp("/datasets/detail?selection_key=dataset%3Aunnamed"); + + expect(screen.getByTestId("main-layout")).toHaveAttribute( + "data-current-view", + "datasets" + ); + expect(screen.getByTestId("dataset-selection")).toHaveAttribute( + "data-location", + "/datasets/detail?selection_key=dataset%3Aunnamed" + ); + }); + + it("switches to the datasets view via the sidebar", () => { + renderApp(); + + fireEvent.click(screen.getByTestId("nav-datasets")); + + expect(screen.getByTestId("main-layout")).toHaveAttribute( + "data-current-view", + "datasets" + ); + expect(screen.getByTestId("dataset-catalog")).toBeInTheDocument(); + }); + it("renders the scenario catalog when deep-linked to /scanner", () => { renderApp("/scanner"); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fed3129f4e..ccd71ceb99 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,8 @@ import ScenarioHistory from './components/History/ScenarioHistory' import ScenarioCatalog from './components/Scenarios/ScenarioCatalog' import ScenarioDetail from './components/Scenarios/ScenarioDetail' import ScenarioRunPage from './components/Scenarios/ScenarioRunPage' +import DatasetCatalog from './components/Datasets/DatasetCatalog' +import DatasetSelection from './components/Datasets/DatasetSelection' import FeedbackDialog from './components/Feedback/FeedbackDialog' import type { HistoryFilters } from './components/History/historyFilters' import { ConnectionBanner } from './components/ConnectionBanner' @@ -54,6 +56,8 @@ import { useTour } from './hooks/useTour' import { attackConversationRoutePath, attackRoutePath, + DATASET_DETAIL_PATH, + DATASETS_PATH, routerPathParamValue, scenarioRunProvenance, scenarioRunRoutePath, @@ -70,6 +74,7 @@ const VIEW_PATHS: Record = { history: HISTORY_ATTACKS_PATH, registry: '/registry/targets', scenarios: '/scanner', + datasets: DATASETS_PATH, configuration: '/config', } @@ -92,6 +97,12 @@ function viewFromPath(pathname: string): ViewName { ) { return 'scenarios' } + if ( + pathname === VIEW_PATHS.datasets + || pathname.startsWith(`${VIEW_PATHS.datasets}/`) + ) { + return 'datasets' + } const match = (Object.entries(VIEW_PATHS) as [ViewName, string][]).find( ([, path]) => path === pathname, ) @@ -735,6 +746,8 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { } /> } /> + } /> + } /> {NONE_LABEL} + } + return ( +
+ {values.map((value, index) => ( + {value} + ))} + {unlabeled && ( + {NOT_LABELED_LABEL} + )} +
+ ) +} + +/** Summary card built only from one catalog record. */ +export default function DatasetCard({ dataset, linked }: DatasetCardProps) { + const styles = useDatasetCardStyles() + const location = useLocation() + const titleId = useId() + const descriptionId = useId() + const catalogPath = `${location.pathname}${location.search}` + + return ( +
+ {linked && ( +

+ + {dataset.name} + +

+ )} + {linked && ( + + Opens this dataset. Individual examples are not yet listed in this view. We are still building this experience. + + )} +
+
+
Logical examples
+
{formatDatasetCount(dataset.logical_examples)}
+
+
+
Seed pieces
+
{formatDatasetCount(dataset.seed_pieces)}
+
+
+
Objectives
+
{formatDatasetCount(dataset.objectives)}
+
+
+
+ Modalities + +
+
+ Harm categories + +
+
+ ) +} diff --git a/frontend/src/components/Datasets/DatasetCatalog.styles.ts b/frontend/src/components/Datasets/DatasetCatalog.styles.ts new file mode 100644 index 0000000000..8570982e8f --- /dev/null +++ b/frontend/src/components/Datasets/DatasetCatalog.styles.ts @@ -0,0 +1,116 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +import { + mobileTouchTarget, + mobileTouchTargetHeight, + NARROW_VIEWPORT_QUERY, + TOUCH_INPUT_QUERY, + MINIMUM_TOUCH_TARGET_SIZE, +} from '@/styles/touchTargets' +import { WORKSPACE_CANVAS_BACKGROUND } from '@/styles/workspaceBackground' + +export const useDatasetCatalogStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + height: '100%', + width: '100%', + minWidth: 0, + padding: tokens.spacingVerticalXXL, + overflowX: 'hidden', + overflowY: 'auto', + backgroundColor: WORKSPACE_CANVAS_BACKGROUND, + [NARROW_VIEWPORT_QUERY]: { + padding: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalM}`, + }, + }, + header: { + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'space-between', + flexWrap: 'wrap', + gap: tokens.spacingVerticalL, + marginBottom: tokens.spacingVerticalL, + [NARROW_VIEWPORT_QUERY]: { + flexDirection: 'column', + alignItems: 'stretch', + }, + }, + headerText: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXS, + minWidth: 0, + }, + subtitle: { + maxWidth: '70ch', + color: tokens.colorNeutralForeground3, + }, + filters: { + display: 'flex', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalS, + alignItems: 'center', + marginBottom: tokens.spacingVerticalL, + [NARROW_VIEWPORT_QUERY]: { + alignItems: 'stretch', + }, + }, + search: { + minWidth: '16rem', + [NARROW_VIEWPORT_QUERY]: { + minWidth: 0, + flex: '1 1 100%', + }, + [TOUCH_INPUT_QUERY]: { + minHeight: MINIMUM_TOUCH_TARGET_SIZE, + }, + }, + filterControl: { + minWidth: '12rem', + ...mobileTouchTargetHeight, + '& > input, & select': { + [TOUCH_INPUT_QUERY]: { + height: MINIMUM_TOUCH_TARGET_SIZE, + minHeight: MINIMUM_TOUCH_TARGET_SIZE, + }, + }, + [NARROW_VIEWPORT_QUERY]: { + minWidth: 0, + flex: '1 1 12rem', + }, + }, + touchTarget: { + ...mobileTouchTarget, + }, + centeredState: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: tokens.spacingVerticalM, + padding: tokens.spacingVerticalXXXL, + textAlign: 'center', + color: tokens.colorNeutralForeground3, + }, + guideLink: { + display: 'inline-flex', + alignItems: 'center', + ...mobileTouchTargetHeight, + }, + resultCount: { + margin: `0 0 ${tokens.spacingVerticalS}`, + color: tokens.colorNeutralForeground3, + }, + grid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(18rem, 1fr))', + gap: tokens.spacingHorizontalL, + margin: 0, + padding: 0, + listStyleType: 'none', + [NARROW_VIEWPORT_QUERY]: { + gridTemplateColumns: 'minmax(0, 1fr)', + }, + }, +}) diff --git a/frontend/src/components/Datasets/DatasetCatalog.test.tsx b/frontend/src/components/Datasets/DatasetCatalog.test.tsx new file mode 100644 index 0000000000..ef53e38411 --- /dev/null +++ b/frontend/src/components/Datasets/DatasetCatalog.test.tsx @@ -0,0 +1,489 @@ +import { useState } from 'react' +import { act, render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' +import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router' + +import { apiClient } from '@/services/api' +import type { DatasetInfo } from '@/types' + +import { DATASET_DETAIL_PATH, datasetDetailPath } from '@/utils/routeParams' + +import DatasetCatalog from './DatasetCatalog' +import DatasetSelection from './DatasetSelection' + +let mockGet: jest.SpyInstance + +function mockCatalog(items: DatasetInfo[]): void { + mockGet.mockResolvedValue({ data: { items } }) +} + +function expectOnlyLoadedDatasetGets(): void { + expect(mockGet.mock.calls.length).toBeGreaterThan(0) + for (const call of mockGet.mock.calls) { + expect(call[0]).toBe('/datasets') + expect(call[1]).toEqual(expect.objectContaining({ + params: { loaded_only: true }, + })) + } +} + +function makeDataset( + overrides: Partial & Pick, +): DatasetInfo { + return { + loaded: true, + provider_available: false, + logical_examples: 2, + seed_pieces: 3, + objectives: 1, + modalities: ['text'], + harm_categories: ['hate'], + has_unlabeled_harm_categories: false, + ...overrides, + } +} + +function Harness({ initialPath }: { initialPath: string }) { + return ( + + + + + + ) +} + +function RefreshableRoutes() { + const [epoch, setEpoch] = useState(0) + const location = useLocation() + const navigate = useNavigate() + return ( + <> + + } /> + } /> + + {`${location.pathname}${location.search}${location.hash}`} + + + + + ) +} + +function currentUrl(): URL { + return new URL(screen.getByLabelText('Current URL').textContent ?? '', 'http://localhost') +} + +function countValue(card: HTMLElement, label: string): string { + const term = within(card).getByText(label) + return term.parentElement?.querySelector('dd')?.textContent ?? '' +} + +const LOADED = makeDataset({ + name: 'harmbench', + selection_key: 'dataset:named:harmbench', + provider_available: true, + logical_examples: 1250000, + seed_pieces: 1400000, + objectives: 12, + modalities: ['text', 'image_path'], + harm_categories: ['hate'], + has_unlabeled_harm_categories: true, +}) + +const UNNAMED = makeDataset({ + name: '(unnamed)', + selection_key: 'dataset:unnamed', + logical_examples: 4, + seed_pieces: 4, + objectives: 0, + modalities: [], + harm_categories: [], + has_unlabeled_harm_categories: true, +}) + +const NAMED_UNNAMED = makeDataset({ + name: '__unnamed__', + selection_key: 'dataset:named:__unnamed__', + logical_examples: 1, + seed_pieces: 1, + objectives: null, + modalities: ['audio_path'], + harm_categories: [], + has_unlabeled_harm_categories: false, +}) + +describe('DatasetCatalog', () => { + beforeEach(() => { + mockGet = jest.spyOn(apiClient, 'get') + mockCatalog([LOADED, UNNAMED, NAMED_UNNAMED]) + }) + + afterEach(() => { + expectOnlyLoadedDatasetGets() + jest.restoreAllMocks() + }) + + it('shows a loading state while the catalog request is in flight', () => { + mockGet.mockReturnValue(new Promise(() => {})) + render() + expect(screen.getByText('Loading datasets...')).toBeInTheDocument() + }) + + it('renders summary cards from the catalog response', async () => { + render() + + const loaded = await screen.findByRole('article', { name: 'harmbench' }) + expect(screen.getByRole('heading', { name: 'Datasets' })).toBeInTheDocument() + expect(within(loaded).getByRole('heading', { level: 2, name: 'harmbench' })).toBeInTheDocument() + expect(within(loaded).getByRole('link', { name: 'harmbench' })).toHaveAttribute( + 'href', + datasetDetailPath('dataset:named:harmbench'), + ) + expect(within(loaded).getByRole('link', { name: 'harmbench' })).toHaveAccessibleDescription( + 'Opens this dataset. Individual examples are not yet listed in this view. We are still building this experience.', + ) + expect(within(loaded).queryByText('Loaded')).not.toBeInTheDocument() + expect(within(loaded).queryByText('Not loaded')).not.toBeInTheDocument() + expect(within(loaded).queryByText('Provider available')).not.toBeInTheDocument() + expect(countValue(loaded, 'Logical examples')).toBe('1,250,000') + expect(countValue(loaded, 'Seed pieces')).toBe('1,400,000') + expect(countValue(loaded, 'Objectives')).toBe('12') + expect(within(loaded).getByText('text')).toBeInTheDocument() + expect(within(loaded).getByText('image_path')).toBeInTheDocument() + expect(within(loaded).getByText('hate')).toBeInTheDocument() + expect(within(loaded).getByText('Not labeled')).toBeInTheDocument() + expect(within(loaded).queryByText('Safe')).not.toBeInTheDocument() + expect(screen.queryByRole('table')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /download|load dataset/i })).not.toBeInTheDocument() + + const unnamed = screen.getByRole('article', { name: '(unnamed)' }) + expect(within(unnamed).getByRole('link', { name: '(unnamed)' })).toHaveAttribute( + 'href', + datasetDetailPath('dataset:unnamed'), + ) + expect(within(unnamed).queryByRole('link', { name: '__unnamed__' })).not.toBeInTheDocument() + expect(countValue(unnamed, 'Objectives')).toBe('0') + expect(within(unnamed).getByText('None')).toBeInTheDocument() + expect(within(unnamed).getByText('Not labeled')).toBeInTheDocument() + + const literal = screen.getByRole('article', { name: '__unnamed__' }) + expect(within(literal).getByRole('link', { name: '__unnamed__' })).toHaveAttribute( + 'href', + datasetDetailPath('dataset:named:__unnamed__'), + ) + expect(countValue(literal, 'Objectives')).toBe('Unknown') + expect(within(literal).getByText('None')).toBeInTheDocument() + }) + + it('shows an empty state when memory has no datasets', async () => { + mockCatalog([]) + render() + + expect(await screen.findByText('No datasets in memory')).toBeInTheDocument() + expect(screen.getByText(/does not download them/i)).toBeInTheDocument() + const guide = screen.getByRole('link', { name: 'Learn how to load datasets into memory' }) + expect(guide).toHaveAttribute('href', 'https://microsoft.github.io/PyRIT/latest/code/datasets/loading-datasets/') + expect(guide).toHaveAttribute('target', '_blank') + expect(guide).toHaveAttribute('rel', 'noopener noreferrer') + expect(screen.queryByRole('link', { name: /harmbench|gated/i })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /load|download/i })).not.toBeInTheDocument() + }) + + it('shows an error and retries the catalog request', async () => { + const user = userEvent.setup() + mockGet.mockRejectedValueOnce(new Error('Catalog unavailable')) + render() + + expect(await screen.findByText('Catalog unavailable')).toBeInTheDocument() + await waitFor(() => expect(screen.getByRole('button', { name: 'Retry' })).toHaveFocus()) + + mockGet.mockResolvedValueOnce({ data: { items: [LOADED] } }) + await user.click(screen.getByRole('button', { name: 'Retry' })) + + expect(await screen.findByRole('link', { name: 'harmbench' })).toBeInTheDocument() + expect(mockGet).toHaveBeenCalledTimes(2) + }) + + it('keeps one live status through results, no matches, and clearing the search', async () => { + const user = userEvent.setup() + render() + + const catalog = await screen.findByTestId('dataset-catalog') + const status = await within(catalog).findByRole('status') + expect(status).toHaveTextContent('3 datasets') + + await user.type(screen.getByRole('textbox', { name: 'Search datasets' }), 'does-not-exist') + + expect(status).toHaveTextContent('No datasets match "does-not-exist"') + expect(within(catalog).getByRole('status')).toBe(status) + + await user.clear(screen.getByRole('textbox', { name: 'Search datasets' })) + + expect(status).toHaveTextContent('3 datasets') + expect(within(catalog).getByRole('status')).toBe(status) + }) + + it('filters by name and reports no matches without rendering the query as HTML', async () => { + const user = userEvent.setup() + render() + const search = await screen.findByRole('textbox', { name: 'Search datasets' }) + + await user.type(search, '') + + expect(await screen.findByText(/No datasets match ""/)).toBeInTheDocument() + expect(document.querySelector('img')).toBeNull() + expect(currentUrl().searchParams.get('q')).toBe('') + + await user.clear(search) + await user.type(search, 'zzz') + expect(await screen.findByText('No datasets match "zzz"')).toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'harmbench' })).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Clear filters' })) + expect(await screen.findByRole('link', { name: 'harmbench' })).toBeInTheDocument() + expect(currentUrl().search).toBe('') + }) + + it('keeps catalog filters when the back link returns from a card', async () => { + const user = userEvent.setup() + mockCatalog([ + makeDataset({ + name: 'policy bench', + selection_key: 'dataset:named:policy', + modalities: ['text'], + }), + ]) + render() + + await user.click(await screen.findByRole('link', { name: 'policy bench' })) + expect(await screen.findByRole('heading', { level: 1, name: 'policy bench' })).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Back to datasets' })).toHaveAttribute( + 'href', + '/datasets?q=policy&modality=text&sort=logical_examples_desc', + ) + + await user.click(screen.getByRole('link', { name: 'Back to datasets' })) + + expect(await screen.findByRole('link', { name: 'policy bench' })).toBeInTheDocument() + expect(currentUrl().pathname).toBe('/datasets') + expect(currentUrl().searchParams.get('q')).toBe('policy') + expect(currentUrl().searchParams.get('modality')).toBe('text') + expect(currentUrl().searchParams.get('sort')).toBe('logical_examples_desc') + }) + + it('shows the no-match copy when only facets exclude every dataset', async () => { + render() + expect(await screen.findByText('No datasets match these filters')).toBeInTheDocument() + }) + + it('updates the URL from the sort, modality, and harm controls', async () => { + const user = userEvent.setup() + render() + await screen.findByRole('link', { name: 'harmbench' }) + + await user.selectOptions(screen.getByRole('combobox', { name: 'Sort datasets' }), 'logical_examples_desc') + expect(currentUrl().searchParams.get('sort')).toBe('logical_examples_desc') + + await user.click(screen.getByTestId('dataset-modality-filter')) + await user.click(await screen.findByRole('menuitemcheckbox', { name: 'audio_path' })) + expect(currentUrl().searchParams.get('modality')).toBe('audio_path') + expect(screen.getByRole('link', { name: '__unnamed__' })).toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'harmbench' })).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Clear filters' })) + await user.click(screen.getByTestId('dataset-harm-filter')) + await user.click(await screen.findByRole('menuitemcheckbox', { name: 'hate' })) + expect(currentUrl().searchParams.get('harm_category')).toBe('hate') + expect(screen.getByRole('link', { name: 'harmbench' })).toBeInTheDocument() + + await user.click(screen.getByRole('checkbox', { name: 'Not labeled' })) + expect(currentUrl().searchParams.get('unlabeled')).toBe('true') + expect(screen.getByRole('link', { name: '(unnamed)' })).toBeInTheDocument() + }) + + it('reaches search, clear, and the dataset link from the keyboard', async () => { + const user = userEvent.setup() + render() + const search = await screen.findByRole('textbox', { name: 'Search datasets' }) + + for (let step = 0; step < 8 && document.activeElement !== search; step += 1) { + await user.tab() + } + expect(search).toHaveFocus() + await user.keyboard('zzz') + + const clear = await screen.findByRole('button', { name: 'Clear filters' }) + for (let step = 0; step < 12 && document.activeElement !== clear; step += 1) { + await user.tab() + } + expect(clear).toHaveFocus() + await user.keyboard('{Enter}') + + const link = await screen.findByRole('link', { name: 'harmbench' }) + for (let step = 0; step < 15 && document.activeElement !== link; step += 1) { + await user.tab() + } + expect(link).toHaveFocus() + await user.keyboard('{Enter}') + + expect(await screen.findByRole('heading', { level: 1, name: 'harmbench' })).toBeInTheDocument() + expect(currentUrl().pathname).toBe(DATASET_DETAIL_PATH) + expect(currentUrl().searchParams.get('selection_key')).toBe('dataset:named:harmbench') + expect(screen.queryByRole('table')).not.toBeInTheDocument() + }) + + it('ignores a catalog response that arrives after the page is left', async () => { + let resolveLoad: (value: { data: { items: DatasetInfo[] } }) => void = () => {} + mockGet.mockReturnValue(new Promise((resolve) => { + resolveLoad = resolve + })) + const { unmount } = render() + unmount() + await act(async () => { + resolveLoad({ data: { items: [LOADED] } }) + }) + expect(screen.queryByRole('link', { name: 'harmbench' })).not.toBeInTheDocument() + }) +}) + +function catalogUrl(): string { + const params = new URLSearchParams() + params.set('q', 'データ') + params.append('modality', 'image/png') + params.append('harm_category', 'a&b') + params.set('unlabeled', 'true') + params.set('sort', 'logical_examples_desc') + return `/datasets?${params.toString()}` +} + +describe('DatasetCatalog URL state', () => { + beforeEach(() => { + mockGet = jest.spyOn(apiClient, 'get') + mockCatalog([ + makeDataset({ + name: 'データ set', + selection_key: 'dataset:named:データ set', + modalities: ['image/png'], + harm_categories: ['a&b'], + has_unlabeled_harm_categories: true, + objectives: 9, + }), + makeDataset({ + name: 'other', + selection_key: 'dataset:named:other', + modalities: ['text'], + harm_categories: ['violence'], + objectives: 1, + }), + ]) + }) + + afterEach(() => { + expectOnlyLoadedDatasetGets() + jest.restoreAllMocks() + }) + + it('keeps search, facets, and sort in the URL across refresh, back, and forward', async () => { + const user = userEvent.setup() + render() + + expect(await screen.findByRole('link', { name: 'データ set' })).toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'other' })).not.toBeInTheDocument() + expect(screen.getByRole('textbox', { name: 'Search datasets' })).toHaveValue('データ') + expect(screen.getByRole('combobox', { name: 'Sort datasets' })).toHaveValue('logical_examples_desc') + expect(screen.getByRole('checkbox', { name: 'Not labeled' })).toBeChecked() + expect(screen.getByTestId('dataset-modality-filter')).toHaveValue('Modalities: image/png') + expect(screen.getByTestId('dataset-harm-filter')).toHaveValue('Harm categories: a&b') + + await user.click(screen.getByRole('button', { name: 'Refresh page' })) + expect(await screen.findByRole('link', { name: 'データ set' })).toBeInTheDocument() + expect(currentUrl().searchParams.get('q')).toBe('データ') + expect(currentUrl().searchParams.getAll('modality')).toEqual(['image/png']) + expect(currentUrl().searchParams.getAll('harm_category')).toEqual(['a&b']) + expect(currentUrl().searchParams.get('unlabeled')).toBe('true') + expect(currentUrl().searchParams.get('sort')).toBe('logical_examples_desc') + + await user.click(screen.getByRole('link', { name: 'データ set' })) + expect(await screen.findByRole('heading', { level: 1, name: 'データ set' })).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'History back' })) + + expect(await screen.findByRole('link', { name: 'データ set' })).toBeInTheDocument() + expect(currentUrl().pathname).toBe('/datasets') + expect(currentUrl().searchParams.get('q')).toBe('データ') + expect(currentUrl().searchParams.get('harm_category')).toBe('a&b') + expect(screen.getByRole('textbox', { name: 'Search datasets' })).toHaveValue('データ') + + await user.click(screen.getByRole('button', { name: 'History forward' })) + expect(await screen.findByRole('heading', { level: 1, name: 'データ set' })).toBeInTheDocument() + expect(currentUrl().pathname).toBe(DATASET_DETAIL_PATH) + expect(currentUrl().searchParams.get('selection_key')).toBe('dataset:named:データ set') + }) +}) + +const SELECTION_CASES: Array<[string, string, string]> = [ + ['unnamed population', 'dataset:unnamed', '(unnamed)'], + ['literal __unnamed__ name', 'dataset:named:__unnamed__', '__unnamed__'], + ['spaces', 'dataset:named:my dataset', 'my dataset'], + ['slash', 'dataset:named:a/b', 'a/b'], + ['question mark', 'dataset:named:a?b', 'a?b'], + ['hash', 'dataset:named:a#b', 'a#b'], + ['ampersand', 'dataset:named:a&b', 'a&b'], + ['percent', 'dataset:named:100%', '100%'], + ['unicode', 'dataset:named:データ', 'データ'], +] + +describe('Dataset selection keys', () => { + beforeEach(() => { + mockGet = jest.spyOn(apiClient, 'get') + }) + + afterEach(() => { + expectOnlyLoadedDatasetGets() + jest.restoreAllMocks() + }) + + it.each(SELECTION_CASES)( + 'round-trips the %s key through navigation, refresh, and back', + async (_label, selectionKey, displayName) => { + const user = userEvent.setup() + mockCatalog([makeDataset({ + name: displayName, + selection_key: selectionKey, + modalities: [], + harm_categories: [], + })]) + + render() + const link = await screen.findByRole('link', { name: displayName }) + const href = link.getAttribute('href') ?? '' + const hrefUrl = new URL(href, 'http://localhost') + expect(hrefUrl.pathname).toBe(DATASET_DETAIL_PATH) + expect(href.split('?')[0]).toBe(DATASET_DETAIL_PATH) + expect(hrefUrl.hash).toBe('') + expect(Array.from(hrefUrl.searchParams.keys())).toEqual(['selection_key']) + expect(hrefUrl.searchParams.get('selection_key')).toBe(selectionKey) + + await user.click(link) + expect(await screen.findByRole('heading', { level: 1, name: displayName })).toBeInTheDocument() + expect(currentUrl().pathname).toBe(DATASET_DETAIL_PATH) + expect(currentUrl().hash).toBe('') + expect(currentUrl().searchParams.get('selection_key')).toBe(selectionKey) + expect(screen.queryByRole('table')).not.toBeInTheDocument() + expect(screen.getByText(/Individual examples are not yet listed in this view\.\s+We are still building this experience\./)).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Refresh page' })) + expect(await screen.findByRole('heading', { level: 1, name: displayName })).toBeInTheDocument() + expect(currentUrl().searchParams.get('selection_key')).toBe(selectionKey) + expect(currentUrl().hash).toBe('') + + await user.click(screen.getByRole('button', { name: 'History back' })) + const returned = await screen.findByRole('link', { name: displayName }) + expect(currentUrl().pathname).toBe('/datasets') + expect(new URL(returned.getAttribute('href') ?? '', 'http://localhost').searchParams.get('selection_key')) + .toBe(selectionKey) + }, + ) +}) diff --git a/frontend/src/components/Datasets/DatasetCatalog.tsx b/frontend/src/components/Datasets/DatasetCatalog.tsx new file mode 100644 index 0000000000..5c2d7bee3d --- /dev/null +++ b/frontend/src/components/Datasets/DatasetCatalog.tsx @@ -0,0 +1,221 @@ +import { useEffect, useId, useMemo, useRef } from 'react' + +import { + Button, + Checkbox, + Input, + Link, + Select, + Spinner, + Text, + MessageBar, + MessageBarBody, +} from '@fluentui/react-components' +import { ArrowSyncRegular, SearchRegular } from '@fluentui/react-icons' +import { useSearchParams } from 'react-router' + +import SearchableMultiCombobox from '@/components/SearchableMultiCombobox' +import { useLoadedDatasets } from '@/hooks/useLoadedDatasets' +import type { DatasetInfo, FilterOption } from '@/types' + +import DatasetCard from './DatasetCard' +import { useDatasetCatalogStyles } from './DatasetCatalog.styles' +import { + catalogQueryFromSearchParams, + catalogQueryIsDefault, + catalogQueryToSearchParams, + DATASET_SORT_LABELS, + DATASET_SORTS, + DEFAULT_DATASET_CATALOG_QUERY, + filterDatasets, + isDatasetSort, + type DatasetCatalogQuery, +} from './datasetQuery' + +function uniqueLabels(values: readonly string[]): FilterOption[] { + const seen = new Set() + const options: FilterOption[] = [] + for (const value of values) { + if (seen.has(value)) continue + seen.add(value) + options.push({ value, label: value }) + } + options.sort((left, right) => left.label.localeCompare(right.label, 'en', { sensitivity: 'base' })) + return options +} + +function modalityOptions(datasets: readonly DatasetInfo[]): FilterOption[] { + const values: string[] = [] + for (const dataset of datasets) values.push(...dataset.modalities) + return uniqueLabels(values) +} + +function harmOptions(datasets: readonly DatasetInfo[]): FilterOption[] { + const values: string[] = [] + for (const dataset of datasets) values.push(...dataset.harm_categories) + return uniqueLabels(values) +} + +/** Loaded-dataset catalog. Search, facets, and sort stay in the URL. */ +export default function DatasetCatalog() { + const styles = useDatasetCatalogStyles() + const titleId = useId() + const retryRef = useRef(null) + const [searchParams, setSearchParams] = useSearchParams() + const catalogSearch = searchParams.toString() + const query = useMemo( + () => catalogQueryFromSearchParams(new URLSearchParams(catalogSearch)), + [catalogSearch], + ) + const { datasets, loading, error, retry } = useLoadedDatasets() + + useEffect(() => { + if (error) retryRef.current?.focus() + }, [error]) + + const filtered = useMemo(() => filterDatasets(datasets, query), [datasets, query]) + const modalities = useMemo(() => modalityOptions(datasets), [datasets]) + const harms = useMemo(() => harmOptions(datasets), [datasets]) + const filtersActive = !catalogQueryIsDefault(query) + + const updateQuery = (next: DatasetCatalogQuery): void => { + setSearchParams(catalogQueryToSearchParams(next), { replace: true }) + } + + const resultLabel = filtered.length === 1 ? '1 dataset' : `${filtered.length} datasets` + const statusLabel = filtered.length === 0 + ? (query.search.trim() + ? `No datasets match "${query.search.trim()}"` + : 'No datasets match these filters') + : resultLabel + + return ( +
+
+
+ Datasets + + Loaded datasets in memory. Search, filters, and sort stay in the address bar. + This view does not download datasets or list individual examples. + +
+ +
+ +
+ } + placeholder="Search datasets by name" + value={query.search} + onChange={(_, data) => updateQuery({ ...query, search: data.value })} + aria-label="Search datasets" + /> + updateQuery({ ...query, modalities: selected })} + testId="dataset-modality-filter" + /> + updateQuery({ ...query, harmCategories: selected })} + testId="dataset-harm-filter" + /> + updateQuery({ ...query, unlabeled: data.checked === true })} + label="Not labeled" + /> + + {filtersActive && ( + + )} +
+ + {loading ? ( +
+ +
+ ) : error ? ( +
+ + {error} + + +
+ ) : datasets.length === 0 ? ( +
+ No datasets in memory + + Datasets show up here after they are loaded into memory. This view does not download them. + + + Learn how to load datasets into memory + +
+ ) : ( + <> + {statusLabel} + {filtered.length === 0 ? ( +
+ Clear the search or filters to see every loaded dataset. +
+ ) : ( +
    + {filtered.map((dataset) => ( +
  • + +
  • + ))} +
+ )} + + )} +
+ ) +} diff --git a/frontend/src/components/Datasets/DatasetSelection.styles.ts b/frontend/src/components/Datasets/DatasetSelection.styles.ts new file mode 100644 index 0000000000..3292e8d0d0 --- /dev/null +++ b/frontend/src/components/Datasets/DatasetSelection.styles.ts @@ -0,0 +1,73 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +import { MINIMUM_TOUCH_TARGET_SIZE, NARROW_VIEWPORT_QUERY, TOUCH_INPUT_QUERY } from '@/styles/touchTargets' +import { WORKSPACE_CANVAS_BACKGROUND } from '@/styles/workspaceBackground' + +export const useDatasetSelectionStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: tokens.spacingVerticalL, + height: '100%', + width: '100%', + minWidth: 0, + padding: tokens.spacingVerticalXXL, + overflowX: 'hidden', + overflowY: 'auto', + backgroundColor: WORKSPACE_CANVAS_BACKGROUND, + [NARROW_VIEWPORT_QUERY]: { + padding: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalM}`, + }, + }, + title: { + margin: 0, + color: tokens.colorNeutralForeground1, + fontSize: tokens.fontSizeBase600, + lineHeight: tokens.lineHeightBase600, + fontWeight: tokens.fontWeightSemibold, + overflowWrap: 'anywhere', + ':focus': { + outline: `2px solid ${tokens.colorStrokeFocus2}`, + outlineOffset: '2px', + }, + }, + backLink: { + color: tokens.colorBrandForegroundLink, + fontWeight: tokens.fontWeightSemibold, + textDecorationLine: 'none', + ':hover': { + textDecorationLine: 'underline', + }, + ':focus-visible': { + outline: `2px solid ${tokens.colorStrokeFocus2}`, + outlineOffset: '2px', + }, + [TOUCH_INPUT_QUERY]: { + display: 'inline-flex', + alignItems: 'center', + minHeight: MINIMUM_TOUCH_TARGET_SIZE, + }, + }, + summary: { + width: '100%', + maxWidth: '40rem', + }, + note: { + maxWidth: '65ch', + margin: 0, + color: tokens.colorNeutralForeground2, + }, + selectionKey: { + margin: 0, + maxWidth: '100%', + overflowWrap: 'anywhere', + color: tokens.colorNeutralForeground2, + }, + centeredState: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: tokens.spacingVerticalM, + }, +}) diff --git a/frontend/src/components/Datasets/DatasetSelection.test.tsx b/frontend/src/components/Datasets/DatasetSelection.test.tsx new file mode 100644 index 0000000000..ff88daae6a --- /dev/null +++ b/frontend/src/components/Datasets/DatasetSelection.test.tsx @@ -0,0 +1,150 @@ +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' +import { MemoryRouter, Route, Routes } from 'react-router' + +import { apiClient } from '@/services/api' +import type { DatasetInfo } from '@/types' + +import { datasetDetailPath } from '@/utils/routeParams' + +import DatasetSelection from './DatasetSelection' + +let mockGet: jest.SpyInstance + +function expectOnlyLoadedDatasetGets(): void { + expect(mockGet.mock.calls.length).toBeGreaterThan(0) + for (const call of mockGet.mock.calls) { + expect(call[0]).toBe('/datasets') + expect(call[1]).toEqual(expect.objectContaining({ + params: { loaded_only: true }, + })) + } +} + +function makeDataset( + overrides: Partial & Pick, +): DatasetInfo { + return { + loaded: true, + provider_available: true, + logical_examples: 2, + seed_pieces: 2, + objectives: 2, + modalities: ['text'], + harm_categories: ['hate'], + has_unlabeled_harm_categories: false, + ...overrides, + } +} + +function renderSelection(initialPath: string, state?: unknown) { + const url = new URL(initialPath, 'http://localhost') + return render( + + + + Catalog

} /> + } /> +
+
+
, + ) +} + +const LOADED = makeDataset({ + name: 'harmbench', + selection_key: 'dataset:named:harmbench', +}) + +describe('DatasetSelection', () => { + beforeEach(() => { + mockGet = jest.spyOn(apiClient, 'get').mockResolvedValue({ data: { items: [LOADED] } }) + }) + + afterEach(() => { + expectOnlyLoadedDatasetGets() + jest.restoreAllMocks() + }) + + it('focuses the dataset name when the selection key matches the catalog', async () => { + renderSelection(datasetDetailPath(LOADED.selection_key)) + + const heading = await screen.findByRole('heading', { level: 1, name: 'harmbench' }) + await waitFor(() => expect(heading).toHaveFocus()) + expect(screen.getByText(/Individual examples are not yet listed in this view\.\s+We are still building this experience\./)).toBeInTheDocument() + expect(screen.queryByRole('table')).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'harmbench' })).not.toBeInTheDocument() + const summary = screen.getByRole('article', { name: 'harmbench' }) + expect(within(summary).queryByRole('heading')).not.toBeInTheDocument() + }) + + it('shows not-found for an unknown key and renders that key as text', async () => { + const key = '' + renderSelection(datasetDetailPath(key)) + + const heading = await screen.findByRole('heading', { name: 'Dataset not found' }) + await waitFor(() => expect(heading).toHaveFocus()) + expect(screen.getByText(key)).toBeInTheDocument() + expect(document.querySelector('img')).toBeNull() + expect(document.querySelector('script')).toBeNull() + expect(screen.queryByRole('link', { name: 'harmbench' })).not.toBeInTheDocument() + }) + + it('truncates a long unknown selection key and renders it as text', async () => { + const key = `${'a'.repeat(200)}` + renderSelection(datasetDetailPath(key)) + + const shown = `${key.slice(0, 79)}…` + expect(await screen.findByText(shown)).toBeInTheDocument() + expect(screen.queryByText(key)).not.toBeInTheDocument() + expect(document.querySelector('img')).toBeNull() + expect(document.querySelector('script')).toBeNull() + }) + + it('does not choose between repeated keys or fetch them', async () => { + renderSelection('/datasets/detail?selection_key=dataset:named:harmbench&selection_key=dataset:named:other') + + expect(await screen.findByRole('heading', { name: 'Dataset not found' })).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'harmbench' })).not.toBeInTheDocument() + expect(screen.queryByText('dataset:named:harmbench')).not.toBeInTheDocument() + }) + + it('shows not-found when the selection key is missing', async () => { + renderSelection('/datasets/detail') + + expect(await screen.findByRole('heading', { name: 'Dataset not found' })).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Back to datasets' })).toHaveAttribute('href', '/datasets') + }) + + it('focuses retry after a catalog error and can load again', async () => { + const user = userEvent.setup() + mockGet.mockRejectedValueOnce(new Error('Catalog unavailable')) + renderSelection(datasetDetailPath(LOADED.selection_key)) + + expect(await screen.findByText('Catalog unavailable')).toBeInTheDocument() + await waitFor(() => expect(screen.getByRole('button', { name: 'Retry' })).toHaveFocus()) + + mockGet.mockResolvedValueOnce({ data: { items: [LOADED] } }) + await user.click(screen.getByRole('button', { name: 'Retry' })) + expect(await screen.findByRole('heading', { level: 1, name: 'harmbench' })).toBeInTheDocument() + }) + + it('returns to the catalog from the back link', async () => { + const user = userEvent.setup() + renderSelection(datasetDetailPath(LOADED.selection_key)) + await screen.findByRole('heading', { level: 1, name: 'harmbench' }) + + expect(screen.getByRole('link', { name: 'Back to datasets' })).toHaveAttribute('href', '/datasets') + await user.click(screen.getByRole('link', { name: 'Back to datasets' })) + expect(await screen.findByText('Catalog')).toBeInTheDocument() + }) + + it('ignores a return path that is not the datasets catalog', async () => { + renderSelection(datasetDetailPath(LOADED.selection_key), { + catalogPath: 'https://evil.example/datasets?q=policy', + }) + + expect(await screen.findByRole('link', { name: 'Back to datasets' })).toHaveAttribute('href', '/datasets') + }) +}) diff --git a/frontend/src/components/Datasets/DatasetSelection.tsx b/frontend/src/components/Datasets/DatasetSelection.tsx new file mode 100644 index 0000000000..69d868423a --- /dev/null +++ b/frontend/src/components/Datasets/DatasetSelection.tsx @@ -0,0 +1,89 @@ +import { useEffect, useRef } from 'react' + +import { Button, MessageBar, MessageBarBody, Spinner, Text } from '@fluentui/react-components' +import { ArrowSyncRegular } from '@fluentui/react-icons' +import { Link, useLocation, useSearchParams } from 'react-router' + +import { useLoadedDatasets } from '@/hooks/useLoadedDatasets' +import { catalogPathFromNavigationState, selectionKeyFromSearchParams } from '@/utils/routeParams' + +import DatasetCard from './DatasetCard' +import { useDatasetSelectionStyles } from './DatasetSelection.styles' + +const MAX_SHOWN_SELECTION_KEY_LENGTH = 80 + +function shownSelectionKey(selectionKey: string): string { + if (selectionKey.length <= MAX_SHOWN_SELECTION_KEY_LENGTH) return selectionKey + return `${selectionKey.slice(0, MAX_SHOWN_SELECTION_KEY_LENGTH - 1)}…` +} + +/** + * Navigation contract for a later prompt table. The selection key is compared + * with the catalog response and is not sent to a seed endpoint. + */ +export default function DatasetSelection() { + const styles = useDatasetSelectionStyles() + const headingRef = useRef(null) + const retryRef = useRef(null) + const location = useLocation() + const [searchParams] = useSearchParams() + const selectionKey = selectionKeyFromSearchParams(searchParams) + const catalogPath = catalogPathFromNavigationState(location.state) + const { datasets, loading, error, retry } = useLoadedDatasets() + const dataset = selectionKey == null + ? undefined + : datasets.find((item) => item.selection_key === selectionKey) + + useEffect(() => { + if (loading) return + if (error) { + retryRef.current?.focus() + return + } + headingRef.current?.focus() + }, [loading, error, selectionKey, dataset?.selection_key]) + + const title = loading + ? 'Loading dataset' + : error + ? 'Could not load datasets' + : dataset + ? dataset.name + : 'Dataset not found' + + return ( +
+ Back to datasets +

+ {title} +

+ {loading ? ( + + ) : error ? ( +
+ + {error} + + +
+ ) : dataset ? ( + <> +
+ +
+

+ Individual examples are not yet listed in this view. + We are still building this experience. +

+ + ) : ( + <> + No loaded dataset matches this selection. + {selectionKey ?

{shownSelectionKey(selectionKey)}

: null} + + )} +
+ ) +} diff --git a/frontend/src/components/Datasets/datasetQuery.test.ts b/frontend/src/components/Datasets/datasetQuery.test.ts new file mode 100644 index 0000000000..5bb1578a9a --- /dev/null +++ b/frontend/src/components/Datasets/datasetQuery.test.ts @@ -0,0 +1,142 @@ +import type { DatasetInfo } from '@/types' + +import { + catalogQueryFromSearchParams, + catalogQueryIsDefault, + catalogQueryToSearchParams, + DEFAULT_DATASET_CATALOG_QUERY, + filterDatasets, + type DatasetCatalogQuery, + type DatasetSort, +} from './datasetQuery' + +function makeDataset(overrides: Partial & Pick): DatasetInfo { + return { + loaded: true, + provider_available: false, + logical_examples: 1, + seed_pieces: 1, + objectives: 1, + modalities: [], + harm_categories: [], + has_unlabeled_harm_categories: false, + ...overrides, + } +} + +describe('datasetQuery', () => { + it('omits default catalog filters and restores special characters', () => { + expect(catalogQueryToSearchParams(DEFAULT_DATASET_CATALOG_QUERY).toString()).toBe('') + expect(catalogQueryIsDefault(DEFAULT_DATASET_CATALOG_QUERY)).toBe(true) + + const query: DatasetCatalogQuery = { + search: 'a/b?c#d&e% fü', + modalities: ['image/png', 'text'], + harmCategories: ['a&b', 'データ'], + unlabeled: true, + sort: 'logical_examples_desc', + } + const params = catalogQueryToSearchParams(query) + const restored = catalogQueryFromSearchParams(params) + + expect(restored).toEqual(query) + expect(catalogQueryIsDefault(restored)).toBe(false) + expect(params.getAll('modality')).toEqual(['image/png', 'text']) + expect(params.getAll('harm_category')).toEqual(['a&b', 'データ']) + }) + + it('ignores an unknown sort value', () => { + expect(catalogQueryFromSearchParams(new URLSearchParams('sort=nope')).sort).toBe('name') + }) + + it('filters by name, modality, and harm without treating unlabeled rows as safe', () => { + const hate = makeDataset({ + name: 'Hate bench', + selection_key: 'dataset:named:hate', + modalities: ['text'], + harm_categories: ['hate'], + }) + const unlabeled = makeDataset({ + name: 'Plain', + selection_key: 'dataset:named:plain', + modalities: ['image_path'], + has_unlabeled_harm_categories: true, + }) + const datasets = [hate, unlabeled] + + expect(filterDatasets(datasets, { ...DEFAULT_DATASET_CATALOG_QUERY, search: ' HATE ' }).map((item) => item.name)) + .toEqual(['Hate bench']) + expect(filterDatasets(datasets, { ...DEFAULT_DATASET_CATALOG_QUERY, modalities: ['image_path'] }).map((item) => item.name)) + .toEqual(['Plain']) + expect(filterDatasets(datasets, { ...DEFAULT_DATASET_CATALOG_QUERY, harmCategories: ['hate'] }).map((item) => item.name)) + .toEqual(['Hate bench']) + expect(filterDatasets(datasets, { ...DEFAULT_DATASET_CATALOG_QUERY, unlabeled: true }).map((item) => item.name)) + .toEqual(['Plain']) + expect(filterDatasets(datasets, { + ...DEFAULT_DATASET_CATALOG_QUERY, + harmCategories: ['hate'], + unlabeled: true, + }).map((item) => item.name)).toEqual(['Hate bench', 'Plain']) + expect(filterDatasets(datasets, { + ...DEFAULT_DATASET_CATALOG_QUERY, + modalities: ['text'], + unlabeled: true, + })).toEqual([]) + }) + + it('sorts names and known counts with deterministic ties', () => { + const alpha = makeDataset({ + name: 'alpha', + selection_key: 'dataset:named:alpha', + logical_examples: 2, + }) + const beta = makeDataset({ + name: 'beta', + selection_key: 'dataset:named:beta', + logical_examples: 10, + }) + const same = makeDataset({ + name: 'alpha', + selection_key: 'dataset:named:alpha-2', + logical_examples: 2, + }) + const rows = [beta, same, alpha] + + expect(filterDatasets(rows, DEFAULT_DATASET_CATALOG_QUERY).map((item) => item.selection_key)).toEqual([ + 'dataset:named:alpha', + 'dataset:named:alpha-2', + 'dataset:named:beta', + ]) + expect(filterDatasets(rows, { ...DEFAULT_DATASET_CATALOG_QUERY, sort: 'name_desc' }).map((item) => item.selection_key)).toEqual([ + 'dataset:named:beta', + 'dataset:named:alpha', + 'dataset:named:alpha-2', + ]) + expect(filterDatasets(rows, { ...DEFAULT_DATASET_CATALOG_QUERY, sort: 'logical_examples' }).map((item) => item.selection_key)).toEqual([ + 'dataset:named:alpha', + 'dataset:named:alpha-2', + 'dataset:named:beta', + ]) + expect(filterDatasets(rows, { ...DEFAULT_DATASET_CATALOG_QUERY, sort: 'logical_examples_desc' }).map((item) => item.selection_key)).toEqual([ + 'dataset:named:beta', + 'dataset:named:alpha', + 'dataset:named:alpha-2', + ]) + }) + + it.each<[DatasetSort, string[]]>([ + ['logical_examples', ['zero', 'few', 'many', 'a-unknown', 'b-unknown']], + ['logical_examples_desc', ['many', 'few', 'zero', 'a-unknown', 'b-unknown']], + ])('keeps unknown logical-example counts last for %s', (sort: DatasetSort, expected: string[]) => { + const rows = [ + makeDataset({ name: 'b-unknown', selection_key: 'dataset:named:b-unknown', logical_examples: null }), + makeDataset({ name: 'zero', selection_key: 'dataset:named:zero', logical_examples: 0 }), + makeDataset({ name: 'many', selection_key: 'dataset:named:many', logical_examples: 10 }), + makeDataset({ name: 'a-unknown', selection_key: 'dataset:named:a-unknown', logical_examples: null }), + makeDataset({ name: 'few', selection_key: 'dataset:named:few', logical_examples: 2 }), + ] + + expect(filterDatasets(rows, { ...DEFAULT_DATASET_CATALOG_QUERY, sort }).map((item: DatasetInfo) => item.name)) + .toEqual(expected) + }) +}) diff --git a/frontend/src/components/Datasets/datasetQuery.ts b/frontend/src/components/Datasets/datasetQuery.ts new file mode 100644 index 0000000000..9774056b02 --- /dev/null +++ b/frontend/src/components/Datasets/datasetQuery.ts @@ -0,0 +1,144 @@ +import type { DatasetInfo } from '@/types' + +const SEARCH_PARAM = 'q' +const MODALITY_PARAM = 'modality' +const HARM_CATEGORY_PARAM = 'harm_category' +const UNLABELED_PARAM = 'unlabeled' +const SORT_PARAM = 'sort' + +export const DATASET_SORTS = [ + 'name', + 'name_desc', + 'logical_examples_desc', + 'logical_examples', +] as const + +export type DatasetSort = (typeof DATASET_SORTS)[number] + +export const DEFAULT_DATASET_SORT: DatasetSort = 'name' + +export const DATASET_SORT_LABELS: Record = { + name: 'Name (A-Z)', + name_desc: 'Name (Z-A)', + logical_examples_desc: 'Most examples', + logical_examples: 'Fewest examples', +} + +export interface DatasetCatalogQuery { + readonly search: string + readonly modalities: readonly string[] + readonly harmCategories: readonly string[] + readonly unlabeled: boolean + readonly sort: DatasetSort +} + +export const DEFAULT_DATASET_CATALOG_QUERY: DatasetCatalogQuery = { + search: '', + modalities: [], + harmCategories: [], + unlabeled: false, + sort: DEFAULT_DATASET_SORT, +} + +export function isDatasetSort(value: string): value is DatasetSort { + return DATASET_SORTS.some((sort) => sort === value) +} + +/** Reads catalog search, facets, and sort from the page query. */ +export function catalogQueryFromSearchParams(params: URLSearchParams): DatasetCatalogQuery { + const sortValue = params.get(SORT_PARAM) + return { + search: params.get(SEARCH_PARAM) ?? '', + modalities: params.getAll(MODALITY_PARAM), + harmCategories: params.getAll(HARM_CATEGORY_PARAM), + unlabeled: params.get(UNLABELED_PARAM) === 'true', + sort: sortValue != null && isDatasetSort(sortValue) ? sortValue : DEFAULT_DATASET_SORT, + } +} + +/** Encodes catalog search, facets, and sort, omitting filters that are already the default. */ +export function catalogQueryToSearchParams(query: DatasetCatalogQuery): URLSearchParams { + const params = new URLSearchParams() + if (query.search) params.set(SEARCH_PARAM, query.search) + for (const modality of query.modalities) params.append(MODALITY_PARAM, modality) + for (const category of query.harmCategories) params.append(HARM_CATEGORY_PARAM, category) + if (query.unlabeled) params.set(UNLABELED_PARAM, 'true') + if (query.sort !== DEFAULT_DATASET_SORT) params.set(SORT_PARAM, query.sort) + return params +} + +export function catalogQueryIsDefault(query: DatasetCatalogQuery): boolean { + return query.search === '' + && query.modalities.length === 0 + && query.harmCategories.length === 0 + && !query.unlabeled + && query.sort === DEFAULT_DATASET_SORT +} + +function matchesSearch(name: string, search: string): boolean { + const query = search.trim().toLowerCase() + if (!query) return true + return name.toLowerCase().includes(query) +} + +function matchesModalities(dataset: DatasetInfo, modalities: readonly string[]): boolean { + if (modalities.length === 0) return true + return modalities.some((modality) => dataset.modalities.includes(modality)) +} + +function matchesHarm(dataset: DatasetInfo, query: DatasetCatalogQuery): boolean { + const harmSelected = query.harmCategories.length > 0 || query.unlabeled + if (!harmSelected) return true + const categoryMatch = query.harmCategories.some((category) => dataset.harm_categories.includes(category)) + const unlabeledMatch = query.unlabeled && dataset.has_unlabeled_harm_categories + return categoryMatch || unlabeledMatch +} + +function countSort(sort: DatasetSort): 'asc' | 'desc' | null { + switch (sort) { + case 'logical_examples': + return 'asc' + case 'logical_examples_desc': + return 'desc' + default: + return null + } +} + +function compareCounts(left: number | null, right: number | null, direction: 'asc' | 'desc'): number { + if (left == null && right == null) return 0 + if (left == null) return 1 + if (right == null) return -1 + return direction === 'asc' ? left - right : right - left +} + +function compareNames(left: DatasetInfo, right: DatasetInfo, direction: 'asc' | 'desc'): number { + const nameOrder = left.name.localeCompare(right.name, 'en', { sensitivity: 'base' }) + const directed = direction === 'asc' ? nameOrder : -nameOrder + if (directed !== 0) return directed + return left.selection_key.localeCompare(right.selection_key, 'en') +} + +/** Returns catalog rows matching the query. Unknown counts sort after known counts. */ +export function filterDatasets( + datasets: readonly DatasetInfo[], + query: DatasetCatalogQuery, +): DatasetInfo[] { + const matched: DatasetInfo[] = [] + for (const dataset of datasets) { + if (!matchesSearch(dataset.name, query.search)) continue + if (!matchesModalities(dataset, query.modalities)) continue + if (!matchesHarm(dataset, query)) continue + matched.push(dataset) + } + + const countDirection = countSort(query.sort) + const nameDirection = query.sort === 'name_desc' ? 'desc' : 'asc' + return matched.sort((left, right) => { + if (countDirection) { + const countOrder = compareCounts(left.logical_examples, right.logical_examples, countDirection) + if (countOrder !== 0) return countOrder + } + return compareNames(left, right, countDirection ? 'asc' : nameDirection) + }) +} diff --git a/frontend/src/components/Sidebar/Navigation.test.tsx b/frontend/src/components/Sidebar/Navigation.test.tsx index bc83c3e581..c4b4a0e7b1 100644 --- a/frontend/src/components/Sidebar/Navigation.test.tsx +++ b/frontend/src/components/Sidebar/Navigation.test.tsx @@ -142,6 +142,7 @@ describe("Navigation", () => { "Chat", "History", "Scanner", + "Datasets", "Registry", "Configuration", ]); @@ -185,6 +186,32 @@ describe("Navigation", () => { ); }); + it("renders the Datasets button", () => { + renderWithProvider(); + expect(screen.getByRole("button", { name: "Datasets" })).toBeInTheDocument(); + }); + + it("calls onNavigate with 'datasets' when the datasets button is clicked", async () => { + const user = userEvent.setup(); + const onNavigate = jest.fn(); + renderWithProvider( + + ); + + await user.click(screen.getByRole("button", { name: "Datasets" })); + expect(onNavigate).toHaveBeenCalledWith("datasets"); + }); + + it("marks the datasets button current when it is the active view", () => { + renderWithProvider( + + ); + expect(screen.getByRole("button", { name: "Datasets" })).toHaveAttribute( + "aria-current", + "page" + ); + }); + it("renders one feedback button and forwards clicks to onOpenFeedback", async () => { const user = userEvent.setup(); const onOpenFeedback = jest.fn(); diff --git a/frontend/src/components/Sidebar/Navigation.tsx b/frontend/src/components/Sidebar/Navigation.tsx index 2bfee46427..3112aebf6e 100644 --- a/frontend/src/components/Sidebar/Navigation.tsx +++ b/frontend/src/components/Sidebar/Navigation.tsx @@ -10,6 +10,7 @@ import { import type { MenuCheckedValueChangeData, MenuCheckedValueChangeEvent } from '@fluentui/react-components' import { ChatRegular, + DatabaseRegular, HomeRegular, SettingsRegular, HistoryRegular, @@ -32,6 +33,7 @@ export type ViewName = | 'registry' | 'configuration' | 'scenarios' + | 'datasets' interface NavigationProps { currentView: ViewName @@ -112,6 +114,17 @@ export default function Navigation({ onClick={() => onNavigate('scenarios')} /> +