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:
parent
d306d7a983
commit
a472de1919
27 changed files with 2595 additions and 107 deletions
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
131
api/http/handler/edgejobs/edgejob_tasks_list_test.go
Normal file
131
api/http/handler/edgejobs/edgejob_tasks_list_test.go
Normal 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
|
||||
})
|
||||
|
||||
}
|
38
api/http/utils/filters/filters.go
Normal file
38
api/http/utils/filters/filters.go
Normal 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))
|
||||
}
|
465
api/http/utils/filters/filters_test.go
Normal file
465
api/http/utils/filters/filters_test.go
Normal 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)
|
||||
}
|
||||
}
|
22
api/http/utils/filters/pagination.go
Normal file
22
api/http/utils/filters/pagination.go
Normal 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]
|
||||
}
|
245
api/http/utils/filters/pagination_test.go
Normal file
245
api/http/utils/filters/pagination_test.go
Normal 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)
|
||||
}
|
38
api/http/utils/filters/query_params.go
Normal file
38
api/http/utils/filters/query_params.go
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
300
api/http/utils/filters/query_params_test.go
Normal file
300
api/http/utils/filters/query_params_test.go
Normal 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)
|
||||
}
|
||||
}
|
36
api/http/utils/filters/search.go
Normal file
36
api/http/utils/filters/search.go
Normal 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
|
||||
}
|
283
api/http/utils/filters/search_test.go
Normal file
283
api/http/utils/filters/search_test.go
Normal 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)
|
||||
}
|
40
api/http/utils/filters/sort.go
Normal file
40
api/http/utils/filters/sort.go
Normal 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)
|
||||
}
|
||||
}
|
287
api/http/utils/filters/sort_test.go
Normal file
287
api/http/utils/filters/sort_test.go
Normal 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)
|
||||
}
|
63
api/http/utils/filters/types_test.go
Normal file
63
api/http/utils/filters/types_test.go
Normal 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")
|
||||
}
|
117
app/react/common/api/common.test.ts
Normal file
117
app/react/common/api/common.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
65
app/react/common/api/listQueryParams.ts
Normal file
65
app/react/common/api/listQueryParams.ts
Normal 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),
|
||||
};
|
||||
}
|
114
app/react/common/api/pagination.types.ts
Normal file
114
app/react/common/api/pagination.types.ts
Normal 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,
|
||||
};
|
||||
}
|
47
app/react/common/api/search.types.ts
Normal file
47
app/react/common/api/search.types.ts
Normal 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;
|
||||
};
|
139
app/react/common/api/sort.types.ts
Normal file
139
app/react/common/api/sort.types.ts
Normal 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
|
||||
)
|
||||
);
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>[];
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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),
|
||||
}));
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue