Skip to content

Sort and Filter the Users Table by Role, With Counts #493

Description

@b-at-neu

Goal

Make it easy to tell admins and managers apart from everyone else on /users, without splitting the page into separate tables.

Three changes to the existing table:

  1. Default sort by role — Admins first, then Managers, then users with no role
  2. Role becomes a sortable column — it currently is not
  3. Counts and filters above the table

Current state

components/features/users-table.tsx:

  • COLUMNS contains only user, joined and applicationsrole is absent, so the Roles column header is not sortable
  • defaultSort is { key: 'joined', direction: 'desc' }
  • The Roles cell renders an Admin badge, a Manager badge, both, or — when neither applies — a bare em dash
  • Search filters name and email only, client-side
  • getUsersForAdmin already returns isAdmin, managedPositions (id + title) and a non-draft applications count

So this is presentation only. No query or schema change is needed, and the filter options derive from data already on the client.

1. Role ordering

Roles are not mutually exclusive — an admin can also manage positions. A user is placed in the highest category they qualify for:

Rank Group Rule Renders as
0 Admin isAdmin Admin badge
1 Manager managedPositions.length > 0 Manager badge
2 neither , unchanged

The third group stays unnamed. No "Applicant" label, no new badge — users with no role keep the existing em dash and simply sort to the bottom.

Add a role entry to COLUMNS whose accessor returns that rank, so sorting is deterministic rather than alphabetical on a badge string. A rank also keeps the unnamed group ordered correctly without needing a string to sort on.

This is the default sort, not a permanent primary sort. Clicking any other column re-sorts the whole table and the role grouping goes away — that is intended. Sorting by the Role column restores it.

Within a group the order must be stable and meaningful — sort secondarily by name, falling back to email, rather than leaving it to the query's createdAt desc.

2. Counts

A summary line above the table showing how many admins and how many managers, alongside the total number of users. The unnamed third group is not called out as its own figure — it is the remainder, and naming it in the counts would undo the decision above.

Counts should reflect the currently filtered set, not the total, so they stay truthful as filters narrow the list.

3. Filters

  • Role — All / Admin / Manager. No option for the unnamed group; sorting by the Role column is how you bring those users together
  • Managed position — pick a position, see who manages it. Options derive from the managedPositions already present on the loaded rows, so no new query
  • Both compose with the existing search box rather than replacing it

Worth considering: extend the search to match managed position titles too, since that is the other thing an admin scans this table for.

Acceptance criteria

  • role added to COLUMNS with a rank-based accessor (Admin 0, Manager 1, none 2)
  • Default sort is role ascending, so users with no role fall to the bottom
  • Clicking another column overrides the role grouping
  • The Roles column header is sortable, with correct aria-sort
  • Ties within a role group sort by name, then email
  • The third group remains unlabelled — the cell is unchanged, and no new badge is introduced
  • Counts for admins and managers shown above the table, plus the total
  • Counts reflect the filtered set
  • Role filter (All / Admin / Manager) and managed-position filter, both composing with search
  • Filter and sort state is keyboard accessible and announced

Overlapping tickets


Implementation Plan

SESSION REQUIRED: touches CLAUDE.md / .claude/** — a dispatched agent can't edit those

Overview

#392 landed, so users-table.tsx already renders through DataTable + filterRows and the mechanism this ticket plugs into exists. The work is the role dimension: give the existing roles column a rank-based sortAccessor, make it the default sort, and add a Role / Managed-position filter pair plus admin & manager figures to the toolbar count line.

Two small, general extensions to lib/data-table.ts keep it declarative instead of hand-rolled on this one table:

  • Tuple sort accessorssortAccessor may return an array compared left-to-right, so the role column sorts by [rank, displayName, email] in one accessor. No new prop, no second sort pass.
  • searchValue split out of filterValue — today filterValue feeds both free-text search and exact-match filters, which breaks the moment a filter token is an id (managed position) or a word the user might type (admin). searchValue feeds the query, filterValue feeds the filters. users-table is the only consumer of filterValue today, so the migration is one column.

No query, action or schema change: getUsersForAdmin already returns everything the counts and both filters need.

Changes

  • lib/data-table.ts — add SortValue; widen sortAccessor to SortValue | SortValue[] and teach compareValues to compare tuples left-to-right; add searchValue to DataTableColumn<T> and narrow filterValue's doc/role to exact-match filters only; filterRows reads searchValue for query, filterValue for filters.
  • lib/types.tsUserRoleFilter = 'admin' | 'manager'.
  • lib/constants.tsUSER_ROLE_FILTER_OPTIONS: { value: UserRoleFilter; label: string }[], array order = rank order, mirroring REVIEWER_APPLICATION_STATUS_OPTIONS.
  • lib/utils.tsgetUserRoleTokens() and getUserRoleRank() (pure, next to formatTableCount); rank derives from the first token so the two can't drift.
  • components/features/users-table.tsx — role/position filter state + selects, Clear filters, role counts in the count line; roles column gains sortAccessor + filterValue; managedPositions gains searchValue (titles) + filterValue (ids); user's filterValuesearchValue; defaultSort{ key: 'roles', direction: 'asc' }.
  • app/(main)/(auth)/users/loading.tsx — toolbar skeleton gains the two select placeholders and a wider count line, so the header doesn't shift on resolve.
  • .claude/docs/WORKFLOWS.md — new AD-10 Find a user entry (default role ordering, the three composing controls, the count line, the no-match state) + its TOC link on the Admin line. Appended rather than inserted, so AD-7…AD-9 anchors don't renumber.
  • tests/unit/data-table.test.tsnew: tuple compare and the search/filter split.
  • tests/unit/utils.test.ts — cases for getUserRoleTokens / getUserRoleRank.

Implementation

  • lib/data-table.ts: export type SortValue = string | number | Date; sortAccessor?: (row: T) => SortValue | SortValue[] | null | undefined.
  • Extend compareValues to accept tuples: normalise both sides to arrays, compare index-by-index with the existing scalar logic, first non-zero wins; a null/undefined element sorts after a present one at that position; equal prefix → shorter array first. Existing scalar call sites keep their exact behaviour.
  • Add searchValue?: (row: T) => string | string[] | null to DataTableColumn<T>; in filterRows the query pass reads searchValue only and the filters pass reads filterValue only. Update both field comments (one line each).
  • lib/types.ts + lib/constants.ts: UserRoleFilter and USER_ROLE_FILTER_OPTIONS (admin → "Admin", manager → "Manager").
  • lib/utils.ts: getUserRoleTokens({ isAdmin, managedPositions }) returns UserRoleFilter[] in rank order — badge semantics, so an admin who also manages positions returns both; getUserRoleRank() returns USER_ROLE_FILTER_OPTIONS.findIndex(first token), falling back to 2 when there are none.
  • users-table.tsx: roles column gets sortAccessor: (u) => [getUserRoleRank(u), u.name ?? u.email, u.email] and filterValue: getUserRoleTokens. Cell markup unchanged — keep the and add no badge. Reuse getUserRoleTokens for the existing isManager derivation in the cell and the mobile card rather than re-deriving managedPositions.length > 0.
  • managedPositions column: filterValue: (u) => u.managedPositions.map((p) => p.id) (ids — titles are not unique) and searchValue: (u) => u.managedPositions.map((p) => p.title), which is what makes search match position titles.
  • user column: rename its filterValue to searchValue (unchanged body).
  • Add roleFilter / positionFilter useState<string>('') next to the existing query; build DataTableFilter[] from whichever are set and pass filterRows(users, COLUMNS, { query, filters }). Local state, not nuqs — matches the search box already on this table (see Risks).
  • Derive positionOptions from users (not filtered, so options never vanish mid-filter): flatten managedPositions, dedupe by id, sort by title. Render the position select only when positionOptions.length > 0.
  • Toolbar: Role select, Managed position select, then the existing search input, then Clear filters (only when any of the three is active), then the count line — same flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end row as today. Each select gets a <Label htmlFor> and an id on SelectTrigger, w-full sm:w-48; the "All …" item uses value="" like applications-toolbar.
  • Count line: keep formatTableCount for the user total (isFiltered = search or either select active), then append · N admins · N managers computed over filtered with singular/plural. Stays in the existing aria-live="polite" paragraph so filtering is announced.
  • defaultSort={{ key: 'roles', direction: 'asc' }}; noMatchMessage="No users match your filters.". DataTable picks roles up as a valid ?sort value automatically (sortableKeys is derived from the columns).
  • Update app/(main)/(auth)/users/loading.tsx toolbar block to match the new control row.
  • Add AD-10 Find a user to .claude/docs/WORKFLOWS.md and link it from the Admin line in the index.
  • Unit tests: tuple compare (rank then name then email; null element last), filterRows (a filterValue-only column is not searched; a searchValue-only column is not filterable; role + position + query compose with AND), and the role token/rank helpers (admin, manager, admin-and-manager, neither).
  • npm run prettier:check, npm run eslint:check, npm run tsc:check, npm run test.

UX states

  • Default view — table opens grouped: admins, then managers, then everyone else, alphabetical by display name inside each group. The Roles header shows the ascending arrow and aria-sort="ascending" on load (DataTable already reflects defaultSort when no ?sort param is present).
  • Toolbar — one wrapping row above the table: Role (All roles / Admin / Manager) · Managed position (All positions / …titles) · Search (placeholder unchanged: "Search by name or email") · Clear filters · count line pushed right. Stacks full-width below sm.
  • Counts12 users · 2 admins · 3 managers; filtered: 4 / 12 users · 1 admin · 2 managers. Both role figures always render, including 0 admins, so the live region's shape is stable.
  • Filtered, no match — the table card renders the single centred row "No users match your filters." on desktop and the same sentence in the mobile block; Clear filters in the toolbar is the way out, and the count line reads 0 / 12 users · 0 admins · 0 managers.
  • Empty (no users at all) — unchanged: the existing EmptyState ("No users found" / "Active users will appear here.") replaces the whole table, toolbar included.
  • Loading — route-level loading.tsx only; no new async surface. Skeleton updated so the two selects don't pop in.
  • Error — no new failure path; the promote/deactivate toasts and the global boundary are untouched.
  • Accessibility — every control has a visible <Label> bound by htmlFor/id; both selects are Radix (keyboard + typeahead + focus return for free); the Roles header is the same <button> inside a <th aria-sort> as the other sortable columns, with the "Sort by Roles, currently ascending" label; the count line's existing aria-live="polite" announces every filter, search and clear.
  • Mobile (375px) — controls stack full-width; no fixed widths. The mobile card is unchanged. Broader touch-target and type-scale work on this toolbar is Mobile Layout Pass — Users Page, Dashboard, Tables and Filters #378 — do not pre-empt it, just don't overflow.

Testing

  • Sign in as an admin and open /users — rows are grouped admins → managers → the rest, and within each group ordered by name (email for users with no name).
  • A user who is both an admin and a manager shows both badges and sits in the admin group.
  • Click the Roles header — arrow and aria-sort cycle asc → desc → cleared; desc puts the unlabelled group first; cleared returns to the default role order.
  • Click Joined — the role grouping is replaced by the date sort; reload with ?sort=roles&dir=desc in the URL and the table honours it.
  • Set Role → Admin — only admins remain, the count line reads N / M users · N admins · … managers, and the badges match.
  • Set Role → Manager — admins who also manage positions are included (intended: the filter follows the badges, not the sort rank).
  • Pick a Managed position — only that position's managers remain; combine it with Role and with a search term and confirm all three narrow together (AND).
  • Search a managed position title — matching managers appear; search a name and an email — both still work.
  • Search something with no matches — "No users match your filters.", count line 0 / M users · 0 admins · 0 managers; Clear filters restores every row and resets all three controls.
  • Keyboard only: Tab to each select, open with Enter/Space, choose with arrows + Enter, Escape closes; Tab to the Roles header and toggle with Enter. Confirm a screen reader announces the updated count line after each change.
  • At 375px: controls stack full-width, no horizontal scroll, mobile cards reflect the active filters and sort order.
  • Throttle to Slow 3G and reload /users — the skeleton toolbar matches the real one, no visible shift.
  • On an install where no user manages a position, the Managed position select is absent and the rest of the toolbar still lays out correctly.
  • Promote and deactivate still work from both the desktop row and the mobile card, with their confirmations and toasts.
  • Both light and dark theme.

Risks / notes

  • Counts and filters use badge semantics; the rank is only for sorting. An admin who manages positions is counted and filtered as both — that is what their row visibly shows — while the rank still places them in the admin group. Consequence: the two figures can sum above the number of rows. The alternative (mutually-exclusive buckets) would make the Manager filter hide users wearing a Manager badge. Veto at the plan gate if the exclusive reading was intended.
  • The column key stays roles. The ticket says "add a role entry to COLUMNS", but the column already exists as roles — it just has no sortAccessor. Keeping the key avoids churn and keeps the filter key aligned with the column it filters. It also fixes the URL contract as ?sort=roles.
  • Filter state is component-local, not URL state. The search box on this table already is, and the filtering is client-side over an already-loaded list; a half-URL/half-local toolbar is worse than either. nuqs for all three is a reasonable follow-up, best bundled with Add Pagination to the Applications Hub #389 (pagination) or Mobile Layout Pass — Users Page, Dashboard, Tables and Filters #378.
  • filterValue semantics change under other callersusers-table is the only consumer today (grep confirms), so the split is a one-column migration, but any table written against the old dual-purpose filterValue after this lands needs searchValue instead.
  • First click on the already-default Roles header is a no-op visuallyDataTable moves from "no ?sort param" to ?sort=roles&dir=asc, which is the order already shown. Pre-existing for joined today; not worth changing DataTable's toggle cycle here.
  • No schema, migration, prisma/data/ or prisma/actions/ change — this is presentation over data already fetched, so there is no new validation or auth surface.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

claudeWill be worked on by ClaudeenhancementNew feature or requestpr openedPull request has been opened

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions