Skip to content

fix(ui): keep tx column-filter state in canonical values across language switches - #184

Merged
argszero merged 1 commit into
mainfrom
fix/tx-filter-locale-state
Sep 12, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/tx-filter-locale-state

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

With a column filter applied on the transactions table, switching the UI language emptied the visible table. Reproduction, booting in zh:

  1. Set the type column filter to 消费 → 1 row is shown (correct).
  2. Switch the UI language to en.

Result: 0 rows + a blank empty-state, while #tx-count still reports the count the server returned. The table header is a second, independent half of the same defect — after the switch the column titles and both dropdowns stay in the boot language, so the table renders half in the old language and half in the new one.

Root cause (state): the filter state stored the localized option label, while filterVal() re-localizes the row value on every comparison. TX_TYPE_INV / TX_STATUS_INV were inverse maps keyed by runtime language output — they can only express "translate whatever the dropdown currently shows", so as soon as the boot language and the current language disagree the comparison matches nothing and every fetched row is filtered out. TX_TYPE_INV / TX_STATUS_INV were born in the same commit as locale-valued options and filterVal (68f9f70, #86, i18n v1.21).

Root cause (header): buildDataTable builds <thead> only when the container has no <table>let table = container.querySelector("table"); if (!table), introduced by 4ee2a2c (#148, "keep filter row alive during table rebuild") to stop destroying the filter inputs and thereby preserve input focus. Column titles (col.title()) and option labels (options()) are therefore written exactly once per container lifetime and never follow a language switch. An A/B against 4ee2a2c~1 shows the pre-#148 table did re-localize its header while still blanking the rows, so #148 added the stuck header; it did not cause the blanking — the two halves are genuinely separate.

Why 167 green Rust tests and three green i18n gates never saw this: they are static assertions over source text, whereas TX_TYPE_INV is keyed by runtime language output. That is also why the fix here is an assertion (a real-DOM A/B), not a new gate — a fourth static gate over the same three files would have been green on the bug.

What this work started from, and what it refuted

The previous cycle's analysis claimed the switch made the filter send a raw i18n label to the backend as type=. It does not reproduce. Because the header is never re-rendered, the dropdown keeps both its labels and its values in the boot language, so the "stale" inverse map still matches. Measured on the pre-change revision: organic requests carrying a NON-DB value = 0. The earlier probe had hand-built the stale map instead of driving the product, which is why it could not refute its own premise. The real defect is the one fixed here.

Related Issue

None — no issue exists for this. The direction came from an internal audit of how the UI layer stores filter state, not from a report. No issue number was fabricated.

Changes

  • ui/js/app.jsTX_COLUMNS type/status options now emit { value, label }: value is the language-independent DB value (consume / earn / topup / withdraw / gift, and 成功 / 入账 / 处理中), label is the localized display text. filterVal() now compares against the raw row value ((t) => t.type / (t) => t.status) instead of the localized string. The row objects already carry the raw DB values (txsToView passes t.type / t.status through), so no data-layer change was needed.
  • ui/js/app.jsdeleted TX_TYPE_INV / TX_STATUS_INV and their two consumers (txFilterParams()'s TX_STATUS_INV[f.status] || f.status, loadTransactions()'s TX_TYPE_INV[txTable.filters.type] || …). The select value is now the canonical DB value and is sent directly. These maps existed solely to translate a displayed label back into a DB value; with the state holding the DB value they have nothing left to do, so removing them eliminates the class rather than one instance.
  • ui/js/app.jstableTheadHtml's option renderer accepts both { value, label } objects and plain strings (value == label), so the generic table renderer is unchanged for columns with no locale-dependent label.
  • ui/js/app.js — added rebuildDataTableHeader() and called it from the single atp:langchange handler, so the header and filter row are rebuilt in the language the user just selected. Filter values live in state.filters (now canonical values) and are re-applied as selected, so the user's selection survives the switch.
  • ui/index.html — cache-bust for app.js (20260912-520260912-6). api.js / data.js / i18n.js and the stylesheet are untouched and keep their existing versions.
  • No i18n keys added, no gate constants changed (src/i18n_pack.rs counts stay 786/786/330/305/537/430).

Tests

  • cargo test 全部通过 — 167 passed; 0 failed
  • cargo fmt --check 通过
  • cargo clippy --all-targets -- -D warnings 通过
  • 新增/更新了单元测试 — see the note on instrumentation below

A/B probe over the real DOM. jsdom loads the real index.html, api.js, data.js, i18n.js and app.js; the only stub is fetch. Every step is a real DOM input event and the real I18n.setLang() — no part of the code under test is reimplemented by the probe. The same probe is run against the pre-change revision and the post-change revision:

run before (6f8b599) after
zh → en exit 1 — rows blanked, header in the wrong language exit 0 — clean
en → zh exit 1 — rows blanked, header in the wrong language exit 0 — clean
zh → zh exit 0 — clean exit 0 — clean
en → en exit 0 — clean exit 0 — clean

The two same-language runs are the negative controls: they must stay clean in both revisions and they do, so the flip is attributable to the change rather than to the probe. Both fatal controls hold in both revisions (otherwise the run proves nothing): the in-view data-i18n tabs must follow the switch, and clearing the filter must restore every fetched row. The header check is by language, not "contains CJK" — under en a correct header is CJK-free, but under zh a CJK-free header would itself be the bug.

Regression guard for #148. A separate probe confirms the focus-preserving property the build-once guard exists for is intact: the same node identity is preserved across typing + re-render and across a sort re-render, and the typed value survives both; the filter value additionally survives the language switch (where the node identity is expected to change, since the header is deliberately rebuilt).

Honest request audit. Every /api/transactions? request is tagged with the phase that issued it, and only the two organic phases are allowed to testify — the probe cannot credit its own control traffic to the defect. Those organic requests carry DB values only, in both revisions.

On instrumentation. The fix lives in jsdom-level event/DOM behaviour that src/i18n_pack.rs cannot reach (it asserts over source text). The evidence is therefore the DOM probe rather than a new #[cfg(test)] case; adding a Rust test that greps for { value, label } would be a static check that was green on the bug. Same reasoning as above: assert the behaviour, not the text.

Checklist

  • 分支命名符合约定(fix/
  • Commit message 使用 Conventional Commits 格式
  • 单一职责,改动最小化(一个 PR 只处理交易表列筛选的筛选状态 + 表头语言)

…age switches

Setting a column filter on the transactions table and then switching the UI
language emptied the visible table (0 rows and a blank empty-state) while
#tx-count still reported the server's row count, because the filter state
stored the localized option label while filterVal() re-localized on every
comparison. The table header also stayed in the previous language: it is built
once per container lifetime to preserve filter-input focus (#148), so column
titles and dropdown labels never re-rendered.

- TX_COLUMNS type/status options now emit {value, label}: value is the
  language-independent DB value (consume/earn/... , 成功/入账/处理中), label is
  the localized text. filterVal() compares against the raw row value.
- Delete TX_TYPE_INV / TX_STATUS_INV: the "localized label -> DB value"
  inverse maps were keyed by runtime language output, so they could only
  express "translate whatever the dropdown currently shows". With the state
  holding the DB value they have nothing left to do.
- tableTheadHtml renders both {value, label} and plain-string options.
- Rebuild the header on atp:langchange so titles and options render in the
  newly selected language; the state (and therefore the user's filters) is
  preserved.

Verified with a real-DOM A/B probe driving actual input events and
I18n.setLang(): zh->en and en->zh flip from 0 rows + wrong-language header +
blank empty-state to the correct rows and a re-localized header, while the
zh->zh and en->en runs stay clean in both revisions. A separate guard confirms
#148's focus-preserving property is intact (same node identity across typing
and sort re-renders).
@argszero
argszero merged commit 75979ad into main Sep 12, 2026
1 check passed
@argszero
argszero deleted the fix/tx-filter-locale-state branch September 12, 2026 10:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant