mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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 <details> <summary>Implementation plan</summary> ## 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)`). </details> --- _Created by Coder Agents on behalf of @aqandrew._
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+6
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 = <T extends SelectedUser>({
|
||||
<ComboboxItem
|
||||
key={option.username}
|
||||
value={option.username}
|
||||
keywords={[option.username, option.email ?? ""]}
|
||||
keywords={[
|
||||
option.username,
|
||||
option.email ?? "",
|
||||
option.name ?? "",
|
||||
]}
|
||||
className="m-1"
|
||||
>
|
||||
<AvatarData
|
||||
|
||||
Reference in New Issue
Block a user