Add export history controls
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-06-17 23:26:27 +02:00
parent db9aff7263
commit 212be9ae48
6 changed files with 156 additions and 8 deletions
+10
View File
@@ -625,3 +625,13 @@ Added:
- Reset page scroll on workspace changes so workspaces open from their heading instead of inheriting stale scroll positions. - Reset page scroll on workspace changes so workspaces open from their heading instead of inheriting stale scroll positions.
- Added regression coverage for the standard-desktop layout and workspace scroll reset. - Added regression coverage for the standard-desktop layout and workspace scroll reset.
- No API contracts, migrations, product capabilities, live provider fetching or AI/model dependency changes were introduced. - No API contracts, migrations, product capabilities, live provider fetching or AI/model dependency changes were introduced.
## Sprint 56 export history controls (2026-06-17)
- Added frontend-only export history search across export type, status, id and storage path.
- Added export type and status filters based on the currently loaded export records.
- Made the existing latest-10 export limiter operate on filtered results instead of the whole export list.
- Added a no-match empty state and reset view action for filtered export history.
- Slightly compacted export action buttons so filters are visible earlier on standard desktop viewports.
- Added regression coverage for filtering controls, filtered list limiting and the no-match state.
- No API contracts, migrations, product capabilities, live provider fetching or AI/model dependency changes were introduced.
@@ -29,9 +29,17 @@ def test_export_center_uses_artifact_actions_and_cards() -> None:
assert 'className="export-center"' in export_center assert 'className="export-center"' in export_center
assert 'className="export-action-grid"' in export_center assert 'className="export-action-grid"' in export_center
assert 'className="latest-export-card"' in export_center assert 'className="latest-export-card"' in export_center
assert 'className="export-history-controls"' in export_center
assert 'className="export-list"' in export_center assert 'className="export-list"' in export_center
assert 'className="export-card"' in export_center assert 'className="export-card"' in export_center
assert "visibleExports = showAllExports ? exports : exports.slice(0, 10)" in export_center assert "exportTypeFilter" in export_center
assert "exportStatusFilter" in export_center
assert "exportSearchQuery" in export_center
assert "exportMatchesSearch" in export_center
assert "filteredExports = useMemo" in export_center
assert "visibleExports = showAllExports ? filteredExports : filteredExports.slice(0, 10)" in export_center
assert "No exports match the current filters." in export_center
assert "Reset view" in export_center
assert "Show all exports" in export_center assert "Show all exports" in export_center
assert "onExportDataset" in export_center assert "onExportDataset" in export_center
assert "onExportDetectionRun" in export_center assert "onExportDetectionRun" in export_center
+27
View File
@@ -2240,3 +2240,30 @@ Limitations:
Next recommended pass: Next recommended pass:
- Continue with export history filtering or retention controls if repeated demo runs keep growing artifact history. - Continue with export history filtering or retention controls if repeated demo runs keep growing artifact history.
## Sprint 56 export history controls (2026-06-17)
Changed:
- Added frontend-only search for export type, status, id and storage path in the Exports workspace.
- Added export type and status filters generated from the loaded export records.
- Made the latest-10 limiter apply after filtering so large histories stay manageable without hiding matching records unexpectedly.
- Added a clear no-match empty state and reset view action.
- Slightly compacted export action buttons so the history controls are visible earlier on standard desktop viewports.
- Added regression coverage for the export history controls, filtered list limiting and no-match state.
Tested:
- `cd frontend && npm run typecheck`
- `cd backend && python -m pytest tests/test_sprint51_quality_export_polish.py -q`
- `cd frontend && npm run build`
- Local Browser visual audit against `http://127.0.0.1:5178` using the live Tower API proxy.
- Verified Exports history controls render with 50 live artifacts and a `report` search narrows the list to 17 matching artifacts.
- Browser console error/warning check returned no entries.
Open:
- Run full readiness, deploy Tower and verify the live Exports filters on `http://192.168.10.150:1202`.
Limitations:
- This pass remains frontend UI hardening only. It does not add backend filtering, retention deletion, API changes, migrations, live provider fetching or AI model behavior.
Next recommended pass:
- Consider a safe export retention/cleanup command if the artifact table keeps growing beyond demo needs.
+2 -1
View File
@@ -325,4 +325,5 @@ This file now starts with the current implementation status. Older preparation/b
- [x] Improve map/dataset selection ergonomics from the workbench canvas and inspector. - [x] Improve map/dataset selection ergonomics from the workbench canvas and inspector.
- [x] Improve populated Data/Exports readability after a demo workflow run. - [x] Improve populated Data/Exports readability after a demo workflow run.
- [x] Improve live visual shell width, scroll behavior and Map workspace layout at 1280px. - [x] Improve live visual shell width, scroll behavior and Map workspace layout at 1280px.
- [ ] Add export history filtering or retention controls for long-running demo environments. - [x] Add export history filtering controls for long-running demo environments.
- [ ] Add a safe export retention/cleanup command for demo environments.
@@ -1,4 +1,4 @@
import { useState } from 'react' import { useMemo, useState } from 'react'
import type { DatasetCreateResponse, ExportCreateResponse, ExportRead } from '../../types' import type { DatasetCreateResponse, ExportCreateResponse, ExportRead } from '../../types'
interface ExportCenterProps { interface ExportCenterProps {
@@ -43,6 +43,16 @@ function compactPath(value: string): string {
return parts.slice(-2).join('/') return parts.slice(-2).join('/')
} }
function exportMatchesSearch(item: ExportRead, query: string): boolean {
const normalizedQuery = query.trim().toLowerCase()
if (!normalizedQuery) {
return true
}
return [item.export_type, item.status, item.id, item.storage_path]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(normalizedQuery))
}
export function ExportCenter({ export function ExportCenter({
selectedProjectId, selectedProjectId,
selectedDataset, selectedDataset,
@@ -63,11 +73,26 @@ export function ExportCenter({
onDownload, onDownload,
}: ExportCenterProps): JSX.Element { }: ExportCenterProps): JSX.Element {
const [showAllExports, setShowAllExports] = useState(false) const [showAllExports, setShowAllExports] = useState(false)
const [exportTypeFilter, setExportTypeFilter] = useState('all')
const [exportStatusFilter, setExportStatusFilter] = useState('all')
const [exportSearchQuery, setExportSearchQuery] = useState('')
const canExportDataset = Boolean(selectedDataset && isVectorDatasetType(selectedDataset.dataset_type)) const canExportDataset = Boolean(selectedDataset && isVectorDatasetType(selectedDataset.dataset_type))
const geojsonExports = exports.filter((item) => item.export_type.includes('geojson')).length const geojsonExports = exports.filter((item) => item.export_type.includes('geojson')).length
const reportExports = exports.filter((item) => item.export_type === 'project_report_html').length const reportExports = exports.filter((item) => item.export_type === 'project_report_html').length
const visibleExports = showAllExports ? exports : exports.slice(0, 10) const exportTypes = useMemo(() => Array.from(new Set(exports.map((item) => item.export_type))).sort(), [exports])
const hiddenExportCount = Math.max(exports.length - visibleExports.length, 0) const exportStatuses = useMemo(() => Array.from(new Set(exports.map((item) => item.status))).sort(), [exports])
const filteredExports = useMemo(
() =>
exports.filter((item) => {
const typeMatches = exportTypeFilter === 'all' || item.export_type === exportTypeFilter
const statusMatches = exportStatusFilter === 'all' || item.status === exportStatusFilter
return typeMatches && statusMatches && exportMatchesSearch(item, exportSearchQuery)
}),
[exports, exportTypeFilter, exportStatusFilter, exportSearchQuery],
)
const visibleExports = showAllExports ? filteredExports : filteredExports.slice(0, 10)
const hiddenExportCount = Math.max(filteredExports.length - visibleExports.length, 0)
const hasActiveExportFilters = exportTypeFilter !== 'all' || exportStatusFilter !== 'all' || exportSearchQuery.trim().length > 0
return ( return (
<section className="export-center" data-testid="export-center"> <section className="export-center" data-testid="export-center">
@@ -138,16 +163,71 @@ export function ExportCenter({
<p>Create metadata, GeoJSON or HTML report artifacts once the active project has data to hand off.</p> <p>Create metadata, GeoJSON or HTML report artifacts once the active project has data to hand off.</p>
</div> </div>
) : null} ) : null}
{exports.length > 10 ? ( {exports.length > 0 ? (
<div className="export-history-controls" aria-label="Export history filters">
<label>
Search exports
<input
type="search"
placeholder="Type, id or path"
value={exportSearchQuery}
onChange={(event) => setExportSearchQuery(event.target.value)}
/>
</label>
<label>
Type
<select value={exportTypeFilter} onChange={(event) => setExportTypeFilter(event.target.value)}>
<option value="all">All types</option>
{exportTypes.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</label>
<label>
Status
<select value={exportStatusFilter} onChange={(event) => setExportStatusFilter(event.target.value)}>
<option value="all">All statuses</option>
{exportStatuses.map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
</label>
<button
type="button"
className="secondary-action"
onClick={() => {
setExportSearchQuery('')
setExportTypeFilter('all')
setExportStatusFilter('all')
setShowAllExports(false)
}}
disabled={!hasActiveExportFilters && !showAllExports}
>
Reset view
</button>
</div>
) : null}
{filteredExports.length > 10 ? (
<div className="list-limit-banner"> <div className="list-limit-banner">
<span> <span>
Showing {visibleExports.length} latest exports. {hiddenExportCount > 0 ? `${hiddenExportCount} older artifacts hidden.` : 'All artifacts shown.'} Showing {visibleExports.length} of {filteredExports.length} matching exports.
{hiddenExportCount > 0 ? ` ${hiddenExportCount} older artifacts hidden.` : ' All matching artifacts shown.'}
</span> </span>
<button type="button" className="secondary-action" onClick={() => setShowAllExports((value) => !value)}> <button type="button" className="secondary-action" onClick={() => setShowAllExports((value) => !value)}>
{showAllExports ? 'Show latest 10' : 'Show all exports'} {showAllExports ? 'Show latest 10' : 'Show all exports'}
</button> </button>
</div> </div>
) : null} ) : null}
{exports.length > 0 && filteredExports.length === 0 ? (
<div className="empty-state">
<strong>No exports match the current filters.</strong>
<p>Clear the search, type or status filter to return to the latest artifacts.</p>
</div>
) : null}
<ul className="export-list"> <ul className="export-list">
{visibleExports.map((item) => ( {visibleExports.map((item) => (
<li className="export-card" key={item.id}> <li className="export-card" key={item.id}>
+23 -1
View File
@@ -1075,7 +1075,7 @@ button.entity-card {
} }
.export-action-grid button { .export-action-grid button {
min-height: 3.2rem; min-height: 2.8rem;
text-align: left; text-align: left;
} }
@@ -1087,6 +1087,27 @@ button.entity-card {
background: var(--accent-soft); background: var(--accent-soft);
} }
.export-history-controls {
display: grid;
grid-template-columns: minmax(14rem, 1.2fr) minmax(10rem, 0.9fr) minmax(9rem, 0.8fr) auto;
gap: 0.65rem;
align-items: end;
margin-top: 0.8rem;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.72rem;
background: #ffffff;
}
.export-history-controls label {
margin-top: 0;
}
.export-history-controls button {
min-height: 2.35rem;
width: auto;
}
.list-limit-banner { .list-limit-banner {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
@@ -1378,6 +1399,7 @@ button.entity-card {
.quality-summary-grid, .quality-summary-grid,
.quality-score-row, .quality-score-row,
.export-action-grid, .export-action-grid,
.export-history-controls,
.inspector-action-bar, .inspector-action-bar,
.list-limit-banner { .list-limit-banner {
grid-template-columns: 1fr; grid-template-columns: 1fr;