Repository navigation
Bugfix Issue 1680 usersAssignPlan onRoleChange TypeError
Bugfix — Issue #1680: onRoleChange threw TypeError: Cannot read properties of undefined (reading 'row') on the 2nd change of the same row
Issue: #1680 — usersAssignPlan.html: onRoleChange throws TypeError on the 2nd change of the same row — stale Save button + stale cell cache
Branch: fix/issue-1680
Affected screen: Assign Test Plan Roles (gui/templates/usermanagement/usersAssignPlan.html), function onRoleChange()
Related: #939 (introduced the cell re-sync), #1664 (where it was hit), #1707 (added the global ext.search predicate that adds draw transitions around the same <tbody>)
Regression suite: tmp/TLU_Test_Cases.md → Suite "Regression — Issue #1680" (11/11 PASS)
Commits: 1b653171e (fix), 9eac412e2 (CHANGELOG)
DataTables: 1.13.7 (https://cdn.datatables.net/1.13.7/js/jquery.dataTables.min.js, usersAssignPlan.html:189)
In Assign Test Plan Roles the per-user Plan Role Override <select> re-syncs its
DataTables cell cache on every change, so a paging/search/sort re-draw of an off-page row
cannot resurrect the previous option (the fix added by #939). That re-sync threw
Uncaught TypeError: Cannot read properties of undefined (reading 'row')
at (jquery.dataTables.min.js:4:66727) <-- cell().data()
at (jquery.dataTables.min.js:4:53720) <-- API methodExt wrapper
at onRoleChange (usersAssignPlan.html:1334) <-- assignDt.cell(tr, 4).data(...)
at onchange (VM172 usersAssignPlan.html:1:1)
on the second change of the same row's select. Because the exception escaped the handler, everything scheduled after the failing line never ran:
-
$('#saveBtn').prop('disabled', !isDirty())— the Save button kept its previous state: it stays enabled althoughisDirty()isfalse(change back to the original value), and it stays disabled when it should be enabled. - The DataTables cell cache was never re-synced, so a later paging/search/sort re-draw resurrected the previous option as the selected one (the "my change vanished" report).
Role assignment still saved correctly — the users model is the source of truth and the
Save payload is built from it — which is exactly what makes this the class of
silent-misread defect the triage expects to be fixed rather than filed.
Not a server-side defect: the events table gained no Error/Warning row for the
whole session, and the BFF answered GET /api/roles/index.php/meta/tplan-roles with
200 status: ok. Client-side only.

Fixtures (fresh DB — testplans/testprojects are empty at the start of a run; see
tmp/fixtures_1680.sql):
- test project #1680: a
testprojectsrow and anodes_hierarchyrow(1680,'Issue 1680 Repro Project',0,1,1680). ⚠ The project NAME lives innodes_hierarchy, not intestprojects(lib/functions/testproject.class.php:551selectsNHTPROJ.name). Without thenodes_hierarchyrow the project combo comes back empty and the screen shows theassign.rolesForPlansDisablednotice instead of the grid. - test plan #1680 (
testproject_id=1680,active=1,is_public=1). - users #1681
tina.tester(global role 7, explicit plan role 5 = guest) and #1682tom.tester(global role 7, explicit plan role 4 = test designer), both with auser_testplan_rolesrow.
Steps
-
http://localhost:8082/index.php→ log inadmin/admin. - Open
http://localhost:8082/gui/templates/usermanagement/usersAssignPlan.html?tproject_id=1680&tplan_id=1680. The grid renders 3 rows (data-uid=1admin,1681,1682). - Change the Plan Role Override select of row 1681 (
tina.tester) to6. → row highlighted, Modified badge, Save enabled. ✔ -
Change the very same row's select again (e.g. back to
5, or on to7). - Open the console:
Uncaught TypeError: Cannot read properties of undefined (reading 'row'). - Observe:
#saveBtn.disabledno longer agrees withisDirty(). - Optional: type a search term in the grid search box (forces a DataTables re-draw) — the select of the changed row snaps back to the value it had before step 4.
jquery.dataTables.min.js:4:66727 is the cell().data() setter:
_api_register('cell().data()', function ( data, redraw, display ) {
var context = this.context, first = this[0];
return data === undefined ?
( context.length && first.length ? _fnGetCellData(context[0], first[0].row, first[0].column) : undefined )
: ( _fnSetCellData(context[0], first[0].row, first[0].column, data),
_fnSetCellData ? ... : null,
this );
});The throw is first[0].row with first === [] — i.e. this[0] is undefined.
The exception is not raised inside .data(); assignDt.cell(tr, 4) returned an EMPTY
API instance and the empty-API dereference is what surfaces as this TypeError.
Proven deterministically in the live page:
assignDt.cell($('<tr data-x="1">'), 4).data('zz')
// → THROW: Cannot read properties of undefined (reading 'row') ← byte-identical message
// (api.length === 0, identical minified frame)cell(row, col) (jquery.dataTables.js:9096-9110) resolves the row through
this.rows( rowSelector, internalOpts ), i.e. __row_selector()
(jquery.dataTables.js:8084). Its exact fast path is guarded by sel.nodeName:
// Selector - node
if ( sel.nodeName ) { // ← DOM NODE ONLY
var rowIdx = sel._DT_RowIndex;
...
return aoData[ rowIdx ] && aoData[ rowIdx ].nTr === sel ? [ rowIdx ] : [];
}A jQuery object has no nodeName, so assignDt.cell(tr, 4) never reaches it. It falls
into the generic catch-all:
var nodes = _removeEmpty( _pluck_order( settings.aoData, rows, 'nTr' ) );
return $(nodes).filter( sel ).map( function () { return this._DT_RowIndex; } ).toArray();which resolves the row by strict node identity (jQuery winnow → indexOf.call(qualifier, elem))
against aoData[ aiDisplayMaster[i] ].nTr, after _removeEmpty() has silently dropped every
row whose nTr is still null (a row that has never been drawn). Any mismatch — a re-created
<tr>, a row the current draw cycle has not produced yet — yields [] → empty API →
the TypeError of §3.1.
Measured in the live page for the healthy case, which shows the catch-all is what the call site is really using today:
nodes = aiDisplayMaster.map(i => aoData[i] && aoData[i].nTr).filter(Boolean) // length 3
$(nodes).filter($tr).length → 1 // identity match through the jQuery catch-all
$(nodes).filter(tr ).length → 1 // identity match through the node pathSo the jQuery form is a coincidentally working path, not the API contract the call site
intends: it depends on the row's <tr> being the very node DataTables cached, and it
degrades to an empty selection instead of resolving the row DataTables itself is holding.
$ git grep -n '\.cell(' -- gui/templates
gui/templates/usermanagement/usersAssignPlan.html:1334: assignDt.cell(tr, 4).data(buildSelectHtml(u), false);
One call site in the repository — the fix is entirely local. api/**, the DB schema and
all ten i18n bundles are untouched.
gui/templates/usermanagement/usersAssignPlan.html — onRoleChange():
-
Pass the DOM node, not the jQuery wrapper —
assignDt.cell(tr[0], 4). This takes DataTables' documented, exact path (sel.nodeName→aoData[sel._DT_RowIndex].nTr === sel), so the row is resolved by the row index DataTables stamped on the node itself instead of by a fragile identity scan. Added atr.lengthguard so an empty wrapper can never reach the API. -
Never let a best-effort cell refresh abort the save-state update. Only the refresh is
wrapped in
try/catch. Theusersmodel stays the source of truth for the Save payload, so a cache refresh that cannot be applied must never abort$('#saveBtn')— a stale Save button is the worse of the two possible failure modes, and the model keeps Save correct either way.
No user-facing string was touched → no i18n key added (nothing to validate in the bundles). No BFF change.
-
renderUsersTable(keep)instead of the targeted.data()— a full grid rebuild + DataTables re-init on every select change: destroys the user's page/search focus and thestateSaveview and re-runscreatedRowfor every row. Heavy, and a behaviour regression. -
assignDt.row(tr).data(...)/.row(tr[0]).index()— same class of fix as (1) but an extra API round trip for no gain. -
Numeric index
assignDt.cell(assignDt.row(tr[0]).index(), 4)— resolves too, but adds a second lookup that can itself fail and is harder to read than the node form.
| # | Case | Expected | Observed | Result |
|---|---|---|---|---|
| 1 | first change on a non-admin row | highlight + Modified badge + Save enabled |
dirty=true, saveDisabled=false, badge present |
PASS |
| 2 | second change of the same row (the reported case) | no console error, Save state correct |
saveDisabled === !isDirty(), 0 console messages
|
PASS |
| 3 | change back to the original value | Save disabled, badge + class gone |
saveDisabled=true, dirty=false, no badge, no changed
|
PASS |
| 4 | change on a second row | Save enabled, both rows tracked |
saveDisabled=false, badge on 1682 only |
PASS |
| 5 | sort re-draw (order([[4,'desc']]) then [[1,'asc']]) |
chosen options survive |
1681:4 before/after both sorts |
PASS |
| 5b | search re-draw (search('tina') then clear) |
chosen option survives |
1681:4 during search and after clearing |
PASS |
| 6 | bulk "Do" rebuilds the grid, then a per-row change | grid rebuilt, per-row change still updates Save |
bulkVals=[1:0,1681:7,1682:7], saveDisabled=false
|
PASS |
| 7 | admin row (locked select) | never dirty, Save stays disabled |
select.disabled=true, dirty=false, saveDisabled=true
|
PASS |
| 8 | pagination disabled (paginationCfg.enabled=false) |
no DataTable, handler still works, no exception |
assignDt === null, saveDisabled=false, badge present |
PASS |
| 9 | Event Viewer / events
|
no new Error/Warning rows | only the 2 audit_login_succeeded rows (log_level=16) |
PASS |
| 10 | Save round trip |
user_testplan_roles.role_id persisted, Save re-disabled |
1681 → 6 in the DB; saveDisabled=true, dirty=false
|
PASS |
| 11 | syntax gate | inline <script> parses |
new Function(<inline script>) → clean |
PASS |
Browser console after the whole matrix: no messages at all (list_console_messages types=[error,warn] → <no console messages found>) — the pre-fix TypeError is gone.
| File | Purpose |
|---|---|
gui/templates/usermanagement/usersAssignPlan.html |
onRoleChange() — the cell re-sync now takes the DOM node, a tr.length guard, and a try/catch that cannot abort the Save-state update |
CHANGELOG |
one-line 2.0.1 KEY BUGFIX entry (AGENTS.md rule 22) |
tmp/TLU_Test_Cases.md |
the 11-case regression suite |
tmp/fixtures_1680.sql |
reproduction fixture (project + nodes_hierarchy row + plan + 2 users + plan roles) |
docs/Bugfix-Issue-1680-usersAssignPlan-onRoleChange-TypeError.md |
this page |