Add QA result filtering controls
This commit is contained in:
@@ -7,6 +7,15 @@
|
|||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Sprint 73 QA/QC result filtering (2026-06-20)
|
||||||
|
|
||||||
|
- Added client-side QA/QC result search, status and check-type filters.
|
||||||
|
- Added latest-eight density control with a show-all toggle for long-lived demo projects.
|
||||||
|
- Added a no-match empty state and reset action for filtered QA/QC result views.
|
||||||
|
- Kept metric evidence cards and raw persisted metrics unchanged.
|
||||||
|
- Added static regression coverage for QA/QC filtering and dense history styles.
|
||||||
|
- No API contracts, migrations, backend behavior, provider fetching or AI model behavior changed.
|
||||||
|
|
||||||
## Sprint 72 Mobile overflow hardening (2026-06-20)
|
## Sprint 72 Mobile overflow hardening (2026-06-20)
|
||||||
|
|
||||||
- Clamped page-level horizontal overflow for the workbench shell on mobile.
|
- Clamped page-level horizontal overflow for the workbench shell on mobile.
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_quality_panel_adds_result_filters_and_density_limit() -> None:
|
||||||
|
quality_panel = (
|
||||||
|
ROOT / "frontend" / "src" / "components" / "quality" / "QualityResultsPanel.tsx"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "useState" in quality_panel
|
||||||
|
assert "qualityStatusFilter" in quality_panel
|
||||||
|
assert "qualityTypeFilter" in quality_panel
|
||||||
|
assert "qualitySearchQuery" in quality_panel
|
||||||
|
assert "qualityMatchesSearch" in quality_panel
|
||||||
|
assert "filteredQualityChecks = useMemo" in quality_panel
|
||||||
|
assert "visibleQualityChecks = showAllQualityChecks ? filteredQualityChecks : filteredQualityChecks.slice(0, 8)" in quality_panel
|
||||||
|
assert "quality-history-controls" in quality_panel
|
||||||
|
assert "Search QA/QC results" in quality_panel
|
||||||
|
assert "All statuses" in quality_panel
|
||||||
|
assert "All check types" in quality_panel
|
||||||
|
assert "Reset view" in quality_panel
|
||||||
|
assert "Show all QA/QC results" in quality_panel
|
||||||
|
assert "No QA/QC results match the current filters." in quality_panel
|
||||||
|
|
||||||
|
|
||||||
|
def test_quality_filter_styles_reuse_dense_history_patterns() -> None:
|
||||||
|
css = (ROOT / "frontend" / "src" / "styles" / "app.css").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert ".quality-history-controls" in css
|
||||||
|
assert "repeat(auto-fit, minmax(9rem, 1fr))" in css
|
||||||
|
assert ".quality-history-controls label" in css
|
||||||
|
assert ".quality-history-controls button" in css
|
||||||
|
assert ".quality-list-count" in css
|
||||||
@@ -2729,3 +2729,33 @@ Limitations:
|
|||||||
|
|
||||||
Next recommended pass:
|
Next recommended pass:
|
||||||
- Continue with QA/QC result filtering/density for long-lived demo projects, or a broader mobile visual pass across Data and Map once the overflow baseline is stable.
|
- Continue with QA/QC result filtering/density for long-lived demo projects, or a broader mobile visual pass across Data and Map once the overflow baseline is stable.
|
||||||
|
|
||||||
|
## Sprint 73 QA/QC result filtering (2026-06-20)
|
||||||
|
|
||||||
|
Changed:
|
||||||
|
- Added client-side QA/QC result search across check id, type, status, candidate/reference dataset ids, analysis/job ids and resolved dataset names.
|
||||||
|
- Added status and check-type filters derived from the loaded quality-check list.
|
||||||
|
- Added latest-eight result density control with a show-all toggle.
|
||||||
|
- Added a no-match empty state and reset action for filtered result views.
|
||||||
|
- Reused the dense history control styling pattern while allowing the QA/QC controls to wrap inside narrower workspace columns.
|
||||||
|
- Added `backend/tests/test_sprint73_quality_result_filtering.py`.
|
||||||
|
- Updated `frontend/README.md`, `docs/TODO.md` and `CHANGELOG.md`.
|
||||||
|
|
||||||
|
Tested:
|
||||||
|
- Red step: `python -m pytest backend/tests/test_sprint73_quality_result_filtering.py -q` failed on missing filter state, filtered list logic and styles.
|
||||||
|
- `python -m pytest backend/tests/test_sprint73_quality_result_filtering.py backend/tests/test_sprint72_mobile_overflow_hardening.py backend/tests/test_sprint71_quality_metric_polish.py backend/tests/test_sprint70_quality_handoff_polish.py backend/tests/test_sprint51_quality_export_polish.py -q` (`13 passed`)
|
||||||
|
- `cd frontend && npm run typecheck`
|
||||||
|
- `cd frontend && npm run build`
|
||||||
|
- Local browser check against `http://127.0.0.1:5177` with live API proxy: QA/QC filters rendered against 2 persisted demo checks; search/status conflict showed the no-match state; reset restored 2 checks and 12 metric cards; no console warnings/errors and no body-level overflow.
|
||||||
|
- Local mobile browser check at a 390px viewport: filter controls stayed within viewport width, with 2 checks and 12 metric cards visible.
|
||||||
|
- Screenshots captured under `artifacts/sprint73-quality-result-filtering/`.
|
||||||
|
- `bash scripts/run_readiness_check.sh` (`257 passed`)
|
||||||
|
|
||||||
|
Open:
|
||||||
|
- Redeploy Tower and verify live QA/QC filtering after deployment.
|
||||||
|
|
||||||
|
Limitations:
|
||||||
|
- Frontend filtering/density pass only; filtering is client-side over already-loaded persisted checks and does not change API pagination, persistence, QA calculations, migrations, provider behavior or AI/model behavior.
|
||||||
|
|
||||||
|
Next recommended pass:
|
||||||
|
- Continue with Data/Map mobile visual polish, especially dataset upload/action forms and map toolbar density on narrow screens.
|
||||||
|
|||||||
@@ -344,3 +344,4 @@ This file now starts with the current implementation status. Older preparation/b
|
|||||||
- [x] Add QA/QC handoff polish for candidate/reference context and persisted results.
|
- [x] Add QA/QC handoff polish for candidate/reference context and persisted results.
|
||||||
- [x] Add QA/QC metric card polish for precision, recall, F1, IoU and error counts.
|
- [x] Add QA/QC metric card polish for precision, recall, F1, IoU and error counts.
|
||||||
- [x] Add mobile overflow hardening for workbench navigation, inspector and long QA identifiers.
|
- [x] Add mobile overflow hardening for workbench navigation, inspector and long QA identifiers.
|
||||||
|
- [x] Add QA/QC result filtering and density controls for long-running demo projects.
|
||||||
|
|||||||
@@ -287,6 +287,8 @@ The QA/QC workspace promotes core metrics into evidence cards before the raw met
|
|||||||
|
|
||||||
The workbench shell clamps page-level horizontal overflow on mobile while keeping the sidebar and workspace shortcut rows intentionally scrollable. Long QA identifiers and inspector values wrap inside their cards instead of widening the viewport.
|
The workbench shell clamps page-level horizontal overflow on mobile while keeping the sidebar and workspace shortcut rows intentionally scrollable. Long QA identifiers and inspector values wrap inside their cards instead of widening the viewport.
|
||||||
|
|
||||||
|
The QA/QC result list includes client-side search, status and check-type filters plus a latest-results cap so long-lived demo projects remain scan-friendly without changing the API response shape.
|
||||||
|
|
||||||
## Useful repository scripts
|
## Useful repository scripts
|
||||||
|
|
||||||
- `bash scripts/frontend_install.sh`
|
- `bash scripts/frontend_install.sh`
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import type { DatasetCreateResponse, MetricRead, QualityCheckRead } from '../../types'
|
import type { DatasetCreateResponse, MetricRead, QualityCheckRead } from '../../types'
|
||||||
|
|
||||||
const CORE_METRIC_ORDER = [
|
const CORE_METRIC_ORDER = [
|
||||||
@@ -42,6 +42,30 @@ function qualityMetricValue(metric: MetricRead | undefined): string {
|
|||||||
return Number.isInteger(value) ? String(value) : value.toFixed(3)
|
return Number.isInteger(value) ? String(value) : value.toFixed(3)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function qualityMatchesSearch(check: QualityCheckRead, query: string, datasetNameById: Map<string, string>): boolean {
|
||||||
|
const normalizedQuery = query.trim().toLowerCase()
|
||||||
|
if (!normalizedQuery) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const candidateName = check.candidate_dataset_id ? datasetNameById.get(check.candidate_dataset_id) : null
|
||||||
|
const referenceName = datasetNameById.get(check.reference_dataset_id)
|
||||||
|
const searchable = [
|
||||||
|
check.id,
|
||||||
|
check.check_type,
|
||||||
|
check.status,
|
||||||
|
check.candidate_dataset_id,
|
||||||
|
check.reference_dataset_id,
|
||||||
|
check.analysis_run_id,
|
||||||
|
check.job_id,
|
||||||
|
candidateName,
|
||||||
|
referenceName,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
return searchable.includes(normalizedQuery)
|
||||||
|
}
|
||||||
|
|
||||||
export function QualityResultsPanel({
|
export function QualityResultsPanel({
|
||||||
selectedProjectId,
|
selectedProjectId,
|
||||||
qualityChecks,
|
qualityChecks,
|
||||||
@@ -50,6 +74,10 @@ export function QualityResultsPanel({
|
|||||||
referenceDatasets,
|
referenceDatasets,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
}: QualityResultsPanelProps): JSX.Element {
|
}: QualityResultsPanelProps): JSX.Element {
|
||||||
|
const [showAllQualityChecks, setShowAllQualityChecks] = useState(false)
|
||||||
|
const [qualityStatusFilter, setQualityStatusFilter] = useState('all')
|
||||||
|
const [qualityTypeFilter, setQualityTypeFilter] = useState('all')
|
||||||
|
const [qualitySearchQuery, setQualitySearchQuery] = useState('')
|
||||||
const completedChecks = qualityChecks.filter((check) => check.status === 'ok' || check.status === 'completed').length
|
const completedChecks = qualityChecks.filter((check) => check.status === 'ok' || check.status === 'completed').length
|
||||||
const latestCheck = qualityChecks[0] ?? null
|
const latestCheck = qualityChecks[0] ?? null
|
||||||
const datasetNameById = useMemo(() => {
|
const datasetNameById = useMemo(() => {
|
||||||
@@ -61,6 +89,20 @@ export function QualityResultsPanel({
|
|||||||
}, [candidateDatasets, referenceDatasets])
|
}, [candidateDatasets, referenceDatasets])
|
||||||
const latestCandidateName = latestCheck?.candidate_dataset_id ? datasetNameById.get(latestCheck.candidate_dataset_id) : null
|
const latestCandidateName = latestCheck?.candidate_dataset_id ? datasetNameById.get(latestCheck.candidate_dataset_id) : null
|
||||||
const latestReferenceName = latestCheck?.reference_dataset_id ? datasetNameById.get(latestCheck.reference_dataset_id) : null
|
const latestReferenceName = latestCheck?.reference_dataset_id ? datasetNameById.get(latestCheck.reference_dataset_id) : null
|
||||||
|
const qualityStatuses = useMemo(() => Array.from(new Set(qualityChecks.map((check) => check.status))).sort(), [qualityChecks])
|
||||||
|
const qualityTypes = useMemo(() => Array.from(new Set(qualityChecks.map((check) => check.check_type))).sort(), [qualityChecks])
|
||||||
|
const filteredQualityChecks = useMemo(
|
||||||
|
() =>
|
||||||
|
qualityChecks.filter((check) => {
|
||||||
|
const statusMatches = qualityStatusFilter === 'all' || check.status === qualityStatusFilter
|
||||||
|
const typeMatches = qualityTypeFilter === 'all' || check.check_type === qualityTypeFilter
|
||||||
|
return statusMatches && typeMatches && qualityMatchesSearch(check, qualitySearchQuery, datasetNameById)
|
||||||
|
}),
|
||||||
|
[qualityChecks, qualityStatusFilter, qualityTypeFilter, qualitySearchQuery, datasetNameById],
|
||||||
|
)
|
||||||
|
const visibleQualityChecks = showAllQualityChecks ? filteredQualityChecks : filteredQualityChecks.slice(0, 8)
|
||||||
|
const hiddenQualityCheckCount = Math.max(filteredQualityChecks.length - visibleQualityChecks.length, 0)
|
||||||
|
const hasActiveQualityFilters = qualityStatusFilter !== 'all' || qualityTypeFilter !== 'all' || qualitySearchQuery.trim().length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="quality-results-panel" data-testid="quality-results-panel">
|
<section className="quality-results-panel" data-testid="quality-results-panel">
|
||||||
@@ -122,8 +164,73 @@ export function QualityResultsPanel({
|
|||||||
<p>Run dataset, detection or segmentation QA against a reference layer to populate this workspace.</p>
|
<p>Run dataset, detection or segmentation QA against a reference layer to populate this workspace.</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{qualityChecks.length > 0 ? (
|
||||||
|
<div className="quality-history-controls" aria-label="QA/QC result filters">
|
||||||
|
<label>
|
||||||
|
Search QA/QC results
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Type, id or dataset"
|
||||||
|
value={qualitySearchQuery}
|
||||||
|
onChange={(event) => setQualitySearchQuery(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Status
|
||||||
|
<select value={qualityStatusFilter} onChange={(event) => setQualityStatusFilter(event.target.value)}>
|
||||||
|
<option value="all">All statuses</option>
|
||||||
|
{qualityStatuses.map((status) => (
|
||||||
|
<option key={status} value={status}>
|
||||||
|
{status}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Check type
|
||||||
|
<select value={qualityTypeFilter} onChange={(event) => setQualityTypeFilter(event.target.value)}>
|
||||||
|
<option value="all">All check types</option>
|
||||||
|
{qualityTypes.map((checkType) => (
|
||||||
|
<option key={checkType} value={checkType}>
|
||||||
|
{checkType}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-action"
|
||||||
|
onClick={() => {
|
||||||
|
setQualitySearchQuery('')
|
||||||
|
setQualityStatusFilter('all')
|
||||||
|
setQualityTypeFilter('all')
|
||||||
|
setShowAllQualityChecks(false)
|
||||||
|
}}
|
||||||
|
disabled={!hasActiveQualityFilters && !showAllQualityChecks}
|
||||||
|
>
|
||||||
|
Reset view
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{filteredQualityChecks.length > 8 ? (
|
||||||
|
<div className="list-limit-banner">
|
||||||
|
<span className="quality-list-count">
|
||||||
|
Showing {visibleQualityChecks.length} of {filteredQualityChecks.length} matching QA/QC results.
|
||||||
|
{hiddenQualityCheckCount > 0 ? ` ${hiddenQualityCheckCount} older checks hidden.` : ' All matching checks shown.'}
|
||||||
|
</span>
|
||||||
|
<button type="button" className="secondary-action" onClick={() => setShowAllQualityChecks((value) => !value)}>
|
||||||
|
{showAllQualityChecks ? 'Show latest 8' : 'Show all QA/QC results'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{qualityChecks.length > 0 && filteredQualityChecks.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<strong>No QA/QC results match the current filters.</strong>
|
||||||
|
<p>Clear the search, status or check type filter to return to the latest quality results.</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<ul className="quality-check-list">
|
<ul className="quality-check-list">
|
||||||
{qualityChecks.map((check) => (
|
{visibleQualityChecks.map((check) => (
|
||||||
<li className="quality-check-card" key={check.id}>
|
<li className="quality-check-card" key={check.id}>
|
||||||
<div className="quality-check-header">
|
<div className="quality-check-header">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1573,6 +1573,7 @@ button.entity-card {
|
|||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quality-history-controls,
|
||||||
.export-history-controls {
|
.export-history-controls {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(14rem, 1.2fr) minmax(10rem, 0.9fr) minmax(9rem, 0.8fr) auto;
|
grid-template-columns: minmax(14rem, 1.2fr) minmax(10rem, 0.9fr) minmax(9rem, 0.8fr) auto;
|
||||||
@@ -1585,15 +1586,29 @@ button.entity-card {
|
|||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quality-history-controls label,
|
||||||
.export-history-controls label {
|
.export-history-controls label {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quality-history-controls button,
|
||||||
.export-history-controls button {
|
.export-history-controls button {
|
||||||
min-height: 2.35rem;
|
min-height: 2.35rem;
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quality-history-controls {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-history-controls button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-list-count {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.list-limit-banner {
|
.list-limit-banner {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
@@ -1951,6 +1966,7 @@ button.entity-card {
|
|||||||
.quality-score-row,
|
.quality-score-row,
|
||||||
.export-action-grid,
|
.export-action-grid,
|
||||||
.handoff-action-grid,
|
.handoff-action-grid,
|
||||||
|
.quality-history-controls,
|
||||||
.export-history-controls,
|
.export-history-controls,
|
||||||
.inspector-action-bar,
|
.inspector-action-bar,
|
||||||
.list-limit-banner {
|
.list-limit-banner {
|
||||||
|
|||||||
Reference in New Issue
Block a user