feat(keys): support sorting API keys by group name

This commit is contained in:
William
2026-10-01 13:01:01 -04:00
parent d6adebd22d
commit 0cecf3c367
4 changed files with 107 additions and 5 deletions
@@ -652,6 +652,17 @@ func apiKeyListOrder(params pagination.PaginationParams) []func(*entsql.Selector
sortBy := strings.ToLower(strings.TrimSpace(params.SortBy))
sortOrder := params.NormalizedSortOrder(pagination.SortOrderDesc)
if sortBy == "group" {
// Sort before pagination, keeping ungrouped keys last in either direction.
opts := []entsql.OrderTermOption{entsql.OrderNullsLast()}
tieOrder := dbent.Asc(apikey.FieldID)
if sortOrder == pagination.SortOrderDesc {
opts = append(opts, entsql.OrderDesc())
tieOrder = dbent.Desc(apikey.FieldID)
}
return []func(*entsql.Selector){apikey.ByGroupField(group.FieldName, opts...), tieOrder}
}
var field string
switch sortBy {
case "name":
@@ -0,0 +1,78 @@
package repository
import (
"context"
"testing"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func TestAPIKeyRepositoryListByUserIDSortByGroup(t *testing.T) {
repo, client := newAPIKeyRepoSQLite(t)
ctx := context.Background()
user := mustCreateAPIKeyRepoUser(t, ctx, client, "group-sort@test.com")
otherUser := mustCreateAPIKeyRepoUser(t, ctx, client, "other-group-sort@test.com")
createGroup := func(name string) *dbent.Group {
g, err := client.Group.Create().SetName(name).Save(ctx)
require.NoError(t, err)
return g
}
// Create groups in reverse name order so sorting by group ID cannot pass.
zulu := createGroup("Zulu")
alpha := createGroup("Alpha")
createKey := func(userID int64, name string, groupID *int64, status string) int64 {
key := &service.APIKey{
UserID: userID, Key: "sk-" + name, Name: name, GroupID: groupID, Status: status,
}
require.NoError(t, repo.Create(ctx, key))
return key.ID
}
zuluFirst := createKey(user.ID, "match-zulu-first", &zulu.ID, service.StatusActive)
ungroupedFirst := createKey(user.ID, "match-ungrouped-first", nil, service.StatusActive)
alphaFirst := createKey(user.ID, "match-alpha-first", &alpha.ID, service.StatusActive)
zuluSecond := createKey(user.ID, "match-zulu-second", &zulu.ID, service.StatusActive)
alphaSecond := createKey(user.ID, "match-alpha-second", &alpha.ID, service.StatusActive)
ungroupedSecond := createKey(user.ID, "match-ungrouped-second", nil, service.StatusActive)
createKey(otherUser.ID, "match-other-user", &alpha.ID, service.StatusActive)
createKey(user.ID, "excluded-search", &alpha.ID, service.StatusActive)
createKey(user.ID, "match-inactive", &alpha.ID, service.StatusDisabled)
deleted := createKey(user.ID, "match-deleted", &alpha.ID, service.StatusActive)
require.NoError(t, repo.Delete(ctx, deleted))
ungroupedID := int64(0)
for _, tc := range []struct {
name string
order string
groupID *int64
want []int64
}{
{"ascending", "asc", nil, []int64{alphaFirst, alphaSecond, zuluFirst, zuluSecond, ungroupedFirst, ungroupedSecond}},
{"descending", "desc", nil, []int64{zuluSecond, zuluFirst, alphaSecond, alphaFirst, ungroupedSecond, ungroupedFirst}},
{"group filter", "asc", &alpha.ID, []int64{alphaFirst, alphaSecond}},
{"ungrouped filter", "desc", &ungroupedID, []int64{ungroupedSecond, ungroupedFirst}},
} {
t.Run(tc.name, func(t *testing.T) {
var got []int64
const pageSize = 3
pages := (len(tc.want) + pageSize - 1) / pageSize
for page := 1; page <= pages; page++ {
keys, result, err := repo.ListByUserID(ctx, user.ID, pagination.PaginationParams{
Page: page, PageSize: pageSize, SortBy: "group", SortOrder: tc.order,
}, service.APIKeyListFilters{Search: "match", Status: service.StatusActive, GroupID: tc.groupID})
require.NoError(t, err)
require.EqualValues(t, len(tc.want), result.Total)
require.Equal(t, pages, result.Pages)
for _, key := range keys {
got = append(got, key.ID)
if key.GroupID != nil {
require.NotNil(t, key.Group, "sorting must preserve group preloading")
}
}
}
require.Equal(t, tc.want, got)
})
}
}
+1 -1
View File
@@ -1266,7 +1266,7 @@ const allColumns = computed<Column[]>(() => [
{ key: 'name', label: t('common.name'), sortable: true },
{ key: 'id', label: t('keys.id'), sortable: true },
{ key: 'key', label: t('keys.apiKey'), sortable: false },
{ key: 'group', label: t('keys.group'), sortable: false },
{ key: 'group', label: t('keys.group'), sortable: true },
{ key: 'current_concurrency', label: t('keys.currentConcurrency'), sortable: true },
{ key: 'usage', label: t('keys.usage'), sortable: false },
{ key: 'rate_limit', label: t('keys.rateLimitColumn'), sortable: false },
@@ -210,6 +210,7 @@ const PaginationStub = {
template: `
<div>
<button data-test="page-size-50" @click="$emit('update:pageSize', 50)">50</button>
<button data-test="page-2" @click="$emit('update:page', 2)">Page 2</button>
</div>
`,
}
@@ -511,7 +512,11 @@ describe('user KeysView column settings', () => {
expect(currentConcurrencyColumn?.sortable).toBe(true)
})
it('keeps filters and selected page size when sorting by current concurrency', async () => {
it.each([
{ key: 'current_concurrency', order: 'asc' },
{ key: 'group', order: 'asc' },
{ key: 'group', order: 'desc' },
] as const)('keeps filters and resets pagination and selection when sorting $key $order', async ({ key, order }) => {
getAvailableGroups.mockResolvedValue([{ id: 42, name: 'OpenAI' }])
const wrapper = await mountView()
@@ -528,11 +533,19 @@ describe('user KeysView column settings', () => {
await selects[1].vm.$emit('update:modelValue', 'active')
await flushPromises()
await wrapper.get('[data-test="page-2"]').trigger('click')
await flushPromises()
const table = wrapper.findComponent({ name: 'DataTable' })
table.vm.$emit('update:selectedKeys', [1])
await nextTick()
expect(table.props('selectedKeys')).toEqual([1])
expect(visibleColumnMeta(wrapper).find((column) => column.key === key)?.sortable).toBe(true)
listKeys.mockClear()
await wrapper.get('[data-test="sort-current-concurrency"]').trigger('click')
table.vm.$emit('sort', key, order)
await flushPromises()
expect(table.props('selectedKeys')).toEqual([])
expect(listKeys).toHaveBeenLastCalledWith(
1,
50,
@@ -540,8 +553,8 @@ describe('user KeysView column settings', () => {
search: 'target',
status: 'active',
group_id: 42,
sort_by: 'current_concurrency',
sort_order: 'asc',
sort_by: key,
sort_order: order,
},
expect.objectContaining({ signal: expect.any(AbortSignal) })
)