1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-06 14:25:31 +02:00

fix(app/edge-jobs): edge job results page crash at scale (#954)

This commit is contained in:
LP B 2025-08-04 17:10:46 +02:00 committed by GitHub
parent d306d7a983
commit a472de1919
27 changed files with 2595 additions and 107 deletions

View file

@ -1,21 +1,25 @@
package edgejobs
import (
"errors"
"fmt"
"maps"
"net/http"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/http/utils/filters"
"github.com/portainer/portainer/api/internal/edge"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
)
type taskContainer struct {
ID string `json:"Id"`
EndpointID portainer.EndpointID `json:"EndpointId"`
LogsStatus portainer.EdgeJobLogsStatus `json:"LogsStatus"`
ID string `json:"Id"`
EndpointID portainer.EndpointID `json:"EndpointId"`
EndpointName string `json:"EndpointName"`
LogsStatus portainer.EdgeJobLogsStatus `json:"LogsStatus"`
}
// @id EdgeJobTasksList
@ -37,16 +41,42 @@ func (handler *Handler) edgeJobTasksList(w http.ResponseWriter, r *http.Request)
return httperror.BadRequest("Invalid Edge job identifier route variable", err)
}
var tasks []taskContainer
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
params := filters.ExtractListModifiersQueryParams(r)
var tasks []*taskContainer
err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
tasks, err = listEdgeJobTasks(tx, portainer.EdgeJobID(edgeJobID))
return err
})
return txResponse(w, tasks, err)
results := filters.SearchOrderAndPaginate(tasks, params, filters.Config[*taskContainer]{
SearchAccessors: []filters.SearchAccessor[*taskContainer]{
func(tc *taskContainer) (string, error) {
switch tc.LogsStatus {
case portainer.EdgeJobLogsStatusPending:
return "pending", nil
case 0, portainer.EdgeJobLogsStatusIdle:
return "idle", nil
case portainer.EdgeJobLogsStatusCollected:
return "collected", nil
}
return "", errors.New("unknown state")
},
func(tc *taskContainer) (string, error) {
return tc.EndpointName, nil
},
},
SortBindings: []filters.SortBinding[*taskContainer]{
{Key: "EndpointName", Fn: func(a, b *taskContainer) int { return strings.Compare(a.EndpointName, b.EndpointName) }},
},
})
filters.ApplyFilterResultsHeaders(&w, results)
return txResponse(w, results.Items, err)
}
func listEdgeJobTasks(tx dataservices.DataStoreTx, edgeJobID portainer.EdgeJobID) ([]taskContainer, error) {
func listEdgeJobTasks(tx dataservices.DataStoreTx, edgeJobID portainer.EdgeJobID) ([]*taskContainer, error) {
edgeJob, err := tx.EdgeJob().Read(edgeJobID)
if tx.IsErrObjectNotFound(err) {
return nil, httperror.NotFound("Unable to find an Edge job with the specified identifier inside the database", err)
@ -54,7 +84,12 @@ func listEdgeJobTasks(tx dataservices.DataStoreTx, edgeJobID portainer.EdgeJobID
return nil, httperror.InternalServerError("Unable to find an Edge job with the specified identifier inside the database", err)
}
tasks := make([]taskContainer, 0)
endpoints, err := tx.Endpoint().Endpoints()
if err != nil {
return nil, err
}
tasks := make([]*taskContainer, 0)
endpointsMap := map[portainer.EndpointID]portainer.EdgeJobEndpointMeta{}
if len(edgeJob.EdgeGroups) > 0 {
@ -70,10 +105,19 @@ func listEdgeJobTasks(tx dataservices.DataStoreTx, edgeJobID portainer.EdgeJobID
maps.Copy(endpointsMap, edgeJob.Endpoints)
for endpointID, meta := range endpointsMap {
tasks = append(tasks, taskContainer{
ID: fmt.Sprintf("edgejob_task_%d_%d", edgeJob.ID, endpointID),
EndpointID: endpointID,
LogsStatus: meta.LogsStatus,
endpointName := ""
for idx := range endpoints {
if endpoints[idx].ID == endpointID {
endpointName = endpoints[idx].Name
}
}
tasks = append(tasks, &taskContainer{
ID: fmt.Sprintf("edgejob_task_%d_%d", edgeJob.ID, endpointID),
EndpointID: endpointID,
EndpointName: endpointName,
LogsStatus: meta.LogsStatus,
})
}

View file

@ -0,0 +1,131 @@
package edgejobs
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/roar"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_EdgeJobTasksListHandler(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, false)
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
addEnv := func(env *portainer.Endpoint) {
err := store.EndpointService.Create(env)
require.NoError(t, err)
}
addEdgeGroup := func(group *portainer.EdgeGroup) {
err := store.EdgeGroupService.Create(group)
require.NoError(t, err)
}
addJob := func(job *portainer.EdgeJob) {
err := store.EdgeJobService.Create(job)
require.NoError(t, err)
}
envCount := 6
for i := range envCount {
addEnv(&portainer.Endpoint{ID: portainer.EndpointID(i + 1), Name: "env_" + strconv.Itoa(i+1)})
}
addEdgeGroup(&portainer.EdgeGroup{ID: 1, Name: "edge_group_1", EndpointIDs: roar.FromSlice([]portainer.EndpointID{5, 6})})
addJob(&portainer.EdgeJob{
ID: 1,
Endpoints: map[portainer.EndpointID]portainer.EdgeJobEndpointMeta{
1: {},
2: {LogsStatus: portainer.EdgeJobLogsStatusIdle},
3: {LogsStatus: portainer.EdgeJobLogsStatusPending},
4: {LogsStatus: portainer.EdgeJobLogsStatusCollected}},
EdgeGroups: []portainer.EdgeGroupID{1},
})
test := func(params string, expect []taskContainer, expectedCount int) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/edge_jobs/1/tasks"+params, nil)
handler.ServeHTTP(rr, req)
require.Equal(t, http.StatusOK, rr.Result().StatusCode)
var response []taskContainer
err := json.NewDecoder(rr.Body).Decode(&response)
require.NoError(t, err)
assert.ElementsMatch(t, expect, response)
tcStr := rr.Header().Get("x-total-count")
assert.NotEmpty(t, tcStr)
totalCount, err := strconv.Atoi(tcStr)
assert.NoError(t, err)
assert.Equal(t, expectedCount, totalCount)
taStr := rr.Header().Get("x-total-available")
assert.NotEmpty(t, taStr)
totalAvailable, err := strconv.Atoi(taStr)
assert.NoError(t, err)
assert.Equal(t, envCount, totalAvailable)
}
tasks := []taskContainer{
{},
{"edgejob_task_1_1", 1, "env_1", 0},
{"edgejob_task_1_2", 2, "env_2", portainer.EdgeJobLogsStatusIdle},
{"edgejob_task_1_3", 3, "env_3", portainer.EdgeJobLogsStatusPending},
{"edgejob_task_1_4", 4, "env_4", portainer.EdgeJobLogsStatusCollected},
{"edgejob_task_1_5", 5, "env_5", 0},
{"edgejob_task_1_6", 6, "env_6", 0},
}
t.Run("should return no results", func(t *testing.T) {
test("?search=foo", []taskContainer{}, 0) // unknown search
test("?start=100&limit=1", []taskContainer{}, 6) // overflowing start. Still return the correct count header
})
t.Run("should return one element", func(t *testing.T) {
// limit the *returned* results but not the total count
test("?start=0&limit=1&sort=EndpointName&order=asc", []taskContainer{tasks[1]}, envCount) // limit
test("?start=5&limit=10&sort=EndpointName&order=asc", []taskContainer{tasks[6]}, envCount) // start = last element + overflowing limit
// limit the number of results
test("?search=env_1", []taskContainer{tasks[1]}, 1) // only 1 result
})
t.Run("should filter by status", func(t *testing.T) {
test("?search=idle", []taskContainer{tasks[1], tasks[2], tasks[5], tasks[6]}, 4) // 0 (default value) is IDLE
test("?search=pending", []taskContainer{tasks[3]}, 1)
test("?search=collected", []taskContainer{tasks[4]}, 1)
})
t.Run("should return all elements", func(t *testing.T) {
test("", tasks[1:], envCount) // default
test("?some=invalid_param", tasks[1:], envCount) // unknown query params
test("?limit=-1", tasks[1:], envCount) // underflowing limit
test("?start=100", tasks[1:], envCount) // overflowing start without limit
test("?search=env", tasks[1:], envCount) // search in a match-all keyword
})
testError := func(params string, status int) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/edge_jobs/2/tasks"+params, nil)
handler.ServeHTTP(rr, req)
require.Equal(t, status, rr.Result().StatusCode)
}
t.Run("errors", func(t *testing.T) {
testError("", http.StatusNotFound) // unknown job id
})
}

View file

@ -0,0 +1,38 @@
package filters
import (
"net/http"
"strconv"
)
type FilterResult[T any] struct {
Items []T
TotalCount int
TotalAvailable int
}
type Config[T any] struct {
SearchAccessors []SearchAccessor[T]
SortBindings []SortBinding[T]
}
func SearchOrderAndPaginate[T any](items []T, params QueryParams, searchConfig Config[T]) FilterResult[T] {
totalAvailable := len(items)
items = searchFn(items, params.SearchQueryParams, searchConfig.SearchAccessors)
items = sortFn(items, params.SortQueryParams, searchConfig.SortBindings)
totalCount := len(items)
items = paginateFn(items, params.PaginationQueryParams)
return FilterResult[T]{
Items: items,
TotalCount: totalCount,
TotalAvailable: totalAvailable,
}
}
func ApplyFilterResultsHeaders[T any](w *http.ResponseWriter, result FilterResult[T]) {
(*w).Header().Set("X-Total-Count", strconv.Itoa(result.TotalCount))
(*w).Header().Set("X-Total-Available", strconv.Itoa(result.TotalAvailable))
}

View file

@ -0,0 +1,465 @@
package filters
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// Helper functions for creating test data
func createUsers() []User {
return []User{
{ID: 1, Name: "Alice Johnson", Email: "alice@example.com", Age: 25},
{ID: 2, Name: "Bob Smith", Email: "bob@example.com", Age: 30},
{ID: 3, Name: "Charlie Brown", Email: "charlie@example.com", Age: 35},
{ID: 4, Name: "Diana Prince", Email: "diana@example.com", Age: 28},
{ID: 5, Name: "Eve Adams", Email: "eve@example.com", Age: 22},
}
}
func createProducts() []Product {
return []Product{
{ID: 1, Name: "Laptop", Description: "High-performance laptop", Price: 999, Category: "Electronics"},
{ID: 2, Name: "Mouse", Description: "Wireless mouse", Price: 29, Category: "Electronics"},
{ID: 3, Name: "Book", Description: "Programming book", Price: 49, Category: "Books"},
{ID: 4, Name: "Keyboard", Description: "Mechanical keyboard", Price: 129, Category: "Electronics"},
{ID: 5, Name: "Chair", Description: "Office chair", Price: 199, Category: "Furniture"},
}
}
// Sort functions
func userNameSort(a, b User) int {
return strings.Compare(a.Name, b.Name)
}
func userAgeSort(a, b User) int {
return a.Age - b.Age
}
func productPriceSort(a, b Product) int {
if a.Price < b.Price {
return -1
}
if a.Price > b.Price {
return 1
}
return 0
}
func productNameSort(a, b Product) int {
return strings.Compare(a.Name, b.Name)
}
func TestSearchOrderAndPaginate(t *testing.T) {
users := createUsers()
products := createProducts()
userConfig := Config[User]{
SearchAccessors: []SearchAccessor[User]{userNameAccessor, userEmailAccessor},
SortBindings: []SortBinding[User]{
{Key: "name", Fn: userNameSort},
{Key: "age", Fn: userAgeSort},
},
}
productConfig := Config[Product]{
SearchAccessors: []SearchAccessor[Product]{productNameAccessor, productDescriptionAccessor, productCategoryAccessor},
SortBindings: []SortBinding[Product]{
{Key: "price", Fn: productPriceSort},
{Key: "name", Fn: productNameSort},
},
}
t.Run("no filters applied", func(t *testing.T) {
params := QueryParams{
SearchQueryParams: SearchQueryParams{search: ""},
SortQueryParams: SortQueryParams{sort: "", order: ""},
PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0},
}
result := SearchOrderAndPaginate(users, params, userConfig)
require.Equal(t, 5, len(result.Items), "Should return all items when no filters applied")
require.Equal(t, 5, result.TotalCount, "TotalCount should equal filtered items")
require.Equal(t, 5, result.TotalAvailable, "TotalAvailable should equal original items")
require.Equal(t, users, result.Items, "Items should be unchanged")
})
t.Run("search only", func(t *testing.T) {
params := QueryParams{
SearchQueryParams: SearchQueryParams{search: "alice"},
SortQueryParams: SortQueryParams{sort: "", order: ""},
PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0},
}
result := SearchOrderAndPaginate(users, params, userConfig)
require.Equal(t, 1, len(result.Items), "Should find one user matching 'alice'")
require.Equal(t, 1, result.TotalCount, "TotalCount should reflect filtered items")
require.Equal(t, 5, result.TotalAvailable, "TotalAvailable should be original count")
require.Equal(t, "Alice Johnson", result.Items[0].Name, "Should return Alice")
})
t.Run("search case insensitive", func(t *testing.T) {
params := QueryParams{
SearchQueryParams: SearchQueryParams{search: "ALICE"},
SortQueryParams: SortQueryParams{sort: "", order: ""},
PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0},
}
result := SearchOrderAndPaginate(users, params, userConfig)
require.Equal(t, 1, len(result.Items), "Search should be case insensitive")
require.Equal(t, "Alice Johnson", result.Items[0].Name, "Should return Alice")
})
t.Run("search by email", func(t *testing.T) {
params := QueryParams{
SearchQueryParams: SearchQueryParams{search: "bob@example"},
SortQueryParams: SortQueryParams{sort: "", order: ""},
PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0},
}
result := SearchOrderAndPaginate(users, params, userConfig)
require.Equal(t, 1, len(result.Items), "Should find user by email")
require.Equal(t, "Bob Smith", result.Items[0].Name, "Should return Bob")
})
t.Run("search no matches", func(t *testing.T) {
params := QueryParams{
SearchQueryParams: SearchQueryParams{search: "nonexistent"},
SortQueryParams: SortQueryParams{sort: "", order: ""},
PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0},
}
result := SearchOrderAndPaginate(users, params, userConfig)
require.Equal(t, 0, len(result.Items), "Should return empty when no matches")
require.Equal(t, 0, result.TotalCount, "TotalCount should be 0")
require.Equal(t, 5, result.TotalAvailable, "TotalAvailable should remain original count")
})
t.Run("search with whitespace", func(t *testing.T) {
params := QueryParams{
SearchQueryParams: SearchQueryParams{search: " alice "},
SortQueryParams: SortQueryParams{sort: "", order: ""},
PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0},
}
result := SearchOrderAndPaginate(users, params, userConfig)
require.Equal(t, 1, len(result.Items), "Should trim whitespace from search")
require.Equal(t, "Alice Johnson", result.Items[0].Name, "Should return Alice")
})
t.Run("sort ascending", func(t *testing.T) {
params := QueryParams{
SearchQueryParams: SearchQueryParams{search: ""},
SortQueryParams: SortQueryParams{sort: "name", order: SortAsc},
PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0},
}
result := SearchOrderAndPaginate(users, params, userConfig)
require.Equal(t, 5, len(result.Items), "Should return all items")
require.Equal(t, "Alice Johnson", result.Items[0].Name, "First should be Alice")
require.Equal(t, "Eve Adams", result.Items[4].Name, "Last should be Eve")
})
t.Run("sort descending", func(t *testing.T) {
params := QueryParams{
SearchQueryParams: SearchQueryParams{search: ""},
SortQueryParams: SortQueryParams{sort: "name", order: SortDesc},
PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0},
}
result := SearchOrderAndPaginate(users, params, userConfig)
require.Equal(t, 5, len(result.Items), "Should return all items")
require.Equal(t, "Eve Adams", result.Items[0].Name, "First should be Eve (desc order)")
require.Equal(t, "Alice Johnson", result.Items[4].Name, "Last should be Alice (desc order)")
})
t.Run("sort by age", func(t *testing.T) {
params := QueryParams{
SearchQueryParams: SearchQueryParams{search: ""},
SortQueryParams: SortQueryParams{sort: "age", order: SortAsc},
PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0},
}
result := SearchOrderAndPaginate(users, params, userConfig)
require.Equal(t, 5, len(result.Items), "Should return all items")
require.Equal(t, 22, result.Items[0].Age, "First should be youngest (22)")
require.Equal(t, 35, result.Items[4].Age, "Last should be oldest (35)")
})
t.Run("sort invalid key", func(t *testing.T) {
params := QueryParams{
SearchQueryParams: SearchQueryParams{search: ""},
SortQueryParams: SortQueryParams{sort: "invalid", order: SortAsc},
PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0},
}
result := SearchOrderAndPaginate(users, params, userConfig)
require.Equal(t, 5, len(result.Items), "Should return all items")
// Items should remain in original order since no valid sort key
require.Equal(t, users, result.Items, "Should maintain original order with invalid sort key")
})
t.Run("pagination basic", func(t *testing.T) {
params := QueryParams{
SearchQueryParams: SearchQueryParams{search: ""},
SortQueryParams: SortQueryParams{sort: "", order: ""},
PaginationQueryParams: PaginationQueryParams{start: 1, limit: 2},
}
result := SearchOrderAndPaginate(users, params, userConfig)
require.Equal(t, 2, len(result.Items), "Should return 2 items")
require.Equal(t, 5, result.TotalCount, "TotalCount should be all items")
require.Equal(t, 5, result.TotalAvailable, "TotalAvailable should be original count")
require.Equal(t, users[1], result.Items[0], "Should start from index 1")
require.Equal(t, users[2], result.Items[1], "Should include index 2")
})
t.Run("pagination zero limit", func(t *testing.T) {
params := QueryParams{
SearchQueryParams: SearchQueryParams{search: ""},
SortQueryParams: SortQueryParams{sort: "", order: ""},
PaginationQueryParams: PaginationQueryParams{start: 1, limit: 0},
}
result := SearchOrderAndPaginate(users, params, userConfig)
require.Equal(t, 5, len(result.Items), "Should return all items when limit is 0")
require.Equal(t, users, result.Items, "Should return all original items")
})
t.Run("pagination negative limit", func(t *testing.T) {
params := QueryParams{
SearchQueryParams: SearchQueryParams{search: ""},
SortQueryParams: SortQueryParams{sort: "", order: ""},
PaginationQueryParams: PaginationQueryParams{start: 1, limit: -1},
}
result := SearchOrderAndPaginate(users, params, userConfig)
require.Equal(t, 5, len(result.Items), "Should return all items when limit is negative")
})
t.Run("pagination start beyond length", func(t *testing.T) {
params := QueryParams{
SearchQueryParams: SearchQueryParams{search: ""},
SortQueryParams: SortQueryParams{sort: "", order: ""},
PaginationQueryParams: PaginationQueryParams{start: 10, limit: 2},
}
result := SearchOrderAndPaginate(users, params, userConfig)
require.Equal(t, 0, len(result.Items), "Should return empty slice when start is beyond length")
require.Equal(t, 5, result.TotalCount, "TotalCount should still be original count")
})
t.Run("pagination negative start", func(t *testing.T) {
params := QueryParams{
SearchQueryParams: SearchQueryParams{search: ""},
SortQueryParams: SortQueryParams{sort: "", order: ""},
PaginationQueryParams: PaginationQueryParams{start: -1, limit: 2},
}
result := SearchOrderAndPaginate(users, params, userConfig)
require.Equal(t, 2, len(result.Items), "Should return 2 items starting from 0")
require.Equal(t, users[0], result.Items[0], "Should start from index 0")
require.Equal(t, users[1], result.Items[1], "Should include index 1")
})
t.Run("combined search sort and pagination", func(t *testing.T) {
params := QueryParams{
SearchQueryParams: SearchQueryParams{search: "example.com"},
SortQueryParams: SortQueryParams{sort: "age", order: SortAsc},
PaginationQueryParams: PaginationQueryParams{start: 1, limit: 2},
}
result := SearchOrderAndPaginate(users, params, userConfig)
// All users have "example.com" in email, so all 5 should match search
// Then sorted by age: Eve(22), Alice(25), Diana(28), Bob(30), Charlie(35)
// Then paginated: start=1, limit=2 should give Alice(25), Diana(28)
require.Equal(t, 2, len(result.Items), "Should return 2 items after pagination")
require.Equal(t, 5, result.TotalCount, "TotalCount should be all filtered items")
require.Equal(t, 5, result.TotalAvailable, "TotalAvailable should be original count")
require.Equal(t, 25, result.Items[0].Age, "First item should be Alice (age 25)")
require.Equal(t, 28, result.Items[1].Age, "Second item should be Diana (age 28)")
})
t.Run("products test", func(t *testing.T) {
params := QueryParams{
SearchQueryParams: SearchQueryParams{search: "electronics"},
SortQueryParams: SortQueryParams{sort: "price", order: SortAsc},
PaginationQueryParams: PaginationQueryParams{start: 0, limit: 2},
}
result := SearchOrderAndPaginate(products, params, productConfig)
// Should find 3 electronics, sorted by price: Mouse(29.99), Keyboard(129.99), Laptop(999.99)
// Paginated to first 2: Mouse, Keyboard
require.Equal(t, 2, len(result.Items), "Should return 2 items")
require.Equal(t, 3, result.TotalCount, "Should find 3 electronics items")
require.Equal(t, 5, result.TotalAvailable, "Should have 5 total products")
require.Equal(t, "Mouse", result.Items[0].Name, "First should be Mouse (cheapest)")
require.Equal(t, "Keyboard", result.Items[1].Name, "Second should be Keyboard")
})
t.Run("empty input slice", func(t *testing.T) {
emptyUsers := []User{}
params := QueryParams{
SearchQueryParams: SearchQueryParams{search: "test"},
SortQueryParams: SortQueryParams{sort: "name", order: SortAsc},
PaginationQueryParams: PaginationQueryParams{start: 0, limit: 10},
}
result := SearchOrderAndPaginate(emptyUsers, params, userConfig)
require.Equal(t, 0, len(result.Items), "Should return empty slice")
require.Equal(t, 0, result.TotalCount, "TotalCount should be 0")
require.Equal(t, 0, result.TotalAvailable, "TotalAvailable should be 0")
})
}
func TestSearchOrderAndPaginateWithErrors(t *testing.T) {
users := createUsers()
// Config with error-prone accessor
errorConfig := Config[User]{
SearchAccessors: []SearchAccessor[User]{errorAccessor[User], userNameAccessor},
SortBindings: []SortBinding[User]{
{Key: "name", Fn: userNameSort},
},
}
t.Run("search with accessor errors", func(t *testing.T) {
params := QueryParams{
SearchQueryParams: SearchQueryParams{search: "alice"},
SortQueryParams: SortQueryParams{sort: "", order: ""},
PaginationQueryParams: PaginationQueryParams{start: 0, limit: 0},
}
result := SearchOrderAndPaginate(users, params, errorConfig)
// Should still find Alice through the working accessor
require.Equal(t, 1, len(result.Items), "Should find user despite error in first accessor")
require.Equal(t, "Alice Johnson", result.Items[0].Name, "Should return Alice")
})
}
func TestApplyFilterResultsHeaders(t *testing.T) {
t.Run("sets headers correctly", func(t *testing.T) {
w := httptest.NewRecorder()
var responseWriter http.ResponseWriter = w
result := FilterResult[User]{
Items: createUsers()[:3],
TotalCount: 10,
TotalAvailable: 25,
}
ApplyFilterResultsHeaders(&responseWriter, result)
require.Equal(t, "10", w.Header().Get("X-Total-Count"), "Should set X-Total-Count header")
require.Equal(t, "25", w.Header().Get("X-Total-Available"), "Should set X-Total-Available header")
})
t.Run("sets headers with zero values", func(t *testing.T) {
w := httptest.NewRecorder()
var responseWriter http.ResponseWriter = w
result := FilterResult[User]{
Items: []User{},
TotalCount: 0,
TotalAvailable: 0,
}
ApplyFilterResultsHeaders(&responseWriter, result)
require.Equal(t, "0", w.Header().Get("X-Total-Count"), "Should set X-Total-Count to 0")
require.Equal(t, "0", w.Header().Get("X-Total-Available"), "Should set X-Total-Available to 0")
})
t.Run("overwrites existing headers", func(t *testing.T) {
w := httptest.NewRecorder()
var responseWriter http.ResponseWriter = w
w.Header().Set("X-Total-Count", "999")
w.Header().Set("X-Total-Available", "999")
result := FilterResult[User]{
Items: createUsers()[:2],
TotalCount: 5,
TotalAvailable: 15,
}
ApplyFilterResultsHeaders(&responseWriter, result)
require.Equal(t, "5", w.Header().Get("X-Total-Count"), "Should overwrite existing X-Total-Count")
require.Equal(t, "15", w.Header().Get("X-Total-Available"), "Should overwrite existing X-Total-Available")
})
t.Run("simulates real handler usage", func(t *testing.T) {
// Simulate how it's actually used in handlers
handler := func(w http.ResponseWriter, r *http.Request) {
result := FilterResult[Product]{
Items: createProducts(),
TotalCount: 5,
TotalAvailable: 10,
}
ApplyFilterResultsHeaders(&w, result)
}
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/test", nil)
handler(w, req)
require.Equal(t, "5", w.Header().Get("X-Total-Count"), "Should work in handler context")
require.Equal(t, "10", w.Header().Get("X-Total-Available"), "Should work in handler context")
})
}
// Benchmark tests
func BenchmarkSearchOrderAndPaginate(b *testing.B) {
users := createUsers()
config := Config[User]{
SearchAccessors: []SearchAccessor[User]{userNameAccessor, userEmailAccessor},
SortBindings: []SortBinding[User]{
{Key: "name", Fn: userNameSort},
{Key: "age", Fn: userAgeSort},
},
}
params := QueryParams{
SearchQueryParams: SearchQueryParams{search: "example"},
SortQueryParams: SortQueryParams{sort: "name", order: SortAsc},
PaginationQueryParams: PaginationQueryParams{start: 0, limit: 10},
}
for b.Loop() {
SearchOrderAndPaginate(users, params, config)
}
}
func BenchmarkApplyFilterResultsHeaders(b *testing.B) {
w := httptest.NewRecorder()
var responseWriter http.ResponseWriter = w
result := FilterResult[User]{
Items: createUsers(),
TotalCount: 100,
TotalAvailable: 500,
}
for b.Loop() {
ApplyFilterResultsHeaders(&responseWriter, result)
}
}

View file

@ -0,0 +1,22 @@
package filters
type PaginationQueryParams struct {
start int
limit int
}
func paginateFn[T any](items []T, params PaginationQueryParams) []T {
if params.limit <= 0 {
return items
}
itemsCount := len(items)
// enforce start in [0, len(items)]
start := min(max(params.start, 0), itemsCount)
// enforce end <= len(items) (max is unnecessary since limit > 0 and start >= 0)
end := min(start+params.limit, itemsCount)
return items[start:end]
}

View file

@ -0,0 +1,245 @@
package filters
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestPaginateFn_BasicPagination(t *testing.T) {
items := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
// First page
params := PaginationQueryParams{start: 0, limit: 3}
result := paginateFn(items, params)
require.Equal(t, []int{1, 2, 3}, result)
// Second page
params = PaginationQueryParams{start: 3, limit: 3}
result = paginateFn(items, params)
require.Equal(t, []int{4, 5, 6}, result)
// Third page
params = PaginationQueryParams{start: 6, limit: 3}
result = paginateFn(items, params)
require.Equal(t, []int{7, 8, 9}, result)
// Last partial page
params = PaginationQueryParams{start: 9, limit: 3}
result = paginateFn(items, params)
require.Equal(t, []int{10}, result)
}
func TestPaginateFn_ZeroLimit(t *testing.T) {
items := []int{1, 2, 3, 4, 5}
params := PaginationQueryParams{start: 2, limit: 0}
result := paginateFn(items, params)
// Should return all items when limit is 0
require.Equal(t, items, result)
}
func TestPaginateFn_NegativeLimit(t *testing.T) {
items := []int{1, 2, 3, 4, 5}
params := PaginationQueryParams{start: 2, limit: -5}
result := paginateFn(items, params)
// Should return all items when limit is negative
require.Equal(t, items, result)
}
func TestPaginateFn_NegativeStart(t *testing.T) {
items := []int{1, 2, 3, 4, 5}
params := PaginationQueryParams{start: -3, limit: 2}
result := paginateFn(items, params)
// Should start from index 0 when start is negative
require.Equal(t, []int{1, 2}, result)
}
func TestPaginateFn_StartBeyondLength(t *testing.T) {
items := []int{1, 2, 3, 4, 5}
params := PaginationQueryParams{start: 10, limit: 3}
result := paginateFn(items, params)
// Should return empty slice when start is beyond length
require.Empty(t, result)
}
func TestPaginateFn_StartAtLength(t *testing.T) {
items := []int{1, 2, 3, 4, 5}
params := PaginationQueryParams{start: 5, limit: 3}
result := paginateFn(items, params)
// Should return empty slice when start equals length
require.Empty(t, result)
}
func TestPaginateFn_LimitLargerThanRemaining(t *testing.T) {
items := []int{1, 2, 3, 4, 5}
params := PaginationQueryParams{start: 3, limit: 10}
result := paginateFn(items, params)
// Should return remaining items when limit exceeds available items
require.Equal(t, []int{4, 5}, result)
}
func TestPaginateFn_EmptySlice(t *testing.T) {
items := []int{}
params := PaginationQueryParams{start: 0, limit: 5}
result := paginateFn(items, params)
// Should return empty slice
require.Empty(t, result)
}
func TestPaginateFn_EmptySliceWithNegativeStart(t *testing.T) {
items := []int{}
params := PaginationQueryParams{start: -5, limit: 3}
result := paginateFn(items, params)
// Should return empty slice
require.Empty(t, result)
}
func TestPaginateFn_SingleElement(t *testing.T) {
items := []int{42}
// Take the single element
params := PaginationQueryParams{start: 0, limit: 1}
result := paginateFn(items, params)
require.Equal(t, []int{42}, result)
// Start beyond the single element
params = PaginationQueryParams{start: 1, limit: 1}
result = paginateFn(items, params)
require.Empty(t, result)
}
func TestPaginateFn_LimitOfOne(t *testing.T) {
items := []int{1, 2, 3, 4, 5}
results := [][]int{}
for i := range items {
params := PaginationQueryParams{start: i, limit: 1}
result := paginateFn(items, params)
results = append(results, result)
}
expected := [][]int{
{1}, {2}, {3}, {4}, {5},
}
require.Equal(t, expected, results)
}
func TestPaginateFn_StringSlice(t *testing.T) {
items := []string{"apple", "banana", "cherry", "date", "elderberry"}
params := PaginationQueryParams{start: 1, limit: 3}
result := paginateFn(items, params)
require.Equal(t, []string{"banana", "cherry", "date"}, result)
}
func TestPaginateFn_StructSlice(t *testing.T) {
type User struct {
ID int
Name string
}
users := []User{
{ID: 1, Name: "Alice"},
{ID: 2, Name: "Bob"},
{ID: 3, Name: "Charlie"},
{ID: 4, Name: "David"},
}
params := PaginationQueryParams{start: 1, limit: 2}
result := paginateFn(users, params)
expected := []User{
{ID: 2, Name: "Bob"},
{ID: 3, Name: "Charlie"},
}
require.Equal(t, expected, result)
}
func TestPaginateFn_BoundaryConditions(t *testing.T) {
items := []int{1, 2, 3, 4, 5}
testCases := []struct {
name string
start int
limit int
expected []int
}{
{"start=0, limit=0", 0, 0, []int{1, 2, 3, 4, 5}},
{"start=0, limit=5", 0, 5, []int{1, 2, 3, 4, 5}},
{"start=0, limit=6", 0, 6, []int{1, 2, 3, 4, 5}},
{"start=4, limit=1", 4, 1, []int{5}},
{"start=4, limit=2", 4, 2, []int{5}},
{"start=5, limit=1", 5, 1, []int{}},
{"start=-1, limit=1", -1, 1, []int{1}},
{"start=-10, limit=3", -10, 3, []int{1, 2, 3}},
{"start=100, limit=1", 100, 1, []int{}},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
params := PaginationQueryParams{start: tc.start, limit: tc.limit}
result := paginateFn(items, params)
require.Equal(t, tc.expected, result)
})
}
}
func TestPaginateFn_ReturnsSliceView(t *testing.T) {
items := []int{1, 2, 3, 4, 5}
params := PaginationQueryParams{start: 1, limit: 3}
result := paginateFn(items, params)
// Result should be a slice view of the original
require.Equal(t, []int{2, 3, 4}, result)
// Modifying result WILL affect the original slice (shares underlying array)
if len(result) > 0 {
result[0] = 999
require.Equal(t, 999, items[1]) // Original is modified because they share memory
}
}
func TestPaginateFn_TypicalAPIUseCases(t *testing.T) {
// Simulate API responses with different page sizes
items := make([]int, 100)
for i := range items {
items[i] = i + 1
}
// Page size 10
params := PaginationQueryParams{start: 0, limit: 10}
page1 := paginateFn(items, params)
require.Len(t, page1, 10)
require.Equal(t, []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, page1)
// Page size 20, offset 20
params = PaginationQueryParams{start: 20, limit: 20}
page2 := paginateFn(items, params)
require.Len(t, page2, 20)
require.Equal(t, 21, page2[0])
require.Equal(t, 40, page2[19])
// Last page (partial)
params = PaginationQueryParams{start: 95, limit: 10}
lastPage := paginateFn(items, params)
require.Len(t, lastPage, 5)
require.Equal(t, []int{96, 97, 98, 99, 100}, lastPage)
}

View file

@ -0,0 +1,38 @@
package filters
import (
"net/http"
"github.com/portainer/portainer/pkg/libhttp/request"
)
type QueryParams struct {
SearchQueryParams
SortQueryParams
PaginationQueryParams
}
func ExtractListModifiersQueryParams(r *http.Request) QueryParams {
// search
search, _ := request.RetrieveQueryParameter(r, "search", true)
// sorting
sortField, _ := request.RetrieveQueryParameter(r, "sort", true)
sortOrder, _ := request.RetrieveQueryParameter(r, "order", true)
// pagination
start, _ := request.RetrieveNumericQueryParameter(r, "start", true)
limit, _ := request.RetrieveNumericQueryParameter(r, "limit", true)
return QueryParams{
SearchQueryParams{
search: search,
},
SortQueryParams{
sort: sortField,
order: SortOrder(sortOrder),
},
PaginationQueryParams{
start: start,
limit: limit,
},
}
}

View file

@ -0,0 +1,300 @@
package filters
import (
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/require"
)
func TestExtractListModifiersQueryParams(t *testing.T) {
tests := []struct {
name string
queryParams map[string]string
expectedResult QueryParams
description string
}{
{
name: "all parameters provided",
queryParams: map[string]string{
"search": "test query",
"sort": "name",
"order": "asc",
"start": "10",
"limit": "25",
},
expectedResult: QueryParams{
SearchQueryParams: SearchQueryParams{
search: "test query",
},
SortQueryParams: SortQueryParams{
sort: "name",
order: SortAsc,
},
PaginationQueryParams: PaginationQueryParams{
start: 10,
limit: 25,
},
},
description: "Should correctly parse all query parameters when provided",
},
{
name: "descending sort order",
queryParams: map[string]string{
"search": "another test",
"sort": "date",
"order": "desc",
"start": "0",
"limit": "50",
},
expectedResult: QueryParams{
SearchQueryParams: SearchQueryParams{
search: "another test",
},
SortQueryParams: SortQueryParams{
sort: "date",
order: SortDesc,
},
PaginationQueryParams: PaginationQueryParams{
start: 0,
limit: 50,
},
},
description: "Should correctly handle descending sort order",
},
{
name: "no parameters provided",
queryParams: map[string]string{},
expectedResult: QueryParams{
SearchQueryParams: SearchQueryParams{
search: "",
},
SortQueryParams: SortQueryParams{
sort: "",
order: SortOrder(""),
},
PaginationQueryParams: PaginationQueryParams{
start: 0,
limit: 0,
},
},
description: "Should return zero values when no parameters are provided",
},
{
name: "partial parameters - search only",
queryParams: map[string]string{
"search": "partial test",
},
expectedResult: QueryParams{
SearchQueryParams: SearchQueryParams{
search: "partial test",
},
SortQueryParams: SortQueryParams{
sort: "",
order: SortOrder(""),
},
PaginationQueryParams: PaginationQueryParams{
start: 0,
limit: 0,
},
},
description: "Should handle partial parameters correctly",
},
{
name: "partial parameters - pagination only",
queryParams: map[string]string{
"start": "5",
"limit": "15",
},
expectedResult: QueryParams{
SearchQueryParams: SearchQueryParams{
search: "",
},
SortQueryParams: SortQueryParams{
sort: "",
order: SortOrder(""),
},
PaginationQueryParams: PaginationQueryParams{
start: 5,
limit: 15,
},
},
description: "Should handle pagination parameters when other params are missing",
},
{
name: "invalid sort order",
queryParams: map[string]string{
"search": "test",
"sort": "name",
"order": "invalid",
"start": "0",
"limit": "10",
},
expectedResult: QueryParams{
SearchQueryParams: SearchQueryParams{
search: "test",
},
SortQueryParams: SortQueryParams{
sort: "name",
order: SortOrder("invalid"),
},
PaginationQueryParams: PaginationQueryParams{
start: 0,
limit: 10,
},
},
description: "Should accept invalid sort order as SortOrder type",
},
{
name: "empty string values",
queryParams: map[string]string{
"search": "",
"sort": "",
"order": "",
"start": "0",
"limit": "0",
},
expectedResult: QueryParams{
SearchQueryParams: SearchQueryParams{
search: "",
},
SortQueryParams: SortQueryParams{
sort: "",
order: SortOrder(""),
},
PaginationQueryParams: PaginationQueryParams{
start: 0,
limit: 0,
},
},
description: "Should handle empty string values correctly",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create HTTP request with query parameters
req := createRequestWithParams(tt.queryParams)
// Execute the function
result := ExtractListModifiersQueryParams(req)
// Assertions
require.Equal(t, tt.expectedResult.SearchQueryParams.search, result.SearchQueryParams.search,
"Search parameter should match expected value")
require.Equal(t, tt.expectedResult.SortQueryParams.sort, result.SortQueryParams.sort,
"Sort parameter should match expected value")
require.Equal(t, tt.expectedResult.SortQueryParams.order, result.SortQueryParams.order,
"Order parameter should match expected value")
require.Equal(t, tt.expectedResult.PaginationQueryParams.start, result.PaginationQueryParams.start,
"Start parameter should match expected value")
require.Equal(t, tt.expectedResult.PaginationQueryParams.limit, result.PaginationQueryParams.limit,
"Limit parameter should match expected value")
// Verify the complete struct
require.Equal(t, tt.expectedResult, result, tt.description)
})
}
}
func TestSortOrderConstants(t *testing.T) {
t.Run("sort order constants", func(t *testing.T) {
require.Equal(t, SortOrder("asc"), SortAsc, "SortAsc constant should equal 'asc'")
require.Equal(t, SortOrder("desc"), SortDesc, "SortDesc constant should equal 'desc'")
})
}
func TestQueryParamsStructEmbedding(t *testing.T) {
t.Run("struct embedding", func(t *testing.T) {
qp := QueryParams{
SearchQueryParams: SearchQueryParams{search: "test"},
SortQueryParams: SortQueryParams{sort: "name", order: SortAsc},
PaginationQueryParams: PaginationQueryParams{start: 10, limit: 20},
}
// Test that embedded fields are accessible
require.Equal(t, "test", qp.search, "Embedded search field should be accessible")
require.Equal(t, "name", qp.sort, "Embedded sort field should be accessible")
require.Equal(t, SortAsc, qp.order, "Embedded order field should be accessible")
require.Equal(t, 10, qp.start, "Embedded start field should be accessible")
require.Equal(t, 20, qp.limit, "Embedded limit field should be accessible")
})
}
func TestExtractListModifiersQueryParamsEdgeCases(t *testing.T) {
t.Run("special characters in search", func(t *testing.T) {
req := createRequestWithParams(map[string]string{
"search": "test & special chars %20",
})
result := ExtractListModifiersQueryParams(req)
require.Equal(t, "test & special chars %20", result.search,
"Should handle special characters in search parameter")
})
t.Run("unicode characters", func(t *testing.T) {
req := createRequestWithParams(map[string]string{
"search": "test 测试 🔍",
"sort": "título",
})
result := ExtractListModifiersQueryParams(req)
require.Equal(t, "test 测试 🔍", result.search, "Should handle unicode in search")
require.Equal(t, "título", result.sort, "Should handle unicode in sort field")
})
t.Run("very long values", func(t *testing.T) {
longSearch := "a very long search query that contains many words and goes on for quite some time to test handling of long strings"
req := createRequestWithParams(map[string]string{
"search": longSearch,
})
result := ExtractListModifiersQueryParams(req)
require.Equal(t, longSearch, result.search, "Should handle long search strings")
})
}
// Helper function to create HTTP request with query parameters
func createRequestWithParams(params map[string]string) *http.Request {
// Create URL with query parameters
u := &url.URL{
Scheme: "https",
Host: "example.com",
Path: "/test",
}
// Add query parameters
q := u.Query()
for key, value := range params {
q.Set(key, value)
}
u.RawQuery = q.Encode()
// Create request
req, _ := http.NewRequest("GET", u.String(), nil)
return req
}
// Benchmark tests
func BenchmarkExtractListModifiersQueryParams(b *testing.B) {
req := createRequestWithParams(map[string]string{
"search": "benchmark test",
"sort": "name",
"order": "asc",
"start": "10",
"limit": "25",
})
for b.Loop() {
ExtractListModifiersQueryParams(req)
}
}
func BenchmarkExtractListModifiersQueryParamsEmpty(b *testing.B) {
req := createRequestWithParams(map[string]string{})
for b.Loop() {
ExtractListModifiersQueryParams(req)
}
}

View file

@ -0,0 +1,36 @@
package filters
import (
"strings"
)
// Return any error to skip the field (for when matching an unknown state on an enum)
//
// Note: returning ("", nil) will match!
type SearchAccessor[T any] = func(T) (string, error)
type SearchQueryParams struct {
search string
}
func searchFn[T any](items []T, params SearchQueryParams, accessors []SearchAccessor[T]) []T {
search := strings.TrimSpace(params.search)
if search == "" {
return items
}
results := []T{}
for iIdx := range items {
for aIdx := range accessors {
value, err := accessors[aIdx](items[iIdx])
if err == nil && strings.Contains(strings.ToLower(value), strings.ToLower(search)) {
results = append(results, items[iIdx])
break
}
}
}
return results
}

View file

@ -0,0 +1,283 @@
package filters
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestSearchFn_BasicSearch(t *testing.T) {
users := []User{
{ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25},
{ID: 2, Name: "Bob Johnson", Email: "bob@company.com", Age: 30},
{ID: 3, Name: "Charlie Brown", Email: "charlie@test.org", Age: 35},
}
accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor}
params := SearchQueryParams{search: "Alice"}
result := searchFn(users, params, accessors)
require.Len(t, result, 1)
require.Equal(t, "Alice Smith", result[0].Name)
}
func TestSearchFn_EmptySearch(t *testing.T) {
users := []User{
{ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25},
{ID: 2, Name: "Bob Johnson", Email: "bob@company.com", Age: 30},
}
accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor}
params := SearchQueryParams{search: ""}
result := searchFn(users, params, accessors)
// Should return all items when search is empty
require.Equal(t, users, result)
}
func TestSearchFn_NoMatches(t *testing.T) {
users := []User{
{ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25},
{ID: 2, Name: "Bob Johnson", Email: "bob@company.com", Age: 30},
}
accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor}
params := SearchQueryParams{search: "nonexistent"}
result := searchFn(users, params, accessors)
require.Empty(t, result)
}
func TestSearchFn_MultipleMatches(t *testing.T) {
users := []User{
{ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25},
{ID: 2, Name: "Bob Smith", Email: "bob@company.com", Age: 30},
{ID: 3, Name: "Charlie Brown", Email: "charlie@smith.org", Age: 35},
}
accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor}
params := SearchQueryParams{search: "Smith"}
result := searchFn(users, params, accessors)
require.Len(t, result, 3)
require.Equal(t, "Alice Smith", result[0].Name)
require.Equal(t, "Bob Smith", result[1].Name)
require.Equal(t, "Charlie Brown", result[2].Name) // Matches via email
}
func TestSearchFn_MultipleAccessors(t *testing.T) {
users := []User{
{ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25},
{ID: 2, Name: "Bob Johnson", Email: "bob@company.com", Age: 30},
{ID: 3, Name: "Charlie Brown", Email: "charlie@test.org", Age: 35},
}
// Search across name, email, and ID
accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor, userIDAccessor}
// Search by ID
params := SearchQueryParams{search: "2"}
result := searchFn(users, params, accessors)
require.Len(t, result, 1)
require.Equal(t, 2, result[0].ID)
// Search by email domain
params = SearchQueryParams{search: "company.com"}
result = searchFn(users, params, accessors)
require.Len(t, result, 1)
require.Equal(t, "Bob Johnson", result[0].Name)
}
func TestSearchFn_CaseSensitive(t *testing.T) {
users := []User{
{ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25},
{ID: 2, Name: "Bob Johnson", Email: "bob@company.com", Age: 30},
}
accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor}
// Case sensitive search - should not match
params := SearchQueryParams{search: "alice"}
result := searchFn(users, params, accessors)
require.Len(t, result, 1) // Matches email which is lowercase
// Exact case match
params = SearchQueryParams{search: "Alice"}
result = searchFn(users, params, accessors)
require.Len(t, result, 1)
require.Equal(t, "Alice Smith", result[0].Name)
}
func TestSearchFn_PartialMatches(t *testing.T) {
products := []Product{
{ID: 1, Name: "Wireless Mouse", Description: "Ergonomic wireless mouse", Price: 25, Category: "Electronics"},
{ID: 2, Name: "Mechanical Keyboard", Description: "RGB gaming keyboard", Price: 150, Category: "Electronics"},
{ID: 3, Name: "Coffee Mug", Description: "Ceramic coffee mug", Price: 15, Category: "Kitchen"},
}
accessors := []SearchAccessor[Product]{productNameAccessor, productDescriptionAccessor}
// Partial word match
params := SearchQueryParams{search: "wire"}
result := searchFn(products, params, accessors)
require.Len(t, result, 1)
require.Equal(t, "Wireless Mouse", result[0].Name)
// Match in description
params = SearchQueryParams{search: "RGB"}
result = searchFn(products, params, accessors)
require.Len(t, result, 1)
require.Equal(t, "Mechanical Keyboard", result[0].Name)
}
func TestSearchFn_EmptySlice(t *testing.T) {
users := []User{}
accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor}
params := SearchQueryParams{search: "anything"}
result := searchFn(users, params, accessors)
require.Empty(t, result)
}
func TestSearchFn_EmptyAccessors(t *testing.T) {
users := []User{
{ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25},
}
accessors := []SearchAccessor[User]{} // No accessors
params := SearchQueryParams{search: "Alice"}
result := searchFn(users, params, accessors)
// Should return empty since no accessors to search through
require.Empty(t, result)
}
func TestSearchFn_SingleAccessor(t *testing.T) {
users := []User{
{ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25},
{ID: 2, Name: "Bob Johnson", Email: "bob@company.com", Age: 30},
}
// Only search by name
accessors := []SearchAccessor[User]{userNameAccessor}
params := SearchQueryParams{search: "company.com"}
result := searchFn(users, params, accessors)
// Should not match since we're only searching names, not emails
require.Empty(t, result)
}
func TestSearchFn_NumericSearch(t *testing.T) {
users := []User{
{ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25},
{ID: 2, Name: "Bob Johnson", Email: "bob@company.com", Age: 30},
{ID: 3, Name: "Charlie Brown", Email: "charlie@test.org", Age: 35},
}
// Search by age (converted to string)
accessors := []SearchAccessor[User]{userAgeAccessor}
params := SearchQueryParams{search: "30"}
result := searchFn(users, params, accessors)
require.Len(t, result, 1)
require.Equal(t, 30, result[0].Age)
}
func TestSearchFn_FormattedAccessor(t *testing.T) {
products := []Product{
{ID: 1, Name: "Mouse", Description: "Wireless mouse", Price: 25, Category: "Electronics"},
{ID: 2, Name: "Keyboard", Description: "Gaming keyboard", Price: 150, Category: "Electronics"},
}
// Search by formatted price (e.g., "$25")
accessors := []SearchAccessor[Product]{productPriceAccessor}
params := SearchQueryParams{search: "$25"}
result := searchFn(products, params, accessors)
require.Len(t, result, 1)
require.Equal(t, "Mouse", result[0].Name)
}
func TestSearchFn_FirstMatchOnly(t *testing.T) {
users := []User{
{ID: 1, Name: "test user", Email: "test@example.com", Age: 25},
}
// Both accessors would match the search term
accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor}
params := SearchQueryParams{search: "test"}
result := searchFn(users, params, accessors)
// Should only include the item once, even though multiple accessors match
require.Len(t, result, 1)
require.Equal(t, "test user", result[0].Name)
}
func TestSearchFn_PreservesOrder(t *testing.T) {
users := []User{
{ID: 1, Name: "Alice Test", Email: "alice@example.com", Age: 25},
{ID: 2, Name: "Bob Johnson", Email: "bob@test.com", Age: 30},
{ID: 3, Name: "Charlie Test", Email: "charlie@example.com", Age: 35},
}
accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor}
params := SearchQueryParams{search: "Test"}
result := searchFn(users, params, accessors)
require.Len(t, result, 3)
// Should preserve original order
require.Equal(t, 1, result[0].ID)
require.Equal(t, 2, result[1].ID)
require.Equal(t, 3, result[2].ID)
}
func TestSearchFn_ComplexSearch(t *testing.T) {
products := []Product{
{ID: 1, Name: "Gaming Mouse", Description: "High-DPI gaming mouse", Price: 75, Category: "Gaming"},
{ID: 2, Name: "Office Mouse", Description: "Ergonomic office mouse", Price: 25, Category: "Office"},
{ID: 3, Name: "Gaming Keyboard", Description: "Mechanical gaming keyboard", Price: 150, Category: "Gaming"},
{ID: 4, Name: "Wireless Headset", Description: "Gaming headset with mic", Price: 100, Category: "Gaming"},
}
// Search across multiple fields
accessors := []SearchAccessor[Product]{
productNameAccessor,
productDescriptionAccessor,
productCategoryAccessor,
}
params := SearchQueryParams{search: "Gaming"}
result := searchFn(products, params, accessors)
require.Len(t, result, 3)
require.Equal(t, "Gaming Mouse", result[0].Name)
require.Equal(t, "Gaming Keyboard", result[1].Name)
require.Equal(t, "Wireless Headset", result[2].Name)
}
func TestSearchFn_WhitespaceSearch(t *testing.T) {
users := []User{
{ID: 1, Name: "Alice Smith", Email: "alice@example.com", Age: 25},
{ID: 2, Name: "Bob Johnson", Email: "bob@company.com", Age: 30},
}
accessors := []SearchAccessor[User]{userNameAccessor, userEmailAccessor}
// Search with just whitespace should be treated as empty
params := SearchQueryParams{search: " "}
result := searchFn(users, params, accessors)
require.Len(t, result, 2)
}

View file

@ -0,0 +1,40 @@
package filters
import "slices"
type SortOrder string
const (
SortAsc SortOrder = "asc"
SortDesc SortOrder = "desc"
)
type SortQueryParams struct {
sort string
order SortOrder
}
type SortOption[T any] func(a, b T) int
type SortBinding[T any] struct {
Key string
Fn SortOption[T]
}
func sortFn[T any](items []T, params SortQueryParams, sorts []SortBinding[T]) []T {
for _, sort := range sorts {
if sort.Key == params.sort {
fn := sort.Fn
if params.order == SortDesc {
fn = reverSortFn(fn)
}
slices.SortStableFunc(items, fn)
}
}
return items
}
func reverSortFn[T any](fn SortOption[T]) SortOption[T] {
return func(a, b T) int {
return -1 * fn(a, b)
}
}

View file

@ -0,0 +1,287 @@
package filters
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// Helper sort functions
func compareUserByName(a, b User) int {
return strings.Compare(a.Name, b.Name)
}
func compareUserByAge(a, b User) int {
return a.Age - b.Age
}
func compareProductByName(a, b Product) int {
return strings.Compare(a.Name, b.Name)
}
func compareProductByPrice(a, b Product) int {
return a.Price - b.Price
}
func TestSortFn_BasicAscending(t *testing.T) {
users := []User{
{Name: "Charlie", Age: 25},
{Name: "Alice", Age: 30},
{Name: "Bob", Age: 20},
}
sorts := []SortBinding[User]{
{Key: "name", Fn: compareUserByName},
{Key: "age", Fn: compareUserByAge},
}
params := SortQueryParams{sort: "name", order: SortAsc}
result := sortFn(users, params, sorts)
require.Equal(t, []User{
{Name: "Alice", Age: 30},
{Name: "Bob", Age: 20},
{Name: "Charlie", Age: 25},
}, result)
}
func TestSortFn_BasicDescending(t *testing.T) {
users := []User{
{Name: "Charlie", Age: 25},
{Name: "Alice", Age: 30},
{Name: "Bob", Age: 20},
}
sorts := []SortBinding[User]{
{Key: "name", Fn: compareUserByName},
{Key: "age", Fn: compareUserByAge},
}
params := SortQueryParams{sort: "name", order: SortDesc}
result := sortFn(users, params, sorts)
require.Equal(t, []User{
{Name: "Charlie", Age: 25},
{Name: "Bob", Age: 20},
{Name: "Alice", Age: 30},
}, result)
}
func TestSortFn_SortByAge(t *testing.T) {
users := []User{
{Name: "Charlie", Age: 25},
{Name: "Alice", Age: 30},
{Name: "Bob", Age: 20},
}
sorts := []SortBinding[User]{
{Key: "name", Fn: compareUserByName},
{Key: "age", Fn: compareUserByAge},
}
// Test ascending by age
params := SortQueryParams{sort: "age", order: SortAsc}
result := sortFn(users, params, sorts)
require.Equal(t, []User{
{Name: "Bob", Age: 20},
{Name: "Charlie", Age: 25},
{Name: "Alice", Age: 30},
}, result)
// Test descending by age
params = SortQueryParams{sort: "age", order: SortDesc}
result = sortFn(users, params, sorts)
require.Equal(t, []User{
{Name: "Alice", Age: 30},
{Name: "Charlie", Age: 25},
{Name: "Bob", Age: 20},
}, result)
}
func TestSortFn_UnknownSortKey(t *testing.T) {
users := []User{
{Name: "Charlie", Age: 25},
{Name: "Alice", Age: 30},
{Name: "Bob", Age: 20},
}
sorts := []SortBinding[User]{
{Key: "name", Fn: compareUserByName},
}
params := SortQueryParams{sort: "unknown", order: SortAsc}
result := sortFn(users, params, sorts)
// Should return original slice unchanged
require.Equal(t, []User{
{Name: "Charlie", Age: 25},
{Name: "Alice", Age: 30},
{Name: "Bob", Age: 20},
}, result)
}
func TestSortFn_EmptySlice(t *testing.T) {
users := []User{}
sorts := []SortBinding[User]{
{Key: "name", Fn: compareUserByName},
}
params := SortQueryParams{sort: "name", order: SortAsc}
result := sortFn(users, params, sorts)
require.Empty(t, result)
}
func TestSortFn_SingleElement(t *testing.T) {
users := []User{{Name: "Alice", Age: 30}}
sorts := []SortBinding[User]{
{Key: "name", Fn: compareUserByName},
}
params := SortQueryParams{sort: "name", order: SortAsc}
result := sortFn(users, params, sorts)
require.Equal(t, []User{{Name: "Alice", Age: 30}}, result)
}
func TestSortFn_EmptySortBindings(t *testing.T) {
users := []User{
{Name: "Charlie", Age: 25},
{Name: "Alice", Age: 30},
}
sorts := []SortBinding[User]{} // Empty sorts
params := SortQueryParams{sort: "name", order: SortAsc}
result := sortFn(users, params, sorts)
// Should return original slice unchanged
require.Equal(t, []User{
{Name: "Charlie", Age: 25},
{Name: "Alice", Age: 30},
}, result)
}
func TestSortFn_DifferentType(t *testing.T) {
products := []Product{
{Name: "Laptop", Price: 1000},
{Name: "Mouse", Price: 25},
{Name: "Keyboard", Price: 100},
}
sorts := []SortBinding[Product]{
{Key: "name", Fn: compareProductByName},
{Key: "price", Fn: compareProductByPrice},
}
// Test by price ascending
params := SortQueryParams{sort: "price", order: SortAsc}
result := sortFn(products, params, sorts)
require.Equal(t, []Product{
{Name: "Mouse", Price: 25},
{Name: "Keyboard", Price: 100},
{Name: "Laptop", Price: 1000},
}, result)
// Test by name descending
params = SortQueryParams{sort: "name", order: SortDesc}
result = sortFn(products, params, sorts)
require.Equal(t, []Product{
{Name: "Mouse", Price: 25},
{Name: "Laptop", Price: 1000},
{Name: "Keyboard", Price: 100},
}, result)
}
func TestSortFn_StableSort(t *testing.T) {
// Test that sorting is stable (maintains relative order of equal elements)
users := []User{
{Name: "Alice", Age: 25},
{Name: "Bob", Age: 25},
{Name: "Charlie", Age: 25},
{Name: "David", Age: 30},
}
sorts := []SortBinding[User]{
{Key: "age", Fn: compareUserByAge},
}
params := SortQueryParams{sort: "age", order: SortAsc}
result := sortFn(users, params, sorts)
// All users with age 25 should maintain their original relative order
require.Equal(t, []User{
{Name: "Alice", Age: 25},
{Name: "Bob", Age: 25},
{Name: "Charlie", Age: 25},
{Name: "David", Age: 30},
}, result)
}
func TestReverseSortFn(t *testing.T) {
originalFn := compareUserByAge
reversedFn := reverSortFn(originalFn)
userA := User{Name: "Alice", Age: 20}
userB := User{Name: "Bob", Age: 30}
// Original function: A < B (returns negative)
require.Less(t, originalFn(userA, userB), 0)
// Reversed function: A > B (returns positive)
require.Greater(t, reversedFn(userA, userB), 0)
// Test symmetry
require.Equal(t, -originalFn(userA, userB), reversedFn(userA, userB))
require.Equal(t, -originalFn(userB, userA), reversedFn(userB, userA))
}
func TestSortFn_CaseSensitive(t *testing.T) {
users := []User{
{Name: "alice", Age: 25},
{Name: "Bob", Age: 30},
{Name: "Charlie", Age: 20},
}
sorts := []SortBinding[User]{
{Key: "name", Fn: compareUserByName},
}
params := SortQueryParams{sort: "name", order: SortAsc}
result := sortFn(users, params, sorts)
// strings.Compare is case-sensitive, uppercase comes before lowercase
require.Equal(t, []User{
{Name: "Bob", Age: 30},
{Name: "Charlie", Age: 20},
{Name: "alice", Age: 25},
}, result)
}
func TestSortFn_ModifiesOriginalSlice(t *testing.T) {
users := []User{
{Name: "Charlie", Age: 25},
{Name: "Alice", Age: 30},
{Name: "Bob", Age: 20},
}
original := make([]User, len(users))
copy(original, users)
sorts := []SortBinding[User]{
{Key: "name", Fn: compareUserByName},
}
params := SortQueryParams{sort: "name", order: SortAsc}
result := sortFn(users, params, sorts)
// The function modifies the original slice
require.Equal(t, result, users)
require.NotEqual(t, original, users)
}

View file

@ -0,0 +1,63 @@
package filters
import (
"errors"
"fmt"
"strconv"
)
// Test data structures
type User struct {
ID int
Name string
Email string
Age int
}
type Product struct {
ID int
Name string
Description string
Price int
Category string
}
// User accessors
func userIDAccessor(u User) (string, error) {
return strconv.Itoa(u.ID), nil
}
func userNameAccessor(u User) (string, error) {
return u.Name, nil
}
func userEmailAccessor(u User) (string, error) {
return u.Email, nil
}
func userAgeAccessor(u User) (string, error) {
return strconv.Itoa(u.Age), nil
}
// Product accessors
func productNameAccessor(p Product) (string, error) {
return p.Name, nil
}
func productDescriptionAccessor(p Product) (string, error) {
return p.Description, nil
}
func productPriceAccessor(p Product) (string, error) {
return fmt.Sprintf("$%d", p.Price), nil
}
func productCategoryAccessor(p Product) (string, error) {
return p.Category, nil
}
// Other accessors
func errorAccessor[T any](t T) (string, error) {
return "", errors.New("accessor error")
}

View file

@ -0,0 +1,117 @@
import {
queryOptionsFromTableState,
queryParamsFromQueryOptions,
} from './listQueryParams';
import {
withPaginationHeaders,
withPaginationQueryParams,
} from './pagination.types';
import {
makeIsSortTypeFunc,
sortOptionsFromColumns,
withSortQuery,
} from './sort.types';
const sortOptions = sortOptionsFromColumns([
{ enableSorting: true },
{ id: 'one' },
{ id: 'two', enableSorting: true },
{ accessorKey: 'three', enableSorting: true },
{ id: 'four', enableSorting: true, accessorKey: 'four_key' },
]);
describe('listQueryParams', () => {
test('queryOptionsFromTableState', () => {
const fns = {
setPageSize: () => {},
setSearch: () => {},
setSortBy: () => {},
};
expect(
queryOptionsFromTableState(
{
page: 5,
pageSize: 10,
search: 'something',
sortBy: { id: 'one', desc: false },
...fns,
},
sortOptions
)
).toStrictEqual({
search: 'something',
sort: 'one',
order: 'asc',
page: 5,
pageLimit: 10,
});
});
test('queryParamsFromQueryOptions', () => {
expect(
queryParamsFromQueryOptions({
search: 'something',
page: 5,
pageLimit: 10,
sort: 'one',
order: 'asc',
})
).toStrictEqual({
search: 'something',
sort: 'one',
order: 'asc',
start: 50,
limit: 10,
});
});
});
describe('pagination.types', () => {
test('withPaginationQueryParams', () => {
expect(withPaginationQueryParams({ page: 5, pageLimit: 10 })).toStrictEqual(
{
start: 50,
limit: 10,
}
);
});
test('withPaginationHeaders', () => {
expect(
withPaginationHeaders({
data: [],
headers: { 'x-total-count': 10, 'x-total-available': 100 },
})
).toStrictEqual({
data: [],
totalCount: 10,
totalAvailable: 100,
});
});
});
describe('sort.types', () => {
test('makeIsSortType', () => {
const isSortType = makeIsSortTypeFunc(sortOptions);
expect(typeof isSortType).toBe('function');
expect(isSortType('one')).toBe(true);
expect(isSortType('something_else')).toBe(false);
});
test('withSortQuery', () => {
expect(
withSortQuery({ id: 'one', desc: false }, sortOptions)
).toStrictEqual({ sort: 'one', order: 'asc' });
expect(
withSortQuery({ id: 'three', desc: true }, sortOptions)
).toStrictEqual({ sort: 'three', order: 'desc' });
expect(
withSortQuery({ id: 'something_else', desc: true }, sortOptions)
).toStrictEqual({ sort: undefined, order: 'desc' });
});
test('sortOptionsFromColumns', () => {
expect(sortOptions).toEqual(['one', 'two', 'three', 'four']);
});
});

View file

@ -0,0 +1,65 @@
import { BasicTableSettings } from '@@/datatables/types';
import { TableState } from '@@/datatables/useTableState';
import {
PaginationQuery,
PaginationQueryParams,
withPaginationQueryParams,
} from './pagination.types';
import { SearchQuery, SearchQueryParams } from './search.types';
import {
SortOptions,
SortQuery,
SortQueryParams,
withSortQuery,
} from './sort.types';
export type BaseQueryOptions<T extends SortOptions> = SearchQuery &
SortQuery<T> &
PaginationQuery;
/**
* Utility function to transform a TableState (base form) to a query options object
* Used to unify backend pagination common cases
*
* @param tableState TableState {search, sortBy: {id:string, desc:bool }, page, pageSize}
* @param sortOptions SortOptions (generated from columns)
* @returns BaseQuery {search, sort, order, page, pageLimit}
*/
export function queryOptionsFromTableState<T extends SortOptions>(
tableState: TableState<BasicTableSettings> & { page: number },
sortOptions: T
): BaseQueryOptions<T> {
return {
// search/filter
search: tableState.search,
// sorting
...withSortQuery(tableState.sortBy, sortOptions),
// pagination
page: tableState.page,
pageLimit: tableState.pageSize,
};
}
export type BaseQueryParams<T extends SortOptions> = SearchQueryParams &
SortQueryParams<T> &
PaginationQueryParams;
/**
*
* @param query BaseQueryOptions
* @returns BaseQueryParams {search, sort, order, start, limit}
*/
export function queryParamsFromQueryOptions<T extends SortOptions>(
query: BaseQueryOptions<T>
): BaseQueryParams<T> {
return {
// search/filter
search: query.search,
// sorting
sort: query.sort,
order: query.order,
// paginattion
...withPaginationQueryParams(query),
};
}

View file

@ -0,0 +1,114 @@
import { AxiosResponse } from 'axios';
/**
* Used to define axios query functions parameters for queries that support backend pagination
*
* **Example**
*
* ```ts
* type QueryParams = PaginationQueryParams;
*
* async function getSomething({ start, limit }: QueryParams = {}) {
* try {
* const { data } = await axios.get<APIType>(
* buildUrl(),
* { params: { start, limit } },
* );
* return data;
* } catch (err) {
* throw parseAxiosError(err as Error, 'Unable to retrieve something');
* }
* }
*```
*/
export type PaginationQueryParams = {
start?: number;
limit?: number;
};
/**
* Used to define react-query query functions parameters for queries that support backend pagination
*
* Example:
*
* ```ts
* type Query = PaginationQuery;
*
* function useSomething({
* page = 0,
* pageLimit = 10,
* ...query
* }: Query = {}) {
* return useQuery(
* [ ...queryKeys.base(), { page, pageLimit, ...query } ],
* async () => {
* const start = (page - 1) * pageLimit + 1;
* return getSomething({ start, limit: pageLimit, ...query });
* },
* {
* ...withError('Failure retrieving something'),
* }
* );
* }
* ```
*/
export type PaginationQuery = {
page?: number;
pageLimit?: number;
};
/**
* Utility function to convert PaginationQuery to PaginationQueryParams
*
* **Example**
*
* ```ts
* function getSomething(params: PaginationQueryParams) {...}
*
* function useSomething(query: PaginationQuery) {
* return useQuery(
* [ ...queryKeys.base(), query ],
* async () => getSomething({ ...query, ...withPaginationQueryParams(query) })
* )
* }
* ```
*/
export function withPaginationQueryParams({
page = 0,
pageLimit = 10,
}: PaginationQuery): PaginationQueryParams {
const start = page * pageLimit;
return {
start,
limit: pageLimit,
};
}
export type PaginatedResults<T> = {
data: T;
totalCount: number;
totalAvailable: number;
};
/**
* Utility function to extract total count from AxiosResponse headers
*
* @param param0 AxiosReponse-like object {data, headers}
* @returns PaginatedResults {data, totalCount, totalAvailable}
*/
export function withPaginationHeaders<T = unknown>({
data,
headers,
}: {
data: AxiosResponse<T>['data'];
headers: AxiosResponse<T>['headers'];
}): PaginatedResults<T> {
const totalCount = parseInt(headers['x-total-count'], 10);
const totalAvailable = parseInt(headers['x-total-available'], 10);
return {
data,
totalCount,
totalAvailable,
};
}

View file

@ -0,0 +1,47 @@
/**
* Used to define axios query functions parameters for queries that support backend filtering by search
*
* **Example**
*
* ```ts
* type QueryParams = SearchQueryParams;
*
* async function getSomething({ search }: QueryParams = {}) {
* try {
* const { data } = await axios.get<APIType>(
* buildUrl(),
* { params: { search } },
* );
* return data;
* } catch (err) {
* throw parseAxiosError(err as Error, 'Unable to retrieve something');
* }
* }
*```
*/
export type SearchQueryParams = {
search?: string;
};
/**
* Used to define react-query query functions parameters for queries that support backend filtering by search
*
* Example:
*
* ```ts
* type Query = SearchQuery;
*
* function useSomething({ search, ...query }: Query = {}) {
* return useQuery(
* [ ...queryKeys.base(), { search, ...query } ],
* async () => getSomething({ search, ...query }),
* {
* ...withError('Failure retrieving something'),
* }
* );
* }
* ```
*/
export type SearchQuery = {
search?: string;
};

View file

@ -0,0 +1,139 @@
import { compact } from 'lodash';
import { SortableTableSettings } from '@@/datatables/types';
export type SortOptions = readonly string[];
export type SortType<T extends SortOptions> = T[number];
/**
* Used to generate the validation function that allows to check if the sort key is supported or not
*
* **Example**
*
* ```ts
* export const sortOptions: SortOptions = ['Id', 'Name'] as const;
* export const isSortType = makeIsSortTypeFunc(sortOptions)
* ```
*
* **Usage**
*
* ```ts
* // react-query hook definition
* export function useSomething({ sort, order }: SortQuery<typeof sortOptions>) { ... }
*
* // component using the react-query hook, validating the parameters used by the query
* function MyComponent() {
* const tableState = useTableState(settingsStore, tableKey);
* const { data } = useSomething(
* {
* sort: isSortType(tableState.sortBy.id) ? tableState.sortBy.id : undefined,
* order: tableState.sortBy.desc ? 'desc' : 'asc',
* },
* );
* ...
* }
* ```
*
* @param sortOptions list of supported keys
* @returns validation function
*/
export function makeIsSortTypeFunc<T extends SortOptions>(sortOptions: T) {
return (value?: string): value is SortType<T> =>
sortOptions.includes(value as SortType<T>);
}
/**
* Used to define axios query functions parameters for queries that support backend sorting
*
* **Example**
*
* ```ts
* const sortOptions: SortOptions = ['Id', 'Name'] as const; // or generated with `sortOptionsFromColumns`
* type QueryParams = SortQueryParams<typeof sortOptions>;
*
* async function getSomething({ sort, order = 'asc' }: QueryParams = {}) {
* try {
* const { data } = await axios.get<APIType>(
* buildUrl(),
* { params: { sort, order } },
* );
* return data;
* } catch (err) {
* throw parseAxiosError(err as Error, 'Unable to retrieve something');
* }
* }
*```
*/
export type SortQueryParams<T extends SortOptions> = {
sort?: SortType<T>;
order?: 'asc' | 'desc';
};
/**
* Used to define react-query query functions parameters for queries that support backend sorting
*
* Example:
*
* ```ts
* const sortOptions: SortOptions = ['Id', 'Name'] as const;
* type Query = SortQuery<typeof sortOptions>;
*
* function useSomething({
* sort,
* order = 'asc',
* ...query
* }: Query = {}) {
* return useQuery(
* [ ...queryKeys.base(), { ...query, sort, order } ],
* async () => getSomething({ ...query, sort, order }),
* {
* ...withError('Failure retrieving something'),
* }
* );
* }
* ```
*/
export type SortQuery<T extends SortOptions> = {
sort?: SortType<T>;
order?: 'asc' | 'desc';
};
/**
* Utility function to convert react-table `sortBy` state to `SortQuery` query parameter
*
* @param sortBy tableState.sortBy
* @param sortOptions SortOptions - either defined manually, or generated with `sortOptionsFromColumns`
* @returns SortQuery - object usable by react-query functions that have params extending SortQuery
*/
export function withSortQuery<T extends SortOptions>(
sortBy: SortableTableSettings['sortBy'],
sortOptions: T
): SortQuery<T> {
if (!sortBy) {
return {
sort: undefined,
order: 'asc',
};
}
const isSortType = makeIsSortTypeFunc(sortOptions);
return {
sort: isSortType(sortBy.id) ? sortBy.id : undefined,
order: sortBy.desc ? 'desc' : 'asc',
};
}
/**
* Utility function to generate SortOptions from columns definitions
* @param columns Column-like objects { id?:string; enableSorting?:boolean } to extract SortOptions from
* @returns SortOptions
*/
export function sortOptionsFromColumns(
columns: { id?: string; enableSorting?: boolean; accessorKey?: string }[]
): SortOptions {
return compact(
columns.map((c) =>
c.enableSorting === false ? undefined : c.id ?? c.accessorKey
)
);
}

View file

@ -70,6 +70,7 @@ export interface Props<D extends DefaultType> extends AutomationTestingProps {
getRowCanExpand?(row: Row<D>): boolean;
noWidget?: boolean;
extendTableOptions?: (options: TableOptions<D>) => TableOptions<D>;
onSearchChange?: (search: string) => void;
includeSearch?: boolean;
ariaLabel?: string;
id?: string;
@ -97,6 +98,7 @@ export function Datatable<D extends DefaultType>({
getRowCanExpand,
'data-cy': dataCy,
onPageChange = () => {},
onSearchChange = () => {},
page,
totalCount = dataset.length,
isServerSidePagination = false,
@ -158,7 +160,12 @@ export function Datatable<D extends DefaultType>({
getRowCanExpand,
getColumnCanGlobalFilter,
...(isServerSidePagination
? { manualPagination: true, pageCount }
? {
pageCount,
manualPagination: true,
manualFiltering: true,
manualSorting: true,
}
: {
getSortedRowModel: getSortedRowModel(),
}),
@ -231,6 +238,7 @@ export function Datatable<D extends DefaultType>({
function handleSearchBarChange(search: string) {
tableInstance.setGlobalFilter({ search });
settings.setSearch(search);
onSearchChange(search);
}
function handlePageChange(page: number) {

View file

@ -6,16 +6,18 @@ import { keyBuilder } from '@/react/hooks/useLocalStorage';
export type DefaultType = object;
export interface PaginationTableSettings {
pageSize: number;
setPageSize: (pageSize: number) => void;
}
export type ZustandSetFunc<T> = (
partial: T | Partial<T> | ((state: T) => T | Partial<T>),
replace?: boolean | undefined
) => void;
// pagination (page size dropdown)
// for both backend and frontend paginations
export interface PaginationTableSettings {
pageSize: number;
setPageSize: (pageSize: number) => void;
}
export function paginationSettings<T extends PaginationTableSettings>(
set: ZustandSetFunc<T>
): PaginationTableSettings {
@ -25,6 +27,24 @@ export function paginationSettings<T extends PaginationTableSettings>(
};
}
// pagination (page number selector)
// for backend pagination
export interface BackendPaginationTableSettings {
page: number;
setPage: (page: number) => void;
}
export function backendPaginationSettings<
T extends BackendPaginationTableSettings,
>(set: ZustandSetFunc<T>): BackendPaginationTableSettings {
return {
page: 0,
setPage: (page: number) => set((s) => ({ ...s, page })),
};
}
// sorting
// arrows in datatable column headers
export interface SortableTableSettings {
sortBy: { id: string; desc: boolean } | undefined;
setSortBy: (id: string | undefined, desc: boolean) => void;
@ -47,6 +67,8 @@ export function sortableSettings<T extends SortableTableSettings>(
};
}
// hidding columns
// datatable options allowing to hide columns
export interface SettableColumnsTableSettings {
hiddenColumns: string[];
setHiddenColumns: (hiddenColumns: string[]) => void;
@ -63,6 +85,7 @@ export function hiddenColumnsSettings<T extends SettableColumnsTableSettings>(
};
}
// auto refresh settings
export interface RefreshableTableSettings {
autoRefreshRate: number;
setAutoRefreshRate: (autoRefreshRate: number) => void;
@ -70,7 +93,7 @@ export interface RefreshableTableSettings {
export function refreshableSettings<T extends RefreshableTableSettings>(
set: ZustandSetFunc<T>,
autoRefreshRate = 0
autoRefreshRate: number = 0
): RefreshableTableSettings {
return {
autoRefreshRate,

View file

@ -1,10 +0,0 @@
diff a/app/react/components/form-components/Input/Select.tsx b/app/react/components/form-components/Input/Select.tsx (rejected hunks)
@@ -10,7 +10,7 @@ export interface Option<T extends string | number>
disabled?: boolean;
}
-interface Props<T extends string | number> {
+interface Props<T extends string | number> extends AutomationTestingProps {
options: Option<T>[];
}

View file

@ -1,18 +1,16 @@
import { List } from 'lucide-react';
import { useMemo } from 'react';
import { Environment } from '@/react/portainer/environments/types';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { queryOptionsFromTableState } from '@/react/common/api/listQueryParams';
import { Datatable } from '@@/datatables';
import { useTableState } from '@@/datatables/useTableState';
import { withMeta } from '@@/datatables/extend-options/withMeta';
import { mergeOptions } from '@@/datatables/extend-options/mergeOptions';
import { EdgeJob, JobResult, LogsStatus } from '../../types';
import { EdgeJob, LogsStatus } from '../../types';
import { useJobResults } from '../../queries/jobResults/useJobResults';
import { columns } from './columns';
import { columns, sortOptions } from './columns';
import { createStore } from './datatable-store';
const tableKey = 'edge-job-results';
@ -22,8 +20,9 @@ export function ResultsDatatable({ jobId }: { jobId: EdgeJob['Id'] }) {
const tableState = useTableState(store, tableKey);
const jobResultsQuery = useJobResults(jobId, {
...queryOptionsFromTableState({ ...tableState }, sortOptions),
refetchInterval(dataset) {
const anyCollecting = dataset?.some(
const anyCollecting = dataset?.data.some(
(r) => r.LogsStatus === LogsStatus.Pending
);
@ -35,40 +34,17 @@ export function ResultsDatatable({ jobId }: { jobId: EdgeJob['Id'] }) {
},
});
const environmentIds = jobResultsQuery.data?.map(
(result) => result.EndpointId
);
const environmentsQuery = useEnvironmentList(
{ endpointIds: environmentIds },
{ enabled: !!environmentIds && !jobResultsQuery.isLoading }
);
const dataset = useMemo(
() =>
jobResultsQuery.isLoading || environmentsQuery.isLoading
? []
: associateEndpointsToResults(
jobResultsQuery.data || [],
environmentsQuery.environments
),
[
environmentsQuery.environments,
environmentsQuery.isLoading,
jobResultsQuery.data,
jobResultsQuery.isLoading,
]
);
const dataset = jobResultsQuery.data?.data || [];
return (
<Datatable
disableSelect
columns={columns}
dataset={dataset}
isLoading={jobResultsQuery.isLoading || environmentsQuery.isLoading}
title="Results"
titleIcon={List}
columns={columns}
disableSelect
dataset={dataset}
settingsManager={tableState}
isLoading={jobResultsQuery.isLoading}
extendTableOptions={mergeOptions(
withMeta({
table: 'edge-job-results',
@ -76,21 +52,11 @@ export function ResultsDatatable({ jobId }: { jobId: EdgeJob['Id'] }) {
})
)}
data-cy="edge-job-results-datatable"
isServerSidePagination
page={tableState.page}
onPageChange={tableState.setPage}
onSearchChange={() => tableState.setPage(0)}
totalCount={jobResultsQuery.data?.totalCount || 0}
/>
);
}
function associateEndpointsToResults(
results: Array<JobResult>,
environments: Array<Environment>
) {
return results.map((result) => {
const environment = environments.find(
(environment) => environment.Id === result.EndpointId
);
return {
...result,
Endpoint: environment,
};
});
}

View file

@ -1,18 +1,20 @@
import { CellContext, createColumnHelper } from '@tanstack/react-table';
import { sortOptionsFromColumns } from '@/react/common/api/sort.types';
import { Button } from '@@/buttons';
import { LogsStatus } from '../../types';
import { JobResult, LogsStatus } from '../../types';
import { useDownloadLogsMutation } from '../../queries/jobResults/useDownloadLogsMutation';
import { useClearLogsMutation } from '../../queries/jobResults/useClearLogsMutation';
import { useCollectLogsMutation } from '../../queries/jobResults/useCollectLogsMutation';
import { DecoratedJobResult, getTableMeta } from './types';
import { getTableMeta } from './types';
const columnHelper = createColumnHelper<DecoratedJobResult>();
const columnHelper = createColumnHelper<JobResult>();
export const columns = [
columnHelper.accessor('Endpoint.Name', {
columnHelper.accessor('EndpointName', {
header: 'Environment',
meta: {
className: 'w-1/2',
@ -30,7 +32,7 @@ export const columns = [
function ActionsCell({
row: { original: item },
table,
}: CellContext<DecoratedJobResult, unknown>) {
}: CellContext<JobResult, unknown>) {
const tableMeta = getTableMeta(table.options.meta);
const id = tableMeta.jobId;
@ -51,13 +53,13 @@ function ActionsCell({
<>
<Button
onClick={() => downloadLogsMutation.mutate(item.EndpointId)}
data-cy={`edge-job-download-logs-${item.Endpoint?.Name}`}
data-cy={`edge-job-download-logs-${item.EndpointName}`}
>
Download logs
</Button>
<Button
onClick={() => clearLogsMutations.mutate(item.EndpointId)}
data-cy={`edge-job-clear-logs-${item.Endpoint?.Name}`}
data-cy={`edge-job-clear-logs-${item.EndpointName}`}
>
Clear logs
</Button>
@ -68,10 +70,12 @@ function ActionsCell({
return (
<Button
onClick={() => collectLogsMutation.mutate(item.EndpointId)}
data-cy={`edge-job-retrieve-logs-${item.Endpoint?.Name}`}
data-cy={`edge-job-retrieve-logs-${item.EndpointName}`}
>
Retrieve logs
</Button>
);
}
}
export const sortOptions = sortOptionsFromColumns(columns);

View file

@ -3,12 +3,18 @@ import {
createPersistedStore,
BasicTableSettings,
RefreshableTableSettings,
BackendPaginationTableSettings,
backendPaginationSettings,
} from '@@/datatables/types';
interface TableSettings extends BasicTableSettings, RefreshableTableSettings {}
interface TableSettings
extends BasicTableSettings,
RefreshableTableSettings,
BackendPaginationTableSettings {}
export function createStore(storageKey: string) {
return createPersistedStore<TableSettings>(storageKey, undefined, (set) => ({
...refreshableSettings(set),
...backendPaginationSettings(set),
}));
}

View file

@ -1,10 +1,4 @@
import { Environment } from '@/react/portainer/environments/types';
import { EdgeJob, JobResult } from '../../types';
export interface DecoratedJobResult extends JobResult {
Endpoint?: Environment;
}
import { EdgeJob } from '../../types';
interface TableMeta {
table: 'edge-job-results';

View file

@ -1,35 +1,54 @@
import { useQuery } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import {
PaginatedResults,
withPaginationHeaders,
} from '@/react/common/api/pagination.types';
import {
BaseQueryOptions,
BaseQueryParams,
queryParamsFromQueryOptions,
} from '@/react/common/api/listQueryParams';
import { EdgeJob, JobResult } from '../../types';
import { sortOptions } from '../../ItemView/ResultsDatatable/columns';
import { queryKeys } from './query-keys';
import { buildUrl } from './build-url';
type QueryOptions = BaseQueryOptions<typeof sortOptions>;
type RefetchInterval =
| number
| false
| ((data: PaginatedResults<Array<JobResult>> | undefined) => number | false);
export function useJobResults(
id: EdgeJob['Id'],
{
refetchInterval,
...query
}: {
refetchInterval?:
| number
| false
| ((data: Array<JobResult> | undefined) => number | false);
} = {}
refetchInterval?: RefetchInterval;
} & QueryOptions = {}
) {
return useQuery({
queryKey: queryKeys.base(id),
queryFn: () => getJobResults(id),
queryKey: [...queryKeys.base(id), query],
queryFn: () => getJobResults(id, queryParamsFromQueryOptions(query)),
refetchInterval,
});
}
async function getJobResults(id: EdgeJob['Id']) {
try {
const { data } = await axios.get<Array<JobResult>>(buildUrl({ id }));
type QueryParams = BaseQueryParams<typeof sortOptions>;
return data;
async function getJobResults(id: EdgeJob['Id'], params?: QueryParams) {
try {
const response = await axios.get<Array<JobResult>>(
`edge_jobs/${id}/tasks`,
{ params }
);
return withPaginationHeaders(response);
} catch (err) {
throw parseAxiosError(err, 'Failed fetching edge job results');
}

View file

@ -1,4 +1,7 @@
import { EnvironmentId } from '@/react/portainer/environments/types';
import {
Environment,
EnvironmentId,
} from '@/react/portainer/environments/types';
export interface EdgeJob {
Id: number;
@ -28,5 +31,6 @@ interface EndpointMeta {
export interface JobResult {
Id: string;
EndpointId: EnvironmentId;
EndpointName: Environment['Name'];
LogsStatus: LogsStatus;
}