diff --git a/docs/sources/developers/http_api/user.md b/docs/sources/developers/http_api/user.md index 0d449c043a0..ce2dfd122e4 100644 --- a/docs/sources/developers/http_api/user.md +++ b/docs/sources/developers/http_api/user.md @@ -28,7 +28,7 @@ API Tokens can be used with Organization HTTP API to get users of specific organ ## Search Users -`GET /api/users?perpage=10&page=1` +`GET /api/users?perpage=10&page=1&sort=login-asc,email-asc` **Required permissions** @@ -49,6 +49,8 @@ Authorization: Basic YWRtaW46YWRtaW4= Default value for the `perpage` parameter is `1000` and for the `page` parameter is `1`. Requires basic authentication and that the authenticated user is a Grafana Admin. +The `sort` param is an optional comma separated list of options to order the search result. Accepted values for the sort filter are: `login-asc`, `login-desc`, `email-asc`, `email-desc`, `name-asc`, `name-desc`, `lastSeenAtAge-asc`, `lastSeenAtAge-desc`. By default, if `sort` is not specified, the user list will be ordered by `login`, `email` in ascending order. + **Example Response**: ```http @@ -83,7 +85,7 @@ Content-Type: application/json ## Search Users with Paging -`GET /api/users/search?perpage=10&page=1&query=mygraf` +`GET /api/users/search?perpage=10&page=1&query=mygraf&sort=login-asc,email-asc` **Required permissions** @@ -104,6 +106,8 @@ Authorization: Basic YWRtaW46YWRtaW4= Default value for the `perpage` parameter is `1000` and for the `page` parameter is `1`. The `totalCount` field in the response can be used for pagination of the user list E.g. if `totalCount` is equal to 100 users and the `perpage` parameter is set to 10 then there are 10 pages of users. The `query` parameter is optional and it will return results where the query value is contained in one of the `name`, `login` or `email` fields. Query values with spaces need to be URL encoded e.g. `query=Jane%20Doe`. +The `sort` param is an optional comma separated list of options to order the search result. Accepted values for the sort filter are: `login-asc`, `login-desc`, `email-asc`, `email-desc`, `name-asc`, `name-desc`, `lastSeenAtAge-asc`, `lastSeenAtAge-desc`. By default, if `sort` is not specified, the user list will be ordered by `login`, `email` in ascending order. + Requires basic authentication and that the authenticated user is a Grafana Admin. **Example Response**: diff --git a/pkg/api/org_users.go b/pkg/api/org_users.go index 063d17db5aa..f55641edf40 100644 --- a/pkg/api/org_users.go +++ b/pkg/api/org_users.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/login" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/searchusers/sortopts" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" @@ -236,12 +237,18 @@ func (hs *HTTPServer) SearchOrgUsers(c *contextmodel.ReqContext) response.Respon page = 1 } + sortOpts, err := sortopts.ParseSortQueryParam(c.Query("sort")) + if err != nil { + return response.Err(err) + } + result, err := hs.searchOrgUsersHelper(c, &org.SearchOrgUsersQuery{ - OrgID: orgID, - Query: c.Query("query"), - Page: page, - Limit: perPage, - User: c.SignedInUser, + OrgID: orgID, + Query: c.Query("query"), + Page: page, + Limit: perPage, + User: c.SignedInUser, + SortOpts: sortOpts, }) if err != nil { @@ -264,12 +271,18 @@ func (hs *HTTPServer) SearchOrgUsersWithPaging(c *contextmodel.ReqContext) respo page = 1 } + sortOpts, err := sortopts.ParseSortQueryParam(c.Query("sort")) + if err != nil { + return response.Err(err) + } + query := &org.SearchOrgUsersQuery{ - OrgID: c.SignedInUser.GetOrgID(), - Query: c.Query("query"), - Page: page, - Limit: perPage, - User: c.SignedInUser, + OrgID: c.SignedInUser.GetOrgID(), + Query: c.Query("query"), + Page: page, + Limit: perPage, + User: c.SignedInUser, + SortOpts: sortOpts, } result, err := hs.searchOrgUsersHelper(c, query) diff --git a/pkg/services/org/model.go b/pkg/services/org/model.go index d7802c183db..87bf3e03ee8 100644 --- a/pkg/services/org/model.go +++ b/pkg/services/org/model.go @@ -7,6 +7,7 @@ import ( "github.com/grafana/grafana/pkg/models/roletype" "github.com/grafana/grafana/pkg/services/auth/identity" + "github.com/grafana/grafana/pkg/services/search/model" "github.com/grafana/grafana/pkg/util/errutil" ) @@ -178,11 +179,12 @@ type GetOrgUsersQuery struct { } type SearchOrgUsersQuery struct { - UserID int64 `xorm:"user_id"` - OrgID int64 `xorm:"org_id"` - Query string - Page int - Limit int + UserID int64 `xorm:"user_id"` + OrgID int64 `xorm:"org_id"` + Query string + Page int + Limit int + SortOpts []model.SortOption // Flag used to allow oss edition to query users without access control DontEnforceAccessControl bool diff --git a/pkg/services/org/orgimpl/store.go b/pkg/services/org/orgimpl/store.go index c8ab01d84d2..4c34861ff22 100644 --- a/pkg/services/org/orgimpl/store.go +++ b/pkg/services/org/orgimpl/store.go @@ -546,7 +546,7 @@ func (ss *sqlStore) SearchOrgUsers(ctx context.Context, query *org.SearchOrgUser } err := ss.db.WithDbSession(ctx, func(dbSession *db.Session) error { sess := dbSession.Table("org_user") - sess.Join("INNER", ss.dialect.Quote("user"), fmt.Sprintf("org_user.user_id=%s.id", ss.dialect.Quote("user"))) + sess.Join("INNER", []string{ss.dialect.Quote("user"), "u"}, "org_user.user_id=u.id") whereConditions := make([]string, 0) whereParams := make([]any, 0) @@ -559,7 +559,7 @@ func (ss *sqlStore) SearchOrgUsers(ctx context.Context, query *org.SearchOrgUser whereParams = append(whereParams, query.UserID) } - whereConditions = append(whereConditions, fmt.Sprintf("%s.is_service_account = ?", ss.dialect.Quote("user"))) + whereConditions = append(whereConditions, "u.is_service_account = ?") whereParams = append(whereParams, ss.dialect.BooleanStr(false)) if query.User == nil { @@ -593,16 +593,25 @@ func (ss *sqlStore) SearchOrgUsers(ctx context.Context, query *org.SearchOrgUser sess.Cols( "org_user.org_id", "org_user.user_id", - "user.email", - "user.name", - "user.login", + "u.email", + "u.name", + "u.login", "org_user.role", - "user.last_seen_at", - "user.created", - "user.updated", - "user.is_disabled", + "u.last_seen_at", + "u.created", + "u.updated", + "u.is_disabled", ) - sess.Asc("user.email", "user.login") + + if len(query.SortOpts) > 0 { + for i := range query.SortOpts { + for j := range query.SortOpts[i].Filter { + sess.OrderBy(query.SortOpts[i].Filter[j].OrderBy()) + } + } + } else { + sess.Asc("u.login", "u.email") + } if err := sess.Find(&result.OrgUsers); err != nil { return err @@ -611,7 +620,7 @@ func (ss *sqlStore) SearchOrgUsers(ctx context.Context, query *org.SearchOrgUser // get total count orgUser := org.OrgUser{} countSess := dbSession.Table("org_user"). - Join("INNER", ss.dialect.Quote("user"), fmt.Sprintf("org_user.user_id=%s.id", ss.dialect.Quote("user"))) + Join("INNER", []string{ss.dialect.Quote("user"), "u"}, "org_user.user_id=u.id") if len(whereConditions) > 0 { countSess.Where(strings.Join(whereConditions, " AND "), whereParams...) diff --git a/pkg/services/org/orgimpl/store_test.go b/pkg/services/org/orgimpl/store_test.go index 882139fe33e..bc9d0452183 100644 --- a/pkg/services/org/orgimpl/store_test.go +++ b/pkg/services/org/orgimpl/store_test.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/services/auth/identity" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/quota/quotaimpl" + "github.com/grafana/grafana/pkg/services/searchusers/sortopts" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/services/user" @@ -404,6 +405,42 @@ func TestIntegrationOrgUserDataAccess(t *testing.T) { require.Equal(t, len(result.OrgUsers), 1) require.Equal(t, result.OrgUsers[0].Email, ac1.Email) }) + t.Run("Can get organization users with custom ordering login-asc", func(t *testing.T) { + sortOpts, err := sortopts.ParseSortQueryParam("login-asc,email-asc") + require.NoError(t, err) + query := org.SearchOrgUsersQuery{ + OrgID: ac1.OrgID, + SortOpts: sortOpts, + User: &user.SignedInUser{ + OrgID: ac1.OrgID, + Permissions: map[int64]map[string][]string{ac1.OrgID: {accesscontrol.ActionOrgUsersRead: {accesscontrol.ScopeUsersAll}}}, + }, + } + result, err := orgUserStore.SearchOrgUsers(context.Background(), &query) + + require.NoError(t, err) + require.Equal(t, len(result.OrgUsers), 2) + require.Equal(t, result.OrgUsers[0].Email, ac1.Email) + require.Equal(t, result.OrgUsers[1].Email, ac2.Email) + }) + t.Run("Can get organization users with custom ordering login-desc", func(t *testing.T) { + sortOpts, err := sortopts.ParseSortQueryParam("login-desc,email-asc") + require.NoError(t, err) + query := org.SearchOrgUsersQuery{ + OrgID: ac1.OrgID, + SortOpts: sortOpts, + User: &user.SignedInUser{ + OrgID: ac1.OrgID, + Permissions: map[int64]map[string][]string{ac1.OrgID: {accesscontrol.ActionOrgUsersRead: {accesscontrol.ScopeUsersAll}}}, + }, + } + result, err := orgUserStore.SearchOrgUsers(context.Background(), &query) + + require.NoError(t, err) + require.Equal(t, len(result.OrgUsers), 2) + require.Equal(t, result.OrgUsers[0].Email, ac2.Email) + require.Equal(t, result.OrgUsers[1].Email, ac1.Email) + }) t.Run("Cannot update role so no one is admin user", func(t *testing.T) { remCmd := org.RemoveOrgUserCommand{OrgID: ac1.OrgID, UserID: ac2.ID, ShouldDeleteOrphanedUser: true} err := orgUserStore.RemoveOrgUser(context.Background(), &remCmd) diff --git a/pkg/services/search/model/model.go b/pkg/services/search/model/model.go index ac6828fe415..619af0c6531 100644 --- a/pkg/services/search/model/model.go +++ b/pkg/services/search/model/model.go @@ -2,10 +2,44 @@ package model import ( "strings" - - "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" ) +// FilterWhere limits the set of dashboard IDs to the dashboards for +// which the filter is applicable. Results where the first value is +// an empty string are discarded. +type FilterWhere interface { + Where() (string, []any) +} + +// FilterWith returns any recursive CTE queries (if supported) +// and their parameters +type FilterWith interface { + With() (string, []any) +} + +// FilterGroupBy should be used after performing an outer join on the +// search result to ensure there is only one of each ID in the results. +// The id column must be present in the result. +type FilterGroupBy interface { + GroupBy() (string, []any) +} + +// FilterOrderBy provides an ordering for the search result. +type FilterOrderBy interface { + OrderBy() string +} + +// FilterLeftJoin adds the returned string as a "LEFT OUTER JOIN" to +// allow for fetching extra columns from a table outside of the +// dashboard column. +type FilterLeftJoin interface { + LeftJoin() string +} + +type FilterSelect interface { + Select() string +} + type SortOption struct { Name string DisplayName string @@ -16,7 +50,7 @@ type SortOption struct { } type SortOptionFilter interface { - searchstore.FilterOrderBy + FilterOrderBy } type HitType string diff --git a/pkg/services/searchusers/searchusers.go b/pkg/services/searchusers/searchusers.go index 63bb4a3d4ba..efddf8fe51d 100644 --- a/pkg/services/searchusers/searchusers.go +++ b/pkg/services/searchusers/searchusers.go @@ -7,6 +7,7 @@ import ( "github.com/grafana/grafana/pkg/api/response" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/login" + "github.com/grafana/grafana/pkg/services/searchusers/sortopts" "github.com/grafana/grafana/pkg/services/user" ) @@ -42,7 +43,7 @@ func ProvideUsersService(searchUserFilter user.SearchUserFilter, userService use func (s *OSSService) SearchUsers(c *contextmodel.ReqContext) response.Response { result, err := s.SearchUser(c) if err != nil { - return response.Error(500, "Failed to fetch users", err) + return response.ErrOrFallback(500, "Failed to fetch users", err) } return response.JSON(http.StatusOK, result.Users) @@ -61,7 +62,7 @@ func (s *OSSService) SearchUsers(c *contextmodel.ReqContext) response.Response { func (s *OSSService) SearchUsersWithPaging(c *contextmodel.ReqContext) response.Response { result, err := s.SearchUser(c) if err != nil { - return response.Error(500, "Failed to fetch users", err) + return response.ErrOrFallback(500, "Failed to fetch users", err) } return response.JSON(http.StatusOK, result) @@ -87,6 +88,11 @@ func (s *OSSService) SearchUser(c *contextmodel.ReqContext) (*user.SearchUserQue } } + sortOpts, err := sortopts.ParseSortQueryParam(c.Query("sort")) + if err != nil { + return nil, err + } + query := &user.SearchUsersQuery{ // added SignedInUser to the query, as to only list the users that the user has permission to read SignedInUser: c.SignedInUser, @@ -94,6 +100,7 @@ func (s *OSSService) SearchUser(c *contextmodel.ReqContext) (*user.SearchUserQue Filters: filters, Page: page, Limit: perPage, + SortOpts: sortOpts, } res, err := s.userService.Search(c.Req.Context(), query) if err != nil { diff --git a/pkg/services/searchusers/sortopts/sortopts.go b/pkg/services/searchusers/sortopts/sortopts.go new file mode 100644 index 00000000000..da4c3254d9a --- /dev/null +++ b/pkg/services/searchusers/sortopts/sortopts.go @@ -0,0 +1,91 @@ +package sortopts + +import ( + "fmt" + "sort" + "strings" + + "github.com/grafana/grafana/pkg/services/search/model" + "github.com/grafana/grafana/pkg/util/errutil" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +var ( + // SortOptionsByQueryParam is a map to translate the "sort" query param values to SortOption(s) + SortOptionsByQueryParam = map[string]model.SortOption{ + "login-asc": newSortOption("login", false, 0), + "login-desc": newSortOption("login", true, 0), + "email-asc": newSortOption("email", false, 1), + "email-desc": newSortOption("email", true, 1), + "name-asc": newSortOption("name", false, 2), + "name-desc": newSortOption("name", true, 2), + "lastSeenAtAge-asc": newTimeSortOption("last_seen_at", false, 3), + "lastSeenAtAge-desc": newTimeSortOption("last_seen_at", true, 3), + } + + ErrorUnknownSortingOption = errutil.BadRequest("unknown sorting option") +) + +type Sorter struct { + Field string + Descending bool +} + +func (s Sorter) OrderBy() string { + if s.Descending { + return fmt.Sprintf("u.%v DESC", s.Field) + } + return fmt.Sprintf("u.%v ASC", s.Field) +} + +func newSortOption(field string, desc bool, index int) model.SortOption { + direction := "asc" + description := ("A-Z") + if desc { + direction = "desc" + description = ("Z-A") + } + return model.SortOption{ + Name: fmt.Sprintf("%v-%v", field, direction), + DisplayName: fmt.Sprintf("%v (%v)", cases.Title(language.Und).String(field), description), + Description: fmt.Sprintf("Sort %v in an alphabetically %vending order", field, direction), + Index: index, + Filter: []model.SortOptionFilter{Sorter{Field: field, Descending: desc}}, + } +} + +func newTimeSortOption(field string, desc bool, index int) model.SortOption { + direction := "asc" + description := ("Oldest-Newest") + if desc { + direction = "desc" + description = ("Newest-Oldest") + } + return model.SortOption{ + Name: fmt.Sprintf("%v-%v", field, direction), + DisplayName: fmt.Sprintf("%v (%v)", cases.Title(language.Und).String(field), description), + Description: fmt.Sprintf("Sort %v in an alphabetically %vending order", field, direction), + Index: index, + Filter: []model.SortOptionFilter{Sorter{Field: field, Descending: desc}}, + } +} + +// ParseSortQueryParam parses the "sort" query param and returns an ordered list of SortOption(s) +func ParseSortQueryParam(param string) ([]model.SortOption, error) { + opts := []model.SortOption{} + if param != "" { + optsStr := strings.Split(param, ",") + for i := range optsStr { + if opt, ok := SortOptionsByQueryParam[optsStr[i]]; !ok { + return nil, ErrorUnknownSortingOption.Errorf("%v option unknown", optsStr[i]) + } else { + opts = append(opts, opt) + } + } + sort.Slice(opts, func(i, j int) bool { + return opts[i].Index < opts[j].Index || (opts[i].Index == opts[j].Index && opts[i].Name < opts[j].Name) + }) + } + return opts, nil +} diff --git a/pkg/services/sqlstore/searchstore/builder.go b/pkg/services/sqlstore/searchstore/builder.go index b9f1e97239d..cdfd3efbf99 100644 --- a/pkg/services/sqlstore/searchstore/builder.go +++ b/pkg/services/sqlstore/searchstore/builder.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/grafana/grafana/pkg/services/search/model" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" ) @@ -61,11 +62,11 @@ func (b *Builder) buildSelect() { folder.title AS folder_title `) for _, f := range b.Filters { - if f, ok := f.(FilterSelect); ok { + if f, ok := f.(model.FilterSelect); ok { b.sql.WriteString(fmt.Sprintf(", %s", f.Select())) } - if f, ok := f.(FilterWith); ok { + if f, ok := f.(model.FilterWith); ok { recQuery, recQueryParams = f.With() } } @@ -98,14 +99,14 @@ func (b *Builder) applyFilters() (ordering string) { orders := []string{} for _, f := range b.Filters { - if f, ok := f.(FilterLeftJoin); ok { + if f, ok := f.(model.FilterLeftJoin); ok { s := f.LeftJoin() if s != "" { joins = append(joins, fmt.Sprintf(" LEFT OUTER JOIN %s ", s)) } } - if f, ok := f.(FilterWhere); ok { + if f, ok := f.(model.FilterWhere); ok { sql, params := f.Where() if sql != "" { wheres = append(wheres, sql) @@ -113,7 +114,7 @@ func (b *Builder) applyFilters() (ordering string) { } } - if f, ok := f.(FilterGroupBy); ok { + if f, ok := f.(model.FilterGroupBy); ok { sql, params := f.GroupBy() if sql != "" { groups = append(groups, sql) @@ -121,8 +122,8 @@ func (b *Builder) applyFilters() (ordering string) { } } - if f, ok := f.(FilterOrderBy); ok { - if f, ok := f.(FilterLeftJoin); ok { + if f, ok := f.(model.FilterOrderBy); ok { + if f, ok := f.(model.FilterLeftJoin); ok { orderJoins = append(orderJoins, fmt.Sprintf(" LEFT OUTER JOIN %s ", f.LeftJoin())) } orders = append(orders, f.OrderBy()) diff --git a/pkg/services/sqlstore/searchstore/filters.go b/pkg/services/sqlstore/searchstore/filters.go index fa965d96f41..c17cba042ce 100644 --- a/pkg/services/sqlstore/searchstore/filters.go +++ b/pkg/services/sqlstore/searchstore/filters.go @@ -5,45 +5,10 @@ import ( "strings" "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/search/model" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" ) -// FilterWhere limits the set of dashboard IDs to the dashboards for -// which the filter is applicable. Results where the first value is -// an empty string are discarded. -type FilterWhere interface { - Where() (string, []any) -} - -// FilterWith returns any recursive CTE queries (if supported) -// and their parameters -type FilterWith interface { - With() (string, []any) -} - -// FilterGroupBy should be used after performing an outer join on the -// search result to ensure there is only one of each ID in the results. -// The id column must be present in the result. -type FilterGroupBy interface { - GroupBy() (string, []any) -} - -// FilterOrderBy provides an ordering for the search result. -type FilterOrderBy interface { - OrderBy() string -} - -// FilterLeftJoin adds the returned string as a "LEFT OUTER JOIN" to -// allow for fetching extra columns from a table outside of the -// dashboard column. -type FilterLeftJoin interface { - LeftJoin() string -} - -type FilterSelect interface { - Select() string -} - const ( TypeFolder = "dash-folder" TypeDashboard = "dash-db" @@ -220,7 +185,7 @@ func sqlUIDin(column string, uids []string) (string, []any) { type FolderWithAlertsFilter struct { } -var _ FilterWhere = &FolderWithAlertsFilter{} +var _ model.FilterWhere = &FolderWithAlertsFilter{} func (f FolderWithAlertsFilter) Where() (string, []any) { return "EXISTS (SELECT 1 FROM alert_rule WHERE alert_rule.namespace_uid = dashboard.uid)", nil diff --git a/pkg/services/user/model.go b/pkg/services/user/model.go index 0af021ee0fc..9f8f816772a 100644 --- a/pkg/services/user/model.go +++ b/pkg/services/user/model.go @@ -7,6 +7,7 @@ import ( "time" "github.com/grafana/grafana/pkg/services/auth/identity" + "github.com/grafana/grafana/pkg/services/search/model" ) type HelpFlags1 uint64 @@ -112,6 +113,7 @@ type SearchUsersQuery struct { Page int Limit int AuthModule string + SortOpts []model.SortOption Filters []Filter IsDisabled *bool diff --git a/pkg/services/user/userimpl/store.go b/pkg/services/user/userimpl/store.go index 8e91f924d88..cedd27fe3b7 100644 --- a/pkg/services/user/userimpl/store.go +++ b/pkg/services/user/userimpl/store.go @@ -679,7 +679,17 @@ func (ss *sqlStore) Search(ctx context.Context, query *user.SearchUsersQuery) (* } sess.Cols("u.id", "u.email", "u.name", "u.login", "u.is_admin", "u.is_disabled", "u.last_seen_at", "user_auth.auth_module") - sess.Asc("u.login", "u.email") + + if len(query.SortOpts) > 0 { + for i := range query.SortOpts { + for j := range query.SortOpts[i].Filter { + sess.OrderBy(query.SortOpts[i].Filter[j].OrderBy()) + } + } + } else { + sess.Asc("u.login", "u.email") + } + if err := sess.Find(&result.Users); err != nil { return err } diff --git a/pkg/services/user/userimpl/store_test.go b/pkg/services/user/userimpl/store_test.go index c8ac0fda541..ae6ced18c31 100644 --- a/pkg/services/user/userimpl/store_test.go +++ b/pkg/services/user/userimpl/store_test.go @@ -15,6 +15,7 @@ import ( "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/quota/quotaimpl" + "github.com/grafana/grafana/pkg/services/searchusers/sortopts" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/services/user" @@ -822,6 +823,31 @@ func TestIntegrationUserDataAccess(t *testing.T) { require.Nil(t, err) require.Len(t, queryResult.Users, 1) require.EqualValues(t, queryResult.TotalCount, 1) + + // Custom ordering + sortOpts, err := sortopts.ParseSortQueryParam("login-asc,email-asc") + require.NoError(t, err) + query = user.SearchUsersQuery{Query: "", Page: 1, Limit: 3, SignedInUser: usr, SortOpts: sortOpts} + queryResult, err = userStore.Search(context.Background(), &query) + + require.Nil(t, err) + require.Len(t, queryResult.Users, 3) + require.EqualValues(t, queryResult.TotalCount, 5) + for i := 0; i < 3; i++ { + require.Equal(t, fmt.Sprint("loginuser", i), queryResult.Users[i].Login) + } + + sortOpts2, err := sortopts.ParseSortQueryParam("login-desc,email-asc") + require.NoError(t, err) + query = user.SearchUsersQuery{Query: "", Page: 1, Limit: 3, SignedInUser: usr, SortOpts: sortOpts2} + queryResult, err = userStore.Search(context.Background(), &query) + + require.Nil(t, err) + require.Len(t, queryResult.Users, 3) + require.EqualValues(t, queryResult.TotalCount, 5) + for i := 0; i < 3; i++ { + require.Equal(t, fmt.Sprint("loginuser", 4-i), queryResult.Users[i].Login) + } }) t.Run("Can get logged in user projection", func(t *testing.T) {