feat(home): multi-file upload + per-file analysis, drop tool grid
Home is now upload + analysis only. The page accepts multiple files in
one go, analyzes each independently, and renders findings grouped by
filename in bordered containers. The 3-section tool-card grid is gone —
discovery happens via the sidebar now.
Mechanics:
- file_uploader uses accept_multiple_files=True. Each file's findings
cache in session_state["home_findings_by_file"] keyed by filename so
removing a file via Streamlit's "x" button drops its findings too,
and re-clicking Run only re-analyzes pending files.
- The first uploaded file is mirrored into the singular
home_uploaded_{name,bytes,size} keys so tool pages continue to pick
up an "active" upload through pickup_or_upload — no tool-page changes.
- New i18n keys: upload.intro_multi, upload.uploader_label_multi,
upload.clear_results, upload.empty_state. upload.heading text is
updated to "Upload one or more files to start" (EN + ES).
Dropped tests pinning the tool grid:
- TestHomeToolGridLocalization (test_chrome.py)
- test_home_tool_card_uses_es_name (test_smoke.py)
- TestLiteHomeGridBadges (test_lite_tier.py — locked-card lock-badge
assertions; locking is still enforced per-tool-page via
require_feature_or_render_upgrade)
2009 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
158
src/gui/app.py
158
src/gui/app.py
@@ -30,20 +30,10 @@ if str(_project_root) not in sys.path:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _home_page() -> None:
|
||||
"""Render the home page — upload section + tool grid + footer."""
|
||||
from src.gui.components import (
|
||||
findings_count_for_tool,
|
||||
hide_streamlit_chrome,
|
||||
upload_and_analyze_section,
|
||||
)
|
||||
from src.gui.tools_registry import (
|
||||
TOOLS,
|
||||
section_label,
|
||||
tool_description,
|
||||
tool_name,
|
||||
)
|
||||
"""Render the home page — multi-file upload + per-file analysis."""
|
||||
from src.gui.components import hide_streamlit_chrome, render_findings_panel
|
||||
from src.gui.components._legacy import _run_analysis_on_upload
|
||||
from src.i18n import t
|
||||
from src.license import get_manager
|
||||
|
||||
st.set_page_config(
|
||||
page_title=t("home.page_title"),
|
||||
@@ -54,73 +44,95 @@ def _home_page() -> None:
|
||||
|
||||
st.title(t("home.title"))
|
||||
st.caption(t("home.caption"))
|
||||
|
||||
st.divider()
|
||||
|
||||
upload_and_analyze_section()
|
||||
st.markdown(f"### {t('upload.heading')}")
|
||||
st.caption(t("upload.intro_multi"))
|
||||
|
||||
st.divider()
|
||||
|
||||
# Per-tier feature set for badging — Ready tools that the user's
|
||||
# tier doesn't unlock get a red "🔒 Locked" pill instead of the green
|
||||
# "Ready" so they see at a glance which tools an upgrade would
|
||||
# unlock. Dev-mode skips the lock check so the home grid renders the
|
||||
# full SKU during development.
|
||||
_license_state = get_manager().current_state()
|
||||
_unlocked_features = (
|
||||
set(_license_state.features) if _license_state.valid else set()
|
||||
uploaded_files = st.file_uploader(
|
||||
t("upload.uploader_label_multi"),
|
||||
type=["csv", "tsv", "xlsx", "xls"],
|
||||
accept_multiple_files=True,
|
||||
key="home_upload",
|
||||
help=t("upload.uploader_help"),
|
||||
)
|
||||
_dev_mode = get_manager().dev_mode
|
||||
|
||||
# Group tool cards by sidebar section so the home grid mirrors the
|
||||
# left-nav layout — same vocabulary, same ordering.
|
||||
sections: list[tuple[str, list]] = []
|
||||
for section in ("cleaners", "transformations", "automations"):
|
||||
tools = [tool for tool in TOOLS if tool.section == section]
|
||||
if not tools:
|
||||
if not uploaded_files:
|
||||
st.info(t("upload.empty_state"))
|
||||
return
|
||||
|
||||
# Keep tool pages working: they consume a single ``home_uploaded_*``
|
||||
# set via ``pickup_or_upload``. Expose the first uploaded file as
|
||||
# the "active" upload for that contract; the rest live alongside
|
||||
# for per-file analysis on this page.
|
||||
first = uploaded_files[0]
|
||||
if (
|
||||
st.session_state.get("home_uploaded_name") != first.name
|
||||
or st.session_state.get("home_uploaded_size") != first.size
|
||||
):
|
||||
st.session_state["home_uploaded_name"] = first.name
|
||||
st.session_state["home_uploaded_size"] = first.size
|
||||
st.session_state["home_uploaded_bytes"] = first.getvalue()
|
||||
|
||||
# Per-file findings live in a dict so removing a file from the
|
||||
# uploader (Streamlit's "x" button) drops its results too. We only
|
||||
# re-analyze files we haven't already analyzed in this session.
|
||||
findings_by_file: dict = st.session_state.setdefault(
|
||||
"home_findings_by_file", {}
|
||||
)
|
||||
current_names = {f.name for f in uploaded_files}
|
||||
findings_by_file = {
|
||||
name: result for name, result in findings_by_file.items()
|
||||
if name in current_names
|
||||
}
|
||||
st.session_state["home_findings_by_file"] = findings_by_file
|
||||
|
||||
pending = [f for f in uploaded_files if f.name not in findings_by_file]
|
||||
|
||||
col_run, col_clear, _ = st.columns([1, 1, 4])
|
||||
with col_run:
|
||||
run_clicked = st.button(
|
||||
t("upload.run_button"),
|
||||
type="primary",
|
||||
key="home_run_analysis",
|
||||
disabled=not pending,
|
||||
use_container_width=True,
|
||||
)
|
||||
with col_clear:
|
||||
clear_clicked = st.button(
|
||||
t("upload.clear_results"),
|
||||
key="home_clear_results",
|
||||
disabled=not findings_by_file,
|
||||
use_container_width=True,
|
||||
)
|
||||
|
||||
if clear_clicked:
|
||||
st.session_state["home_findings_by_file"] = {}
|
||||
st.rerun()
|
||||
|
||||
if run_clicked:
|
||||
progress = st.progress(0.0, text=t("upload.scanning"))
|
||||
for i, f in enumerate(pending, start=1):
|
||||
findings_by_file[f.name] = _run_analysis_on_upload(f)
|
||||
progress.progress(i / len(pending), text=f"{f.name}")
|
||||
st.session_state["home_findings_by_file"] = findings_by_file
|
||||
progress.empty()
|
||||
st.rerun()
|
||||
|
||||
if findings_by_file:
|
||||
st.divider()
|
||||
# Preserve uploader order so the user sees results in the same
|
||||
# order they appear in the file list above.
|
||||
for f in uploaded_files:
|
||||
if f.name not in findings_by_file:
|
||||
continue
|
||||
sections.append((section_label(section), tools))
|
||||
|
||||
for header, tools in sections:
|
||||
st.subheader(header)
|
||||
for row_start in range(0, len(tools), 3):
|
||||
cols = st.columns(3)
|
||||
for i, col in enumerate(cols):
|
||||
idx = row_start + i
|
||||
if idx >= len(tools):
|
||||
break
|
||||
tool = tools[idx]
|
||||
with col:
|
||||
tool_locked = (
|
||||
tool.status == "Ready"
|
||||
and not _dev_mode
|
||||
and tool.tool_id not in _unlocked_features
|
||||
)
|
||||
if tool_locked:
|
||||
status_key = "license.status_locked"
|
||||
status_color = "red"
|
||||
elif tool.status == "Ready":
|
||||
status_key = "status.ready"
|
||||
status_color = "green"
|
||||
findings = findings_by_file[f.name]
|
||||
with st.container(border=True):
|
||||
if not findings:
|
||||
st.markdown(f"### 📄 {f.name}")
|
||||
st.success(t("findings.none"))
|
||||
else:
|
||||
status_key = "status.coming_soon"
|
||||
status_color = "orange"
|
||||
|
||||
badge = ""
|
||||
n = findings_count_for_tool(tool.tool_id)
|
||||
if n:
|
||||
badge_key = (
|
||||
"home.findings_badge_one"
|
||||
if n == 1
|
||||
else "home.findings_badge_other"
|
||||
)
|
||||
badge = f" :red-background[**{t(badge_key, n=n)}**]"
|
||||
lock_glyph = "🔒 " if tool_locked else ""
|
||||
st.markdown(
|
||||
f"### {tool.icon} {tool_name(tool.tool_id)}{badge}\n\n"
|
||||
f"{tool_description(tool.tool_id)}\n\n"
|
||||
f":{status_color}[**{lock_glyph}{t(status_key)}**]"
|
||||
)
|
||||
render_findings_panel(findings, header=f"📄 {f.name}")
|
||||
|
||||
st.divider()
|
||||
st.caption(t("chrome.footer"))
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"coming_soon": "Coming Soon"
|
||||
},
|
||||
"upload": {
|
||||
"heading": "📤 Upload a file to start",
|
||||
"heading": "📤 Upload one or more files to start",
|
||||
"intro": "Optional: scan an uploaded file for data quality issues and see which tools can fix each one. Skip if you already know what you need.",
|
||||
"limits": "**Up to 1.5 GB.** Formats: CSV, TSV, XLSX, XLS. Delimiters auto-detected: comma, tab, semicolon, pipe. Encodings auto-detected: UTF-8 (with/without BOM), UTF-16, cp1252, Latin-1/9, cp1250, ISO-8859-2, cp1251, KOI8-R, Mac Roman, Shift_JIS, GB18030, Big5, EUC-KR — and override on the Review page.",
|
||||
"uploader_label": "Upload CSV or Excel",
|
||||
@@ -27,7 +27,11 @@
|
||||
"using_session_file": "Using **{name}** from the upload screen.",
|
||||
"use_different_file": "Use a different file",
|
||||
"switch_back": "Switch back to upload-screen file",
|
||||
"pickup_caption": "Up to 1.5 GB. Delimiters auto-detected: comma, tab, semicolon, pipe. Encoding auto-detected (UTF-8 / UTF-16 / cp1252 / Latin-1 family / cp1250 / cp1251 / KOI8-R / Mac Roman / Shift_JIS / GB18030 / Big5 / EUC-KR), with override on the Review page."
|
||||
"pickup_caption": "Up to 1.5 GB. Delimiters auto-detected: comma, tab, semicolon, pipe. Encoding auto-detected (UTF-8 / UTF-16 / cp1252 / Latin-1 family / cp1250 / cp1251 / KOI8-R / Mac Roman / Shift_JIS / GB18030 / Big5 / EUC-KR), with override on the Review page.",
|
||||
"intro_multi": "Drop files below. Each one is analyzed locally — no upload anywhere.",
|
||||
"uploader_label_multi": "Upload CSV, TSV, or Excel files",
|
||||
"clear_results": "Clear results",
|
||||
"empty_state": "Upload one or more files to begin. Your data never leaves this computer."
|
||||
},
|
||||
"findings": {
|
||||
"header": "Detected issues",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"coming_soon": "Próximamente"
|
||||
},
|
||||
"upload": {
|
||||
"heading": "📤 Sube un archivo para empezar",
|
||||
"heading": "📤 Sube uno o más archivos para empezar",
|
||||
"intro": "Opcional: analiza un archivo para detectar problemas de calidad de datos y ver qué herramientas pueden corregir cada uno. Sáltalo si ya sabes lo que necesitas.",
|
||||
"limits": "**Hasta 1,5 GB.** Formatos: CSV, TSV, XLSX, XLS. Delimitadores detectados automáticamente: coma, tabulador, punto y coma, barra vertical. Codificaciones detectadas automáticamente: UTF-8 (con/sin BOM), UTF-16, cp1252, Latin-1/9, cp1250, ISO-8859-2, cp1251, KOI8-R, Mac Roman, Shift_JIS, GB18030, Big5, EUC-KR — y se pueden sustituir desde la página Revisar.",
|
||||
"uploader_label": "Sube un archivo CSV o Excel",
|
||||
@@ -27,7 +27,11 @@
|
||||
"using_session_file": "Usando **{name}** de la pantalla de carga.",
|
||||
"use_different_file": "Usar otro archivo",
|
||||
"switch_back": "Volver al archivo de la pantalla de carga",
|
||||
"pickup_caption": "Hasta 1,5 GB. Delimitadores detectados automáticamente: coma, tabulador, punto y coma, barra vertical. Codificación detectada automáticamente (UTF-8 / UTF-16 / cp1252 / familia Latin-1 / cp1250 / cp1251 / KOI8-R / Mac Roman / Shift_JIS / GB18030 / Big5 / EUC-KR), con opción de sustituirla en la página Revisar."
|
||||
"pickup_caption": "Hasta 1,5 GB. Delimitadores detectados automáticamente: coma, tabulador, punto y coma, barra vertical. Codificación detectada automáticamente (UTF-8 / UTF-16 / cp1252 / familia Latin-1 / cp1250 / cp1251 / KOI8-R / Mac Roman / Shift_JIS / GB18030 / Big5 / EUC-KR), con opción de sustituirla en la página Revisar.",
|
||||
"intro_multi": "Suelta archivos abajo. Cada uno se analiza localmente — no se sube a ningún lado.",
|
||||
"uploader_label_multi": "Sube archivos CSV, TSV o Excel",
|
||||
"clear_results": "Borrar resultados",
|
||||
"empty_state": "Sube uno o más archivos para empezar. Tus datos nunca salen de este equipo."
|
||||
},
|
||||
"findings": {
|
||||
"header": "Problemas detectados",
|
||||
|
||||
@@ -114,8 +114,8 @@ class TestLocalizedChrome:
|
||||
with_language(home_app, "es")
|
||||
home_app.run()
|
||||
text = collected_text(home_app)
|
||||
# ``📤 Sube un archivo para empezar`` from the es pack.
|
||||
assert "Sube un archivo" in text
|
||||
# ``📤 Sube uno o más archivos para empezar`` from the es pack.
|
||||
assert "Sube uno o más archivos" in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -157,25 +157,3 @@ class TestQuitButtonRenders:
|
||||
assert "trabajo sin guardar" in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool cards use localized names on the home grid
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHomeToolGridLocalization:
|
||||
"""The home grid pulls tool display names through ``tool_name()`` in
|
||||
``tools_registry``. The Spanish pack provides translations for every
|
||||
tool id; a regression in that wiring would make Spanish users see
|
||||
English names. Pin a few representative ones."""
|
||||
|
||||
@pytest.mark.parametrize("needle", [
|
||||
"Buscar duplicados",
|
||||
"Limpiar texto",
|
||||
"Estandarizar formatos",
|
||||
"Corregir valores faltantes",
|
||||
"Mapear columnas",
|
||||
])
|
||||
def test_es_tool_name_on_home_grid(self, home_app, needle):
|
||||
with_language(home_app, "es")
|
||||
home_app.run()
|
||||
text = collected_text(home_app)
|
||||
assert needle in text, f"missing localized tool name {needle!r}"
|
||||
|
||||
@@ -94,36 +94,6 @@ class TestLiteLockedPages:
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Home grid shows lock badges
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLiteHomeGridBadges:
|
||||
def test_locked_tool_card_shows_lock_badge(
|
||||
self, lite_license, home_app,
|
||||
):
|
||||
home_app.run()
|
||||
text = collected_text(home_app)
|
||||
# Fix Missing Values is locked under Lite — its card should
|
||||
# have a 🔒 Locked badge.
|
||||
# We assert the lock glyph appears alongside the locked tool's
|
||||
# display name. Streamlit renders the markdown verbatim so the
|
||||
# ``🔒 Locked`` text appears in the page markdown stream.
|
||||
assert "🔒" in text or "Locked" in text, (
|
||||
"home grid missing lock badge for Lite-locked tool"
|
||||
)
|
||||
|
||||
def test_unlocked_tool_card_no_lock(self, lite_license, home_app):
|
||||
home_app.run()
|
||||
# Dedup is unlocked under Lite. Its card markdown should NOT
|
||||
# contain a lock glyph adjacent to its name. We can't easily
|
||||
# scope by card without parsing the markdown stream, but we
|
||||
# can confirm both ``Ready`` (unlocked) and ``Locked``
|
||||
# (locked) badges coexist on the page.
|
||||
text = collected_text(home_app)
|
||||
assert "Ready" in text or "Listo" in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sidebar status shows Lite
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -87,15 +87,6 @@ class TestHomePageRenders:
|
||||
text = collected_text(home_app)
|
||||
assert "Tus datos nunca salen" in text or "Se ejecuta localmente" in text
|
||||
|
||||
def test_home_tool_card_uses_es_name(self, home_app):
|
||||
"""When the home grid renders in Spanish, the dedup card title
|
||||
must use the Spanish display name, not the English fallback."""
|
||||
with_language(home_app, "es")
|
||||
home_app.run()
|
||||
text = collected_text(home_app)
|
||||
assert "Buscar duplicados" in text
|
||||
|
||||
|
||||
class TestEveryPageRenders:
|
||||
"""Parametrize over (page, language). Failure tells you exactly which
|
||||
page + which language broke."""
|
||||
|
||||
Reference in New Issue
Block a user