feat(tools): unified post-run UX across all Ready tool pages
Apply the Clean Text page's post-run UX pattern to every other Ready
tool page (Find Duplicates, Standardize Formats, Fix Missing Values,
Map Columns, Automated Workflows) for consistency and ease of use.
Per page:
1. Preview wrapped in ``st.expander(f"Preview: {filename}",
expanded=not _has_result)``. Open before a result exists, folded
afterwards.
2. Options / configuration controls wrapped in
``st.expander("Options", expanded=not _has_result)``. Inner
sub-expanders preserved (Streamlit 1.36+ supports nesting).
3. After the primary action stashes the result, set a one-shot
``_<tool>_scroll_to_results`` flag in session state and call
``st.rerun()`` so the preview + options expanders see the new
state on the next pass and collapse themselves.
4. ``<div id="<tool>-results-anchor" style="height:1px">`` placed
immediately before the Results subheader.
5. End-of-page: pop the scroll flag and inject a tiny
``streamlit.components.v1.html`` iframe whose ``<script>`` calls
``scrollIntoView`` on the parent document's anchor. One-shot, so
unrelated reruns (toggling Show-hidden, etc.) don't yank the
viewport.
6. Download buttons hardened against the multi-button Streamlit
footgun: byte buffers pre-computed outside the column scopes,
explicit unique ``key="<tool>_dl_<purpose>"`` per button,
``use_container_width=True``, and previously-conditional buttons
now render unconditionally with ``disabled=True`` + a help
tooltip when the underlying data is empty so layout stays steady.
Per-page judgment calls (already noted in agent reports):
- Find Duplicates: sheet picker and delimiter selector kept OUTSIDE
expanders (the user still needs to see them when a file fails to
parse).
- Fix Missing Values: missingness profile wrapped INSIDE the Options
expander together with Strategy — the Results section already
shows a before/after missingness comparison that supersedes the
static input profile.
- Map Columns: all three subsections (Target schema, Strategy,
Mapping) wrapped under one outer Options expander, matching the
Text Cleaner pattern.
- Automated Workflows: inner "Recommended tool order" expander stays
nested inside the outer Options wrap; Run button stays outside
Options so the user can re-run after tweaking the (collapsed)
editor.
2008 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -173,12 +173,23 @@ if uploaded is not None:
|
|||||||
st.session_state["review_decisions"] = {}
|
st.session_state["review_decisions"] = {}
|
||||||
tmp_path.unlink(missing_ok=True)
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
# Collapse the input preview + options once a result exists so
|
||||||
|
# the Results section below becomes the primary visual focus
|
||||||
|
# after Find Duplicates runs. Mirrors the Clean Text pattern.
|
||||||
|
_has_result = st.session_state.get("result") is not None
|
||||||
|
|
||||||
# Preview
|
# Preview
|
||||||
|
with st.expander(f"Preview: {uploaded.name}", expanded=not _has_result):
|
||||||
|
# Subheader retained inside the expander so collected_text in
|
||||||
|
# the workflow tests still finds "Preview: <name>" — Streamlit's
|
||||||
|
# AppTest does not surface expander labels through the
|
||||||
|
# markdown/caption/subheader collections.
|
||||||
st.subheader(f"Preview: {uploaded.name}")
|
st.subheader(f"Preview: {uploaded.name}")
|
||||||
st.caption(f"{len(df)} rows, {len(df.columns)} columns")
|
st.caption(f"{len(df)} rows, {len(df.columns)} columns")
|
||||||
st.dataframe(df.head(10), use_container_width=True)
|
st.dataframe(df.head(10), use_container_width=True)
|
||||||
|
|
||||||
# Advanced options
|
# Advanced options
|
||||||
|
with st.expander("Options", expanded=not _has_result):
|
||||||
settings = config_panel(df)
|
settings = config_panel(df)
|
||||||
|
|
||||||
# Apply loaded config if present
|
# Apply loaded config if present
|
||||||
@@ -218,6 +229,11 @@ if uploaded is not None:
|
|||||||
progress_bar.empty()
|
progress_bar.empty()
|
||||||
st.session_state["result"] = result
|
st.session_state["result"] = result
|
||||||
st.session_state["review_decisions"] = {}
|
st.session_state["review_decisions"] = {}
|
||||||
|
# One-shot flag for the scroll snippet at the bottom of the
|
||||||
|
# page. Force a rerun so the Preview / Options expanders see
|
||||||
|
# the new result on the next pass and collapse themselves.
|
||||||
|
st.session_state["_dedup_scroll_to_results"] = True
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
# Results
|
# Results
|
||||||
@@ -227,6 +243,14 @@ if uploaded is not None:
|
|||||||
|
|
||||||
if result is not None:
|
if result is not None:
|
||||||
st.divider()
|
st.divider()
|
||||||
|
# Anchor target for the post-run auto-scroll snippet at the
|
||||||
|
# bottom of this page. A bare ``<div id="...">`` survives
|
||||||
|
# Streamlit's HTML sanitizer; a 1px-tall div doesn't shift
|
||||||
|
# layout.
|
||||||
|
st.markdown(
|
||||||
|
'<div id="dedup-results-anchor" style="height:1px"></div>',
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
st.subheader("Results")
|
st.subheader("Results")
|
||||||
|
|
||||||
# Summary + download buttons
|
# Summary + download buttons
|
||||||
@@ -324,26 +348,44 @@ if uploaded is not None:
|
|||||||
df, result.match_groups, decisions,
|
df, result.match_groups, decisions,
|
||||||
)
|
)
|
||||||
|
|
||||||
csv_bytes = reviewed_df.to_csv(
|
# Pre-compute every byte buffer up front so each
|
||||||
|
# ``st.download_button`` sees stable ``data``
|
||||||
|
# across reruns. Render the empty-removed case
|
||||||
|
# as a disabled button (rather than hiding it)
|
||||||
|
# so layout stays steady and the user can see
|
||||||
|
# why the download isn't available.
|
||||||
|
reviewed_bytes = reviewed_df.to_csv(
|
||||||
index=False
|
index=False
|
||||||
).encode("utf-8-sig")
|
).encode("utf-8-sig")
|
||||||
|
reviewed_removed_empty = reviewed_removed.empty
|
||||||
|
reviewed_removed_bytes = (
|
||||||
|
reviewed_removed.to_csv(index=False).encode("utf-8-sig")
|
||||||
|
if not reviewed_removed_empty
|
||||||
|
else b""
|
||||||
|
)
|
||||||
|
|
||||||
st.download_button(
|
st.download_button(
|
||||||
"Download Reviewed & Deduplicated CSV",
|
"Download Reviewed & Deduplicated CSV",
|
||||||
data=csv_bytes,
|
data=reviewed_bytes,
|
||||||
file_name="deduplicated_reviewed.csv",
|
file_name="deduplicated_reviewed.csv",
|
||||||
mime="text/csv",
|
mime="text/csv",
|
||||||
key="reviewed_download",
|
key="dedup_dl_reviewed",
|
||||||
|
use_container_width=True,
|
||||||
)
|
)
|
||||||
if not reviewed_removed.empty:
|
|
||||||
removed_bytes = reviewed_removed.to_csv(
|
|
||||||
index=False
|
|
||||||
).encode("utf-8-sig")
|
|
||||||
st.download_button(
|
st.download_button(
|
||||||
"Download Reviewed Removed Rows",
|
"Download Reviewed Removed Rows",
|
||||||
data=removed_bytes,
|
data=reviewed_removed_bytes,
|
||||||
file_name="removed_reviewed.csv",
|
file_name="removed_reviewed.csv",
|
||||||
mime="text/csv",
|
mime="text/csv",
|
||||||
key="reviewed_removed_download",
|
key="dedup_dl_reviewed_removed",
|
||||||
|
disabled=reviewed_removed_empty,
|
||||||
|
help=(
|
||||||
|
"No rows were removed under the current "
|
||||||
|
"review decisions."
|
||||||
|
if reviewed_removed_empty
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
use_container_width=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log entries
|
# Log entries
|
||||||
@@ -365,3 +407,27 @@ st.caption(
|
|||||||
"Runs locally. Your data never leaves this computer. "
|
"Runs locally. Your data never leaves this computer. "
|
||||||
"| DataTools v3.0"
|
"| DataTools v3.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Post-run auto-scroll
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# When Find Duplicates fires, the preview + options collapse, but
|
||||||
|
# Streamlit by itself doesn't scroll — the Results section sits below a
|
||||||
|
# tall page so the user has to hunt for it. Inject a tiny
|
||||||
|
# component-html iframe that calls ``scrollIntoView`` on the parent's
|
||||||
|
# Results anchor. The flag is one-shot (``pop`` removes it) so reruns
|
||||||
|
# triggered by unrelated widgets in the Results section don't yank the
|
||||||
|
# viewport back to the top of Results.
|
||||||
|
if st.session_state.pop("_dedup_scroll_to_results", False):
|
||||||
|
from streamlit.components.v1 import html as _components_html
|
||||||
|
_components_html(
|
||||||
|
"""
|
||||||
|
<script>
|
||||||
|
const doc = window.parent.document;
|
||||||
|
const target = doc.getElementById('dedup-results-anchor');
|
||||||
|
if (target) target.scrollIntoView({behavior: 'smooth', block: 'start'});
|
||||||
|
</script>
|
||||||
|
""",
|
||||||
|
height=0,
|
||||||
|
)
|
||||||
|
|||||||
@@ -99,7 +99,11 @@ except Exception as e:
|
|||||||
)
|
)
|
||||||
st.stop()
|
st.stop()
|
||||||
|
|
||||||
st.subheader(f"Preview: {uploaded.name}")
|
# Collapse the input preview once the user has clicked Standardize Formats
|
||||||
|
# so the Results section below is the primary visual focus. The user can
|
||||||
|
# re-expand the expander to re-inspect the source rows.
|
||||||
|
_has_result = st.session_state.get("fmtstd_result") is not None
|
||||||
|
with st.expander(f"Preview: {uploaded.name}", expanded=not _has_result):
|
||||||
st.caption(f"{len(df)} rows, {len(df.columns)} columns")
|
st.caption(f"{len(df)} rows, {len(df.columns)} columns")
|
||||||
st.dataframe(df.head(10), use_container_width=True)
|
st.dataframe(df.head(10), use_container_width=True)
|
||||||
st.divider()
|
st.divider()
|
||||||
@@ -180,7 +184,16 @@ def _detect_field_type(col: str, samples: list[str]) -> FieldType | None:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Options
|
# Options
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Wrapped in an outer expander whose default state mirrors the preview
|
||||||
|
# expander above: open before a result exists, folded once the user has
|
||||||
|
# clicked Standardize Formats. Together they push the Results section to
|
||||||
|
# the top of the visible area after a run.
|
||||||
|
|
||||||
|
column_types: dict[str, FieldType] = {}
|
||||||
|
extra_abbreviations: dict[str, str] = {}
|
||||||
|
|
||||||
|
with st.expander("Options", expanded=not _has_result):
|
||||||
st.subheader("Column types")
|
st.subheader("Column types")
|
||||||
st.caption(
|
st.caption(
|
||||||
"Assign each column to a field type. Auto-detected suggestions are "
|
"Assign each column to a field type. Auto-detected suggestions are "
|
||||||
@@ -202,7 +215,6 @@ _LABELS = list(_FIELD_LABELS.keys())
|
|||||||
sample_size = min(len(df), 200)
|
sample_size = min(len(df), 200)
|
||||||
sample_df = df.head(sample_size)
|
sample_df = df.head(sample_size)
|
||||||
|
|
||||||
column_types: dict[str, FieldType] = {}
|
|
||||||
cols_per_row = 3
|
cols_per_row = 3
|
||||||
columns_iter = list(df.columns)
|
columns_iter = list(df.columns)
|
||||||
for i in range(0, len(columns_iter), cols_per_row):
|
for i in range(0, len(columns_iter), cols_per_row):
|
||||||
@@ -445,7 +457,6 @@ with opt_cols[1]:
|
|||||||
# table. Show it in a data_editor so the override is visible — the table
|
# table. Show it in a data_editor so the override is visible — the table
|
||||||
# is small, this is the right surface.
|
# is small, this is the right surface.
|
||||||
|
|
||||||
extra_abbreviations: dict[str, str] = {}
|
|
||||||
if any(ft == FieldType.ADDRESS for ft in column_types.values()):
|
if any(ft == FieldType.ADDRESS for ft in column_types.values()):
|
||||||
with st.expander("Custom address abbreviations (advanced)", expanded=False):
|
with st.expander("Custom address abbreviations (advanced)", expanded=False):
|
||||||
st.caption(
|
st.caption(
|
||||||
@@ -528,6 +539,14 @@ if st.button(
|
|||||||
st.stop()
|
st.stop()
|
||||||
st.session_state["fmtstd_result"] = result
|
st.session_state["fmtstd_result"] = result
|
||||||
st.session_state["fmtstd_input_name"] = uploaded.name
|
st.session_state["fmtstd_input_name"] = uploaded.name
|
||||||
|
# One-shot flag picked up on the next pass to scroll the parent
|
||||||
|
# document to the Results anchor (see scroll snippet below).
|
||||||
|
st.session_state["_fmtstd_scroll_to_results"] = True
|
||||||
|
# Force a second rerun so the preview and options expanders see
|
||||||
|
# the new result on the NEXT script pass and collapse themselves.
|
||||||
|
# Without this they stay expanded until the user touches any
|
||||||
|
# other widget.
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
result = st.session_state.get("fmtstd_result")
|
result = st.session_state.get("fmtstd_result")
|
||||||
if result is None:
|
if result is None:
|
||||||
@@ -538,6 +557,16 @@ if result is None:
|
|||||||
# Results
|
# Results
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Anchor target for the auto-scroll snippet at the end of this block.
|
||||||
|
# A bare ``<div id="...">`` survives Streamlit's HTML sanitizer (only
|
||||||
|
# ``<script>`` is stripped), and a 1px-tall div doesn't visually shift
|
||||||
|
# anything. Placed before the subheader so the scrolled-to viewport
|
||||||
|
# starts a few pixels above the section heading rather than below it.
|
||||||
|
st.markdown(
|
||||||
|
'<div id="fmtstd-results-anchor" style="height:1px"></div>',
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
|
||||||
st.subheader("Results")
|
st.subheader("Results")
|
||||||
|
|
||||||
pct = (result.cells_changed / result.cells_total * 100.0) if result.cells_total else 0.0
|
pct = (result.cells_changed / result.cells_total * 100.0) if result.cells_total else 0.0
|
||||||
@@ -574,36 +603,83 @@ st.dataframe(result.standardized_df.head(10), use_container_width=True)
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Downloads
|
# Downloads
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# All three byte buffers are prepared up front (outside the columns) so
|
||||||
|
# each ``st.download_button`` sees stable ``data`` across reruns and an
|
||||||
|
# explicit ``key`` — without those, Streamlit auto-derived widget IDs
|
||||||
|
# can collide for multiple download_buttons in adjacent columns and
|
||||||
|
# only the first one actually fires on click. The empty-changes case
|
||||||
|
# now renders a disabled button (rather than vanishing) so the layout
|
||||||
|
# stays steady and the user understands why nothing's available.
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
stem = Path(st.session_state.get("fmtstd_input_name", "input")).stem
|
stem = Path(st.session_state.get("fmtstd_input_name", "input")).stem
|
||||||
|
|
||||||
|
standardized_bytes = result.standardized_df.to_csv(index=False).encode("utf-8-sig")
|
||||||
|
changes_bytes = (
|
||||||
|
result.changes.to_csv(index=False).encode("utf-8-sig")
|
||||||
|
if not result.changes.empty
|
||||||
|
else b""
|
||||||
|
)
|
||||||
|
config_bytes = json.dumps(options.to_dict(), indent=2).encode("utf-8")
|
||||||
|
|
||||||
dl_a, dl_b, dl_c = st.columns(3)
|
dl_a, dl_b, dl_c = st.columns(3)
|
||||||
with dl_a:
|
with dl_a:
|
||||||
standardized_bytes = result.standardized_df.to_csv(index=False).encode("utf-8-sig")
|
|
||||||
st.download_button(
|
st.download_button(
|
||||||
"Download standardized CSV",
|
"Download standardized CSV",
|
||||||
data=standardized_bytes,
|
data=standardized_bytes,
|
||||||
file_name=f"{stem}_standardized.csv",
|
file_name=f"{stem}_standardized.csv",
|
||||||
mime="text/csv",
|
mime="text/csv",
|
||||||
|
key="fmtstd_dl_standardized",
|
||||||
|
use_container_width=True,
|
||||||
)
|
)
|
||||||
with dl_b:
|
with dl_b:
|
||||||
if not result.changes.empty:
|
|
||||||
changes_bytes = result.changes.to_csv(index=False).encode("utf-8-sig")
|
|
||||||
st.download_button(
|
st.download_button(
|
||||||
"Download changes audit",
|
"Download changes audit",
|
||||||
data=changes_bytes,
|
data=changes_bytes,
|
||||||
file_name=f"{stem}_changes.csv",
|
file_name=f"{stem}_changes.csv",
|
||||||
mime="text/csv",
|
mime="text/csv",
|
||||||
|
key="fmtstd_dl_changes",
|
||||||
|
disabled=result.changes.empty,
|
||||||
|
help="No changes to audit." if result.changes.empty else None,
|
||||||
|
use_container_width=True,
|
||||||
)
|
)
|
||||||
with dl_c:
|
with dl_c:
|
||||||
config_bytes = json.dumps(options.to_dict(), indent=2).encode("utf-8")
|
|
||||||
st.download_button(
|
st.download_button(
|
||||||
"Download config JSON",
|
"Download config JSON",
|
||||||
data=config_bytes,
|
data=config_bytes,
|
||||||
file_name="format_standardize_config.json",
|
file_name="format_standardize_config.json",
|
||||||
mime="application/json",
|
mime="application/json",
|
||||||
|
key="fmtstd_dl_config",
|
||||||
|
use_container_width=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0")
|
st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Post-run auto-scroll
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# When the user clicks Standardize Formats, the preview + options collapse
|
||||||
|
# but Streamlit by itself doesn't scroll — the Results section is at the
|
||||||
|
# bottom of a tall script so the user has to find it. Inject a tiny
|
||||||
|
# component-html iframe that calls ``scrollIntoView`` on the parent's
|
||||||
|
# Results anchor. Streamlit's main page is same-origin with component
|
||||||
|
# iframes so ``window.parent.document`` access is allowed.
|
||||||
|
#
|
||||||
|
# The flag is one-shot (``pop`` removes it) so re-renders triggered by
|
||||||
|
# unrelated widgets in the Results section don't yank the viewport back
|
||||||
|
# to the top of Results.
|
||||||
|
if st.session_state.pop("_fmtstd_scroll_to_results", False):
|
||||||
|
from streamlit.components.v1 import html as _components_html
|
||||||
|
_components_html(
|
||||||
|
"""
|
||||||
|
<script>
|
||||||
|
const doc = window.parent.document;
|
||||||
|
const target = doc.getElementById('fmtstd-results-anchor');
|
||||||
|
if (target) target.scrollIntoView({behavior: 'smooth', block: 'start'});
|
||||||
|
</script>
|
||||||
|
""",
|
||||||
|
height=0,
|
||||||
|
)
|
||||||
|
|||||||
@@ -95,16 +95,31 @@ except Exception as e:
|
|||||||
)
|
)
|
||||||
st.stop()
|
st.stop()
|
||||||
|
|
||||||
st.subheader(f"Preview: {uploaded.name}")
|
# Collapse the input preview + options once the user has clicked
|
||||||
|
# Handle Missing Values so the Results section below is the primary
|
||||||
|
# visual focus. The user can re-expand to re-inspect the source rows
|
||||||
|
# or tweak strategy and rerun.
|
||||||
|
_has_result = st.session_state.get("missing_result") is not None
|
||||||
|
|
||||||
|
with st.expander(f"Preview: {uploaded.name}", expanded=not _has_result):
|
||||||
st.caption(f"{len(df)} rows, {len(df.columns)} columns")
|
st.caption(f"{len(df)} rows, {len(df.columns)} columns")
|
||||||
st.dataframe(df.head(10), use_container_width=True)
|
st.dataframe(df.head(10), use_container_width=True)
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Initial profile (read-only)
|
# Options (Missingness profile + Strategy)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Wrapped in an outer expander whose default state mirrors the preview
|
||||||
|
# expander above: open before a result exists, folded once the user has
|
||||||
|
# clicked Handle Missing Values. The Missingness profile lives inside
|
||||||
|
# this expander too — after a run the Results section shows a richer
|
||||||
|
# before-vs-after comparison that supersedes the static input profile,
|
||||||
|
# so keeping it tucked away with the controls cleanly pushes Results
|
||||||
|
# to the top of the visible area.
|
||||||
|
|
||||||
|
with st.expander("Options", expanded=not _has_result):
|
||||||
st.subheader("Missingness profile")
|
st.subheader("Missingness profile")
|
||||||
|
|
||||||
initial_profile = profile_missing(df, MissingOptions())
|
initial_profile = profile_missing(df, MissingOptions())
|
||||||
@@ -123,10 +138,6 @@ if initial_profile.cells_missing == 0:
|
|||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Options
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
st.subheader("Strategy")
|
st.subheader("Strategy")
|
||||||
|
|
||||||
preset_label = st.radio(
|
preset_label = st.radio(
|
||||||
@@ -282,6 +293,14 @@ if st.button("Handle Missing Values", type="primary", use_container_width=True):
|
|||||||
st.session_state["missing_result"] = result
|
st.session_state["missing_result"] = result
|
||||||
st.session_state["missing_input_name"] = uploaded.name
|
st.session_state["missing_input_name"] = uploaded.name
|
||||||
st.session_state["missing_options"] = options.to_dict()
|
st.session_state["missing_options"] = options.to_dict()
|
||||||
|
# One-shot flag picked up on the next pass to scroll the parent
|
||||||
|
# document to the Results anchor (see scroll snippet below).
|
||||||
|
st.session_state["_missing_scroll_to_results"] = True
|
||||||
|
# Force a second rerun so the preview and options expanders see
|
||||||
|
# the new result on the NEXT script pass and collapse themselves.
|
||||||
|
# Without this they stay expanded until the user touches any
|
||||||
|
# other widget.
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
result = st.session_state.get("missing_result")
|
result = st.session_state.get("missing_result")
|
||||||
if result is None:
|
if result is None:
|
||||||
@@ -292,6 +311,16 @@ if result is None:
|
|||||||
# Results
|
# Results
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Anchor target for the auto-scroll snippet at the end of this block.
|
||||||
|
# A bare ``<div id="...">`` survives Streamlit's HTML sanitizer (only
|
||||||
|
# ``<script>`` is stripped), and a 1px-tall div doesn't visually shift
|
||||||
|
# anything. Placed before the subheader so the scrolled-to viewport
|
||||||
|
# starts a few pixels above the section heading rather than below it.
|
||||||
|
st.markdown(
|
||||||
|
'<div id="missing-results-anchor" style="height:1px"></div>',
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
|
||||||
st.subheader("Results")
|
st.subheader("Results")
|
||||||
|
|
||||||
m1, m2, m3, m4 = st.columns(4)
|
m1, m2, m3, m4 = st.columns(4)
|
||||||
@@ -334,38 +363,85 @@ st.dataframe(result.handled_df.head(10), use_container_width=True)
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Downloads
|
# Downloads
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# All three byte buffers are prepared up front (outside the columns) so
|
||||||
|
# each ``st.download_button`` sees stable ``data`` across reruns and an
|
||||||
|
# explicit ``key`` — without those, Streamlit auto-derived widget IDs
|
||||||
|
# can collide for multiple download_buttons in adjacent columns and
|
||||||
|
# only the first one actually fires on click. The empty-changes case
|
||||||
|
# now renders a disabled button (rather than vanishing) so the layout
|
||||||
|
# stays steady and the user understands why nothing's available.
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
stem = Path(st.session_state.get("missing_input_name", "input")).stem
|
stem = Path(st.session_state.get("missing_input_name", "input")).stem
|
||||||
|
|
||||||
|
handled_bytes = result.handled_df.to_csv(index=False).encode("utf-8-sig")
|
||||||
|
changes_bytes = (
|
||||||
|
result.changes.to_csv(index=False).encode("utf-8-sig")
|
||||||
|
if not result.changes.empty
|
||||||
|
else b""
|
||||||
|
)
|
||||||
|
config_bytes = json.dumps(
|
||||||
|
st.session_state.get("missing_options", {}), indent=2, default=str,
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
dl_a, dl_b, dl_c = st.columns(3)
|
dl_a, dl_b, dl_c = st.columns(3)
|
||||||
with dl_a:
|
with dl_a:
|
||||||
handled_bytes = result.handled_df.to_csv(index=False).encode("utf-8-sig")
|
|
||||||
st.download_button(
|
st.download_button(
|
||||||
"Download handled CSV",
|
"Download handled CSV",
|
||||||
data=handled_bytes,
|
data=handled_bytes,
|
||||||
file_name=f"{stem}_missing.csv",
|
file_name=f"{stem}_missing.csv",
|
||||||
mime="text/csv",
|
mime="text/csv",
|
||||||
|
key="missing_dl_handled",
|
||||||
|
use_container_width=True,
|
||||||
)
|
)
|
||||||
with dl_b:
|
with dl_b:
|
||||||
if not result.changes.empty:
|
|
||||||
changes_bytes = result.changes.to_csv(index=False).encode("utf-8-sig")
|
|
||||||
st.download_button(
|
st.download_button(
|
||||||
"Download changes audit",
|
"Download changes audit",
|
||||||
data=changes_bytes,
|
data=changes_bytes,
|
||||||
file_name=f"{stem}_missing_changes.csv",
|
file_name=f"{stem}_missing_changes.csv",
|
||||||
mime="text/csv",
|
mime="text/csv",
|
||||||
|
key="missing_dl_changes",
|
||||||
|
disabled=result.changes.empty,
|
||||||
|
help="No changes to audit." if result.changes.empty else None,
|
||||||
|
use_container_width=True,
|
||||||
)
|
)
|
||||||
with dl_c:
|
with dl_c:
|
||||||
config_bytes = json.dumps(
|
|
||||||
st.session_state.get("missing_options", {}), indent=2, default=str,
|
|
||||||
).encode("utf-8")
|
|
||||||
st.download_button(
|
st.download_button(
|
||||||
"Download config JSON",
|
"Download config JSON",
|
||||||
data=config_bytes,
|
data=config_bytes,
|
||||||
file_name="missing_config.json",
|
file_name="missing_config.json",
|
||||||
mime="application/json",
|
mime="application/json",
|
||||||
|
key="missing_dl_config",
|
||||||
|
use_container_width=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0")
|
st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Post-run auto-scroll
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# When the user clicks Handle Missing Values, the preview + options
|
||||||
|
# collapse but Streamlit by itself doesn't scroll — the Results section
|
||||||
|
# is at the bottom of a tall script so the user has to find it. Inject
|
||||||
|
# a tiny component-html iframe that calls ``scrollIntoView`` on the
|
||||||
|
# parent's Results anchor. Streamlit's main page is same-origin with
|
||||||
|
# component iframes so ``window.parent.document`` access is allowed.
|
||||||
|
#
|
||||||
|
# The flag is one-shot (``pop`` removes it) so re-renders triggered by
|
||||||
|
# unrelated widgets in the Results section don't yank the viewport
|
||||||
|
# back to the top of Results.
|
||||||
|
if st.session_state.pop("_missing_scroll_to_results", False):
|
||||||
|
from streamlit.components.v1 import html as _components_html
|
||||||
|
_components_html(
|
||||||
|
"""
|
||||||
|
<script>
|
||||||
|
const doc = window.parent.document;
|
||||||
|
const target = doc.getElementById('missing-results-anchor');
|
||||||
|
if (target) target.scrollIntoView({behavior: 'smooth', block: 'start'});
|
||||||
|
</script>
|
||||||
|
""",
|
||||||
|
height=0,
|
||||||
|
)
|
||||||
|
|||||||
@@ -88,14 +88,30 @@ except Exception as e:
|
|||||||
)
|
)
|
||||||
st.stop()
|
st.stop()
|
||||||
|
|
||||||
st.subheader(f"Preview: {uploaded.name}")
|
# Collapse the input preview once the user has clicked Apply Column
|
||||||
|
# Mapping so the Results section below is the primary visual focus.
|
||||||
|
# The user can re-expand the expander to re-inspect the source rows.
|
||||||
|
_has_result = st.session_state.get("colmap_result") is not None
|
||||||
|
|
||||||
|
with st.expander(f"Preview: {uploaded.name}", expanded=not _has_result):
|
||||||
st.caption(f"{len(df)} rows, {len(df.columns)} columns")
|
st.caption(f"{len(df)} rows, {len(df.columns)} columns")
|
||||||
st.dataframe(df.head(10), use_container_width=True)
|
st.dataframe(df.head(10), use_container_width=True)
|
||||||
st.divider()
|
st.divider()
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Schema input
|
# Options (Target schema + Strategy + Mapping)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Wrapped in an outer expander whose default state mirrors the preview
|
||||||
|
# expander above: open before a result exists, folded once the user has
|
||||||
|
# clicked Apply Column Mapping. The Mapping editor is the heart of the
|
||||||
|
# tool, but per the Text Cleaner pattern we still collapse everything
|
||||||
|
# post-run — the user can re-expand to tweak any of the three sections.
|
||||||
|
|
||||||
|
with st.expander("Options", expanded=not _has_result):
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Schema input
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
st.subheader("Target schema")
|
st.subheader("Target schema")
|
||||||
|
|
||||||
@@ -196,9 +212,9 @@ elif schema_mode.startswith("Build"):
|
|||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Strategy
|
# Strategy
|
||||||
# ---------------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
st.subheader("Strategy")
|
st.subheader("Strategy")
|
||||||
|
|
||||||
@@ -240,9 +256,9 @@ with st.expander("Advanced options"):
|
|||||||
"Enforce required fields", value=options.enforce_required,
|
"Enforce required fields", value=options.enforce_required,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Mapping editor — show inferred and let user override
|
# Mapping editor — show inferred and let user override
|
||||||
# ---------------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
st.subheader("Mapping")
|
st.subheader("Mapping")
|
||||||
|
|
||||||
@@ -324,6 +340,12 @@ if st.button("Apply Column Mapping", type="primary", use_container_width=True):
|
|||||||
st.session_state["colmap_result"] = result
|
st.session_state["colmap_result"] = result
|
||||||
st.session_state["colmap_input_name"] = uploaded.name
|
st.session_state["colmap_input_name"] = uploaded.name
|
||||||
st.session_state["colmap_options"] = options.to_dict()
|
st.session_state["colmap_options"] = options.to_dict()
|
||||||
|
# One-shot flag picked up on the next pass to scroll the parent
|
||||||
|
# document to the Results anchor (see scroll snippet below).
|
||||||
|
st.session_state["_colmap_scroll_to_results"] = True
|
||||||
|
# Force a second rerun so the preview and options expanders see
|
||||||
|
# the new result on the NEXT script pass and collapse themselves.
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
result = st.session_state.get("colmap_result")
|
result = st.session_state.get("colmap_result")
|
||||||
if result is None:
|
if result is None:
|
||||||
@@ -334,6 +356,16 @@ if result is None:
|
|||||||
# Results
|
# Results
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Anchor target for the auto-scroll snippet at the end of this block.
|
||||||
|
# A bare ``<div id="...">`` survives Streamlit's HTML sanitizer (only
|
||||||
|
# ``<script>`` is stripped), and a 1px-tall div doesn't visually shift
|
||||||
|
# anything. Placed before the subheader so the scrolled-to viewport
|
||||||
|
# starts a few pixels above the section heading rather than below it.
|
||||||
|
st.markdown(
|
||||||
|
'<div id="colmap-results-anchor" style="height:1px"></div>',
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
|
||||||
st.subheader("Results")
|
st.subheader("Results")
|
||||||
|
|
||||||
m1, m2, m3, m4 = st.columns(4)
|
m1, m2, m3, m4 = st.columns(4)
|
||||||
@@ -371,20 +403,17 @@ st.dataframe(result.mapped_df.head(10), use_container_width=True)
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Downloads
|
# Downloads
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# All three byte buffers are prepared up front (outside the columns) so
|
||||||
|
# each ``st.download_button`` sees stable ``data`` across reruns and an
|
||||||
|
# explicit ``key`` — without those, Streamlit auto-derived widget IDs
|
||||||
|
# can collide for multiple download_buttons in adjacent columns and
|
||||||
|
# only the first one actually fires on click.
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
stem = Path(st.session_state.get("colmap_input_name", "input")).stem
|
stem = Path(st.session_state.get("colmap_input_name", "input")).stem
|
||||||
|
|
||||||
dl_a, dl_b, dl_c = st.columns(3)
|
|
||||||
with dl_a:
|
|
||||||
mapped_bytes = result.mapped_df.to_csv(index=False).encode("utf-8-sig")
|
mapped_bytes = result.mapped_df.to_csv(index=False).encode("utf-8-sig")
|
||||||
st.download_button(
|
|
||||||
"Download mapped CSV",
|
|
||||||
data=mapped_bytes,
|
|
||||||
file_name=f"{stem}_mapped.csv",
|
|
||||||
mime="text/csv",
|
|
||||||
)
|
|
||||||
with dl_b:
|
|
||||||
audit_bytes = json.dumps({
|
audit_bytes = json.dumps({
|
||||||
"mapping": result.mapping,
|
"mapping": result.mapping,
|
||||||
"inferred_pairs": result.inferred_pairs,
|
"inferred_pairs": result.inferred_pairs,
|
||||||
@@ -395,22 +424,69 @@ with dl_b:
|
|||||||
"unmapped_kept": result.unmapped_kept,
|
"unmapped_kept": result.unmapped_kept,
|
||||||
"missing_required_targets": result.missing_required_targets,
|
"missing_required_targets": result.missing_required_targets,
|
||||||
}, indent=2, default=str).encode("utf-8")
|
}, indent=2, default=str).encode("utf-8")
|
||||||
|
config_bytes = json.dumps(
|
||||||
|
st.session_state.get("colmap_options", {}), indent=2, default=str,
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
_no_mapping = not result.mapping
|
||||||
|
|
||||||
|
dl_a, dl_b, dl_c = st.columns(3)
|
||||||
|
with dl_a:
|
||||||
|
st.download_button(
|
||||||
|
"Download mapped CSV",
|
||||||
|
data=mapped_bytes,
|
||||||
|
file_name=f"{stem}_mapped.csv",
|
||||||
|
mime="text/csv",
|
||||||
|
key="colmap_dl_mapped",
|
||||||
|
use_container_width=True,
|
||||||
|
)
|
||||||
|
with dl_b:
|
||||||
st.download_button(
|
st.download_button(
|
||||||
"Download mapping audit",
|
"Download mapping audit",
|
||||||
data=audit_bytes,
|
data=audit_bytes,
|
||||||
file_name=f"{stem}_mapping.json",
|
file_name=f"{stem}_mapping.json",
|
||||||
mime="application/json",
|
mime="application/json",
|
||||||
|
key="colmap_dl_audit",
|
||||||
|
disabled=_no_mapping,
|
||||||
|
help="No mapping was applied." if _no_mapping else None,
|
||||||
|
use_container_width=True,
|
||||||
)
|
)
|
||||||
with dl_c:
|
with dl_c:
|
||||||
config_bytes = json.dumps(
|
|
||||||
st.session_state.get("colmap_options", {}), indent=2, default=str,
|
|
||||||
).encode("utf-8")
|
|
||||||
st.download_button(
|
st.download_button(
|
||||||
"Download config JSON",
|
"Download config JSON",
|
||||||
data=config_bytes,
|
data=config_bytes,
|
||||||
file_name="column_map_config.json",
|
file_name="column_map_config.json",
|
||||||
mime="application/json",
|
mime="application/json",
|
||||||
|
key="colmap_dl_config",
|
||||||
|
use_container_width=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0")
|
st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Post-run auto-scroll
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# When the user clicks Apply Column Mapping, the preview + options
|
||||||
|
# collapse but Streamlit by itself doesn't scroll — the Results section
|
||||||
|
# is at the bottom of a tall script so the user has to find it. Inject
|
||||||
|
# a tiny component-html iframe that calls ``scrollIntoView`` on the
|
||||||
|
# parent's Results anchor. Streamlit's main page is same-origin with
|
||||||
|
# component iframes so ``window.parent.document`` access is allowed.
|
||||||
|
#
|
||||||
|
# The flag is one-shot (``pop`` removes it) so re-renders triggered by
|
||||||
|
# unrelated widgets in the Results section don't yank the viewport back
|
||||||
|
# to the top of Results.
|
||||||
|
if st.session_state.pop("_colmap_scroll_to_results", False):
|
||||||
|
from streamlit.components.v1 import html as _components_html
|
||||||
|
_components_html(
|
||||||
|
"""
|
||||||
|
<script>
|
||||||
|
const doc = window.parent.document;
|
||||||
|
const target = doc.getElementById('colmap-results-anchor');
|
||||||
|
if (target) target.scrollIntoView({behavior: 'smooth', block: 'start'});
|
||||||
|
</script>
|
||||||
|
""",
|
||||||
|
height=0,
|
||||||
|
)
|
||||||
|
|||||||
@@ -89,18 +89,28 @@ except Exception as e:
|
|||||||
)
|
)
|
||||||
st.stop()
|
st.stop()
|
||||||
|
|
||||||
st.subheader(f"Preview: {uploaded.name}")
|
# Collapse the input preview and pipeline editor once the user has clicked
|
||||||
|
# Run Pipeline so the Results section below is the primary visual focus.
|
||||||
|
# The user can re-expand either expander to re-inspect or adjust.
|
||||||
|
_has_result = st.session_state.get("pipeline_result") is not None
|
||||||
|
|
||||||
|
with st.expander(f"Preview: {uploaded.name}", expanded=not _has_result):
|
||||||
st.caption(f"{len(df)} rows, {len(df.columns)} columns")
|
st.caption(f"{len(df)} rows, {len(df.columns)} columns")
|
||||||
st.dataframe(df.head(10), use_container_width=True)
|
st.dataframe(df.head(10), use_container_width=True)
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Pipeline builder
|
# Pipeline builder
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Wrapped in an outer expander whose default state mirrors the preview
|
||||||
|
# expander above: open before a result exists, folded once the user has
|
||||||
|
# clicked Run Pipeline. The pipeline editor is this page's "Options"
|
||||||
|
# section — structurally analogous to Text Cleaner's options block.
|
||||||
|
|
||||||
st.subheader("Pipeline")
|
with st.expander("Options", expanded=not _has_result):
|
||||||
|
|
||||||
mode = st.radio(
|
mode = st.radio(
|
||||||
"How would you like to define the pipeline?",
|
"How would you like to define the pipeline?",
|
||||||
[
|
[
|
||||||
@@ -274,6 +284,14 @@ if st.button(
|
|||||||
progress.progress(1.0, text="Done")
|
progress.progress(1.0, text="Done")
|
||||||
st.session_state["pipeline_result"] = result
|
st.session_state["pipeline_result"] = result
|
||||||
st.session_state["pipeline_input_name"] = uploaded.name
|
st.session_state["pipeline_input_name"] = uploaded.name
|
||||||
|
# One-shot flag picked up on the next pass to scroll the parent
|
||||||
|
# document to the Results anchor (see scroll snippet at end of file).
|
||||||
|
st.session_state["_pipeline_scroll_to_results"] = True
|
||||||
|
# Force a second rerun so the preview and options expanders see
|
||||||
|
# the new result on the NEXT script pass and collapse themselves.
|
||||||
|
# Without this they stay expanded until the user touches any
|
||||||
|
# other widget.
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
result = st.session_state.get("pipeline_result")
|
result = st.session_state.get("pipeline_result")
|
||||||
if result is None:
|
if result is None:
|
||||||
@@ -287,6 +305,16 @@ if result is None:
|
|||||||
# Results
|
# Results
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Anchor target for the auto-scroll snippet at the end of this block.
|
||||||
|
# A bare ``<div id="...">`` survives Streamlit's HTML sanitizer (only
|
||||||
|
# ``<script>`` is stripped), and a 1px-tall div doesn't visually shift
|
||||||
|
# anything. Placed before the subheader so the scrolled-to viewport
|
||||||
|
# starts a few pixels above the section heading rather than below it.
|
||||||
|
st.markdown(
|
||||||
|
'<div id="pipeline-results-anchor" style="height:1px"></div>',
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
|
||||||
st.subheader("Results")
|
st.subheader("Results")
|
||||||
|
|
||||||
m1, m2, m3, m4 = st.columns(4)
|
m1, m2, m3, m4 = st.columns(4)
|
||||||
@@ -318,32 +346,23 @@ st.dataframe(result.final_df.head(10), use_container_width=True)
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Downloads
|
# Downloads
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# All three byte buffers are prepared up front (outside the columns) so
|
||||||
|
# each ``st.download_button`` sees stable ``data`` across reruns and an
|
||||||
|
# explicit ``key`` — without those, Streamlit auto-derived widget IDs
|
||||||
|
# can collide for multiple download_buttons in adjacent columns and
|
||||||
|
# only the first one actually fires on click. The pipeline-JSON button
|
||||||
|
# now renders unconditionally (disabled when no pipeline is defined)
|
||||||
|
# so the layout stays steady.
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
stem = Path(st.session_state.get("pipeline_input_name", "input")).stem
|
stem = Path(st.session_state.get("pipeline_input_name", "input")).stem
|
||||||
|
|
||||||
dl_a, dl_b, dl_c = st.columns(3)
|
cleaned_bytes = result.final_df.to_csv(index=False).encode("utf-8-sig")
|
||||||
with dl_a:
|
|
||||||
bytes_csv = result.final_df.to_csv(index=False).encode("utf-8-sig")
|
|
||||||
st.download_button(
|
|
||||||
"Download cleaned CSV",
|
|
||||||
data=bytes_csv,
|
|
||||||
file_name=f"{stem}_pipeline.csv",
|
|
||||||
mime="text/csv",
|
|
||||||
)
|
|
||||||
with dl_b:
|
|
||||||
pipeline_bytes = json.dumps(
|
pipeline_bytes = json.dumps(
|
||||||
current_pipeline.to_dict() if current_pipeline else {"steps": []},
|
current_pipeline.to_dict() if current_pipeline else {"steps": []},
|
||||||
indent=2, default=str,
|
indent=2, default=str,
|
||||||
).encode("utf-8")
|
).encode("utf-8")
|
||||||
st.download_button(
|
|
||||||
"Download pipeline JSON",
|
|
||||||
data=pipeline_bytes,
|
|
||||||
file_name="pipeline.json",
|
|
||||||
mime="application/json",
|
|
||||||
help="Save this and pass --pipeline pipeline.json to the CLI to re-run on next week's file.",
|
|
||||||
)
|
|
||||||
with dl_c:
|
|
||||||
audit_bytes = json.dumps({
|
audit_bytes = json.dumps({
|
||||||
"warnings": result.warnings,
|
"warnings": result.warnings,
|
||||||
"initial_rows": result.initial_rows,
|
"initial_rows": result.initial_rows,
|
||||||
@@ -362,12 +381,70 @@ with dl_c:
|
|||||||
for sr in result.step_results
|
for sr in result.step_results
|
||||||
],
|
],
|
||||||
}, indent=2, default=str).encode("utf-8")
|
}, indent=2, default=str).encode("utf-8")
|
||||||
|
|
||||||
|
_pipeline_empty = current_pipeline is None or not current_pipeline.steps
|
||||||
|
|
||||||
|
dl_a, dl_b, dl_c = st.columns(3)
|
||||||
|
with dl_a:
|
||||||
|
st.download_button(
|
||||||
|
"Download cleaned CSV",
|
||||||
|
data=cleaned_bytes,
|
||||||
|
file_name=f"{stem}_pipeline.csv",
|
||||||
|
mime="text/csv",
|
||||||
|
key="pipeline_dl_cleaned",
|
||||||
|
use_container_width=True,
|
||||||
|
)
|
||||||
|
with dl_b:
|
||||||
|
st.download_button(
|
||||||
|
"Download pipeline JSON",
|
||||||
|
data=pipeline_bytes,
|
||||||
|
file_name="pipeline.json",
|
||||||
|
mime="application/json",
|
||||||
|
key="pipeline_dl_pipeline",
|
||||||
|
disabled=_pipeline_empty,
|
||||||
|
help=(
|
||||||
|
"No pipeline defined."
|
||||||
|
if _pipeline_empty
|
||||||
|
else "Save this and pass --pipeline pipeline.json to the CLI to re-run on next week's file."
|
||||||
|
),
|
||||||
|
use_container_width=True,
|
||||||
|
)
|
||||||
|
with dl_c:
|
||||||
st.download_button(
|
st.download_button(
|
||||||
"Download run audit",
|
"Download run audit",
|
||||||
data=audit_bytes,
|
data=audit_bytes,
|
||||||
file_name=f"{stem}_pipeline_audit.json",
|
file_name=f"{stem}_pipeline_audit.json",
|
||||||
mime="application/json",
|
mime="application/json",
|
||||||
|
key="pipeline_dl_audit",
|
||||||
|
use_container_width=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
st.divider()
|
st.divider()
|
||||||
st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0")
|
st.caption("Runs locally. Your data never leaves this computer. | DataTools v3.0")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Post-run auto-scroll
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# When the user clicks Run Pipeline, the preview + options collapse but
|
||||||
|
# Streamlit by itself doesn't scroll — the Results section is at the
|
||||||
|
# bottom of a tall script so the user has to find it. Inject a tiny
|
||||||
|
# component-html iframe that calls ``scrollIntoView`` on the parent's
|
||||||
|
# Results anchor. Streamlit's main page is same-origin with component
|
||||||
|
# iframes so ``window.parent.document`` access is allowed.
|
||||||
|
#
|
||||||
|
# The flag is one-shot (``pop`` removes it) so re-renders triggered by
|
||||||
|
# unrelated widgets in the Results section don't yank the viewport
|
||||||
|
# back to the top of Results.
|
||||||
|
if st.session_state.pop("_pipeline_scroll_to_results", False):
|
||||||
|
from streamlit.components.v1 import html as _components_html
|
||||||
|
_components_html(
|
||||||
|
"""
|
||||||
|
<script>
|
||||||
|
const doc = window.parent.document;
|
||||||
|
const target = doc.getElementById('pipeline-results-anchor');
|
||||||
|
if (target) target.scrollIntoView({behavior: 'smooth', block: 'start'});
|
||||||
|
</script>
|
||||||
|
""",
|
||||||
|
height=0,
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user