Skip to content

The Filter Chip That Quietly Dropped Rows

Adityo Guni Waluyo

A client-side filter over a paginated endpoint drops rows it never fetched. The fix: a 500-row fetch ceiling, aria-pressed, and a sort deliberately left out.

TL;DR

The chip filters submissions in the browser. It works with a limited set of rows. A 500-row limit was added with a comment naming the ceiling.

The SK-habis chip sat in the submission toolbar as a single button. One click shrank the list to five rows. Organizations known to have run out of SK were not among them, though they appeared in the unfiltered list.

The first assumption was bad data. Some submission records stored the flag as a string, some left it null, and the chip probably read the wrong field. That accounted for a row, maybe two. It did not account for rows visible in the unfiltered list that disappeared the moment the chip was active.

The chip was filtering correctly. It was filtering a smaller set than the screen suggested.

What the chip actually filtered

The submissions endpoint accepts no sk_habis param. There is no server-side way to request only the expired ones, so the chip filters in the browser over whatever the list page already fetched. Array.prototype.filter builds a new array from the elements already in memory [6]. If a page fetches fifty rows while the dataset holds four hundred, the chip works with fifty; the remaining three hundred and fifty were never present. Nothing in the interface distinguishes the two cases.

That is the failure mode. Not a filter that breaks, a filter that answers a narrower question than the one asked, and looks complete while doing it.

A ceiling, written down

The commit raises the fetch limit to 500 rows in a single page and adds a comment naming the ceiling and the upgrade path. The choice is not elegant. It trades memory and query time for the guarantee that the filter cannot lose a row, and it holds only until a bidang passes 500 submissions. At that point the comment says what to do: add the server param.

// Client-side filter over a paginated endpoint needs a fetch ceiling.
// Ceiling: 500 rows in one page. Upgrade path: a server-side sk_habis param.
const PAGE_LIMIT = 500;

const res = await fetch(`/api/admin/organizations?limit=${PAGE_LIMIT}`);
const rows = await res.json();
return rows.filter((row) => row.sk_habis === true);

The alternative was to ship the param now. That means a backend change, an index on the flag, an adjustment to the query builder, and a follow-up frontend deploy that depends on it. The ceiling comment is the cheaper version of the same decision, recorded where the next person will read it. A limit with a comment is a documented trade. A limit without one is just a number somebody will raise again in six months.

Organizations whose SK has run out now carry a destructive badge in the list, five rows at audit time.

The toggle and the age label

The chip was a plain button. Toggling it changed the visible label but never announced state, so assistive tech read a button whose text flipped with no indication that a filter was active. aria-pressed turns a button into a toggle button and represents the pressed state in the accessibility tree [9]. Two words of markup, and the control now matches what it does.

Next to each ticket, the commit adds submission age: a manual day count printed as "today" or "X days ago". Intl.RelativeTimeFormat is the platform's built-in formatter for this, language-sensitive and aware of each locale's plural forms [7]. It is not what the commit uses. The existing timeAgo helper does manual math, and the new label follows that pattern rather than introducing a second formatting path. The cost is real and small: locale and plural edge cases stay unsolved in two places instead of one.

Sorting by age was considered and left out. Ordering belongs to the server. A client-side sort would reorder the rows on the current page and nothing else, producing a list that looks sorted while the pages underneath are not. That is the same defect as the filter, one step worse, because a sorted page reads as authoritative.

The audit found something else

Separate from the filter work, the same pass audited the dashboard endpoints. Two of them, the views and popular stats routes, return data across all bidang to accounts scoped to a single bidang. The scoping exists on other admin routes; these two ignore it. The finding was written up and left in place for a backend follow-up rather than patched inside a CMS ergonomics commit. An authorization boundary is not a side fix, and the commit that changes how a filter chip renders is the wrong place to also change who can read what.

The five-row count on the destructive badge is a snapshot taken at audit time against the current data, and it will drift. The 500-row limit will hold until it does not. Both are written down where the next change will find them, which is the part worth keeping.

Sources:

  1. [6] MDN Web Docs: Array.prototype.filter
  2. [7] MDN Web Docs: Intl.RelativeTimeFormat
  3. [9] MDN Web Docs: aria-pressed

Related articles