Bugfix Issue 1633 Column Filters Object Object - sebiboga/testlink-upgraded GitHub Wiki
Issue: #1633 โ Column filters polluted with '[object Object]' after re-render (DataTables 1.13 state.search is an object)
Branch: fix/issue-1633
Affected screen: Test Project Management (gui/templates/projectsView.html) โ front-end only, no BFF / DB / legacy change
Regression suite: tmp/TLU_Test_Cases.md โ Suite 1633 (12 cases: 11 PASS, 0 FAIL, 1 N/A)
On the modern Test Project Management grid, every per-column filter box (the legacy
{#SMART_SEARCH#} feature) contained the literal string [object Object] from the second
render onwards. Per-column filtering then silently matched nothing โ no error, no warning,
just an empty result set.
Measured on the live page with 4 fixture projects and DataTables 1.13.7:
// after typing "Alpha" in a filter box and reloading the page
Array.from(document.querySelectorAll('#projectsTable thead tr.filters input'))
.map(e => e.value)
// == ["[object Object]","[object Object]","[object Object]","[object Object]","[object Object]"]Downstream damage, measured:
user appends to a polluted box -> box value == "[object Object]Beta"
column search == "" // smart search splits on space
recordsDisplay == 0 // "[object" AND "Object]" AND "Beta"
So after a single reload, any term the user types matches nothing.
The bug is easy to miss on a manual pass, for two independent reasons:
-
the first render is clean โ on a genuinely first visit
tbl.state.loaded()isnull, so the restore helper short-circuits, and the boxes stay empty; - the table still looks correctly filtered right after the pollution appears โ DataTables applies the saved state to the grid itself, so the row count is right and only the boxes are corrupt;
- and with an empty
testprojectstable the filter row is never built at all (the screen shows the empty state), so a freshly imported CI database cannot show the bug until a project exists.
-
http://localhost:8082/index.phpโ log inadmin/admin. -
Create at least one project (a fresh CI database has none):
POST /api/projects/ {"name":"Alpha Banking","prefix":"ALP"} -> 200 {"success":true,"id":1} POST /api/projects/ {"name":"Beta Retail","prefix":"BET"} -> 200 {"success":true,"id":2} POST /api/projects/ {"name":"Gamma Insurance","prefix":"GAM"} -> 200 {"success":true,"id":3} POST /api/projects/ {"name":"Delta Utilities","prefix":"DEL"} -> 200 {"success":true,"id":4} -
Open
http://localhost:8082/gui/templates/projectsView.htmlwith a clean profile โ 5 filter inputs, all"". -
Type
Alphainto filter box #0 (the box under Project Name), wait for the ~500 msstateSavedebounce. -
Reload the page.
-
Read the filter inputs โ all 5 hold
[object Object].
The state shape that causes it, straight out of localStorage:
"columns":[
{"visible":true,"search":{"search":"","smart":true,"regex":false,"caseInsensitive":true}},
{"visible":true,"search":{"search":"Alpha","smart":true,"regex":false,"caseInsensitive":true}},
...]columns[1].search is an object; the term the user typed is one level deeper, at .search.search.
| # | File:line | Fact |
|---|---|---|
| 1 | gui/templates/projectsView.html:537 |
The grid is built with stateSave: true, so DataTables serialises its own state to localStorage["DataTables_projectsTable_/gui/templates/projectsView.html"]. |
| 2 |
third_party/DataTables-1.10.24/datatables.js:6372 (and -1.10.4/โฆ:4901) |
_fnSaveState() writes search: _fnSearchToCamel( settings.aoPreSearchCols[i] ), and _fnSearchToCamel() (:4613-4621) returns the object {search, smart, regex, caseInsensitive}. |
| 3 |
gui/templates/projectsView.html:594 (pre-fix) |
The restore helper read the descriptor itself: const value = (state.columns[idx] && state.columns[idx].search) || '';
|
| 4 |
gui/templates/projectsView.html:595 (pre-fix) |
$(this).find('input').val(value) receives an object with no value key, so jQuery falls through to val + "" โ the literal "[object Object]" lands in the input. |
| 5 | gui/templates/projectsView.html:566-568 |
The keyup handler pushes the box's value straight into projectsTbl.column(idx).search(this.value, false, true), so the next keystroke searches for [object Object]<term>. |
The real trap is a conflation of two different DataTables APIs:
-
column(idx).search()โ the getter โ returns a plain string, in every version; -
state.columns[idx].searchโ the saved state entry โ is the descriptor object, also in every version.
The 2.0.1 screen was written against the getter while reading the state, so it was broken from the
start: this is not a 1.10 โ 1.13 regression. Verified: git show HEAD~1:gui/templates/projectsView.html
carries the same raw read at line 544.
Legacy 1.9.20 is unaffected: the Smarty DataTablesColumnFiltering.inc.tpl:40-46 filled the boxes
from the request value, not from a DataTables state blob.
One function, one expression โ restoreColumnFilterState() in
gui/templates/projectsView.html:590-608:
const colSearch = (state.columns[idx] && state.columns[idx].search) || '';
const value = (typeof colSearch === 'object')
? String(colSearch.search || '')
: String(colSearch);
$(this).find('input').val(value);Why this method
- Minimal โ 3 lines, one file, no behaviour change on the clean-state path.
- Shape-tolerant โ the bare-string branch keeps the helper correct if the DataTables pin is ever rolled back, or if the helper is reused against a table that stores strings.
-
Consistent with the codebase โ this is verbatim the guard the two sibling screens already
ship for the global search (
usersAssignPlan.html:1158-1160,usersAssignProject.html:734-736), so the repo now has exactly one way to read a search out of a DataTables state blob. -
It fixes the cause, not just the symptom โ unwrapping the descriptor means the box holds the
user's real term, so step 5 of the chain is correct on the first keystroke after a reload too.
Stripping
[object Object]out of the box instead would have left the saved state unreadable.
Rejected alternatives
-
Read
tbl.column(idx).search()instead of the state blob โ a live getter, but it returns what DataTables already applied and would not restore a box on a freshly rebuilt table. -
Move the filter row into a DataTables
columns.renderhook โ architecturally cleaner, but a rewrite of a working screen, far beyond the minimal-correct-fix rule. -
Drop
stateSaveโ regresses the feature the report praises. - Disable smart search to dodge the ANDed-term failure โ loses legacy behaviour for no gain once the box holds the real term.
No user-facing string changed, so no i18n key was added and no locale bundle was touched.
$ grep -rn "state.loaded()\|restoreColumnFilterState" --include=*.html gui/templates/
projectsView.html:552, 590 <- only consumer of state.columns[i].search (FIXED)
usermanagement/usersAssignPlan.html:1149 <- global state.search only, already guarded
usermanagement/usersAssignProject.html:723 <- global state.search only, already guarded
$ grep -rn "state.columns\[" --include=*.html gui/templates/
projectsView.html:594 <- the single defective line
stateSave: true exists on exactly those three screens. The other per-column-filter screens build
their inputs themselves and never read a state blob (results/tplanWithCF.html:258,
results/metricsDashboard.html:506 call column(idx).search(...) as a setter). One file, one
line, one screen.
Regression suite 1633 (tmp/TLU_Test_Cases.md): 12 cases โ 11 PASS, 0 FAIL, 1 N/A.
| # | Case | Result |
|---|---|---|
| T1 | Clean profile, first render, no saved state โ 5 boxes all ""
|
PASS |
| T2 | Type Alpha in box #0 โ column(1).search() === "Alpha", 1 row |
PASS |
| T3 |
The exact pre-fix repro โ reload โ ["Alpha","","","",""]
|
PASS |
| T4 | No box contains [object Object]
|
PASS |
| T5 | Term in a non-first box (th#4, Issue Tracker) โ restores into its own box | PASS |
| T6 | State present but no column term โ all 5 boxes ""
|
PASS |
| T7 | Append a char to the restored box โ real term, 0 rows | PASS |
| T8 | Replace content with e โ term="e", 3 rows |
PASS |
| T9 | In-page re-render via loadProjects() โ box keeps the term |
PASS |
| T10 |
events table โ 0 new Error/Warning (log_level=16 only) |
PASS |
| T11 | Browser console โ 0 errors / warnings | PASS |
| T12 | DataTables 1.10 bare-string state (downgrade guard) | N/A โ unreachable, not claimed as passing |
T12 in full. I hand-wrote a 1.10-shaped state (columns[1].search = "Alpha" as a bare string)
and reloaded; the boxes stayed empty, so I did not mark it green. A controlled A/B on the same
page with the same terms, only the shape differing, showed why:
A string shape ("Alpha" / "GAM") loadProjects() -> ["","","","",""] restore did nothing
B object shape ({search:"Alpha", ...}) loadProjects() -> ["Alpha","","GAM","",""] PASS
DataTables 1.13's own state loader normalises a bare-string column search away, so the string branch
is defensive-only โ 1.13 never writes that shape and the app never produces it. I verified the
expression is correct by calling restoreColumnFilterState() on the pre-normalisation instance,
which restored ["Alpha","","GAM","",""] from the string state.
Before / after (identical saved state, columns[1].search.search === "Alpha"):
- Before:
["[object Object]","[object Object]","[object Object]","[object Object]","[object Object]"]โhttps://github.com/sebiboga/testlink-upgraded/raw/0674bbdd5/docs/screenshots/issue-1633-column-filter-object-object.png - After:
["Alpha","","","",""]โhttps://github.com/sebiboga/testlink-upgraded/blob/fix/issue-1633/docs/screenshots/issue-1633-column-filter-restored.png
Also checked: events (5 rows in the hour, all log_level=16 audit โ the login plus the 4 fixture
project creations; 0 error/warning), browser console clean, and the code path is value โ jQuery
.val() only, never an HTML sink, so no XSS surface is added.
-
#1741 โ the same screen never
restores the global search into
#searchInput, while DataTables does re-apply it. Measured: savedsearch: "retail",table.search() === "retail",recordsDisplay = 1of 4 projects, and#searchInput.value === ""โ a silently filtered list behind an empty box. Same class of confusion, but a missing restore rather than a wrong-shape read, so it needs its own suite. Left for a dedicated run per the one-bug-per-run rule.
# terminal 1
cd /home/runner/work/testlink-upgraded/testlink-upgraded
php -S 0.0.0.0:8082 -t . # or: the CI's PHP built-in server
# terminal 2 โ create fixtures, then drive the browser
mysql -h 127.0.0.1 -utestlink -ptestlink testlink -e "select count(*) from testprojects;"
# expect 4 after the four POST /api/projects/ calls from ยง2In the browser at http://localhost:8082/gui/templates/projectsView.html (as admin/admin),
open the console and run:
// T1 - clean slate
localStorage.removeItem('DataTables_projectsTable_/gui/templates/projectsView.html');
location.reload();
// (after reload) T2 - type a term, then T3 - reload and read
const $ = jQuery, wait = ms => new Promise(r => setTimeout(r, ms));
const boxes = () => [...document.querySelectorAll('#projectsTable thead tr.filters input')].map(e => e.value);
const inp = document.querySelectorAll('#projectsTable thead tr.filters input')[0];
inp.value = 'Alpha'; $(inp).trigger('keyup');
await new Promise(r => setTimeout(r, 600)); // T2: past the 500ms stateSave debounce
location.reload();After the second reload boxes() must be ["Alpha","","","",""] and must never contain
[object Object].
Harness pitfalls that produced false failures while writing this suite
- The
stateSavekey isDataTables_projectsTable_+ the page URL, so cache-busting with?p=โฆ/?nocache=โฆchanges the key and makes the save/restore path look broken when it is not. Always load exactly/gui/templates/projectsView.html.- Input-list index โ DataTable column index. The 5 filter inputs sit in
th1, 3, 4, 5, 7 (only those carrydata-col-filter), so input #0 โ column 1, input #2 โ column 4.stateSaveis debounced (~500 ms); readinglocalStoragestraight after a keystroke returns the previous state.