mirror of
https://github.com/portainer/portainer.git
synced 2025-08-07 06:45:23 +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,12 +1,15 @@
|
||||||
package edgejobs
|
package edgejobs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
|
"github.com/portainer/portainer/api/http/utils/filters"
|
||||||
"github.com/portainer/portainer/api/internal/edge"
|
"github.com/portainer/portainer/api/internal/edge"
|
||||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||||
|
@ -15,6 +18,7 @@ import (
|
||||||
type taskContainer struct {
|
type taskContainer struct {
|
||||||
ID string `json:"Id"`
|
ID string `json:"Id"`
|
||||||
EndpointID portainer.EndpointID `json:"EndpointId"`
|
EndpointID portainer.EndpointID `json:"EndpointId"`
|
||||||
|
EndpointName string `json:"EndpointName"`
|
||||||
LogsStatus portainer.EdgeJobLogsStatus `json:"LogsStatus"`
|
LogsStatus portainer.EdgeJobLogsStatus `json:"LogsStatus"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -37,16 +41,42 @@ func (handler *Handler) edgeJobTasksList(w http.ResponseWriter, r *http.Request)
|
||||||
return httperror.BadRequest("Invalid Edge job identifier route variable", err)
|
return httperror.BadRequest("Invalid Edge job identifier route variable", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var tasks []taskContainer
|
params := filters.ExtractListModifiersQueryParams(r)
|
||||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
|
||||||
|
var tasks []*taskContainer
|
||||||
|
err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||||
tasks, err = listEdgeJobTasks(tx, portainer.EdgeJobID(edgeJobID))
|
tasks, err = listEdgeJobTasks(tx, portainer.EdgeJobID(edgeJobID))
|
||||||
return err
|
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)
|
edgeJob, err := tx.EdgeJob().Read(edgeJobID)
|
||||||
if tx.IsErrObjectNotFound(err) {
|
if tx.IsErrObjectNotFound(err) {
|
||||||
return nil, httperror.NotFound("Unable to find an Edge job with the specified identifier inside the database", 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)
|
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{}
|
endpointsMap := map[portainer.EndpointID]portainer.EdgeJobEndpointMeta{}
|
||||||
if len(edgeJob.EdgeGroups) > 0 {
|
if len(edgeJob.EdgeGroups) > 0 {
|
||||||
|
@ -70,9 +105,18 @@ func listEdgeJobTasks(tx dataservices.DataStoreTx, edgeJobID portainer.EdgeJobID
|
||||||
maps.Copy(endpointsMap, edgeJob.Endpoints)
|
maps.Copy(endpointsMap, edgeJob.Endpoints)
|
||||||
|
|
||||||
for endpointID, meta := range endpointsMap {
|
for endpointID, meta := range endpointsMap {
|
||||||
tasks = append(tasks, taskContainer{
|
|
||||||
|
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),
|
ID: fmt.Sprintf("edgejob_task_%d_%d", edgeJob.ID, endpointID),
|
||||||
EndpointID: endpointID,
|
EndpointID: endpointID,
|
||||||
|
EndpointName: endpointName,
|
||||||
LogsStatus: meta.LogsStatus,
|
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;
|
getRowCanExpand?(row: Row<D>): boolean;
|
||||||
noWidget?: boolean;
|
noWidget?: boolean;
|
||||||
extendTableOptions?: (options: TableOptions<D>) => TableOptions<D>;
|
extendTableOptions?: (options: TableOptions<D>) => TableOptions<D>;
|
||||||
|
onSearchChange?: (search: string) => void;
|
||||||
includeSearch?: boolean;
|
includeSearch?: boolean;
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
@ -97,6 +98,7 @@ export function Datatable<D extends DefaultType>({
|
||||||
getRowCanExpand,
|
getRowCanExpand,
|
||||||
'data-cy': dataCy,
|
'data-cy': dataCy,
|
||||||
onPageChange = () => {},
|
onPageChange = () => {},
|
||||||
|
onSearchChange = () => {},
|
||||||
page,
|
page,
|
||||||
totalCount = dataset.length,
|
totalCount = dataset.length,
|
||||||
isServerSidePagination = false,
|
isServerSidePagination = false,
|
||||||
|
@ -158,7 +160,12 @@ export function Datatable<D extends DefaultType>({
|
||||||
getRowCanExpand,
|
getRowCanExpand,
|
||||||
getColumnCanGlobalFilter,
|
getColumnCanGlobalFilter,
|
||||||
...(isServerSidePagination
|
...(isServerSidePagination
|
||||||
? { manualPagination: true, pageCount }
|
? {
|
||||||
|
pageCount,
|
||||||
|
manualPagination: true,
|
||||||
|
manualFiltering: true,
|
||||||
|
manualSorting: true,
|
||||||
|
}
|
||||||
: {
|
: {
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
}),
|
}),
|
||||||
|
@ -231,6 +238,7 @@ export function Datatable<D extends DefaultType>({
|
||||||
function handleSearchBarChange(search: string) {
|
function handleSearchBarChange(search: string) {
|
||||||
tableInstance.setGlobalFilter({ search });
|
tableInstance.setGlobalFilter({ search });
|
||||||
settings.setSearch(search);
|
settings.setSearch(search);
|
||||||
|
onSearchChange(search);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePageChange(page: number) {
|
function handlePageChange(page: number) {
|
||||||
|
|
|
@ -6,16 +6,18 @@ import { keyBuilder } from '@/react/hooks/useLocalStorage';
|
||||||
|
|
||||||
export type DefaultType = object;
|
export type DefaultType = object;
|
||||||
|
|
||||||
export interface PaginationTableSettings {
|
|
||||||
pageSize: number;
|
|
||||||
setPageSize: (pageSize: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ZustandSetFunc<T> = (
|
export type ZustandSetFunc<T> = (
|
||||||
partial: T | Partial<T> | ((state: T) => T | Partial<T>),
|
partial: T | Partial<T> | ((state: T) => T | Partial<T>),
|
||||||
replace?: boolean | undefined
|
replace?: boolean | undefined
|
||||||
) => void;
|
) => 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>(
|
export function paginationSettings<T extends PaginationTableSettings>(
|
||||||
set: ZustandSetFunc<T>
|
set: ZustandSetFunc<T>
|
||||||
): PaginationTableSettings {
|
): 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 {
|
export interface SortableTableSettings {
|
||||||
sortBy: { id: string; desc: boolean } | undefined;
|
sortBy: { id: string; desc: boolean } | undefined;
|
||||||
setSortBy: (id: string | undefined, desc: boolean) => void;
|
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 {
|
export interface SettableColumnsTableSettings {
|
||||||
hiddenColumns: string[];
|
hiddenColumns: string[];
|
||||||
setHiddenColumns: (hiddenColumns: string[]) => void;
|
setHiddenColumns: (hiddenColumns: string[]) => void;
|
||||||
|
@ -63,6 +85,7 @@ export function hiddenColumnsSettings<T extends SettableColumnsTableSettings>(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// auto refresh settings
|
||||||
export interface RefreshableTableSettings {
|
export interface RefreshableTableSettings {
|
||||||
autoRefreshRate: number;
|
autoRefreshRate: number;
|
||||||
setAutoRefreshRate: (autoRefreshRate: number) => void;
|
setAutoRefreshRate: (autoRefreshRate: number) => void;
|
||||||
|
@ -70,7 +93,7 @@ export interface RefreshableTableSettings {
|
||||||
|
|
||||||
export function refreshableSettings<T extends RefreshableTableSettings>(
|
export function refreshableSettings<T extends RefreshableTableSettings>(
|
||||||
set: ZustandSetFunc<T>,
|
set: ZustandSetFunc<T>,
|
||||||
autoRefreshRate = 0
|
autoRefreshRate: number = 0
|
||||||
): RefreshableTableSettings {
|
): RefreshableTableSettings {
|
||||||
return {
|
return {
|
||||||
autoRefreshRate,
|
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 { List } from 'lucide-react';
|
||||||
import { useMemo } from 'react';
|
|
||||||
|
|
||||||
import { Environment } from '@/react/portainer/environments/types';
|
import { queryOptionsFromTableState } from '@/react/common/api/listQueryParams';
|
||||||
import { useEnvironmentList } from '@/react/portainer/environments/queries';
|
|
||||||
|
|
||||||
import { Datatable } from '@@/datatables';
|
import { Datatable } from '@@/datatables';
|
||||||
import { useTableState } from '@@/datatables/useTableState';
|
import { useTableState } from '@@/datatables/useTableState';
|
||||||
import { withMeta } from '@@/datatables/extend-options/withMeta';
|
import { withMeta } from '@@/datatables/extend-options/withMeta';
|
||||||
import { mergeOptions } from '@@/datatables/extend-options/mergeOptions';
|
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 { useJobResults } from '../../queries/jobResults/useJobResults';
|
||||||
|
|
||||||
import { columns } from './columns';
|
import { columns, sortOptions } from './columns';
|
||||||
import { createStore } from './datatable-store';
|
import { createStore } from './datatable-store';
|
||||||
|
|
||||||
const tableKey = 'edge-job-results';
|
const tableKey = 'edge-job-results';
|
||||||
|
@ -22,8 +20,9 @@ export function ResultsDatatable({ jobId }: { jobId: EdgeJob['Id'] }) {
|
||||||
const tableState = useTableState(store, tableKey);
|
const tableState = useTableState(store, tableKey);
|
||||||
|
|
||||||
const jobResultsQuery = useJobResults(jobId, {
|
const jobResultsQuery = useJobResults(jobId, {
|
||||||
|
...queryOptionsFromTableState({ ...tableState }, sortOptions),
|
||||||
refetchInterval(dataset) {
|
refetchInterval(dataset) {
|
||||||
const anyCollecting = dataset?.some(
|
const anyCollecting = dataset?.data.some(
|
||||||
(r) => r.LogsStatus === LogsStatus.Pending
|
(r) => r.LogsStatus === LogsStatus.Pending
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -35,40 +34,17 @@ export function ResultsDatatable({ jobId }: { jobId: EdgeJob['Id'] }) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const environmentIds = jobResultsQuery.data?.map(
|
const dataset = jobResultsQuery.data?.data || [];
|
||||||
(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,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Datatable
|
<Datatable
|
||||||
disableSelect
|
|
||||||
columns={columns}
|
|
||||||
dataset={dataset}
|
|
||||||
isLoading={jobResultsQuery.isLoading || environmentsQuery.isLoading}
|
|
||||||
title="Results"
|
title="Results"
|
||||||
titleIcon={List}
|
titleIcon={List}
|
||||||
|
columns={columns}
|
||||||
|
disableSelect
|
||||||
|
dataset={dataset}
|
||||||
settingsManager={tableState}
|
settingsManager={tableState}
|
||||||
|
isLoading={jobResultsQuery.isLoading}
|
||||||
extendTableOptions={mergeOptions(
|
extendTableOptions={mergeOptions(
|
||||||
withMeta({
|
withMeta({
|
||||||
table: 'edge-job-results',
|
table: 'edge-job-results',
|
||||||
|
@ -76,21 +52,11 @@ export function ResultsDatatable({ jobId }: { jobId: EdgeJob['Id'] }) {
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
data-cy="edge-job-results-datatable"
|
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 { CellContext, createColumnHelper } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import { sortOptionsFromColumns } from '@/react/common/api/sort.types';
|
||||||
|
|
||||||
import { Button } from '@@/buttons';
|
import { Button } from '@@/buttons';
|
||||||
|
|
||||||
import { LogsStatus } from '../../types';
|
import { JobResult, LogsStatus } from '../../types';
|
||||||
import { useDownloadLogsMutation } from '../../queries/jobResults/useDownloadLogsMutation';
|
import { useDownloadLogsMutation } from '../../queries/jobResults/useDownloadLogsMutation';
|
||||||
import { useClearLogsMutation } from '../../queries/jobResults/useClearLogsMutation';
|
import { useClearLogsMutation } from '../../queries/jobResults/useClearLogsMutation';
|
||||||
import { useCollectLogsMutation } from '../../queries/jobResults/useCollectLogsMutation';
|
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 = [
|
export const columns = [
|
||||||
columnHelper.accessor('Endpoint.Name', {
|
columnHelper.accessor('EndpointName', {
|
||||||
header: 'Environment',
|
header: 'Environment',
|
||||||
meta: {
|
meta: {
|
||||||
className: 'w-1/2',
|
className: 'w-1/2',
|
||||||
|
@ -30,7 +32,7 @@ export const columns = [
|
||||||
function ActionsCell({
|
function ActionsCell({
|
||||||
row: { original: item },
|
row: { original: item },
|
||||||
table,
|
table,
|
||||||
}: CellContext<DecoratedJobResult, unknown>) {
|
}: CellContext<JobResult, unknown>) {
|
||||||
const tableMeta = getTableMeta(table.options.meta);
|
const tableMeta = getTableMeta(table.options.meta);
|
||||||
const id = tableMeta.jobId;
|
const id = tableMeta.jobId;
|
||||||
|
|
||||||
|
@ -51,13 +53,13 @@ function ActionsCell({
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => downloadLogsMutation.mutate(item.EndpointId)}
|
onClick={() => downloadLogsMutation.mutate(item.EndpointId)}
|
||||||
data-cy={`edge-job-download-logs-${item.Endpoint?.Name}`}
|
data-cy={`edge-job-download-logs-${item.EndpointName}`}
|
||||||
>
|
>
|
||||||
Download logs
|
Download logs
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => clearLogsMutations.mutate(item.EndpointId)}
|
onClick={() => clearLogsMutations.mutate(item.EndpointId)}
|
||||||
data-cy={`edge-job-clear-logs-${item.Endpoint?.Name}`}
|
data-cy={`edge-job-clear-logs-${item.EndpointName}`}
|
||||||
>
|
>
|
||||||
Clear logs
|
Clear logs
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -68,10 +70,12 @@ function ActionsCell({
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => collectLogsMutation.mutate(item.EndpointId)}
|
onClick={() => collectLogsMutation.mutate(item.EndpointId)}
|
||||||
data-cy={`edge-job-retrieve-logs-${item.Endpoint?.Name}`}
|
data-cy={`edge-job-retrieve-logs-${item.EndpointName}`}
|
||||||
>
|
>
|
||||||
Retrieve logs
|
Retrieve logs
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const sortOptions = sortOptionsFromColumns(columns);
|
||||||
|
|
|
@ -3,12 +3,18 @@ import {
|
||||||
createPersistedStore,
|
createPersistedStore,
|
||||||
BasicTableSettings,
|
BasicTableSettings,
|
||||||
RefreshableTableSettings,
|
RefreshableTableSettings,
|
||||||
|
BackendPaginationTableSettings,
|
||||||
|
backendPaginationSettings,
|
||||||
} from '@@/datatables/types';
|
} from '@@/datatables/types';
|
||||||
|
|
||||||
interface TableSettings extends BasicTableSettings, RefreshableTableSettings {}
|
interface TableSettings
|
||||||
|
extends BasicTableSettings,
|
||||||
|
RefreshableTableSettings,
|
||||||
|
BackendPaginationTableSettings {}
|
||||||
|
|
||||||
export function createStore(storageKey: string) {
|
export function createStore(storageKey: string) {
|
||||||
return createPersistedStore<TableSettings>(storageKey, undefined, (set) => ({
|
return createPersistedStore<TableSettings>(storageKey, undefined, (set) => ({
|
||||||
...refreshableSettings(set),
|
...refreshableSettings(set),
|
||||||
|
...backendPaginationSettings(set),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,4 @@
|
||||||
import { Environment } from '@/react/portainer/environments/types';
|
import { EdgeJob } from '../../types';
|
||||||
|
|
||||||
import { EdgeJob, JobResult } from '../../types';
|
|
||||||
|
|
||||||
export interface DecoratedJobResult extends JobResult {
|
|
||||||
Endpoint?: Environment;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TableMeta {
|
interface TableMeta {
|
||||||
table: 'edge-job-results';
|
table: 'edge-job-results';
|
||||||
|
|
|
@ -1,35 +1,54 @@
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
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 { EdgeJob, JobResult } from '../../types';
|
||||||
|
import { sortOptions } from '../../ItemView/ResultsDatatable/columns';
|
||||||
|
|
||||||
import { queryKeys } from './query-keys';
|
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(
|
export function useJobResults(
|
||||||
id: EdgeJob['Id'],
|
id: EdgeJob['Id'],
|
||||||
{
|
{
|
||||||
refetchInterval,
|
refetchInterval,
|
||||||
|
...query
|
||||||
}: {
|
}: {
|
||||||
refetchInterval?:
|
refetchInterval?: RefetchInterval;
|
||||||
| number
|
} & QueryOptions = {}
|
||||||
| false
|
|
||||||
| ((data: Array<JobResult> | undefined) => number | false);
|
|
||||||
} = {}
|
|
||||||
) {
|
) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.base(id),
|
queryKey: [...queryKeys.base(id), query],
|
||||||
queryFn: () => getJobResults(id),
|
queryFn: () => getJobResults(id, queryParamsFromQueryOptions(query)),
|
||||||
refetchInterval,
|
refetchInterval,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getJobResults(id: EdgeJob['Id']) {
|
type QueryParams = BaseQueryParams<typeof sortOptions>;
|
||||||
try {
|
|
||||||
const { data } = await axios.get<Array<JobResult>>(buildUrl({ id }));
|
|
||||||
|
|
||||||
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) {
|
} catch (err) {
|
||||||
throw parseAxiosError(err, 'Failed fetching edge job results');
|
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 {
|
export interface EdgeJob {
|
||||||
Id: number;
|
Id: number;
|
||||||
|
@ -28,5 +31,6 @@ interface EndpointMeta {
|
||||||
export interface JobResult {
|
export interface JobResult {
|
||||||
Id: string;
|
Id: string;
|
||||||
EndpointId: EnvironmentId;
|
EndpointId: EnvironmentId;
|
||||||
|
EndpointName: Environment['Name'];
|
||||||
LogsStatus: LogsStatus;
|
LogsStatus: LogsStatus;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue