From 09a69e624a0e3d21a36b5804f7ce52acb19c3158 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Tue, 28 Jul 2026 12:13:58 -0700 Subject: [PATCH] feat: search users by display name (#27398) Free-text member search previously matched only username and email, so typing a person's display name returned no results even though the UI shows the display name as the primary label. This broadens the free-text `@search` filter to also match `users.name`. The change is in three queries: `GetUsers`, `PaginatedOrganizationMembers`, and `GetGroupMembersByGroupIDPaginated`. This covers every server-filtered surface: the Users page, the Organization Members page, the Group Members page, and the `UserAutocomplete` / `WorkspaceUserAutocomplete` pickers (which query `GetUsers` with `q`). The org member picker (`MemberAutocomplete`) filters client-side via cmdk, so display name is added to its `keywords`. Explicit filters (`name:`, `username`/`email`) and pagination counts are unchanged; the group members count still comes from the filtered `COUNT(*) OVER()` in the same query. Refs DEVEX-484 Refs DEVEX-565
Implementation plan ## Problem Member search (both the global Users page and the Organization Members page) matches only on `username` and `email`. It does not match on the user's display name (`users.name`), even though the Organization Members table shows `name` as the primary title. So typing a person's full name in the search box returns nothing. Today a bare search term (`alice`) is routed to the SQL `@search` filter, which only checks `email`/`username`. Display name is only matched if the user explicitly types `name:alice`, which is undiscoverable. ## Design decision Include `name` in the free-text `@search` condition in the affected SQL queries. A bare term then matches `email OR username OR name`, using the same case-insensitive substring `ILIKE` already in place. This keeps the existing explicit `name:` filter working. Tradeoff: this broadens the meaning of free-text `search` globally (anything using these queries now also matches display name). This is the intended behavior, confirmed against DEVEX-565 (display name search in the user picker). ## Affected files Backend: - `coderd/database/queries/users.sql` (`GetUsers`) - `coderd/database/queries/organizationmembers.sql` (`PaginatedOrganizationMembers`) - `coderd/database/queries/groupmembers.sql` (`GetGroupMembersByGroupIDPaginated`) - `coderd/database/queries.sql.go` regenerated via `make gen` Frontend: - `site/src/components/UserAutocomplete/UserAutocomplete.tsx` (add `name` to client-side cmdk keywords) Tests: - `coderd/coderdtest/users.go` (shared `UsersFilter` helper): added a `DisplayNameSearch` case and extended search-based expectations to include `name`. Exercised by `TestGetUsersFilter`, `TestGetOrgMembersFilter`, and `TestGetGroupMembersFilter`. Docs: - `docs/admin/users/index.md`: documented that free-text search matches username, email, and display name. ## Frontend surface coverage | Surface | Sends | Backend | Query | |---|---|---|---| | Users page | `q` | `GET /users` | `GetUsers` | | Organization Members page | `q` | paginated members | `PaginatedOrganizationMembers` | | Group Members page | `q` | `groupMembers` | `GetGroupMembersByGroupIDPaginated` | | User pickers (server-filtered) | `q` | `GET /users` | `GetUsers` | | Org member picker (client-filtered) | local cmdk | n/a | keyword change | ## Out of scope - Trigram/similarity (fuzzy) matching; keeps `ILIKE` substring semantics. - Sort/pagination ordering (still `LOWER(username)`).
--- _Created by Coder Agents on behalf of @aqandrew._ --- coderd/coderdtest/users.go | 18 +++++++++++++++--- coderd/database/queries.sql.go | 9 ++++++--- coderd/database/queries/groupmembers.sql | 3 ++- .../database/queries/organizationmembers.sql | 3 ++- coderd/database/queries/users.sql | 3 ++- docs/admin/users/index.md | 5 +++++ .../UserAutocomplete/UserAutocomplete.tsx | 7 ++++++- 7 files changed, 38 insertions(+), 10 deletions(-) diff --git a/coderd/coderdtest/users.go b/coderd/coderdtest/users.go index 6023b2b072..9b2130bcc7 100644 --- a/coderd/coderdtest/users.go +++ b/coderd/coderdtest/users.go @@ -401,7 +401,19 @@ func UsersFilter( Search: "a", }, FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { - return (strings.ContainsAny(u.Username, "aA") || strings.ContainsAny(u.Email, "aA")) + return (strings.ContainsAny(u.Username, "aA") || strings.ContainsAny(u.Email, "aA") || strings.ContainsAny(u.Name, "aA")) + }, + }, + { + Name: "DisplayNameSearch", + Filter: codersdk.UsersRequest{ + Search: "user", + }, + FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { + const term = "user" + return strings.Contains(strings.ToLower(u.Username), term) || + strings.Contains(strings.ToLower(u.Email), term) || + strings.Contains(strings.ToLower(u.Name), term) }, }, { @@ -470,7 +482,7 @@ func UsersFilter( FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { for _, r := range u.Roles { if r.Name == codersdk.RoleOwner { - return (strings.ContainsAny(u.Username, "iI") || strings.ContainsAny(u.Email, "iI")) && + return (strings.ContainsAny(u.Username, "iI") || strings.ContainsAny(u.Email, "iI") || strings.ContainsAny(u.Name, "iI")) && u.Status == codersdk.UserStatusActive } } @@ -485,7 +497,7 @@ func UsersFilter( FilterF: func(_ codersdk.UsersRequest, u codersdk.User) bool { for _, r := range u.Roles { if r.Name == codersdk.RoleOwner { - return (strings.ContainsAny(u.Username, "iI") || strings.ContainsAny(u.Email, "iI")) && + return (strings.ContainsAny(u.Username, "iI") || strings.ContainsAny(u.Email, "iI") || strings.ContainsAny(u.Name, "iI")) && u.Status == codersdk.UserStatusActive } } diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ee88e6cdba..1178f7f048 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -15036,11 +15036,12 @@ WHERE ELSE true END -- Start filters - -- Filter by email or username + -- Filter by email, username, or name (display name) AND CASE WHEN $3 :: text != '' THEN ( user_email ILIKE concat('%', $3, '%') OR user_username ILIKE concat('%', $3, '%') + OR user_name ILIKE concat('%', $3, '%') ) ELSE true END @@ -20435,11 +20436,12 @@ WHERE organization_id = $2 ELSE true END - -- Filter by email or username + -- Filter by email, username, or name (display name) AND CASE WHEN $3 :: text != '' THEN ( users.email ILIKE concat('%', $3, '%') OR users.username ILIKE concat('%', $3, '%') + OR users.name ILIKE concat('%', $3, '%') ) ELSE true END @@ -30922,11 +30924,12 @@ WHERE ELSE true END -- Start filters - -- Filter by email or username + -- Filter by email, username, or name (display name) AND CASE WHEN $2 :: text != '' THEN ( email ILIKE concat('%', $2, '%') OR username ILIKE concat('%', $2, '%') + OR name ILIKE concat('%', $2, '%') ) ELSE true END diff --git a/coderd/database/queries/groupmembers.sql b/coderd/database/queries/groupmembers.sql index fd167d219a..1be9815521 100644 --- a/coderd/database/queries/groupmembers.sql +++ b/coderd/database/queries/groupmembers.sql @@ -45,11 +45,12 @@ WHERE ELSE true END -- Start filters - -- Filter by email or username + -- Filter by email, username, or name (display name) AND CASE WHEN @search :: text != '' THEN ( user_email ILIKE concat('%', @search, '%') OR user_username ILIKE concat('%', @search, '%') + OR user_name ILIKE concat('%', @search, '%') ) ELSE true END diff --git a/coderd/database/queries/organizationmembers.sql b/coderd/database/queries/organizationmembers.sql index 78e7e31163..bf75d1e5d7 100644 --- a/coderd/database/queries/organizationmembers.sql +++ b/coderd/database/queries/organizationmembers.sql @@ -121,11 +121,12 @@ WHERE organization_id = @organization_id ELSE true END - -- Filter by email or username + -- Filter by email, username, or name (display name) AND CASE WHEN @search :: text != '' THEN ( users.email ILIKE concat('%', @search, '%') OR users.username ILIKE concat('%', @search, '%') + OR users.name ILIKE concat('%', @search, '%') ) ELSE true END diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index e283b43c6b..47940485ef 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -472,11 +472,12 @@ WHERE ELSE true END -- Start filters - -- Filter by email or username + -- Filter by email, username, or name (display name) AND CASE WHEN @search :: text != '' THEN ( email ILIKE concat('%', @search, '%') OR username ILIKE concat('%', @search, '%') + OR name ILIKE concat('%', @search, '%') ) ELSE true END diff --git a/docs/admin/users/index.md b/docs/admin/users/index.md index 0a38fa8710..32707ab430 100644 --- a/docs/admin/users/index.md +++ b/docs/admin/users/index.md @@ -194,6 +194,11 @@ to use the Coder's filter query: `login_type:github` - To find service accounts: `service_account:true`. +Any text that is not part of a `key:value` filter is treated as a free-text +search and matches against a user's username, email, and display name. For +example, entering `jane` returns users whose username, email, or display name +contains `jane`. + The following filters are supported: - `status` - Indicates the status of the user. It can be either `active`, diff --git a/site/src/components/UserAutocomplete/UserAutocomplete.tsx b/site/src/components/UserAutocomplete/UserAutocomplete.tsx index 7a0bf3f2b4..b04f4251eb 100644 --- a/site/src/components/UserAutocomplete/UserAutocomplete.tsx +++ b/site/src/components/UserAutocomplete/UserAutocomplete.tsx @@ -31,6 +31,7 @@ import { prepareQuery } from "#/utils/filters"; type SelectedUser = { avatar_url?: string; email?: string; + name?: string; username: string; }; @@ -233,7 +234,11 @@ const InnerAutocomplete = ({