diff --git a/README.md b/README.md
index 0d885af..8693020 100644
--- a/README.md
+++ b/README.md
@@ -4,10 +4,11 @@ The Data Quality Platform is a learning and software-engineering project for bui
## Current status
-Milestone 1 provides the project foundation. Milestone 2 completed the PostgreSQL persistence foundation and the Dataset, Validation Profile, and Validation Rule persistence vertical slices. Milestone 3 is complete with SourceFile upload, deterministic CSV parsing, Validation Run creation and parser lifecycle persistence, and Validation Run retrieval. Milestone 4 is complete with deterministic rule-specific parameter validation, end-to-end Validation Rule execution, Validation Issue persistence and retrieval, and completed Validation Run summaries:
+Milestones 1 through 4 are complete. Milestone 5 now has a read-only frontend Dashboard and addressable Validation Run detail experience implemented; it remains in progress pending final review, CI, manual end-to-end verification, and merge:
- a Java 21 and Spring Boot backend
- a React and TypeScript frontend
+- a read-only Dashboard for persisted Datasets, Validation Runs, summaries, and Issues
- a local PostgreSQL service through Docker Compose
- environment-backed backend datasource configuration
- Spring Data JPA, Bean Validation, and Flyway infrastructure
@@ -30,9 +31,9 @@ Milestone 1 provides the project foundation. Milestone 2 completed the PostgreSQ
- backend and frontend tests and formatting checks
- a GitHub Actions workflow for repository checks
-The backend connects to PostgreSQL at startup. Flyway is the sole schema owner and applies migrations V1 through V9 for Dataset, Validation Profile, Validation Rule, rule-specific parameter constraints, SourceFile, Validation Run, Validation Run lifecycle and completed-summary constraints, and Validation Issue persistence. Hibernate validates the JPA mappings with `spring.jpa.hibernate.ddl-auto=validate` and does not generate schema changes. The backend exposes the Actuator health endpoint and the Dataset, Validation Profile, Validation Rule, SourceFile upload, Validation Run, and Validation Issue retrieval endpoints documented below. The frontend remains a static application shell.
+The backend connects to PostgreSQL at startup. Flyway is the sole schema owner and applies migrations V1 through V9 for Dataset, Validation Profile, Validation Rule, rule-specific parameter constraints, SourceFile, Validation Run, Validation Run lifecycle and completed-summary constraints, and Validation Issue persistence. Hibernate validates the JPA mappings with `spring.jpa.hibernate.ddl-auto=validate` and does not generate schema changes. The backend exposes the Actuator health endpoint and the Dataset, Validation Profile, Validation Rule, SourceFile upload, Validation Run, and Validation Issue retrieval endpoints documented below. The frontend provides the read-only Milestone 5 Dashboard documented below.
-Dataset metadata can be created, listed, and retrieved. Validation Profiles can be created and listed for an existing Dataset. Validation Rules can be created and listed for an existing Validation Profile, and every Rule's parameters are validated against its type-specific contract before persistence. CSV files can be uploaded for an existing Dataset, and the backend stores their metadata, exact bytes, and SHA-256 checksums. Creating a Validation Run for a SourceFile and a Validation Profile from the same Dataset reads the private stored bytes, parses them, loads the Profile's enabled Rules, and validates the parsed rows synchronously. Successful validation atomically persists generated Issues and the `totalRows`, `validRows`, `invalidRows`, and `issueCount` summary before completing the Run. Expected parser failures, missing required headers, and recovered validation-processing failures persist safe `FAILED` outcomes without partial Issues. Unexpected SourceFile-access or parser runtime failures retain the separately committed `PENDING` Run for diagnosis. Validation Runs can be listed globally and retrieved by ID, and their persisted Issues can be retrieved through a read-only endpoint. The completion audit has passed, and Milestone 4 is complete. Issue filtering remains planned for Milestone 5, and report generation remains planned for Milestone 6. Dataset, profile, rule, SourceFile, and Validation Run updates or deletion, profile, rule, and SourceFile detail retrieval, pagination, authentication, and AI features are also not implemented yet.
+Dataset metadata can be created, listed, and retrieved. Validation Profiles can be created and listed for an existing Dataset. Validation Rules can be created and listed for an existing Validation Profile, and every Rule's parameters are validated against its type-specific contract before persistence. CSV files can be uploaded for an existing Dataset, and the backend stores their metadata, exact bytes, and SHA-256 checksums. Creating a Validation Run for a SourceFile and a Validation Profile from the same Dataset reads the private stored bytes, parses them, loads the Profile's enabled Rules, and validates the parsed rows synchronously. Successful validation atomically persists generated Issues and the `totalRows`, `validRows`, `invalidRows`, and `issueCount` summary before completing the Run. Expected parser failures, missing required headers, and recovered validation-processing failures persist safe `FAILED` outcomes without partial Issues. Unexpected SourceFile-access or parser runtime failures retain the separately committed `PENDING` Run for diagnosis. Validation Runs can be listed globally and retrieved by ID, and their persisted Issues can be retrieved through a read-only endpoint. The frontend filters retrieved Issues client-side; the backend collection remains unfiltered and unpaged. Report and export functionality remains deferred to Milestone 6 or later. Dataset, profile, rule, SourceFile, and Validation Run updates or deletion, profile, rule, and SourceFile detail retrieval, pagination, authentication, and AI features are also not implemented yet.
## Repository layout
@@ -123,6 +124,42 @@ Get-Content ..\.env |
The backend listens on `http://localhost:8080`. Its health endpoint is `http://localhost:8080/actuator/health`. A missing or incorrect database password causes startup to fail when Flyway connects.
+## Read-only frontend Dashboard
+
+The `/` Dashboard route presents the persisted Dataset collection and the global Validation Run collection. Each Dataset row shows its name, optional description, UUID, and creation timestamp. Each Validation Run row links to its addressable `/runs/:runId` detail route and shows its textual status, Dataset context, and persisted `totalRows`, `validRows`, `invalidRows`, and `issueCount` summary counters. Collections stay in the order supplied by the backend; the frontend does not sort them.
+
+Dataset names are resolved from the Dataset collection when possible. The Dataset UUID remains visible and is the fallback when a matching name is unavailable or the Dataset request fails. SourceFile and Validation Profile references remain UUID-only because the frontend has no SourceFile or Profile detail endpoint to query.
+
+The Validation Run detail route presents persisted metadata, lifecycle timestamps, any failure reason, the four persisted summary counters, and the Run's persisted Validation Issues. Issue rows show the logical row number, exact field name, Rule type, severity text, message, and observed value. Markup-like observed values render literally as text. Non-empty whitespace is preserved, while null and empty strings have distinct labels. Entering or refreshing a `/runs/:runId` URL works with the Vite development server.
+
+Issue filtering happens entirely in the browser after the collection is retrieved. Severity filtering matches `ERROR` or `WARNING`. Field-name filtering uses exact, case-sensitive, and whitespace-sensitive equality. When both filters are active, an Issue must match both conditions. Filtering preserves the backend collection order and sends no filter parameters to the API.
+
+The Dashboard has independent loading, error, Retry, and empty states for Datasets and Validation Runs. Validation Run loading and Issue loading have their own error and Retry states. An existing Run without persisted Issues has a dedicated empty state. Active filters with no matches show a distinct empty-filter-result state and a Clear filters control. Retry controls repeat the relevant read-only GET request; they do not retry backend processing.
+
+Milestone 5 remains read-only. It has no UI for creating, uploading, updating, deleting, starting, retrying processing, or cancelling resources. The existing REST API and commands below can create representative local data. The current MVP collections are unpaged and provide no pagination, server-side filtering, or server-side sorting. Report and export functionality remains deferred to Milestone 6 or later.
+
+### Run the frontend locally
+
+With PostgreSQL and the backend running, start Vite in another terminal.
+
+Unix-like shells:
+
+```sh
+cd frontend
+npm ci
+npm run dev
+```
+
+Windows PowerShell:
+
+```powershell
+cd frontend
+npm.cmd ci
+npm.cmd run dev
+```
+
+Open the URL printed by Vite, normally `http://localhost:5173/`. During development, Vite proxies relative `/api` requests to `http://localhost:8080`; this workflow does not require backend CORS configuration.
+
## Dataset API
The current API manages Dataset metadata only. A Dataset contains a generated UUID, a required name, an optional description, and a creation timestamp.
@@ -686,30 +723,12 @@ Issues are ordered by `rowNumber` ascending, `fieldName` ascending, `ruleType` a
A valid but unknown Run UUID returns the existing `Validation Run not found` `404 Not Found` Problem Details response with the Issue collection path as its `instance`. A malformed Run UUID returns `400 Bad Request`.
-Issue retrieval is read-only. It does not parse CSV, invoke validation, create or delete Issues, calculate summaries, compare the result with `issueCount`, or change the Run lifecycle. No filtering, pagination, user-selectable sorting, or public Issue write endpoint is implemented. Issue filtering belongs to Milestone 5, and report generation belongs to Milestone 6.
+Issue retrieval is read-only. It does not parse CSV, invoke validation, create or delete Issues, calculate summaries, compare the result with `issueCount`, or change the Run lifecycle. The endpoint itself provides no filtering, pagination, user-selectable sorting, or public Issue write operation. The Milestone 5 frontend filters the retrieved collection client-side by severity and exact field name; it does not send filter parameters to the backend. Report and export functionality remains deferred to Milestone 6 or later.
## Persistence relationships
Validation Profiles and SourceFiles require an existing Dataset, Validation Rules require an existing Validation Profile, Validation Runs require an existing Dataset, SourceFile, and Validation Profile, and Validation Issues require an existing Validation Run. Validation Run creation also requires the SourceFile and Validation Profile to belong to the same Dataset. All foreign keys use `ON DELETE RESTRICT`, and no cascading deletion is configured. If rows are removed directly during local cleanup, delete Validation Issues first, then Validation Runs, then Validation Rules and SourceFiles, then Validation Profiles, and finally Datasets.
-Run the frontend on Unix-like systems:
-
-```sh
-cd frontend
-npm ci
-npm run dev
-```
-
-Run the frontend on Windows PowerShell:
-
-```powershell
-cd frontend
-npm.cmd ci
-npm.cmd run dev
-```
-
-The Vite development server prints its local URL, normally `http://localhost:5173`.
-
## Backend commands
Run these commands from `backend/`. Replace `./mvnw` with `.\mvnw.cmd` on Windows.
@@ -774,7 +793,7 @@ The GitHub Actions workflow runs three independent jobs on pushes and pull reque
- Milestone 2: complete, with PostgreSQL persistence and Dataset, Validation Profile, and Validation Rule REST vertical slices
- Milestone 3: complete, with SourceFile upload, exact byte storage and SHA-256 checksums, synchronous CSV parsing, persisted `PROCESSING` and parser-failure lifecycle states, and Validation Run retrieval
- Milestone 4: complete, with deterministic rule-specific parameter validation, synchronous Rule execution, Validation Issue persistence and retrieval, validation-derived Run summaries, and successful transition to `COMPLETED`
-- Milestone 5: dataset, run, summary, and issue screens
+- Milestone 5: in progress, with the read-only Dashboard, addressable Run detail, persisted summaries and Issues, client-side filters, and user-visible async and empty states implemented and awaiting final manual verification and review
- Milestone 6: report export, structured logs, runtime metrics, and final documentation
The detailed product scope and milestone definitions are maintained in `PROJECT_BRIEF.md`.
diff --git a/frontend/index.html b/frontend/index.html
index fef75a2..b190c41 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -5,7 +5,7 @@
Data Quality Platform
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 9ab6492..bfe5257 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -9,7 +9,8 @@
"version": "0.0.1",
"dependencies": {
"react": "^19.2.7",
- "react-dom": "^19.2.7"
+ "react-dom": "^19.2.7",
+ "react-router-dom": "^7.18.2"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -1856,6 +1857,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3221,6 +3235,44 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/react-router": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
+ "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
+ "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.18.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -3308,6 +3360,12 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 5e9000e..e3a4cad 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -19,7 +19,8 @@
},
"dependencies": {
"react": "^19.2.7",
- "react-dom": "^19.2.7"
+ "react-dom": "^19.2.7",
+ "react-router-dom": "^7.18.2"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index bd21231..c1f4e3a 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -1,16 +1,78 @@
-import { render, screen } from '@testing-library/react';
-import { describe, expect, it } from 'vitest';
+import { fireEvent, screen } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
import App from './App';
+import {
+ getDatasets,
+ getValidationIssues,
+ getValidationRun,
+ getValidationRuns,
+} from './api/client';
+import { datasetFixture, validationRunFixture } from './test/fixtures';
+import { renderWithRouter } from './test/renderWithRouter';
-describe('App', () => {
- it('renders the foundation shell without claiming later features are available', () => {
- render( );
+vi.mock('./api/client', () => ({
+ getDatasets: vi.fn(),
+ getValidationIssues: vi.fn(),
+ getValidationRun: vi.fn(),
+ getValidationRuns: vi.fn(),
+}));
+
+const getDatasetsMock = vi.mocked(getDatasets);
+const getValidationIssuesMock = vi.mocked(getValidationIssues);
+const getValidationRunMock = vi.mocked(getValidationRun);
+const getValidationRunsMock = vi.mocked(getValidationRuns);
+
+describe('App routing', () => {
+ beforeEach(() => {
+ getDatasetsMock.mockReset().mockResolvedValue([]);
+ getValidationIssuesMock.mockReset().mockResolvedValue([]);
+ getValidationRunMock.mockReset().mockResolvedValue(validationRunFixture);
+ getValidationRunsMock.mockReset().mockResolvedValue([]);
+ });
+
+ it('renders the dashboard route', async () => {
+ renderWithRouter( );
expect(
- screen.getByRole('heading', { level: 1, name: 'Data Quality Platform' }),
+ screen.getByRole('heading', { level: 2, name: 'Data quality dashboard' }),
).toBeInTheDocument();
+ expect(await screen.findByText('No Datasets')).toBeInTheDocument();
+ expect(await screen.findByText('No Validation Runs')).toBeInTheDocument();
+ });
+
+ it('renders an addressable Validation Run detail route', async () => {
+ renderWithRouter( , `/runs/${validationRunFixture.id}`);
+
+ expect(await screen.findByRole('heading', { name: 'Run summary' })).toBeInTheDocument();
+ expect(screen.getByText(validationRunFixture.id)).toBeInTheDocument();
+ expect(getValidationRunMock).toHaveBeenCalledWith(
+ validationRunFixture.id,
+ expect.any(AbortSignal),
+ );
+ });
+
+ it('navigates from a Run link to the addressable detail route', async () => {
+ getDatasetsMock.mockResolvedValue([datasetFixture]);
+ getValidationRunsMock.mockResolvedValue([validationRunFixture]);
+
+ renderWithRouter( );
+
+ fireEvent.click(await screen.findByRole('link', { name: validationRunFixture.id }));
+
+ expect(await screen.findByRole('heading', { name: 'Run summary' })).toBeInTheDocument();
+ expect(screen.getByText(validationRunFixture.id)).toBeInTheDocument();
+ });
+
+ it('renders an unmatched route and links back to the dashboard', async () => {
+ renderWithRouter( , '/does-not-exist');
+
+ expect(screen.getByRole('heading', { level: 2, name: 'Page not found' })).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('link', { name: 'Return to dashboard' }));
+
expect(
- screen.getByText('These workflows are not available in the frontend yet.'),
+ screen.getByRole('heading', { level: 2, name: 'Data quality dashboard' }),
).toBeInTheDocument();
+ expect(await screen.findByText('No Datasets')).toBeInTheDocument();
});
});
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index d2d5151..abf7049 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,8 +1,22 @@
-const plannedCapabilities = [
- 'Dataset management and CSV ingestion',
- 'Configurable validation rules and issue tracking',
- 'Run summaries, issue filtering, and report export',
-];
+import { Link, Route, Routes, useParams } from 'react-router-dom';
+import { DashboardPage } from './pages/DashboardPage';
+import { ValidationRunDetailPage } from './pages/ValidationRunDetailPage';
+
+function ValidationRunRoute() {
+ const { runId } = useParams();
+
+ return runId === undefined ? : ;
+}
+
+function NotFoundRoute() {
+ return (
+
+ Page not found
+ The requested frontend route does not exist.
+ Return to dashboard
+
+ );
+}
function App() {
return (
@@ -13,44 +27,20 @@ function App() {
-
-
-
- Foundation available
-
- Project foundation
-
- The React application shell, Spring Boot service, local PostgreSQL configuration, and
- automated checks are in place. PostgreSQL-backed Dataset, Validation Profile, Validation
- Rule, SourceFile upload, and Validation Run creation, list, and detail APIs are
- available in the backend, together with Validation Issue retrieval. Creating a
- Validation Run synchronously parses and validates its private stored CSV bytes. Normal
- processing persists Issues and summary counters as COMPLETED, while expected or
- recovered processing failures record safe FAILED outcomes. Unexpected server failures
- can retain a durable PENDING Run. The frontend shell does not call these APIs or provide
- Validation Run screens yet.
-
-
-
-
- Planned frontend capabilities
-
- {plannedCapabilities.map((capability) => (
- {capability}
- ))}
-
- These workflows are not available in the frontend yet.
-
+
+ } />
+ } />
+ } />
+
diff --git a/frontend/src/api/client.test.ts b/frontend/src/api/client.test.ts
new file mode 100644
index 0000000..6f4231c
--- /dev/null
+++ b/frontend/src/api/client.test.ts
@@ -0,0 +1,284 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ ApiError,
+ getDatasets,
+ getValidationIssues,
+ getValidationRun,
+ getValidationRuns,
+} from './client';
+import {
+ datasetFixture,
+ problemDetailsFixture,
+ validationIssueFixture,
+ validationRunFixture,
+} from '../test/fixtures';
+import type { ValidationIssueSeverity, ValidationRuleType, ValidationRunStatus } from './contracts';
+
+const fetchMock = vi.fn();
+
+function jsonResponse(body: unknown, status = 200, contentType = 'application/json'): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': contentType },
+ });
+}
+
+function validationRunForStatus(status: ValidationRunStatus) {
+ switch (status) {
+ case 'PENDING':
+ return {
+ ...validationRunFixture,
+ status,
+ totalRows: 0,
+ validRows: 0,
+ invalidRows: 0,
+ issueCount: 0,
+ startedAt: null,
+ finishedAt: null,
+ failureReason: null,
+ };
+ case 'PROCESSING':
+ return {
+ ...validationRunFixture,
+ status,
+ validRows: 0,
+ invalidRows: 0,
+ issueCount: 0,
+ finishedAt: null,
+ failureReason: null,
+ };
+ case 'COMPLETED':
+ return { ...validationRunFixture, status };
+ case 'FAILED':
+ return {
+ ...validationRunFixture,
+ status,
+ totalRows: 0,
+ validRows: 0,
+ invalidRows: 0,
+ issueCount: 0,
+ failureReason: 'CSV content is malformed.',
+ };
+ }
+}
+
+describe('Data Quality API client', () => {
+ beforeEach(() => {
+ fetchMock.mockReset();
+ vi.stubGlobal('fetch', fetchMock);
+ });
+
+ it('loads and validates Datasets through the relative API URL', async () => {
+ const controller = new AbortController();
+ fetchMock.mockResolvedValueOnce(jsonResponse([{ ...datasetFixture, ignored: 'value' }]));
+
+ await expect(getDatasets(controller.signal)).resolves.toEqual([datasetFixture]);
+ expect(fetchMock).toHaveBeenCalledWith('/api/datasets', {
+ method: 'GET',
+ headers: { Accept: 'application/json' },
+ signal: controller.signal,
+ });
+ });
+
+ it('accepts a null Dataset description', async () => {
+ const datasetWithoutDescription = { ...datasetFixture, description: null };
+ fetchMock.mockResolvedValueOnce(jsonResponse([datasetWithoutDescription]));
+
+ await expect(getDatasets()).resolves.toEqual([datasetWithoutDescription]);
+ });
+
+ it('loads and validates the Validation Run collection', async () => {
+ fetchMock.mockResolvedValueOnce(jsonResponse([validationRunFixture]));
+
+ await expect(getValidationRuns()).resolves.toEqual([validationRunFixture]);
+ expect(fetchMock).toHaveBeenCalledWith('/api/validation-runs', expect.any(Object));
+ });
+
+ it('loads one Validation Run with its nullable lifecycle fields', async () => {
+ const pendingRun = {
+ ...validationRunFixture,
+ status: 'PENDING',
+ totalRows: 0,
+ validRows: 0,
+ invalidRows: 0,
+ issueCount: 0,
+ startedAt: null,
+ finishedAt: null,
+ failureReason: null,
+ };
+ fetchMock.mockResolvedValueOnce(jsonResponse(pendingRun));
+
+ await expect(getValidationRun(validationRunFixture.id)).resolves.toEqual(pendingRun);
+ expect(fetchMock).toHaveBeenCalledWith(
+ `/api/validation-runs/${validationRunFixture.id}`,
+ expect.any(Object),
+ );
+ });
+
+ it.each(['PENDING', 'PROCESSING', 'COMPLETED', 'FAILED'])(
+ 'accepts the %s Validation Run status',
+ async (status) => {
+ const run = validationRunForStatus(status);
+ fetchMock.mockResolvedValueOnce(jsonResponse(run));
+
+ await expect(getValidationRun(validationRunFixture.id)).resolves.toEqual(run);
+ },
+ );
+
+ it('rejects an unknown Validation Run status', async () => {
+ fetchMock.mockResolvedValueOnce(
+ jsonResponse({ ...validationRunFixture, status: 'UNKNOWN_STATUS' }),
+ );
+
+ await expect(getValidationRun(validationRunFixture.id)).rejects.toMatchObject({
+ kind: 'invalid-response',
+ });
+ });
+
+ it('loads and validates Validation Issues', async () => {
+ fetchMock.mockResolvedValueOnce(jsonResponse([validationIssueFixture]));
+
+ await expect(getValidationIssues(validationRunFixture.id)).resolves.toEqual([
+ validationIssueFixture,
+ ]);
+ expect(fetchMock).toHaveBeenCalledWith(
+ `/api/validation-runs/${validationRunFixture.id}/issues`,
+ expect.any(Object),
+ );
+ });
+
+ it.each([
+ 'REQUIRED_FIELD',
+ 'DATA_TYPE',
+ 'UNIQUENESS',
+ 'NUMERIC_RANGE',
+ 'DATE_FORMAT',
+ ])('accepts the %s Validation Rule type', async (ruleType) => {
+ const issue = { ...validationIssueFixture, ruleType };
+ fetchMock.mockResolvedValueOnce(jsonResponse([issue]));
+
+ await expect(getValidationIssues(validationRunFixture.id)).resolves.toEqual([issue]);
+ });
+
+ it.each(['ERROR', 'WARNING'])(
+ 'accepts the %s Validation Issue severity',
+ async (severity) => {
+ const issue = { ...validationIssueFixture, severity };
+ fetchMock.mockResolvedValueOnce(jsonResponse([issue]));
+
+ await expect(getValidationIssues(validationRunFixture.id)).resolves.toEqual([issue]);
+ },
+ );
+
+ it('accepts a null Validation Issue observedValue', async () => {
+ const issueWithoutObservedValue = { ...validationIssueFixture, observedValue: null };
+ fetchMock.mockResolvedValueOnce(jsonResponse([issueWithoutObservedValue]));
+
+ await expect(getValidationIssues(validationRunFixture.id)).resolves.toEqual([
+ issueWithoutObservedValue,
+ ]);
+ });
+
+ it.each([
+ ['Validation Rule type', { ruleType: 'UNKNOWN_RULE' }],
+ ['Validation Issue severity', { severity: 'INFO' }],
+ ])('rejects an unknown %s', async (_label, override) => {
+ fetchMock.mockResolvedValueOnce(jsonResponse([{ ...validationIssueFixture, ...override }]));
+
+ await expect(getValidationIssues(validationRunFixture.id)).rejects.toMatchObject({
+ kind: 'invalid-response',
+ });
+ });
+
+ it('uses the RFC Problem Details detail for an HTTP error', async () => {
+ fetchMock.mockResolvedValueOnce(
+ jsonResponse(problemDetailsFixture, 404, 'application/problem+json'),
+ );
+
+ await expect(getValidationRun(validationRunFixture.id)).rejects.toMatchObject({
+ name: 'ApiError',
+ kind: 'http',
+ status: 404,
+ message: problemDetailsFixture.detail,
+ });
+ });
+
+ it('uses the Problem Details title when no detail is available', async () => {
+ fetchMock.mockResolvedValueOnce(
+ jsonResponse({ title: 'Request rejected', status: 400 }, 400, 'application/problem+json'),
+ );
+
+ await expect(getDatasets()).rejects.toMatchObject({
+ kind: 'http',
+ status: 400,
+ message: 'Request rejected',
+ });
+ });
+
+ it('does not trust Problem Details-like text from another media type', async () => {
+ fetchMock.mockResolvedValueOnce(
+ jsonResponse({ detail: 'Internal gateway implementation details' }, 502, 'application/json'),
+ );
+
+ await expect(getDatasets()).rejects.toMatchObject({
+ kind: 'http',
+ status: 502,
+ message: 'Request failed with status 502.',
+ });
+ });
+
+ it('uses a status-only fallback for a non-JSON HTTP error without exposing its body', async () => {
+ fetchMock.mockResolvedValueOnce(
+ new Response('Internal implementation details', {
+ status: 500,
+ headers: { 'Content-Type': 'text/html' },
+ }),
+ );
+
+ const request = getDatasets();
+
+ await expect(request).rejects.toMatchObject({
+ kind: 'http',
+ status: 500,
+ message: 'Request failed with status 500.',
+ });
+ await expect(request).rejects.not.toThrow('Internal implementation details');
+ });
+
+ it('reports network failures with an application-owned message', async () => {
+ fetchMock.mockRejectedValueOnce(new TypeError('Connection refused at a private address'));
+
+ await expect(getDatasets()).rejects.toEqual(
+ new ApiError('network', 'The Data Quality API could not be reached.'),
+ );
+ });
+
+ it('rejects malformed successful JSON responses', async () => {
+ fetchMock.mockResolvedValueOnce(
+ new Response('{not-json', {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ }),
+ );
+
+ await expect(getDatasets()).rejects.toEqual(
+ new ApiError('invalid-response', 'The Data Quality API returned an unexpected response.'),
+ );
+ });
+
+ it('rejects successful responses with an unexpected contract shape', async () => {
+ fetchMock.mockResolvedValueOnce(jsonResponse([{ ...datasetFixture, id: 'not-a-uuid' }]));
+
+ await expect(getDatasets()).rejects.toMatchObject({
+ kind: 'invalid-response',
+ message: 'The Data Quality API returned an unexpected response.',
+ });
+ });
+
+ it('does not convert an aborted request into a network error', async () => {
+ const abortError = new DOMException('The operation was aborted.', 'AbortError');
+ fetchMock.mockRejectedValueOnce(abortError);
+
+ await expect(getDatasets()).rejects.toBe(abortError);
+ });
+});
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
new file mode 100644
index 0000000..713cea7
--- /dev/null
+++ b/frontend/src/api/client.ts
@@ -0,0 +1,109 @@
+import {
+ decodeDatasets,
+ decodeProblemDetails,
+ decodeValidationIssues,
+ decodeValidationRun,
+ decodeValidationRuns,
+ type Dataset,
+ type ValidationIssue,
+ type ValidationRun,
+} from './contracts';
+
+export type ApiErrorKind = 'http' | 'network' | 'invalid-response';
+
+export class ApiError extends Error {
+ readonly kind: ApiErrorKind;
+ readonly status?: number;
+
+ constructor(kind: ApiErrorKind, message: string, status?: number) {
+ super(message);
+ this.name = 'ApiError';
+ this.kind = kind;
+ this.status = status;
+ }
+}
+
+type Decoder = (value: unknown) => T | null;
+
+const INVALID_RESPONSE_MESSAGE = 'The Data Quality API returned an unexpected response.';
+const NETWORK_ERROR_MESSAGE = 'The Data Quality API could not be reached.';
+
+async function parseJson(response: Response): Promise {
+ const body = await response.text();
+ if (body.trim().length === 0) {
+ return null;
+ }
+
+ try {
+ return JSON.parse(body) as unknown;
+ } catch {
+ return null;
+ }
+}
+
+function httpError(response: Response, body: unknown): ApiError {
+ const contentType = response.headers.get('Content-Type')?.split(';', 1)[0]?.trim().toLowerCase();
+ const problemDetails =
+ contentType === 'application/problem+json' ? decodeProblemDetails(body) : null;
+ const message =
+ problemDetails?.detail?.trim() ||
+ problemDetails?.title?.trim() ||
+ `Request failed with status ${response.status}.`;
+
+ return new ApiError('http', message, response.status);
+}
+
+function isAbortError(error: unknown): boolean {
+ return error instanceof DOMException && error.name === 'AbortError';
+}
+
+async function getJson(url: string, decoder: Decoder, signal?: AbortSignal): Promise {
+ try {
+ const response = await fetch(url, {
+ method: 'GET',
+ headers: { Accept: 'application/json' },
+ signal,
+ });
+ const body = await parseJson(response);
+
+ if (!response.ok) {
+ throw httpError(response, body);
+ }
+
+ const decoded = decoder(body);
+ if (decoded === null) {
+ throw new ApiError('invalid-response', INVALID_RESPONSE_MESSAGE);
+ }
+
+ return decoded;
+ } catch (error) {
+ if (error instanceof ApiError || isAbortError(error)) {
+ throw error;
+ }
+
+ throw new ApiError('network', NETWORK_ERROR_MESSAGE);
+ }
+}
+
+export function getDatasets(signal?: AbortSignal): Promise {
+ return getJson('/api/datasets', decodeDatasets, signal);
+}
+
+export function getValidationRuns(signal?: AbortSignal): Promise {
+ return getJson('/api/validation-runs', decodeValidationRuns, signal);
+}
+
+export function getValidationRun(runId: string, signal?: AbortSignal): Promise {
+ return getJson(`/api/validation-runs/${encodeURIComponent(runId)}`, decodeValidationRun, signal);
+}
+
+export function getValidationIssues(
+ runId: string,
+ signal?: AbortSignal,
+): Promise {
+ return getJson(
+ `/api/validation-runs/${encodeURIComponent(runId)}/issues`,
+ decodeValidationIssues,
+ signal,
+ );
+}
diff --git a/frontend/src/api/contracts.ts b/frontend/src/api/contracts.ts
new file mode 100644
index 0000000..1be6bec
--- /dev/null
+++ b/frontend/src/api/contracts.ts
@@ -0,0 +1,214 @@
+const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+export const validationRunStatuses = ['PENDING', 'PROCESSING', 'COMPLETED', 'FAILED'] as const;
+export type ValidationRunStatus = (typeof validationRunStatuses)[number];
+
+export const validationRuleTypes = [
+ 'REQUIRED_FIELD',
+ 'DATA_TYPE',
+ 'UNIQUENESS',
+ 'NUMERIC_RANGE',
+ 'DATE_FORMAT',
+] as const;
+export type ValidationRuleType = (typeof validationRuleTypes)[number];
+
+export const validationIssueSeverities = ['ERROR', 'WARNING'] as const;
+export type ValidationIssueSeverity = (typeof validationIssueSeverities)[number];
+
+export interface Dataset {
+ id: string;
+ name: string;
+ description: string | null;
+ createdAt: string;
+}
+
+export interface ValidationRun {
+ id: string;
+ datasetId: string;
+ sourceFileId: string;
+ profileId: string;
+ status: ValidationRunStatus;
+ totalRows: number;
+ validRows: number;
+ invalidRows: number;
+ issueCount: number;
+ startedAt: string | null;
+ finishedAt: string | null;
+ failureReason: string | null;
+}
+
+export interface ValidationIssue {
+ id: string;
+ runId: string;
+ rowNumber: number;
+ fieldName: string;
+ ruleType: ValidationRuleType;
+ severity: ValidationIssueSeverity;
+ message: string;
+ observedValue: string | null;
+}
+
+export interface ProblemDetails {
+ type?: string;
+ title?: string;
+ status?: number;
+ detail?: string;
+ instance?: string;
+}
+
+type JsonRecord = Record;
+
+function isRecord(value: unknown): value is JsonRecord {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function isUuid(value: unknown): value is string {
+ return typeof value === 'string' && UUID_PATTERN.test(value);
+}
+
+function isNullableString(value: unknown): value is string | null {
+ return value === null || typeof value === 'string';
+}
+
+function isNonnegativeSafeInteger(value: unknown): value is number {
+ return Number.isSafeInteger(value) && (value as number) >= 0;
+}
+
+function isOneOf(value: unknown, choices: T): value is T[number] {
+ return typeof value === 'string' && choices.includes(value);
+}
+
+function decodeArray(value: unknown, decodeItem: (item: unknown) => T | null): T[] | null {
+ if (!Array.isArray(value)) {
+ return null;
+ }
+
+ const decoded: T[] = [];
+ for (const item of value) {
+ const decodedItem = decodeItem(item);
+ if (decodedItem === null) {
+ return null;
+ }
+ decoded.push(decodedItem);
+ }
+
+ return decoded;
+}
+
+export function decodeDataset(value: unknown): Dataset | null {
+ if (
+ !isRecord(value) ||
+ !isUuid(value.id) ||
+ typeof value.name !== 'string' ||
+ !isNullableString(value.description) ||
+ typeof value.createdAt !== 'string'
+ ) {
+ return null;
+ }
+
+ return {
+ id: value.id,
+ name: value.name,
+ description: value.description,
+ createdAt: value.createdAt,
+ };
+}
+
+export function decodeDatasets(value: unknown): Dataset[] | null {
+ return decodeArray(value, decodeDataset);
+}
+
+export function decodeValidationRun(value: unknown): ValidationRun | null {
+ if (
+ !isRecord(value) ||
+ !isUuid(value.id) ||
+ !isUuid(value.datasetId) ||
+ !isUuid(value.sourceFileId) ||
+ !isUuid(value.profileId) ||
+ !isOneOf(value.status, validationRunStatuses) ||
+ !isNonnegativeSafeInteger(value.totalRows) ||
+ !isNonnegativeSafeInteger(value.validRows) ||
+ !isNonnegativeSafeInteger(value.invalidRows) ||
+ !isNonnegativeSafeInteger(value.issueCount) ||
+ !isNullableString(value.startedAt) ||
+ !isNullableString(value.finishedAt) ||
+ !isNullableString(value.failureReason)
+ ) {
+ return null;
+ }
+
+ return {
+ id: value.id,
+ datasetId: value.datasetId,
+ sourceFileId: value.sourceFileId,
+ profileId: value.profileId,
+ status: value.status,
+ totalRows: value.totalRows,
+ validRows: value.validRows,
+ invalidRows: value.invalidRows,
+ issueCount: value.issueCount,
+ startedAt: value.startedAt,
+ finishedAt: value.finishedAt,
+ failureReason: value.failureReason,
+ };
+}
+
+export function decodeValidationRuns(value: unknown): ValidationRun[] | null {
+ return decodeArray(value, decodeValidationRun);
+}
+
+export function decodeValidationIssue(value: unknown): ValidationIssue | null {
+ if (
+ !isRecord(value) ||
+ !isUuid(value.id) ||
+ !isUuid(value.runId) ||
+ !isNonnegativeSafeInteger(value.rowNumber) ||
+ value.rowNumber < 2 ||
+ typeof value.fieldName !== 'string' ||
+ !isOneOf(value.ruleType, validationRuleTypes) ||
+ !isOneOf(value.severity, validationIssueSeverities) ||
+ typeof value.message !== 'string' ||
+ !isNullableString(value.observedValue)
+ ) {
+ return null;
+ }
+
+ return {
+ id: value.id,
+ runId: value.runId,
+ rowNumber: value.rowNumber,
+ fieldName: value.fieldName,
+ ruleType: value.ruleType,
+ severity: value.severity,
+ message: value.message,
+ observedValue: value.observedValue,
+ };
+}
+
+export function decodeValidationIssues(value: unknown): ValidationIssue[] | null {
+ return decodeArray(value, decodeValidationIssue);
+}
+
+export function decodeProblemDetails(value: unknown): ProblemDetails | null {
+ if (!isRecord(value)) {
+ return null;
+ }
+
+ const type = typeof value.type === 'string' ? value.type : undefined;
+ const title = typeof value.title === 'string' ? value.title : undefined;
+ const status = isNonnegativeSafeInteger(value.status) ? value.status : undefined;
+ const detail = typeof value.detail === 'string' ? value.detail : undefined;
+ const instance = typeof value.instance === 'string' ? value.instance : undefined;
+
+ if (
+ type === undefined &&
+ title === undefined &&
+ status === undefined &&
+ detail === undefined &&
+ instance === undefined
+ ) {
+ return null;
+ }
+
+ return { type, title, status, detail, instance };
+}
diff --git a/frontend/src/components/AsyncState.tsx b/frontend/src/components/AsyncState.tsx
new file mode 100644
index 0000000..dee839e
--- /dev/null
+++ b/frontend/src/components/AsyncState.tsx
@@ -0,0 +1,44 @@
+interface LoadingStateProps {
+ message: string;
+}
+
+export function LoadingState({ message }: LoadingStateProps) {
+ return (
+
+ );
+}
+
+interface ErrorStateProps {
+ title: string;
+ message: string;
+ retryLabel: string;
+ onRetry: () => void;
+}
+
+export function ErrorState({ title, message, retryLabel, onRetry }: ErrorStateProps) {
+ return (
+
+
{title}
+
{message}
+
+ {retryLabel}
+
+
+ );
+}
+
+interface EmptyStateProps {
+ title: string;
+ message: string;
+}
+
+export function EmptyState({ title, message }: EmptyStateProps) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/DatasetList.tsx b/frontend/src/components/DatasetList.tsx
new file mode 100644
index 0000000..6c1884d
--- /dev/null
+++ b/frontend/src/components/DatasetList.tsx
@@ -0,0 +1,37 @@
+import type { Dataset } from '../api/contracts';
+
+interface DatasetListProps {
+ datasets: Dataset[];
+}
+
+export function DatasetList({ datasets }: DatasetListProps) {
+ return (
+
+
+ Datasets
+
+
+ Name
+ Description
+ Dataset ID
+ Created
+
+
+
+ {datasets.map((dataset) => (
+
+ {dataset.name}
+ {dataset.description ?? No description }
+
+ {dataset.id}
+
+
+ {dataset.createdAt}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/src/components/ValidationIssueList.tsx b/frontend/src/components/ValidationIssueList.tsx
new file mode 100644
index 0000000..20a4e8b
--- /dev/null
+++ b/frontend/src/components/ValidationIssueList.tsx
@@ -0,0 +1,174 @@
+import { useMemo, useState } from 'react';
+import {
+ validationIssueSeverities,
+ type ValidationIssue,
+ type ValidationIssueSeverity,
+} from '../api/contracts';
+import { EmptyState } from './AsyncState';
+
+interface ValidationIssueListProps {
+ issues: ValidationIssue[];
+}
+
+function fieldOptionLabel(fieldName: string) {
+ return JSON.stringify(fieldName) ?? fieldName;
+}
+
+function ObservedValue({ value }: { value: string | null }) {
+ if (value === null) {
+ return Not recorded ;
+ }
+
+ if (value.length === 0) {
+ return Empty string ;
+ }
+
+ const whitespaceOnly = value.trim().length === 0;
+ return (
+
+ {value}
+
+ );
+}
+
+export function ValidationIssueList({ issues }: ValidationIssueListProps) {
+ const [severity, setSeverity] = useState('');
+ const [fieldName, setFieldName] = useState('');
+
+ const fieldNames = useMemo(() => {
+ const seen = new Set();
+ const orderedFields: string[] = [];
+
+ for (const issue of issues) {
+ if (!seen.has(issue.fieldName)) {
+ seen.add(issue.fieldName);
+ orderedFields.push(issue.fieldName);
+ }
+ }
+
+ return orderedFields;
+ }, [issues]);
+
+ const filteredIssues = useMemo(
+ () =>
+ issues.filter(
+ (issue) =>
+ (severity === '' || issue.severity === severity) &&
+ (fieldName === '' || issue.fieldName === fieldName),
+ ),
+ [fieldName, issues, severity],
+ );
+
+ const filtersActive = severity !== '' || fieldName !== '';
+ const filterResultMessage = filtersActive
+ ? `Showing ${filteredIssues.length} of ${issues.length} persisted Issues.`
+ : `Showing all ${issues.length} persisted Issues.`;
+ const clearFilters = () => {
+ setSeverity('');
+ setFieldName('');
+ };
+
+ return (
+ <>
+
+ Filter Issues
+
+ Severity
+ setSeverity(event.target.value as ValidationIssueSeverity | '')}
+ >
+ All severities
+ {validationIssueSeverities.map((option) => (
+
+ {option}
+
+ ))}
+
+
+
+ Field name
+ setFieldName(event.target.value)}>
+ All fields
+ {fieldNames.map((option) => (
+
+ {fieldOptionLabel(option)}
+
+ ))}
+
+
+
+ Clear filters
+
+
+
+
+ {filterResultMessage}
+
+
+ {filteredIssues.length === 0 ? (
+
+ ) : (
+
+
+ Persisted Validation Issues
+
+
+ Row
+ Field
+ Rule type
+ Severity
+ Message
+ Observed value
+
+
+
+ {filteredIssues.map((issue) => (
+
+ {issue.rowNumber}
+
+ {issue.fieldName}
+
+ {issue.ruleType}
+
+
+ {issue.severity}
+
+
+ {issue.message}
+
+
+
+
+ ))}
+
+
+
+ )}
+ >
+ );
+}
diff --git a/frontend/src/components/ValidationRunList.tsx b/frontend/src/components/ValidationRunList.tsx
new file mode 100644
index 0000000..b90836d
--- /dev/null
+++ b/frontend/src/components/ValidationRunList.tsx
@@ -0,0 +1,60 @@
+import { Link } from 'react-router-dom';
+import type { ValidationRun } from '../api/contracts';
+import { ValidationRunStatusBadge } from './ValidationRunStatusBadge';
+
+interface ValidationRunListProps {
+ validationRuns: ValidationRun[];
+ datasetNames: ReadonlyMap;
+}
+
+export function ValidationRunList({ validationRuns, datasetNames }: ValidationRunListProps) {
+ return (
+
+
+ Validation Runs
+
+
+ Run ID
+ Status
+ Dataset
+ Total rows
+ Valid rows
+ Invalid rows
+ Issues
+
+
+
+ {validationRuns.map((validationRun) => {
+ const datasetName = datasetNames.get(validationRun.datasetId);
+
+ return (
+
+
+ {validationRun.id}
+
+
+
+
+
+ {datasetName === undefined ? null : {datasetName} }
+
+ {validationRun.datasetId}
+
+
+ {validationRun.totalRows}
+ {validationRun.validRows}
+ {validationRun.invalidRows}
+ {validationRun.issueCount}
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/frontend/src/components/ValidationRunMetadata.tsx b/frontend/src/components/ValidationRunMetadata.tsx
new file mode 100644
index 0000000..f45fb1a
--- /dev/null
+++ b/frontend/src/components/ValidationRunMetadata.tsx
@@ -0,0 +1,94 @@
+import type { AsyncResourceStatus } from '../hooks/useAsyncResource';
+import type { ValidationRun } from '../api/contracts';
+import { ValidationRunStatusBadge } from './ValidationRunStatusBadge';
+
+interface ValidationRunMetadataProps {
+ validationRun: ValidationRun;
+ datasetName: string | null;
+ datasetContextStatus: AsyncResourceStatus;
+}
+
+interface LifecycleTimeProps {
+ value: string | null;
+ absentLabel: string;
+}
+
+function LifecycleTime({ value, absentLabel }: LifecycleTimeProps) {
+ return value === null ? (
+ {absentLabel}
+ ) : (
+ {value}
+ );
+}
+
+export function ValidationRunMetadata({
+ validationRun,
+ datasetName,
+ datasetContextStatus,
+}: ValidationRunMetadataProps) {
+ return (
+
+ Read-only details
+
+
+
+
Run ID
+
+ {validationRun.id}
+
+
+
+
Status
+
+
+
+
+
+
Dataset
+
+ {datasetName === null ? null : {datasetName} }
+
+ {validationRun.datasetId}
+
+ {datasetName === null && datasetContextStatus === 'loading' ? (
+ Dataset name is loading.
+ ) : null}
+ {datasetName === null && datasetContextStatus !== 'loading' ? (
+ Dataset name is unavailable.
+ ) : null}
+
+
+
+
SourceFile ID
+
+ {validationRun.sourceFileId}
+
+
+
+
Profile ID
+
+ {validationRun.profileId}
+
+
+
+
Started at
+
+
+
+
+
+
Finished at
+
+
+
+
+ {validationRun.failureReason === null ? null : (
+
+
Failure reason
+ {validationRun.failureReason}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/ValidationRunStatusBadge.tsx b/frontend/src/components/ValidationRunStatusBadge.tsx
new file mode 100644
index 0000000..e321a9f
--- /dev/null
+++ b/frontend/src/components/ValidationRunStatusBadge.tsx
@@ -0,0 +1,18 @@
+import type { ValidationRunStatus } from '../api/contracts';
+
+const STATUS_LABELS: Record = {
+ PENDING: 'Pending',
+ PROCESSING: 'Processing',
+ COMPLETED: 'Completed',
+ FAILED: 'Failed',
+};
+
+interface ValidationRunStatusBadgeProps {
+ status: ValidationRunStatus;
+}
+
+export function ValidationRunStatusBadge({ status }: ValidationRunStatusBadgeProps) {
+ return (
+ {STATUS_LABELS[status]}
+ );
+}
diff --git a/frontend/src/components/ValidationRunSummary.tsx b/frontend/src/components/ValidationRunSummary.tsx
new file mode 100644
index 0000000..1c52407
--- /dev/null
+++ b/frontend/src/components/ValidationRunSummary.tsx
@@ -0,0 +1,32 @@
+import type { ValidationRun } from '../api/contracts';
+
+interface ValidationRunSummaryProps {
+ validationRun: ValidationRun;
+}
+
+export function ValidationRunSummary({ validationRun }: ValidationRunSummaryProps) {
+ return (
+
+ Persisted result
+ Run summary
+
+
+
Total rows
+ {validationRun.totalRows}
+
+
+
Valid rows
+ {validationRun.validRows}
+
+
+
Invalid rows
+ {validationRun.invalidRows}
+
+
+
Issue count
+ {validationRun.issueCount}
+
+
+
+ );
+}
diff --git a/frontend/src/hooks/useAsyncResource.test.tsx b/frontend/src/hooks/useAsyncResource.test.tsx
new file mode 100644
index 0000000..03b03c1
--- /dev/null
+++ b/frontend/src/hooks/useAsyncResource.test.tsx
@@ -0,0 +1,88 @@
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { useAsyncResource } from './useAsyncResource';
+
+type Loader = (signal: AbortSignal) => Promise;
+
+describe('useAsyncResource', () => {
+ it('returns to a clean loading state when its loader changes', async () => {
+ const firstLoader = vi.fn().mockResolvedValue('first result');
+ let resolveSecond: (value: string) => void = () => undefined;
+ const secondLoader = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveSecond = resolve;
+ }),
+ );
+
+ const { result, rerender } = renderHook(
+ ({ loader }: { loader: Loader }) => useAsyncResource(loader),
+ { initialProps: { loader: firstLoader } },
+ );
+
+ await waitFor(() => expect(result.current.status).toBe('success'));
+ expect(result.current.data).toBe('first result');
+
+ rerender({ loader: secondLoader });
+
+ await waitFor(() => expect(result.current.status).toBe('loading'));
+ expect(result.current.data).toBeNull();
+ expect(result.current.error).toBeNull();
+
+ act(() => resolveSecond('second result'));
+
+ await waitFor(() => expect(result.current.status).toBe('success'));
+ expect(result.current.data).toBe('second result');
+ });
+
+ it('aborts its active request when unmounted', async () => {
+ let requestSignal: AbortSignal | undefined;
+ const loader = vi.fn(
+ (signal) =>
+ new Promise(() => {
+ requestSignal = signal;
+ }),
+ );
+
+ const { unmount } = renderHook(() => useAsyncResource(loader));
+
+ await waitFor(() => expect(loader).toHaveBeenCalledOnce());
+ unmount();
+
+ expect(requestSignal?.aborted).toBe(true);
+ });
+
+ it('prevents a superseded request from overwriting the newer resource state', async () => {
+ let firstRequestSignal: AbortSignal | undefined;
+ let resolveFirst: (value: string) => void = () => undefined;
+ const firstLoader = vi.fn(
+ (signal) =>
+ new Promise((resolve) => {
+ firstRequestSignal = signal;
+ resolveFirst = resolve;
+ }),
+ );
+ const secondLoader = vi.fn().mockResolvedValue('newer result');
+
+ const { result, rerender } = renderHook(
+ ({ loader }: { loader: Loader }) => useAsyncResource(loader),
+ { initialProps: { loader: firstLoader } },
+ );
+
+ await waitFor(() => expect(firstLoader).toHaveBeenCalledOnce());
+
+ rerender({ loader: secondLoader });
+
+ await waitFor(() => expect(secondLoader).toHaveBeenCalledOnce());
+ expect(firstRequestSignal?.aborted).toBe(true);
+ await waitFor(() => expect(result.current.data).toBe('newer result'));
+
+ await act(async () => {
+ resolveFirst('superseded result');
+ await Promise.resolve();
+ });
+
+ expect(result.current.status).toBe('success');
+ expect(result.current.data).toBe('newer result');
+ });
+});
diff --git a/frontend/src/hooks/useAsyncResource.ts b/frontend/src/hooks/useAsyncResource.ts
new file mode 100644
index 0000000..b3a746d
--- /dev/null
+++ b/frontend/src/hooks/useAsyncResource.ts
@@ -0,0 +1,61 @@
+import { useCallback, useEffect, useState } from 'react';
+
+export type AsyncResourceStatus = 'loading' | 'success' | 'error';
+
+export interface AsyncResource {
+ status: AsyncResourceStatus;
+ data: T | null;
+ error: Error | null;
+ retry: () => void;
+}
+
+function asError(error: unknown): Error {
+ return error instanceof Error ? error : new Error('An unexpected error occurred.');
+}
+
+export function useAsyncResource(load: (signal: AbortSignal) => Promise): AsyncResource {
+ const [attempt, setAttempt] = useState(0);
+ const [status, setStatus] = useState('loading');
+ const [data, setData] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const controller = new AbortController();
+
+ queueMicrotask(() => {
+ if (controller.signal.aborted) {
+ return;
+ }
+
+ setStatus('loading');
+ setData(null);
+ setError(null);
+
+ void load(controller.signal).then(
+ (loadedData) => {
+ if (!controller.signal.aborted) {
+ setData(loadedData);
+ setStatus('success');
+ }
+ },
+ (loadError: unknown) => {
+ if (!controller.signal.aborted) {
+ setError(asError(loadError));
+ setStatus('error');
+ }
+ },
+ );
+ });
+
+ return () => controller.abort();
+ }, [attempt, load]);
+
+ const retry = useCallback(() => {
+ setStatus('loading');
+ setData(null);
+ setError(null);
+ setAttempt((currentAttempt) => currentAttempt + 1);
+ }, []);
+
+ return { status, data, error, retry };
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index cf91907..3520189 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,10 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './styles.css';
createRoot(document.getElementById('root')!).render(
-
+
+
+
,
);
diff --git a/frontend/src/pages/DashboardPage.test.tsx b/frontend/src/pages/DashboardPage.test.tsx
new file mode 100644
index 0000000..93c2f8a
--- /dev/null
+++ b/frontend/src/pages/DashboardPage.test.tsx
@@ -0,0 +1,217 @@
+import { fireEvent, screen, waitFor, within } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { getDatasets, getValidationRuns } from '../api/client';
+import type { Dataset, ValidationRun } from '../api/contracts';
+import { datasetFixture, validationRunFixture } from '../test/fixtures';
+import { renderWithRouter } from '../test/renderWithRouter';
+import { DashboardPage } from './DashboardPage';
+
+vi.mock('../api/client', () => ({
+ getDatasets: vi.fn(),
+ getValidationRuns: vi.fn(),
+}));
+
+const getDatasetsMock = vi.mocked(getDatasets);
+const getValidationRunsMock = vi.mocked(getValidationRuns);
+
+const secondDataset: Dataset = {
+ ...datasetFixture,
+ id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
+ name: 'Orders import',
+ description: null,
+ createdAt: '2026-07-19T08:15:00Z',
+};
+
+const secondRun: ValidationRun = {
+ ...validationRunFixture,
+ id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
+ datasetId: secondDataset.id,
+ status: 'PENDING',
+ totalRows: 0,
+ validRows: 0,
+ invalidRows: 0,
+ issueCount: 0,
+ startedAt: null,
+ finishedAt: null,
+};
+
+const completedRun: ValidationRun = {
+ ...validationRunFixture,
+ totalRows: 17,
+ validRows: 12,
+ invalidRows: 5,
+ issueCount: 8,
+};
+
+function deferred() {
+ let resolvePromise: (value: T) => void = () => undefined;
+ const promise = new Promise((resolve) => {
+ resolvePromise = resolve;
+ });
+
+ return { promise, resolve: resolvePromise };
+}
+
+function bodyRows(tableName: string) {
+ return within(screen.getByRole('table', { name: tableName }))
+ .getAllByRole('row')
+ .slice(1);
+}
+
+describe('DashboardPage', () => {
+ beforeEach(() => {
+ getDatasetsMock.mockReset().mockResolvedValue([datasetFixture]);
+ getValidationRunsMock.mockReset().mockResolvedValue([validationRunFixture]);
+ });
+
+ it('renders both collections in backend order and resolves Dataset names', async () => {
+ getDatasetsMock.mockResolvedValue([secondDataset, datasetFixture]);
+ getValidationRunsMock.mockResolvedValue([secondRun, completedRun]);
+
+ renderWithRouter( );
+
+ await screen.findByRole('table', { name: 'Datasets' });
+ await screen.findByRole('table', { name: 'Validation Runs' });
+ expect(screen.getByRole('region', { name: 'Scrollable Dataset table' })).toHaveAttribute(
+ 'tabindex',
+ '0',
+ );
+ expect(screen.getByRole('region', { name: 'Scrollable Validation Run table' })).toHaveAttribute(
+ 'tabindex',
+ '0',
+ );
+
+ const datasetRows = bodyRows('Datasets');
+ expect(datasetRows).toHaveLength(2);
+ expect(within(datasetRows[0]).getByText(secondDataset.name)).toBeInTheDocument();
+ expect(within(datasetRows[1]).getByText(datasetFixture.name)).toBeInTheDocument();
+ expect(within(datasetRows[0]).getByText('No description')).toBeInTheDocument();
+ expect(within(datasetRows[0]).getByText(secondDataset.id)).toBeInTheDocument();
+ expect(within(datasetRows[0]).getByText(secondDataset.createdAt)).toBeInTheDocument();
+ expect(within(datasetRows[1]).getByText(datasetFixture.description!)).toBeInTheDocument();
+
+ const runRows = bodyRows('Validation Runs');
+ expect(runRows).toHaveLength(2);
+ expect(within(runRows[0]).getByRole('link', { name: secondRun.id })).toBeInTheDocument();
+ expect(within(runRows[1]).getByRole('link', { name: completedRun.id })).toBeInTheDocument();
+ expect(within(runRows[0]).getByText(secondDataset.name)).toBeInTheDocument();
+ expect(within(runRows[1]).getByText(datasetFixture.name)).toBeInTheDocument();
+ expect(within(runRows[0]).getByText('Pending')).toBeInTheDocument();
+ expect(within(runRows[0]).getAllByText('0')).toHaveLength(4);
+ expect(within(runRows[1]).getByText('Completed')).toBeInTheDocument();
+ expect(within(runRows[1]).getByText('17')).toBeInTheDocument();
+ expect(within(runRows[1]).getByText('12')).toBeInTheDocument();
+ expect(within(runRows[1]).getByText('5')).toBeInTheDocument();
+ expect(within(runRows[1]).getByText('8')).toBeInTheDocument();
+
+ expect(screen.queryByText(/recent/i)).not.toBeInTheDocument();
+ expect(screen.queryByText(/background|actively processing/i)).not.toBeInTheDocument();
+ });
+
+ it('falls back to the Dataset UUID when no matching Dataset is loaded', async () => {
+ const unknownDatasetId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
+ getValidationRunsMock.mockResolvedValue([
+ { ...validationRunFixture, datasetId: unknownDatasetId },
+ ]);
+
+ renderWithRouter( );
+
+ const runTable = await screen.findByRole('table', { name: 'Validation Runs' });
+ expect(within(runTable).getByText(unknownDatasetId)).toBeInTheDocument();
+ expect(within(runTable).queryByText(datasetFixture.name)).not.toBeInTheDocument();
+ });
+
+ it('keeps Runs visible with UUID fallback when Datasets fail and retries only Datasets', async () => {
+ getDatasetsMock
+ .mockRejectedValueOnce(new Error('Dataset service is unavailable.'))
+ .mockResolvedValueOnce([datasetFixture]);
+
+ renderWithRouter( );
+
+ const datasetAlert = await screen.findByRole('alert');
+ expect(
+ within(datasetAlert).getByRole('heading', { name: 'Datasets could not be loaded' }),
+ ).toBeInTheDocument();
+ const runTable = await screen.findByRole('table', { name: 'Validation Runs' });
+ expect(within(runTable).getByText(validationRunFixture.datasetId)).toBeInTheDocument();
+ expect(within(runTable).queryByText(datasetFixture.name)).not.toBeInTheDocument();
+
+ fireEvent.click(within(datasetAlert).getByRole('button', { name: 'Retry Datasets' }));
+
+ expect(await within(runTable).findByText(datasetFixture.name)).toBeInTheDocument();
+ expect(getDatasetsMock).toHaveBeenCalledTimes(2);
+ expect(getValidationRunsMock).toHaveBeenCalledOnce();
+ });
+
+ it('keeps Datasets visible when Runs fail and retries only Runs', async () => {
+ getValidationRunsMock
+ .mockRejectedValueOnce(new Error('Validation Run service is unavailable.'))
+ .mockResolvedValueOnce([validationRunFixture]);
+
+ renderWithRouter( );
+
+ const datasetTable = await screen.findByRole('table', { name: 'Datasets' });
+ expect(within(datasetTable).getByText(datasetFixture.name)).toBeInTheDocument();
+ expect(await screen.findByText('Validation Runs could not be loaded')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Retry Validation Runs' }));
+
+ expect(await screen.findByRole('link', { name: validationRunFixture.id })).toBeInTheDocument();
+ expect(getValidationRunsMock).toHaveBeenCalledTimes(2);
+ expect(getDatasetsMock).toHaveBeenCalledOnce();
+ });
+
+ it('shows Runs while the Dataset request is still loading', async () => {
+ const datasetsRequest = deferred();
+ getDatasetsMock.mockImplementation(() => datasetsRequest.promise);
+
+ renderWithRouter( );
+
+ const datasetsSection = screen.getByRole('region', { name: 'Datasets' });
+ const loadingStatus = await within(datasetsSection).findByRole('status');
+ expect(loadingStatus).toHaveTextContent('Loading Datasets…');
+ expect(await screen.findByRole('link', { name: validationRunFixture.id })).toBeInTheDocument();
+ expect(screen.getByText(validationRunFixture.datasetId)).toBeInTheDocument();
+ });
+
+ it('shows Datasets while the Validation Run request is still loading', async () => {
+ const runsRequest = deferred();
+ getValidationRunsMock.mockImplementation(() => runsRequest.promise);
+
+ renderWithRouter( );
+
+ expect(await screen.findByText(datasetFixture.name)).toBeInTheDocument();
+ expect(screen.getByText('Loading Validation Runs…')).toBeInTheDocument();
+ });
+
+ it('shows the Dataset empty state without hiding Validation Runs', async () => {
+ getDatasetsMock.mockResolvedValue([]);
+
+ renderWithRouter( );
+
+ expect(await screen.findByText('No Datasets')).toBeInTheDocument();
+ expect(await screen.findByRole('link', { name: validationRunFixture.id })).toBeInTheDocument();
+ });
+
+ it('shows the Validation Run empty state without hiding Datasets', async () => {
+ getValidationRunsMock.mockResolvedValue([]);
+
+ renderWithRouter( );
+
+ expect(await screen.findByText('No Validation Runs')).toBeInTheDocument();
+ expect(await screen.findByText(datasetFixture.name)).toBeInTheDocument();
+ });
+
+ it('passes independent AbortSignals to the two API requests', async () => {
+ renderWithRouter( );
+
+ await waitFor(() => {
+ expect(getDatasetsMock).toHaveBeenCalledOnce();
+ expect(getValidationRunsMock).toHaveBeenCalledOnce();
+ });
+
+ expect(getDatasetsMock.mock.calls[0]?.[0]).toBeInstanceOf(AbortSignal);
+ expect(getValidationRunsMock.mock.calls[0]?.[0]).toBeInstanceOf(AbortSignal);
+ expect(getDatasetsMock.mock.calls[0]?.[0]).not.toBe(getValidationRunsMock.mock.calls[0]?.[0]);
+ });
+});
diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx
new file mode 100644
index 0000000..668181a
--- /dev/null
+++ b/frontend/src/pages/DashboardPage.tsx
@@ -0,0 +1,97 @@
+import { useCallback, useMemo } from 'react';
+import { getDatasets, getValidationRuns } from '../api/client';
+import { DatasetList } from '../components/DatasetList';
+import { EmptyState, ErrorState, LoadingState } from '../components/AsyncState';
+import { ValidationRunList } from '../components/ValidationRunList';
+import { useAsyncResource } from '../hooks/useAsyncResource';
+
+export function DashboardPage() {
+ const loadDatasets = useCallback((signal: AbortSignal) => getDatasets(signal), []);
+ const loadValidationRuns = useCallback((signal: AbortSignal) => getValidationRuns(signal), []);
+ const datasets = useAsyncResource(loadDatasets);
+ const validationRuns = useAsyncResource(loadValidationRuns);
+
+ const datasetNames = useMemo(() => {
+ if (datasets.status !== 'success' || datasets.data === null) {
+ return new Map();
+ }
+
+ return new Map(datasets.data.map((dataset) => [dataset.id, dataset.name]));
+ }, [datasets.data, datasets.status]);
+
+ return (
+
+
+ Read-only overview
+ Data quality dashboard
+
+ Review persisted Datasets and Validation Runs. Collections remain in the order supplied by
+ the backend.
+
+
+
+
+
+
+ {datasets.status === 'success' && datasets.data !== null ? (
+
{datasets.data.length}
+ ) : null}
+
+
+ {datasets.status === 'loading' ? : null}
+ {datasets.status === 'error' ? (
+
+ ) : null}
+ {datasets.status === 'success' && datasets.data?.length === 0 ? (
+
+ ) : null}
+ {datasets.status === 'success' && datasets.data !== null && datasets.data.length > 0 ? (
+
+ ) : null}
+
+
+
+
+
+
Global collection
+
Validation Runs
+
+ {validationRuns.status === 'success' && validationRuns.data !== null ? (
+
{validationRuns.data.length}
+ ) : null}
+
+
+ {validationRuns.status === 'loading' ? (
+
+ ) : null}
+ {validationRuns.status === 'error' ? (
+
+ ) : null}
+ {validationRuns.status === 'success' && validationRuns.data?.length === 0 ? (
+
+ ) : null}
+ {validationRuns.status === 'success' &&
+ validationRuns.data !== null &&
+ validationRuns.data.length > 0 ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/frontend/src/pages/ValidationRunDetailPage.test.tsx b/frontend/src/pages/ValidationRunDetailPage.test.tsx
new file mode 100644
index 0000000..ca12c8e
--- /dev/null
+++ b/frontend/src/pages/ValidationRunDetailPage.test.tsx
@@ -0,0 +1,529 @@
+import { act, fireEvent, screen, waitFor, within } from '@testing-library/react';
+import { Link, Route, Routes, useParams } from 'react-router-dom';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { getDatasets, getValidationIssues, getValidationRun } from '../api/client';
+import type { Dataset, ValidationIssue, ValidationRun } from '../api/contracts';
+import { datasetFixture, validationIssueFixture, validationRunFixture } from '../test/fixtures';
+import { renderWithRouter } from '../test/renderWithRouter';
+import { ValidationRunDetailPage } from './ValidationRunDetailPage';
+
+vi.mock('../api/client', () => ({
+ getDatasets: vi.fn(),
+ getValidationIssues: vi.fn(),
+ getValidationRun: vi.fn(),
+}));
+
+const getDatasetsMock = vi.mocked(getDatasets);
+const getValidationIssuesMock = vi.mocked(getValidationIssues);
+const getValidationRunMock = vi.mocked(getValidationRun);
+
+const markupValue = ' ';
+const longObservedValue =
+ `BEGIN-${'0123456789abcdef'.repeat(128)}` + '-literal markup -END';
+
+const issues: ValidationIssue[] = [
+ {
+ ...validationIssueFixture,
+ id: '11111111-1111-4111-8111-111111111111',
+ rowNumber: 2,
+ fieldName: 'email',
+ severity: 'ERROR',
+ message: 'Email is required.',
+ observedValue: '',
+ },
+ {
+ ...validationIssueFixture,
+ id: '22222222-2222-4222-8222-222222222222',
+ rowNumber: 3,
+ fieldName: 'Email',
+ ruleType: 'UNIQUENESS',
+ severity: 'WARNING',
+ message: 'Uppercase field warning.',
+ observedValue: null,
+ },
+ {
+ ...validationIssueFixture,
+ id: '33333333-3333-4333-8333-333333333333',
+ rowNumber: 4,
+ fieldName: ' email ',
+ ruleType: 'DATA_TYPE',
+ severity: 'ERROR',
+ message: 'Whitespace field error.',
+ observedValue: ' ',
+ },
+ {
+ ...validationIssueFixture,
+ id: '44444444-4444-4444-8444-444444444444',
+ rowNumber: 5,
+ fieldName: 'email',
+ ruleType: 'DATE_FORMAT',
+ severity: 'WARNING',
+ message: 'Unicode value warning.',
+ observedValue: 'München 東京',
+ },
+ {
+ ...validationIssueFixture,
+ id: '55555555-5555-4555-8555-555555555555',
+ rowNumber: 6,
+ fieldName: 'name',
+ ruleType: 'NUMERIC_RANGE',
+ severity: 'ERROR',
+ message: 'Markup-like value error.',
+ observedValue: markupValue,
+ },
+];
+
+const secondRun: ValidationRun = {
+ ...validationRunFixture,
+ id: '66666666-6666-4666-8666-666666666666',
+ datasetId: '77777777-7777-4777-8777-777777777777',
+ sourceFileId: '88888888-8888-4888-8888-888888888888',
+ profileId: '99999999-9999-4999-8999-999999999999',
+ totalRows: 4,
+ validRows: 4,
+ invalidRows: 0,
+ issueCount: 1,
+};
+
+const secondIssue: ValidationIssue = {
+ ...validationIssueFixture,
+ id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
+ runId: secondRun.id,
+ rowNumber: 2,
+ fieldName: 'second-field',
+ severity: 'WARNING',
+ message: 'Second Run warning.',
+ observedValue: 'second value',
+};
+
+function deferred() {
+ let resolvePromise: (value: T) => void = () => undefined;
+ let rejectPromise: (reason?: unknown) => void = () => undefined;
+ const promise = new Promise((resolve, reject) => {
+ resolvePromise = resolve;
+ rejectPromise = reject;
+ });
+
+ return { promise, resolve: resolvePromise, reject: rejectPromise };
+}
+
+function renderDetail(runId = validationRunFixture.id) {
+ return renderWithRouter( , `/runs/${runId}`);
+}
+
+function issueRows() {
+ return within(screen.getByRole('table', { name: 'Persisted Validation Issues' }))
+ .getAllByRole('row')
+ .slice(1);
+}
+
+function RoutedDetail() {
+ const { runId } = useParams();
+ return runId === undefined ? null : ;
+}
+
+function RouteChangeHarness() {
+ return (
+ <>
+ Open second Run
+
+ } />
+
+ >
+ );
+}
+
+describe('ValidationRunDetailPage', () => {
+ beforeEach(() => {
+ getValidationRunMock.mockReset().mockResolvedValue(validationRunFixture);
+ getValidationIssuesMock.mockReset().mockResolvedValue(issues);
+ getDatasetsMock.mockReset().mockResolvedValue([datasetFixture]);
+ });
+
+ it('loads the Run first, then starts independent Issue and Dataset requests', async () => {
+ const runRequest = deferred();
+ getValidationRunMock.mockReturnValue(runRequest.promise);
+
+ renderDetail();
+
+ expect(await screen.findByText('Loading Validation Run…')).toBeInTheDocument();
+ expect(getValidationIssuesMock).not.toHaveBeenCalled();
+ expect(getDatasetsMock).not.toHaveBeenCalled();
+
+ await act(async () => runRequest.resolve(validationRunFixture));
+
+ expect(await screen.findByRole('heading', { name: 'Run summary' })).toBeInTheDocument();
+ expect(getValidationIssuesMock).toHaveBeenCalledOnce();
+ expect(getValidationIssuesMock).toHaveBeenCalledWith(
+ validationRunFixture.id,
+ expect.any(AbortSignal),
+ );
+ expect(getDatasetsMock).toHaveBeenCalledOnce();
+ expect(getDatasetsMock).toHaveBeenCalledWith(expect.any(AbortSignal));
+ });
+
+ it('renders exact Run metadata, Dataset context, timestamps, and persisted summary values', async () => {
+ const run = {
+ ...validationRunFixture,
+ totalRows: 17,
+ validRows: 12,
+ invalidRows: 5,
+ issueCount: 8,
+ };
+ getValidationRunMock.mockResolvedValue(run);
+
+ renderDetail();
+
+ await screen.findByText(run.id);
+ await screen.findByText(datasetFixture.name);
+ const metadata = screen.getByRole('region', { name: 'Validation Run' });
+ expect(within(metadata).getByText(run.id)).toBeInTheDocument();
+ expect(within(metadata).getByText('Completed')).toBeInTheDocument();
+ expect(within(metadata).getByText(datasetFixture.name)).toBeInTheDocument();
+ expect(within(metadata).getByText(run.datasetId)).toBeInTheDocument();
+ expect(within(metadata).getByText(run.sourceFileId)).toBeInTheDocument();
+ expect(within(metadata).getByText(run.profileId)).toBeInTheDocument();
+
+ const startedAt = within(metadata).getByText(run.startedAt!);
+ const finishedAt = within(metadata).getByText(run.finishedAt!);
+ expect(startedAt.closest('time')).toHaveAttribute('datetime', run.startedAt);
+ expect(finishedAt.closest('time')).toHaveAttribute('datetime', run.finishedAt);
+
+ const summary = screen.getByRole('region', { name: 'Run summary' });
+ expect(within(summary).getByText('17')).toBeInTheDocument();
+ expect(within(summary).getByText('12')).toBeInTheDocument();
+ expect(within(summary).getByText('5')).toBeInTheDocument();
+ expect(within(summary).getByText('8')).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Back to dashboard' })).toHaveAttribute('href', '/');
+ });
+
+ it('presents nullable lifecycle values neutrally', async () => {
+ const pendingRun: ValidationRun = {
+ ...validationRunFixture,
+ status: 'PENDING',
+ totalRows: 0,
+ validRows: 0,
+ invalidRows: 0,
+ issueCount: 0,
+ startedAt: null,
+ finishedAt: null,
+ };
+ getValidationRunMock.mockResolvedValueOnce(pendingRun);
+
+ renderDetail();
+
+ expect(await screen.findByText('Pending')).toBeInTheDocument();
+ expect(screen.getByText('Not started')).toBeInTheDocument();
+ expect(screen.getByText('Not finished')).toBeInTheDocument();
+ expect(screen.queryByText(/background|actively processing/i)).not.toBeInTheDocument();
+ expect(screen.queryByText('Failure reason')).not.toBeInTheDocument();
+ });
+
+ it('shows a persisted failure reason only when present', async () => {
+ const failedRun: ValidationRun = {
+ ...validationRunFixture,
+ id: secondRun.id,
+ status: 'FAILED',
+ totalRows: 0,
+ validRows: 0,
+ invalidRows: 0,
+ issueCount: 0,
+ failureReason: 'CSV content could not be validated.',
+ };
+ getValidationRunMock.mockResolvedValue(failedRun);
+ renderDetail(failedRun.id);
+
+ expect(await screen.findByText('Failed')).toBeInTheDocument();
+ expect(screen.getByText('Failure reason')).toBeInTheDocument();
+ expect(screen.getByText(failedRun.failureReason!)).toBeInTheDocument();
+ });
+
+ it('uses the Dataset UUID when no matching Dataset exists', async () => {
+ getDatasetsMock.mockResolvedValue([]);
+
+ renderDetail();
+
+ expect(await screen.findByText('Dataset name is unavailable.')).toBeInTheDocument();
+ expect(screen.getByText(validationRunFixture.datasetId)).toBeInTheDocument();
+ expect(screen.queryByText(datasetFixture.name)).not.toBeInTheDocument();
+ });
+
+ it('keeps the Run usable when Dataset lookup fails', async () => {
+ const datasetRequest = deferred();
+ getDatasetsMock.mockReturnValue(datasetRequest.promise);
+
+ renderDetail();
+
+ await waitFor(() => expect(getDatasetsMock).toHaveBeenCalledOnce());
+ expect(screen.getByText('Dataset name is loading.')).toBeInTheDocument();
+
+ await act(async () => {
+ datasetRequest.reject(new Error('Dataset context unavailable.'));
+ });
+
+ expect(await screen.findByText('Dataset name is unavailable.')).toBeInTheDocument();
+ expect(await screen.findByRole('heading', { name: 'Run summary' })).toBeInTheDocument();
+ expect(screen.getByText(validationRunFixture.datasetId)).toBeInTheDocument();
+ expect(await screen.findByText(issues[0].message)).toBeInTheDocument();
+ });
+
+ it('renders persisted Issues in API order and safely distinguishes observed values', async () => {
+ renderDetail();
+
+ await screen.findByRole('table', { name: 'Persisted Validation Issues' });
+ expect(
+ screen.getByRole('region', { name: 'Scrollable Validation Issue table' }),
+ ).toHaveAttribute('tabindex', '0');
+ const rows = issueRows();
+ expect(rows).toHaveLength(issues.length);
+ issues.forEach((issue, index) => {
+ expect(within(rows[index]).getByText(issue.message)).toBeInTheDocument();
+ });
+
+ expect(screen.getByText('Empty string')).toBeInTheDocument();
+ expect(screen.getByText('Not recorded')).toBeInTheDocument();
+ const whitespaceValue = screen.getByLabelText('Whitespace-only value');
+ expect(whitespaceValue).toHaveTextContent(' ', { normalizeWhitespace: false });
+ expect(screen.getByText('München 東京')).toBeInTheDocument();
+ expect(screen.getByText(markupValue)).toBeInTheDocument();
+ expect(document.querySelector('img')).toBeNull();
+ });
+
+ it('renders a long observed value completely as inert text inside its presentation', async () => {
+ const longValueIssue: ValidationIssue = {
+ ...validationIssueFixture,
+ id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
+ message: 'Long observed value.',
+ observedValue: longObservedValue,
+ };
+ getValidationIssuesMock.mockResolvedValue([longValueIssue]);
+
+ renderDetail();
+
+ const observedValue = await screen.findByText(longObservedValue, {
+ exact: true,
+ selector: 'code.observed-value',
+ });
+ expect(observedValue).toHaveClass('observed-value');
+ expect(observedValue.closest('td')).toContainElement(observedValue);
+ expect(observedValue.textContent).toBe(longObservedValue);
+ expect(observedValue.childElementCount).toBe(0);
+ expect(observedValue.querySelector('strong')).toBeNull();
+ });
+
+ it('filters by approved severity without refetching or reordering Issues', async () => {
+ renderDetail();
+
+ await screen.findByText(issues[0].message);
+ const filters = screen.getByRole('group', { name: 'Filter Issues' });
+ const filterResults = screen.getByRole('status');
+ expect(filters).toHaveAttribute('aria-describedby', filterResults.id);
+ expect(filterResults).toHaveTextContent(`Showing all ${issues.length} persisted Issues.`);
+
+ fireEvent.change(screen.getByLabelText('Severity'), { target: { value: 'WARNING' } });
+
+ const rows = issueRows();
+ expect(rows).toHaveLength(2);
+ expect(within(rows[0]).getByText(issues[1].message)).toBeInTheDocument();
+ expect(within(rows[1]).getByText(issues[3].message)).toBeInTheDocument();
+ expect(screen.queryByText(issues[0].message)).not.toBeInTheDocument();
+ expect(filterResults).toHaveTextContent(`Showing 2 of ${issues.length} persisted Issues.`);
+ expect(getValidationIssuesMock).toHaveBeenCalledOnce();
+ });
+
+ it('uses exact case-sensitive and whitespace-sensitive field filters in first-seen order', async () => {
+ renderDetail();
+
+ await screen.findByText(issues[0].message);
+ const fieldFilter = screen.getByLabelText('Field name');
+ expect(
+ within(fieldFilter)
+ .getAllByRole('option')
+ .map((option) => option.getAttribute('value')),
+ ).toEqual(['', 'email', 'Email', ' email ', 'name']);
+ expect(
+ within(fieldFilter)
+ .getAllByRole('option')
+ .map((option) => option.textContent),
+ ).toEqual(['All fields', '"email"', '"Email"', '" email "', '"name"']);
+
+ fireEvent.change(fieldFilter, { target: { value: 'email' } });
+ expect(screen.getByText(issues[0].message)).toBeInTheDocument();
+ expect(screen.getByText(issues[3].message)).toBeInTheDocument();
+ expect(screen.queryByText(issues[1].message)).not.toBeInTheDocument();
+ expect(screen.queryByText(issues[2].message)).not.toBeInTheDocument();
+
+ fireEvent.change(fieldFilter, { target: { value: ' email ' } });
+ expect(screen.getByText(issues[2].message)).toBeInTheDocument();
+ expect(screen.queryByText(issues[0].message)).not.toBeInTheDocument();
+ expect(getValidationIssuesMock).toHaveBeenCalledOnce();
+ });
+
+ it('combines filters with AND, distinguishes no matches, and clears without refetching', async () => {
+ renderDetail();
+
+ await screen.findByText(issues[0].message);
+ fireEvent.change(screen.getByLabelText('Severity'), { target: { value: 'WARNING' } });
+ fireEvent.change(screen.getByLabelText('Field name'), { target: { value: ' email ' } });
+
+ expect(screen.getByText('No matching Issues')).toBeInTheDocument();
+ expect(screen.getByRole('status')).toHaveTextContent(
+ `Showing 0 of ${issues.length} persisted Issues.`,
+ );
+ expect(
+ screen.queryByRole('table', { name: 'Persisted Validation Issues' }),
+ ).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Clear filters' }));
+
+ expect(screen.getByLabelText('Severity')).toHaveValue('');
+ expect(screen.getByLabelText('Field name')).toHaveValue('');
+ expect(screen.getByRole('status')).toHaveTextContent(
+ `Showing all ${issues.length} persisted Issues.`,
+ );
+ expect(issueRows()).toHaveLength(issues.length);
+ expect(getValidationIssuesMock).toHaveBeenCalledOnce();
+ });
+
+ it('distinguishes a Run with no persisted Issues', async () => {
+ getValidationIssuesMock.mockResolvedValue([]);
+
+ renderDetail();
+
+ expect(await screen.findByText('No persisted Issues')).toBeInTheDocument();
+ expect(screen.queryByText('No matching Issues')).not.toBeInTheDocument();
+ expect(screen.queryByLabelText('Severity')).not.toBeInTheDocument();
+ });
+
+ it('keeps Issue loading and retry states local to the loaded Run', async () => {
+ getValidationIssuesMock
+ .mockRejectedValueOnce(new Error('Issue service unavailable.'))
+ .mockResolvedValueOnce(issues);
+
+ renderDetail();
+
+ const issueAlert = await screen.findByRole('alert');
+ expect(
+ within(issueAlert).getByRole('heading', {
+ name: 'Validation Issues could not be loaded',
+ }),
+ ).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: 'Run summary' })).toBeInTheDocument();
+ expect(screen.getByText(validationRunFixture.id)).toBeInTheDocument();
+
+ fireEvent.click(within(issueAlert).getByRole('button', { name: 'Retry Validation Issues' }));
+
+ expect(await screen.findByText(issues[0].message)).toBeInTheDocument();
+ expect(getValidationIssuesMock).toHaveBeenCalledTimes(2);
+ expect(getValidationRunMock).toHaveBeenCalledOnce();
+ expect(getDatasetsMock).toHaveBeenCalledOnce();
+ });
+
+ it('shows Issue loading without hiding the Run summary', async () => {
+ const issueRequest = deferred();
+ getValidationIssuesMock.mockReturnValue(issueRequest.promise);
+
+ renderDetail();
+
+ expect(await screen.findByText('Loading Validation Issues…')).toBeInTheDocument();
+ expect(screen.getByRole('status')).toHaveTextContent('Loading Validation Issues…');
+ expect(screen.getByRole('heading', { name: 'Run summary' })).toBeInTheDocument();
+ });
+
+ it('retries a failed Run before requesting dependent resources', async () => {
+ getValidationRunMock
+ .mockRejectedValueOnce(new Error('Run service unavailable.'))
+ .mockResolvedValueOnce(validationRunFixture);
+
+ renderDetail();
+
+ expect(await screen.findByText('Validation Run could not be loaded')).toBeInTheDocument();
+ expect(getValidationIssuesMock).not.toHaveBeenCalled();
+ expect(getDatasetsMock).not.toHaveBeenCalled();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Retry Validation Run' }));
+
+ expect(await screen.findByRole('heading', { name: 'Run summary' })).toBeInTheDocument();
+ expect(getValidationRunMock).toHaveBeenCalledTimes(2);
+ expect(getValidationIssuesMock).toHaveBeenCalledOnce();
+ expect(getDatasetsMock).toHaveBeenCalledOnce();
+ });
+
+ it('resets filters when navigation selects a different Run', async () => {
+ getValidationRunMock.mockImplementation((runId) =>
+ Promise.resolve(runId === secondRun.id ? secondRun : validationRunFixture),
+ );
+ getValidationIssuesMock.mockImplementation((runId) =>
+ Promise.resolve(runId === secondRun.id ? [secondIssue] : issues),
+ );
+
+ renderWithRouter( , `/runs/${validationRunFixture.id}`);
+
+ await screen.findByText(issues[0].message);
+ fireEvent.change(screen.getByLabelText('Severity'), { target: { value: 'ERROR' } });
+ fireEvent.change(screen.getByLabelText('Field name'), { target: { value: 'email' } });
+
+ fireEvent.click(screen.getByRole('link', { name: 'Open second Run' }));
+
+ expect(await screen.findByText(secondIssue.message)).toBeInTheDocument();
+ expect(screen.getByLabelText('Severity')).toHaveValue('');
+ expect(screen.getByLabelText('Field name')).toHaveValue('');
+ expect(getValidationIssuesMock).toHaveBeenCalledTimes(2);
+ });
+
+ it('aborts a superseded Run request and ignores its late result', async () => {
+ const firstRequest = deferred();
+ let firstSignal: AbortSignal | undefined;
+ getValidationRunMock.mockImplementation((runId, signal) => {
+ if (runId === validationRunFixture.id) {
+ firstSignal = signal;
+ return firstRequest.promise;
+ }
+ return Promise.resolve(secondRun);
+ });
+ getValidationIssuesMock.mockResolvedValue([secondIssue]);
+
+ renderWithRouter( , `/runs/${validationRunFixture.id}`);
+
+ await screen.findByText('Loading Validation Run…');
+ fireEvent.click(screen.getByRole('link', { name: 'Open second Run' }));
+
+ expect(await screen.findByText(secondRun.id)).toBeInTheDocument();
+ expect(firstSignal?.aborted).toBe(true);
+
+ await act(async () => firstRequest.resolve(validationRunFixture));
+
+ expect(screen.queryByText(validationRunFixture.id)).not.toBeInTheDocument();
+ expect(screen.getByText(secondRun.id)).toBeInTheDocument();
+ expect(getValidationIssuesMock).toHaveBeenCalledOnce();
+ expect(getValidationIssuesMock).toHaveBeenCalledWith(secondRun.id, expect.any(AbortSignal));
+ });
+
+ it('aborts superseded Issues and prevents their late result from leaking into the next Run', async () => {
+ const firstIssueRequest = deferred();
+ let firstIssueSignal: AbortSignal | undefined;
+ getValidationRunMock.mockImplementation((runId) =>
+ Promise.resolve(runId === secondRun.id ? secondRun : validationRunFixture),
+ );
+ getValidationIssuesMock.mockImplementation((runId, signal) => {
+ if (runId === validationRunFixture.id) {
+ firstIssueSignal = signal;
+ return firstIssueRequest.promise;
+ }
+ return Promise.resolve([secondIssue]);
+ });
+
+ renderWithRouter( , `/runs/${validationRunFixture.id}`);
+
+ await screen.findByText('Loading Validation Issues…');
+ fireEvent.click(screen.getByRole('link', { name: 'Open second Run' }));
+
+ expect(await screen.findByText(secondIssue.message)).toBeInTheDocument();
+ expect(firstIssueSignal?.aborted).toBe(true);
+
+ await act(async () => firstIssueRequest.resolve(issues));
+
+ expect(screen.queryByText(issues[0].message)).not.toBeInTheDocument();
+ expect(screen.getByText(secondIssue.message)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/pages/ValidationRunDetailPage.tsx b/frontend/src/pages/ValidationRunDetailPage.tsx
new file mode 100644
index 0000000..a4599d1
--- /dev/null
+++ b/frontend/src/pages/ValidationRunDetailPage.tsx
@@ -0,0 +1,115 @@
+import { useCallback, useMemo } from 'react';
+import { Link } from 'react-router-dom';
+import { getDatasets, getValidationIssues, getValidationRun } from '../api/client';
+import type { ValidationRun } from '../api/contracts';
+import { EmptyState, ErrorState, LoadingState } from '../components/AsyncState';
+import { ValidationIssueList } from '../components/ValidationIssueList';
+import { ValidationRunMetadata } from '../components/ValidationRunMetadata';
+import { ValidationRunSummary } from '../components/ValidationRunSummary';
+import { useAsyncResource } from '../hooks/useAsyncResource';
+
+interface ValidationRunDetailPageProps {
+ runId: string;
+}
+
+export function ValidationRunDetailPage({ runId }: ValidationRunDetailPageProps) {
+ return ;
+}
+
+function ValidationRunRequest({ runId }: ValidationRunDetailPageProps) {
+ const loadValidationRun = useCallback(
+ (signal: AbortSignal) => getValidationRun(runId, signal),
+ [runId],
+ );
+ const validationRun = useAsyncResource(loadValidationRun);
+
+ return (
+
+
+ Back to dashboard
+
+
+ {validationRun.status === 'loading' ? (
+
+ ) : null}
+
+ {validationRun.status === 'error' ? (
+
+ ) : null}
+
+ {validationRun.status === 'success' && validationRun.data !== null ? (
+
+ ) : null}
+
+ );
+}
+
+function LoadedValidationRun({ validationRun }: { validationRun: ValidationRun }) {
+ const loadIssues = useCallback(
+ (signal: AbortSignal) => getValidationIssues(validationRun.id, signal),
+ [validationRun.id],
+ );
+ const loadDatasets = useCallback((signal: AbortSignal) => getDatasets(signal), []);
+ const issues = useAsyncResource(loadIssues);
+ const datasets = useAsyncResource(loadDatasets);
+
+ const datasetName = useMemo(() => {
+ if (datasets.status !== 'success' || datasets.data === null) {
+ return null;
+ }
+
+ return datasets.data.find((dataset) => dataset.id === validationRun.datasetId)?.name ?? null;
+ }, [datasets.data, datasets.status, validationRun.datasetId]);
+
+ return (
+ <>
+
+
+
+
+
+
Persisted findings
+
Validation Issues
+
+ {issues.status === 'success' && issues.data !== null ? (
+
{issues.data.length}
+ ) : null}
+
+
+ {issues.status === 'loading' ? : null}
+ {issues.status === 'error' ? (
+
+ ) : null}
+ {issues.status === 'success' && issues.data?.length === 0 ? (
+
+ ) : null}
+ {issues.status === 'success' && issues.data !== null && issues.data.length > 0 ? (
+
+ ) : null}
+
+ >
+ );
+}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 8f6dd80..90a64ab 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -114,6 +114,413 @@ main {
margin-bottom: 0;
}
+.dashboard {
+ display: grid;
+ grid-column: 1 / -1;
+ gap: 1.5rem;
+ min-width: 0;
+}
+
+.dashboard-intro {
+ border-top: 0.25rem solid #2b6c9f;
+}
+
+.dashboard-section {
+ min-width: 0;
+}
+
+.run-detail {
+ display: grid;
+ grid-column: 1 / -1;
+ gap: 1.5rem;
+ min-width: 0;
+}
+
+.detail-navigation {
+ grid-column: 1 / -1;
+}
+
+.detail-navigation a {
+ font-weight: 700;
+}
+
+.run-metadata,
+.run-summary,
+.run-issues {
+ min-width: 0;
+}
+
+.metadata-grid,
+.summary-grid {
+ display: grid;
+ gap: 1rem;
+ margin: 0;
+}
+
+.metadata-grid {
+ grid-template-columns: minmax(0, 1fr);
+}
+
+.summary-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.metadata-grid > div,
+.summary-grid > div {
+ min-width: 0;
+ padding: 1rem;
+ background: #f4f6f8;
+ border-radius: 0.5rem;
+}
+
+.metadata-grid dt,
+.summary-grid dt {
+ margin-bottom: 0.35rem;
+ color: #526071;
+ font-size: 0.8125rem;
+ font-weight: 700;
+}
+
+.metadata-grid dd,
+.summary-grid dd {
+ margin: 0;
+ overflow-wrap: anywhere;
+}
+
+.metadata-grid code {
+ overflow-wrap: anywhere;
+}
+
+.metadata-grid time {
+ font-size: 0.875rem;
+ overflow-wrap: anywhere;
+}
+
+.metadata-note {
+ display: block;
+ margin-top: 0.35rem;
+ color: #667386;
+ font-size: 0.8125rem;
+}
+
+.metadata-wide {
+ grid-column: 1 / -1;
+}
+
+.summary-grid dd {
+ color: #173f68;
+ font-size: clamp(1.5rem, 4vw, 2rem);
+ font-weight: 750;
+}
+
+.eyebrow {
+ margin-bottom: 0.35rem;
+ color: #526071;
+ font-size: 0.75rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.section-heading {
+ display: flex;
+ align-items: start;
+ justify-content: space-between;
+ gap: 1rem;
+ margin-bottom: 1rem;
+}
+
+.section-heading h2 {
+ margin-bottom: 0;
+}
+
+.count-badge {
+ display: inline-flex;
+ min-width: 2rem;
+ min-height: 2rem;
+ align-items: center;
+ justify-content: center;
+ padding: 0.25rem 0.625rem;
+ color: #173f68;
+ background: #e5eff8;
+ border-radius: 999px;
+ font-weight: 700;
+}
+
+.table-scroll {
+ max-width: 100%;
+ overflow-x: auto;
+ border: 1px solid #d9e0e7;
+ border-radius: 0.5rem;
+}
+
+.table-scroll:focus-visible {
+ outline: 0.1875rem solid #2b6c9f;
+ outline-offset: 0.1875rem;
+}
+
+.data-table {
+ width: 100%;
+ min-width: 48rem;
+ border-collapse: collapse;
+ background: #ffffff;
+ text-align: left;
+}
+
+.data-table caption {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.data-table th,
+.data-table td {
+ padding: 0.875rem 1rem;
+ vertical-align: top;
+ border-bottom: 1px solid #e1e6eb;
+}
+
+.data-table thead th {
+ color: #344256;
+ background: #f4f6f8;
+ font-size: 0.8125rem;
+ letter-spacing: 0.02em;
+}
+
+.data-table tbody tr:last-child > * {
+ border-bottom: 0;
+}
+
+.data-table tbody th {
+ font-weight: 650;
+}
+
+.data-table tbody th,
+.data-table td {
+ overflow-wrap: anywhere;
+}
+
+.data-table code,
+.data-table time {
+ font-size: 0.8125rem;
+ white-space: nowrap;
+}
+
+.secondary-id {
+ display: block;
+ margin-top: 0.25rem;
+ color: #526071;
+}
+
+.muted-text {
+ color: #667386;
+}
+
+.issue-filters {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: end;
+ gap: 1rem;
+ min-width: 0;
+ min-inline-size: 0;
+ margin: 0 0 1.25rem;
+ padding: 1rem;
+ background: #f4f6f8;
+ border: 1px solid #d9e0e7;
+ border-radius: 0.5rem;
+}
+
+.issue-filters legend {
+ padding-inline: 0.25rem;
+ color: #173f68;
+ font-weight: 700;
+}
+
+.issue-filters label {
+ display: grid;
+ flex: 1 1 12rem;
+ gap: 0.35rem;
+ min-width: 0;
+ color: #344256;
+ font-size: 0.875rem;
+ font-weight: 700;
+}
+
+.issue-filters select {
+ width: 100%;
+ min-width: 0;
+ max-width: 100%;
+ min-height: 2.5rem;
+ padding: 0.5rem 2rem 0.5rem 0.625rem;
+ color: #1b2638;
+ background: #ffffff;
+ border: 1px solid #aeb8c4;
+ border-radius: 0.375rem;
+ font: inherit;
+ font-weight: 400;
+}
+
+.issue-filters select:focus-visible,
+.secondary-action:focus-visible {
+ outline: 0.1875rem solid #2b6c9f;
+ outline-offset: 0.1875rem;
+}
+
+.secondary-action {
+ min-height: 2.5rem;
+ padding: 0.5rem 0.875rem;
+ color: #173f68;
+ background: #ffffff;
+ border: 1px solid #7d8b9b;
+ border-radius: 0.375rem;
+ font: inherit;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.secondary-action:disabled {
+ color: #778392;
+ background: #e9ecef;
+ cursor: not-allowed;
+}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.validation-issue-table {
+ min-width: 64rem;
+}
+
+.issue-severity {
+ display: inline-flex;
+ padding: 0.2rem 0.55rem;
+ border-radius: 999px;
+ font-size: 0.8125rem;
+ font-weight: 700;
+ white-space: nowrap;
+}
+
+.issue-severity-error {
+ color: #842f2f;
+ background: #f8dede;
+}
+
+.issue-severity-warning {
+ color: #74500b;
+ background: #fff0bf;
+}
+
+.data-table .preserve-whitespace,
+.data-table .observed-value {
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+.preserve-whitespace {
+ display: inline-block;
+ padding: 0.15rem 0.3rem;
+ background: #f4f6f8;
+ border-radius: 0.2rem;
+}
+
+.observed-value {
+ display: block;
+ min-width: 2rem;
+ max-width: 24rem;
+ padding: 0.35rem 0.5rem;
+ background: #f4f6f8;
+ border: 1px solid #d9e0e7;
+ border-radius: 0.25rem;
+}
+
+.run-status {
+ display: inline-flex;
+ padding: 0.2rem 0.55rem;
+ border-radius: 999px;
+ font-size: 0.8125rem;
+ font-weight: 700;
+ white-space: nowrap;
+}
+
+.run-status-pending {
+ color: #344256;
+ background: #e6e9ed;
+}
+
+.run-status-processing {
+ color: #174d76;
+ background: #dcecf8;
+}
+
+.run-status-completed {
+ color: #225b36;
+ background: #dcefe2;
+}
+
+.run-status-failed {
+ color: #842f2f;
+ background: #f8dede;
+}
+
+.async-state {
+ padding: 1.25rem;
+ color: #445166;
+ background: #f4f6f8;
+ border-radius: 0.5rem;
+}
+
+.async-state h3 {
+ margin: 0 0 0.5rem;
+ color: #27364a;
+ font-size: 1rem;
+}
+
+.async-state p {
+ margin-bottom: 0;
+}
+
+.async-state-error {
+ color: #6f2828;
+ background: #fff0f0;
+ border: 1px solid #edc4c4;
+}
+
+.async-state-error button {
+ margin-top: 1rem;
+ padding: 0.625rem 0.875rem;
+ color: #ffffff;
+ background: #173f68;
+ border: 0;
+ border-radius: 0.375rem;
+ font: inherit;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.async-state-error button:hover {
+ background: #0f3152;
+}
+
+.async-state-error button:focus-visible {
+ outline: 0.1875rem solid #2b6c9f;
+ outline-offset: 0.1875rem;
+}
+
.status {
display: flex;
align-items: center;
@@ -162,4 +569,27 @@ li + li {
align-items: start;
padding-block: 3.5rem;
}
+
+ .metadata-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (min-width: 64rem) {
+ .dashboard {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .dashboard-intro,
+ .dashboard-section:last-child {
+ grid-column: 1 / -1;
+ }
+
+ .run-detail {
+ grid-template-columns: minmax(0, 2fr) minmax(18rem, 1fr);
+ }
+
+ .run-issues {
+ grid-column: 1 / -1;
+ }
}
diff --git a/frontend/src/test/fixtures.ts b/frontend/src/test/fixtures.ts
new file mode 100644
index 0000000..55f76ec
--- /dev/null
+++ b/frontend/src/test/fixtures.ts
@@ -0,0 +1,41 @@
+import type { Dataset, ProblemDetails, ValidationIssue, ValidationRun } from '../api/contracts';
+
+export const datasetFixture: Dataset = {
+ id: '47d9bea4-1130-4b9b-8fb3-ea23893d51e5',
+ name: 'Customer import',
+ description: 'Customer data received for validation',
+ createdAt: '2026-07-20T12:34:56.123456Z',
+};
+
+export const validationRunFixture: ValidationRun = {
+ id: '1d97a9a7-eb56-44da-a566-a9630f23cbcb',
+ datasetId: datasetFixture.id,
+ sourceFileId: '54985ec5-103b-4d2b-95f3-0b57e2d74336',
+ profileId: '6dc81327-2a6b-46c9-9a09-43a64f989ac2',
+ status: 'COMPLETED',
+ totalRows: 2,
+ validRows: 1,
+ invalidRows: 1,
+ issueCount: 1,
+ startedAt: '2026-07-26T12:34:56.123456Z',
+ finishedAt: '2026-07-26T12:34:56.234567Z',
+ failureReason: null,
+};
+
+export const validationIssueFixture: ValidationIssue = {
+ id: '9f14aeba-fec4-476a-8e2d-216871f44b42',
+ runId: validationRunFixture.id,
+ rowNumber: 2,
+ fieldName: 'email',
+ ruleType: 'REQUIRED_FIELD',
+ severity: 'ERROR',
+ message: 'Value is required.',
+ observedValue: '',
+};
+
+export const problemDetailsFixture: ProblemDetails = {
+ title: 'Validation Run not found',
+ status: 404,
+ detail: `Validation Run '${validationRunFixture.id}' was not found.`,
+ instance: `/api/validation-runs/${validationRunFixture.id}`,
+};
diff --git a/frontend/src/test/renderWithRouter.tsx b/frontend/src/test/renderWithRouter.tsx
new file mode 100644
index 0000000..0a70487
--- /dev/null
+++ b/frontend/src/test/renderWithRouter.tsx
@@ -0,0 +1,7 @@
+import { render, type RenderResult } from '@testing-library/react';
+import type { ReactElement } from 'react';
+import { MemoryRouter } from 'react-router-dom';
+
+export function renderWithRouter(ui: ReactElement, route = '/'): RenderResult {
+ return render({ui} );
+}
diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts
index bb02c60..f6a2347 100644
--- a/frontend/src/test/setup.ts
+++ b/frontend/src/test/setup.ts
@@ -1 +1,9 @@
import '@testing-library/jest-dom/vitest';
+import { cleanup } from '@testing-library/react';
+import { afterEach, vi } from 'vitest';
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 39395a4..9b0ef9b 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -3,6 +3,14 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [react()],
+ server: {
+ proxy: {
+ '/api': {
+ target: 'http://localhost:8080',
+ changeOrigin: true,
+ },
+ },
+ },
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.ts',