Stop Making Users Hunt for Errors: Auto-Scroll oj-table to the First Problem Row
The small UX touch that turns a frustrating “find the needle in 600 rows” experience into “the app already knows where I need to be.”
Stop Making Users Hunt for Errors: Auto-Scroll oj-table to the First Problem Row
The small UX touch that turns a frustrating “find the needle in 600 rows” experience into “the app already knows where I need to be.”
The Frustration That Made Me Build This
Picture a data-conversion screen. The user uploads a CSV. Validation runs. Out of 600 rows, 47 have errors. The oj-table loads all 600 with errored cells highlighted in red.
The user opens the screen. Where are the errors? They scroll. And scroll. The first errored row is at position 184. By the time they find it, they’re already annoyed.
Multiply that by 12 data uploads a day. Multiply that by 50 users. That’s a lot of pointless scrolling.
The fix takes 15 lines of code. Let me show you.
What “Navigate to the First Error” Actually Means
Three things have to happen in sequence:
- Find the first errored row’s primary key
- Scroll the table so that row is visible
- Flash or highlight the row so the user’s eye lands on it
Each one has a small gotcha in oj-table. Get them all right and the experience feels magical. Get one wrong and the user is back to scrolling.
Step 1: Find the First Errored Row
You probably already have an error map keyed by row ID — something like:
$variables.errorsByCell = {
"12": { 3: [{...}], 7: [{...}] },
"47": { 2: [{...}] },
"184": { 9: [{...}] },
"201": { 5: [{...}] }
}
To find the first errored row that appears in the currently rendered table, you can’t just grab the first key from errorsByCell — JavaScript objects don't guarantee insertion order matches table order.
Instead, find the first row in your DataProvider’s data that has any error:
findFirstErroredRowKey($fragment) {
const $vars = $fragment.variables;
const pk = $vars.primaryKeyField;
const errors = $vars.errorsByCell || {};
const rows = $vars.bulkRowsDP.data;
for (let i = 0; i < rows.length; i++) {
const rid = String(rows[i][pk]);
if (errors[rid] && Object.keys(errors[rid]).length > 0) {
return rid;
}
}
return null;
}
This walks the DataProvider in display order, returning the first row key that has errors. Returns null if there are no errored rows.
Step 2: Scroll the Table — The scrollPosition Trick
oj-table has a scrollPosition property that controls where the table is scrolled to. You can set it programmatically:
const table = document.getElementById('bulkTable');
table.scrollPosition = { rowKey: 'someRowKey' };
That’s the official way. But there’s a gotcha that catches everyone the first time.
The Gotcha: Selection State Has to Apply First
If you scroll to a row at the exact same moment you’re also changing the selection, oj-table sometimes scrolls to the wrong position. The selection logic and scroll logic race each other.
The fix is a tiny delay:
_scrollToFirstSelected($fragment, firstKey) {
if (!firstKey) return;
setTimeout(() => {
const table = document.getElementById('bulkTable');
if (!table) return;
table.scrollPosition = { rowKey: String(firstKey) };
}, 100);
}
100ms is enough. The selection settles, then the scroll happens. Smooth.
Step 3: Flash the Row So the User Sees It
Scrolling alone isn’t enough. The user’s eye needs a visual cue to land on the right row. A brief background flash works beautifully:
@keyframes row-flash {
0% { background-color: rgba(37, 99, 235, 0); }
20% { background-color: rgba(37, 99, 235, 0.25); }
80% { background-color: rgba(37, 99, 235, 0.25); }
100% { background-color: rgba(37, 99, 235, 0); }
}
.row-flash {
animation: row-flash 1.5s ease-in-out;
}
Apply it after the scroll:
_flashRow($fragment, rowKey) {
if (!rowKey) return;
setTimeout(() => {
// oj-table renders rows with data-rowkey attribute (or class with the key)
const row = document.querySelector(`#bulkTable tr[data-rowkey="${rowKey}"]`);
if (!row) return;
row.classList.add('row-flash');
setTimeout(() => row.classList.remove('row-flash'), 1500);
}, 250); // wait for scroll to finish
}
The 250ms gives the smooth-scroll animation time to complete before the flash starts. If you flash too early, the user’s eye is still tracking the scroll motion and misses the highlight.
Putting It All Together
Here’s the full handler that runs when the dialog opens (or when the user clicks a “Jump to First Error” button):
async navigateToFirstError($fragment) {
const $vars = $fragment.variables;
// Step 1: Find it
const firstKey = this.findFirstErroredRowKey($fragment);
if (!firstKey) {
this.setStatus($fragment, 'info', 'No errors to navigate to', 2500);
return;
}
// Step 2: Optionally select the row
$vars.bulkSelected = {
row: new KeySetImpl([firstKey]),
column: new KeySetImpl()
};
// Step 3: Scroll
this._scrollToFirstSelected($fragment, firstKey);
// Step 4: Flash
this._flashRow($fragment, firstKey);
// Step 5: Status message
const totalErrored = this._countErroredRows($fragment);
this.setStatus(
$fragment,
'info',
`Showing first errored record. ${totalErrored} total need fixing.`,
4000
);
}
_countErroredRows($fragment) {
const errors = $fragment.variables.errorsByCell || {};
return Object.keys(errors).filter(rid =>
Object.keys(errors[rid]).length > 0
).length;
}
Five steps. ~30 lines of code. Massive UX improvement.
When to Trigger This
There are three natural trigger points:
1. Automatically on Dialog Open
If the user just clicked “Resolve Errors”, they want to be taken to the first one immediately:
async openErrorActionDialog($fragment, input, context) {
// ... your existing init code ...
// After data is loaded and table rendered
await this._yield(300); // let the table render
this.navigateToFirstError($fragment);
}
The _yield(300) gives oj-table time to render before we try to scroll. Without it, the table doesn't yet know about the row, and the scroll silently fails.
2. Via a “Jump to First Error” Button
Add a button to your toolbar:
<oj-button chroming="outlined"
disabled="[[ !$variables.errorsByCell ]]"
on-oj-action="[[$listeners.jumpToFirstError]]">
<span slot="startIcon" class="oj-ux-ico-go-to-first"></span>
Jump to First Error
</oj-button>
Wire it to a tiny action chain that calls navigateToFirstError.
3. After Each Fix — Auto-Advance to the Next Error
This is the killer feature. The user fixes one row, hits Submit, and the app automatically scrolls to the next errored row. They never touch the scroll bar.
async submitChanges($fragment, context) {
// ... existing submit logic ...
// After errors are rebuilt, auto-advance
const nextKey = this.findFirstErroredRowKey($fragment);
if (nextKey) {
this._scrollToFirstSelected($fragment, nextKey);
this._flashRow($fragment, nextKey);
// Also load the new record into the form
$vars.selectedRecordId = nextKey;
this.loadRecordIntoForm($fragment, nextKey);
} else {
this.setStatus($fragment, 'success', 'All errors resolved!', 5000);
}
}
For a user with 47 errors to fix, this turns a click-fix-scroll-find-click cycle into just click-fix-click-fix. Their hands never leave the keyboard.
A More Sophisticated Variant: Errors by Severity
If your errors have severity levels (business rules vs type mismatches), you might want to jump to the most severe one first:
findHighestSeverityErroredRowKey($fragment) {
const $vars = $fragment.variables;
const pk = $vars.primaryKeyField;
const errors = $vars.errorsByCell || {};
const rows = $vars.bulkRowsDP.data;
// Priority 1: business rule errors
for (let i = 0; i < rows.length; i++) {
const rid = String(rows[i][pk]);
const cells = errors[rid] || {};
for (const colNum of Object.keys(cells)) {
const cellErrors = cells[colNum];
if (cellErrors.some(e => e.errorType === 'BUSINESS_RULE')) {
return rid;
}
}
}
// Priority 2: any other error type
for (let i = 0; i < rows.length; i++) {
const rid = String(rows[i][pk]);
if (errors[rid] && Object.keys(errors[rid]).length > 0) {
return rid;
}
}
return null;
}
The user gets taken to the most critical issue first. Once they fix all the business rule errors, they move on to type mismatches. Natural triage flow without them having to think about it.
The Edge Cases You’ll Hit
Edge Case 1: Row Not in the Currently Loaded Data
If your table uses lazy loading (scroll-policy="loadMoreOnScroll"), the row you want to scroll to might not yet exist in the DOM. oj-table is smart about this — setting scrollPosition triggers a fetch — but the flash animation will fail because the DOM element isn't there yet.
Fix: wait longer before flashing, and verify the element exists:
_flashRow($fragment, rowKey) {
if (!rowKey) return;
let attempts = 0;
const tryFlash = () => {
const row = document.querySelector(`#bulkTable tr[data-rowkey="${rowKey}"]`);
if (row) {
row.classList.add('row-flash');
setTimeout(() => row.classList.remove('row-flash'), 1500);
} else if (attempts < 10) {
attempts++;
setTimeout(tryFlash, 100);
}
};
setTimeout(tryFlash, 250);
}
This retries up to 10 times over 1 second, giving the lazy load time to fetch the row.
Edge Case 2: Filtered Tables
If your table has filters applied, the “first errored row” might not be visible because it’s been filtered out. Decide what behavior you want:
- Option A: Clear filters first, then jump. Tell the user via status: “Cleared filters to show first error.”
- Option B: Find the first errored row that’s still visible under current filters. If none, tell the user: “All errors are hidden by current filters.”
I prefer Option B — respect the user’s filter state. They probably filtered for a reason.
Edge Case 3: Table Inside a Scrollable Container
If your oj-table is inside a div with its own scrollbar (not the body), scrollPosition might scroll within the table but the table itself isn't visible in the viewport. You may also need to scroll the parent container:
_scrollToFirstSelected($fragment, firstKey) {
if (!firstKey) return;
setTimeout(() => {
const table = document.getElementById('bulkTable');
if (!table) return;
// Scroll inside the table
table.scrollPosition = { rowKey: String(firstKey) };
// Also scroll the parent container into view
table.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 100);
}
The scrollIntoView on the table itself ensures the table is visible in the page, then scrollPosition handles the within-table scroll.
What This Costs You
Roughly 60 lines of JavaScript, 15 lines of CSS, and one button in your toolbar. Maybe two hours of work including the polish.
What you get is a UX detail that users will remember. The “the app already knows where I should be” feeling is rare. When you build it, users notice — and tell their colleagues.
The Performance Note
If you have an enormous DataProvider (10,000+ rows), the for loop in findFirstErroredRowKey runs over all of them. That's still fast (a tight loop runs millions of iterations per second), but if you're calling this on every submit and the table is huge, consider caching:
get _erroredRowKeysOrdered() {
// Lazily compute once, invalidate when errors change
if (this._cachedKeys) return this._cachedKeys;
const errors = this.$vars.errorsByCell || {};
const pk = this.$vars.primaryKeyField;
const rows = this.$vars.bulkRowsDP.data;
this._cachedKeys = rows
.map(r => String(r[pk]))
.filter(rid => errors[rid] && Object.keys(errors[rid]).length > 0);
return this._cachedKeys;
}
// Invalidate when errors change
invalidateErroredRowsCache() {
this._cachedKeys = null;
}
For 95% of apps you’ll never need this. But it’s good to know the option exists.
The Bigger Pattern
This is part of a larger UX philosophy: the app should always tell you where to look next. Status bars do it for current state. Auto-navigation does it for spatial state. Toast notifications do it (badly) for ephemeral events.
When users open your app and immediately know what to do, they trust it. When they have to hunt for what changed or what needs attention, they don’t.
For data-fixing UIs, “jump to first error” is the bare minimum. Once you have it, think about:
- Jump to next/previous error (keyboard shortcuts: J/K, like Gmail)
- Progress indicator (“12 of 47 errors remaining”)
- Auto-advance after fix (no clicks needed)
- Group jumps (“Jump to next error type”)
Each one adds maybe an hour of work and dramatically improves the experience for power users.
What To Build This Week
Open your data-fixing UI. Time how long it takes a user to find the first error in a 500-row table. If it’s more than 3 seconds, you’re losing them.
Add the navigateToFirstError function. Wire it to dialog open. Watch the difference.
Then add the auto-advance on submit. Watch users go from begrudging to engaged. The app is now working for them, not against them.
If you build any kind of data-fixing UI in VBCS, this is the cheapest UX upgrade you’ll ever make. Five steps, sixty lines, hundreds of hours of saved scrolling.
Tags: Oracle VBCS, Oracle JET, oj-table, UX Design, Frontend Engineering, User Experience
메타데이터
- post_id
- f7e37e438ac7
- slug
- stop-making-users-hunt-for-errors-auto-scroll-oj-table-to-the-first-problem-row-f7e37e438ac7
- url
- https://medium.com/@aman.kant/stop-making-users-hunt-for-errors-auto-scroll-oj-table-to-the-first-problem-row-f7e37e438ac7
- canonical_url
- https://medium.com/@aman.kant/stop-making-users-hunt-for-errors-auto-scroll-oj-table-to-the-first-problem-row-f7e37e438ac7
- author_url
- https://medium.com/@aman.kant
- status
- ok
- fetched_at
- 2026-07-17 18:48:04