From a472de19197a2ee7ce29c45b3c15c9e8cad841f4 Mon Sep 17 00:00:00 2001 From: LP B Date: Mon, 4 Aug 2025 17:10:46 +0200 Subject: [PATCH] fix(app/edge-jobs): edge job results page crash at scale (#954) --- .../handler/edgejobs/edgejob_tasks_list.go | 68 ++- .../edgejobs/edgejob_tasks_list_test.go | 131 +++++ api/http/utils/filters/filters.go | 38 ++ api/http/utils/filters/filters_test.go | 465 ++++++++++++++++++ api/http/utils/filters/pagination.go | 22 + api/http/utils/filters/pagination_test.go | 245 +++++++++ api/http/utils/filters/query_params.go | 38 ++ api/http/utils/filters/query_params_test.go | 300 +++++++++++ api/http/utils/filters/search.go | 36 ++ api/http/utils/filters/search_test.go | 283 +++++++++++ api/http/utils/filters/sort.go | 40 ++ api/http/utils/filters/sort_test.go | 287 +++++++++++ api/http/utils/filters/types_test.go | 63 +++ app/react/common/api/common.test.ts | 117 +++++ app/react/common/api/listQueryParams.ts | 65 +++ app/react/common/api/pagination.types.ts | 114 +++++ app/react/common/api/search.types.ts | 47 ++ app/react/common/api/sort.types.ts | 139 ++++++ app/react/components/datatables/Datatable.tsx | 10 +- app/react/components/datatables/types.ts | 35 +- .../form-components/Input/Select.tsx.rej | 10 - .../ResultsDatatable/ResultsDatatable.tsx | 64 +-- .../ItemView/ResultsDatatable/columns.tsx | 20 +- .../ResultsDatatable/datatable-store.ts | 8 +- .../ItemView/ResultsDatatable/types.ts | 8 +- .../queries/jobResults/useJobResults.ts | 43 +- app/react/edge/edge-jobs/types.ts | 6 +- 27 files changed, 2595 insertions(+), 107 deletions(-) create mode 100644 api/http/handler/edgejobs/edgejob_tasks_list_test.go create mode 100644 api/http/utils/filters/filters.go create mode 100644 api/http/utils/filters/filters_test.go create mode 100644 api/http/utils/filters/pagination.go create mode 100644 api/http/utils/filters/pagination_test.go create mode 100644 api/http/utils/filters/query_params.go create mode 100644 api/http/utils/filters/query_params_test.go create mode 100644 api/http/utils/filters/search.go create mode 100644 api/http/utils/filters/search_test.go create mode 100644 api/http/utils/filters/sort.go create mode 100644 api/http/utils/filters/sort_test.go create mode 100644 api/http/utils/filters/types_test.go create mode 100644 app/react/common/api/common.test.ts create mode 100644 app/react/common/api/listQueryParams.ts create mode 100644 app/react/common/api/pagination.types.ts create mode 100644 app/react/common/api/search.types.ts create mode 100644 app/react/common/api/sort.types.ts delete mode 100644 app/react/components/form-components/Input/Select.tsx.rej diff --git a/api/http/handler/edgejobs/edgejob_tasks_list.go b/api/http/handler/edgejobs/edgejob_tasks_list.go index 70918f69d..64d50137b 100644 --- a/api/http/handler/edgejobs/edgejob_tasks_list.go +++ b/api/http/handler/edgejobs/edgejob_tasks_list.go @@ -1,21 +1,25 @@ package edgejobs import ( + "errors" "fmt" "maps" "net/http" + "strings" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/utils/filters" "github.com/portainer/portainer/api/internal/edge" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" ) type taskContainer struct { - ID string `json:"Id"` - EndpointID portainer.EndpointID `json:"EndpointId"` - LogsStatus portainer.EdgeJobLogsStatus `json:"LogsStatus"` + ID string `json:"Id"` + EndpointID portainer.EndpointID `json:"EndpointId"` + EndpointName string `json:"EndpointName"` + LogsStatus portainer.EdgeJobLogsStatus `json:"LogsStatus"` } // @id EdgeJobTasksList @@ -37,16 +41,42 @@ func (handler *Handler) edgeJobTasksList(w http.ResponseWriter, r *http.Request) return httperror.BadRequest("Invalid Edge job identifier route variable", err) } - var tasks []taskContainer - err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { + params := filters.ExtractListModifiersQueryParams(r) + + var tasks []*taskContainer + err = handler.DataStore.ViewTx(func(tx dataservices.DataStoreTx) error { tasks, err = listEdgeJobTasks(tx, portainer.EdgeJobID(edgeJobID)) return err }) - return txResponse(w, tasks, err) + results := filters.SearchOrderAndPaginate(tasks, params, filters.Config[*taskContainer]{ + SearchAccessors: []filters.SearchAccessor[*taskContainer]{ + func(tc *taskContainer) (string, error) { + switch tc.LogsStatus { + case portainer.EdgeJobLogsStatusPending: + return "pending", nil + case 0, portainer.EdgeJobLogsStatusIdle: + return "idle", nil + case portainer.EdgeJobLogsStatusCollected: + return "collected", nil + } + return "", errors.New("unknown state") + }, + func(tc *taskContainer) (string, error) { + return tc.EndpointName, nil + }, + }, + SortBindings: []filters.SortBinding[*taskContainer]{ + {Key: "EndpointName", Fn: func(a, b *taskContainer) int { return strings.Compare(a.EndpointName, b.EndpointName) }}, + }, + }) + + filters.ApplyFilterResultsHeaders(&w, results) + + return txResponse(w, results.Items, err) } -func listEdgeJobTasks(tx dataservices.DataStoreTx, edgeJobID portainer.EdgeJobID) ([]taskContainer, error) { +func listEdgeJobTasks(tx dataservices.DataStoreTx, edgeJobID portainer.EdgeJobID) ([]*taskContainer, error) { edgeJob, err := tx.EdgeJob().Read(edgeJobID) if tx.IsErrObjectNotFound(err) { return nil, httperror.NotFound("Unable to find an Edge job with the specified identifier inside the database", err) @@ -54,7 +84,12 @@ func listEdgeJobTasks(tx dataservices.DataStoreTx, edgeJobID portainer.EdgeJobID return nil, httperror.InternalServerError("Unable to find an Edge job with the specified identifier inside the database", err) } - tasks := make([]taskContainer, 0) + endpoints, err := tx.Endpoint().Endpoints() + if err != nil { + return nil, err + } + + tasks := make([]*taskContainer, 0) endpointsMap := map[portainer.EndpointID]portainer.EdgeJobEndpointMeta{} if len(edgeJob.EdgeGroups) > 0 { @@ -70,10 +105,19 @@ func listEdgeJobTasks(tx dataservices.DataStoreTx, edgeJobID portainer.EdgeJobID maps.Copy(endpointsMap, edgeJob.Endpoints) for endpointID, meta := range endpointsMap { - tasks = append(tasks, taskContainer{ - ID: fmt.Sprintf("edgejob_task_%d_%d", edgeJob.ID, endpointID), - EndpointID: endpointID, - LogsStatus: meta.LogsStatus, + + endpointName := "" + for idx := range endpoints { + if endpoints[idx].ID == endpointID { + endpointName = endpoints[idx].Name + } + } + + tasks = append(tasks, &taskContainer{ + ID: fmt.Sprintf("edgejob_task_%d_%d", edgeJob.ID, endpointID), + EndpointID: endpointID, + EndpointName: endpointName, + LogsStatus: meta.LogsStatus, }) } diff --git a/api/http/handler/edgejobs/edgejob_tasks_list_test.go b/api/http/handler/edgejobs/edgejob_tasks_list_test.go new file mode 100644 index 000000000..6224ed628 --- /dev/null +++ b/api/http/handler/edgejobs/edgejob_tasks_list_test.go @@ -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 + }) + +} diff --git a/api/http/utils/filters/filters.go b/api/http/utils/filters/filters.go new file mode 100644 index 000000000..2e22d19a2 --- /dev/null +++ b/api/http/utils/filters/filters.go @@ -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)) +} diff --git a/api/http/utils/filters/filters_test.go b/api/http/utils/filters/filters_test.go new file mode 100644 index 000000000..0b0780fdf --- /dev/null +++ b/api/http/utils/filters/filters_test.go @@ -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) + } +} diff --git a/api/http/utils/filters/pagination.go b/api/http/utils/filters/pagination.go new file mode 100644 index 000000000..fd01eb89a --- /dev/null +++ b/api/http/utils/filters/pagination.go @@ -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] +} diff --git a/api/http/utils/filters/pagination_test.go b/api/http/utils/filters/pagination_test.go new file mode 100644 index 000000000..4b09ac9aa --- /dev/null +++ b/api/http/utils/filters/pagination_test.go @@ -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) +} diff --git a/api/http/utils/filters/query_params.go b/api/http/utils/filters/query_params.go new file mode 100644 index 000000000..7dec80365 --- /dev/null +++ b/api/http/utils/filters/query_params.go @@ -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, + }, + } +} diff --git a/api/http/utils/filters/query_params_test.go b/api/http/utils/filters/query_params_test.go new file mode 100644 index 000000000..5865e11d8 --- /dev/null +++ b/api/http/utils/filters/query_params_test.go @@ -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) + } +} diff --git a/api/http/utils/filters/search.go b/api/http/utils/filters/search.go new file mode 100644 index 000000000..b88422da1 --- /dev/null +++ b/api/http/utils/filters/search.go @@ -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 +} diff --git a/api/http/utils/filters/search_test.go b/api/http/utils/filters/search_test.go new file mode 100644 index 000000000..d8f552563 --- /dev/null +++ b/api/http/utils/filters/search_test.go @@ -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) +} diff --git a/api/http/utils/filters/sort.go b/api/http/utils/filters/sort.go new file mode 100644 index 000000000..f7d937b4e --- /dev/null +++ b/api/http/utils/filters/sort.go @@ -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) + } +} diff --git a/api/http/utils/filters/sort_test.go b/api/http/utils/filters/sort_test.go new file mode 100644 index 000000000..44f4c5d72 --- /dev/null +++ b/api/http/utils/filters/sort_test.go @@ -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) +} diff --git a/api/http/utils/filters/types_test.go b/api/http/utils/filters/types_test.go new file mode 100644 index 000000000..2dc9b9ac9 --- /dev/null +++ b/api/http/utils/filters/types_test.go @@ -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") +} diff --git a/app/react/common/api/common.test.ts b/app/react/common/api/common.test.ts new file mode 100644 index 000000000..97d22c2be --- /dev/null +++ b/app/react/common/api/common.test.ts @@ -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']); + }); +}); diff --git a/app/react/common/api/listQueryParams.ts b/app/react/common/api/listQueryParams.ts new file mode 100644 index 000000000..dc2101c5b --- /dev/null +++ b/app/react/common/api/listQueryParams.ts @@ -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 = SearchQuery & + SortQuery & + 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( + tableState: TableState & { page: number }, + sortOptions: T +): BaseQueryOptions { + return { + // search/filter + search: tableState.search, + // sorting + ...withSortQuery(tableState.sortBy, sortOptions), + // pagination + page: tableState.page, + pageLimit: tableState.pageSize, + }; +} + +export type BaseQueryParams = SearchQueryParams & + SortQueryParams & + PaginationQueryParams; + +/** + * + * @param query BaseQueryOptions + * @returns BaseQueryParams {search, sort, order, start, limit} + */ +export function queryParamsFromQueryOptions( + query: BaseQueryOptions +): BaseQueryParams { + return { + // search/filter + search: query.search, + // sorting + sort: query.sort, + order: query.order, + // paginattion + ...withPaginationQueryParams(query), + }; +} diff --git a/app/react/common/api/pagination.types.ts b/app/react/common/api/pagination.types.ts new file mode 100644 index 000000000..390765251 --- /dev/null +++ b/app/react/common/api/pagination.types.ts @@ -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( + * 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 = { + 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({ + data, + headers, +}: { + data: AxiosResponse['data']; + headers: AxiosResponse['headers']; +}): PaginatedResults { + const totalCount = parseInt(headers['x-total-count'], 10); + const totalAvailable = parseInt(headers['x-total-available'], 10); + + return { + data, + totalCount, + totalAvailable, + }; +} diff --git a/app/react/common/api/search.types.ts b/app/react/common/api/search.types.ts new file mode 100644 index 000000000..e146bd8ba --- /dev/null +++ b/app/react/common/api/search.types.ts @@ -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( + * 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; +}; diff --git a/app/react/common/api/sort.types.ts b/app/react/common/api/sort.types.ts new file mode 100644 index 000000000..bccfff1c8 --- /dev/null +++ b/app/react/common/api/sort.types.ts @@ -0,0 +1,139 @@ +import { compact } from 'lodash'; + +import { SortableTableSettings } from '@@/datatables/types'; + +export type SortOptions = readonly string[]; +export type SortType = 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) { ... } + * + * // 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(sortOptions: T) { + return (value?: string): value is SortType => + sortOptions.includes(value as SortType); +} + +/** + * 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; + * + * async function getSomething({ sort, order = 'asc' }: QueryParams = {}) { + * try { + * const { data } = await axios.get( + * buildUrl(), + * { params: { sort, order } }, + * ); + * return data; + * } catch (err) { + * throw parseAxiosError(err as Error, 'Unable to retrieve something'); + * } + * } + *``` + */ +export type SortQueryParams = { + sort?: SortType; + 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; + * + * 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 = { + sort?: SortType; + 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( + sortBy: SortableTableSettings['sortBy'], + sortOptions: T +): SortQuery { + 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 + ) + ); +} diff --git a/app/react/components/datatables/Datatable.tsx b/app/react/components/datatables/Datatable.tsx index dff56aaf6..e4c80051c 100644 --- a/app/react/components/datatables/Datatable.tsx +++ b/app/react/components/datatables/Datatable.tsx @@ -70,6 +70,7 @@ export interface Props extends AutomationTestingProps { getRowCanExpand?(row: Row): boolean; noWidget?: boolean; extendTableOptions?: (options: TableOptions) => TableOptions; + onSearchChange?: (search: string) => void; includeSearch?: boolean; ariaLabel?: string; id?: string; @@ -97,6 +98,7 @@ export function Datatable({ getRowCanExpand, 'data-cy': dataCy, onPageChange = () => {}, + onSearchChange = () => {}, page, totalCount = dataset.length, isServerSidePagination = false, @@ -158,7 +160,12 @@ export function Datatable({ getRowCanExpand, getColumnCanGlobalFilter, ...(isServerSidePagination - ? { manualPagination: true, pageCount } + ? { + pageCount, + manualPagination: true, + manualFiltering: true, + manualSorting: true, + } : { getSortedRowModel: getSortedRowModel(), }), @@ -231,6 +238,7 @@ export function Datatable({ function handleSearchBarChange(search: string) { tableInstance.setGlobalFilter({ search }); settings.setSearch(search); + onSearchChange(search); } function handlePageChange(page: number) { diff --git a/app/react/components/datatables/types.ts b/app/react/components/datatables/types.ts index 45264f337..aecc23a18 100644 --- a/app/react/components/datatables/types.ts +++ b/app/react/components/datatables/types.ts @@ -6,16 +6,18 @@ import { keyBuilder } from '@/react/hooks/useLocalStorage'; export type DefaultType = object; -export interface PaginationTableSettings { - pageSize: number; - setPageSize: (pageSize: number) => void; -} - export type ZustandSetFunc = ( partial: T | Partial | ((state: T) => T | Partial), replace?: boolean | undefined ) => void; +// pagination (page size dropdown) +// for both backend and frontend paginations +export interface PaginationTableSettings { + pageSize: number; + setPageSize: (pageSize: number) => void; +} + export function paginationSettings( set: ZustandSetFunc ): PaginationTableSettings { @@ -25,6 +27,24 @@ export function paginationSettings( }; } +// pagination (page number selector) +// for backend pagination +export interface BackendPaginationTableSettings { + page: number; + setPage: (page: number) => void; +} + +export function backendPaginationSettings< + T extends BackendPaginationTableSettings, +>(set: ZustandSetFunc): BackendPaginationTableSettings { + return { + page: 0, + setPage: (page: number) => set((s) => ({ ...s, page })), + }; +} + +// sorting +// arrows in datatable column headers export interface SortableTableSettings { sortBy: { id: string; desc: boolean } | undefined; setSortBy: (id: string | undefined, desc: boolean) => void; @@ -47,6 +67,8 @@ export function sortableSettings( }; } +// hidding columns +// datatable options allowing to hide columns export interface SettableColumnsTableSettings { hiddenColumns: string[]; setHiddenColumns: (hiddenColumns: string[]) => void; @@ -63,6 +85,7 @@ export function hiddenColumnsSettings( }; } +// auto refresh settings export interface RefreshableTableSettings { autoRefreshRate: number; setAutoRefreshRate: (autoRefreshRate: number) => void; @@ -70,7 +93,7 @@ export interface RefreshableTableSettings { export function refreshableSettings( set: ZustandSetFunc, - autoRefreshRate = 0 + autoRefreshRate: number = 0 ): RefreshableTableSettings { return { autoRefreshRate, diff --git a/app/react/components/form-components/Input/Select.tsx.rej b/app/react/components/form-components/Input/Select.tsx.rej deleted file mode 100644 index 6edb9f9ef..000000000 --- a/app/react/components/form-components/Input/Select.tsx.rej +++ /dev/null @@ -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 - disabled?: boolean; - } - --interface Props { -+interface Props extends AutomationTestingProps { - options: Option[]; - } - diff --git a/app/react/edge/edge-jobs/ItemView/ResultsDatatable/ResultsDatatable.tsx b/app/react/edge/edge-jobs/ItemView/ResultsDatatable/ResultsDatatable.tsx index 7054226f5..698f10bae 100644 --- a/app/react/edge/edge-jobs/ItemView/ResultsDatatable/ResultsDatatable.tsx +++ b/app/react/edge/edge-jobs/ItemView/ResultsDatatable/ResultsDatatable.tsx @@ -1,18 +1,16 @@ import { List } from 'lucide-react'; -import { useMemo } from 'react'; -import { Environment } from '@/react/portainer/environments/types'; -import { useEnvironmentList } from '@/react/portainer/environments/queries'; +import { queryOptionsFromTableState } from '@/react/common/api/listQueryParams'; import { Datatable } from '@@/datatables'; import { useTableState } from '@@/datatables/useTableState'; import { withMeta } from '@@/datatables/extend-options/withMeta'; import { mergeOptions } from '@@/datatables/extend-options/mergeOptions'; -import { EdgeJob, JobResult, LogsStatus } from '../../types'; +import { EdgeJob, LogsStatus } from '../../types'; import { useJobResults } from '../../queries/jobResults/useJobResults'; -import { columns } from './columns'; +import { columns, sortOptions } from './columns'; import { createStore } from './datatable-store'; const tableKey = 'edge-job-results'; @@ -22,8 +20,9 @@ export function ResultsDatatable({ jobId }: { jobId: EdgeJob['Id'] }) { const tableState = useTableState(store, tableKey); const jobResultsQuery = useJobResults(jobId, { + ...queryOptionsFromTableState({ ...tableState }, sortOptions), refetchInterval(dataset) { - const anyCollecting = dataset?.some( + const anyCollecting = dataset?.data.some( (r) => r.LogsStatus === LogsStatus.Pending ); @@ -35,40 +34,17 @@ export function ResultsDatatable({ jobId }: { jobId: EdgeJob['Id'] }) { }, }); - const environmentIds = jobResultsQuery.data?.map( - (result) => result.EndpointId - ); - - const environmentsQuery = useEnvironmentList( - { endpointIds: environmentIds }, - { enabled: !!environmentIds && !jobResultsQuery.isLoading } - ); - - const dataset = useMemo( - () => - jobResultsQuery.isLoading || environmentsQuery.isLoading - ? [] - : associateEndpointsToResults( - jobResultsQuery.data || [], - environmentsQuery.environments - ), - [ - environmentsQuery.environments, - environmentsQuery.isLoading, - jobResultsQuery.data, - jobResultsQuery.isLoading, - ] - ); + const dataset = jobResultsQuery.data?.data || []; return ( tableState.setPage(0)} + totalCount={jobResultsQuery.data?.totalCount || 0} /> ); } - -function associateEndpointsToResults( - results: Array, - environments: Array -) { - return results.map((result) => { - const environment = environments.find( - (environment) => environment.Id === result.EndpointId - ); - return { - ...result, - Endpoint: environment, - }; - }); -} diff --git a/app/react/edge/edge-jobs/ItemView/ResultsDatatable/columns.tsx b/app/react/edge/edge-jobs/ItemView/ResultsDatatable/columns.tsx index 08f554d7c..76bada2a8 100644 --- a/app/react/edge/edge-jobs/ItemView/ResultsDatatable/columns.tsx +++ b/app/react/edge/edge-jobs/ItemView/ResultsDatatable/columns.tsx @@ -1,18 +1,20 @@ import { CellContext, createColumnHelper } from '@tanstack/react-table'; +import { sortOptionsFromColumns } from '@/react/common/api/sort.types'; + import { Button } from '@@/buttons'; -import { LogsStatus } from '../../types'; +import { JobResult, LogsStatus } from '../../types'; import { useDownloadLogsMutation } from '../../queries/jobResults/useDownloadLogsMutation'; import { useClearLogsMutation } from '../../queries/jobResults/useClearLogsMutation'; import { useCollectLogsMutation } from '../../queries/jobResults/useCollectLogsMutation'; -import { DecoratedJobResult, getTableMeta } from './types'; +import { getTableMeta } from './types'; -const columnHelper = createColumnHelper(); +const columnHelper = createColumnHelper(); export const columns = [ - columnHelper.accessor('Endpoint.Name', { + columnHelper.accessor('EndpointName', { header: 'Environment', meta: { className: 'w-1/2', @@ -30,7 +32,7 @@ export const columns = [ function ActionsCell({ row: { original: item }, table, -}: CellContext) { +}: CellContext) { const tableMeta = getTableMeta(table.options.meta); const id = tableMeta.jobId; @@ -51,13 +53,13 @@ function ActionsCell({ <> @@ -68,10 +70,12 @@ function ActionsCell({ return ( ); } } + +export const sortOptions = sortOptionsFromColumns(columns); diff --git a/app/react/edge/edge-jobs/ItemView/ResultsDatatable/datatable-store.ts b/app/react/edge/edge-jobs/ItemView/ResultsDatatable/datatable-store.ts index d9ebb63a2..2db1c2185 100644 --- a/app/react/edge/edge-jobs/ItemView/ResultsDatatable/datatable-store.ts +++ b/app/react/edge/edge-jobs/ItemView/ResultsDatatable/datatable-store.ts @@ -3,12 +3,18 @@ import { createPersistedStore, BasicTableSettings, RefreshableTableSettings, + BackendPaginationTableSettings, + backendPaginationSettings, } from '@@/datatables/types'; -interface TableSettings extends BasicTableSettings, RefreshableTableSettings {} +interface TableSettings + extends BasicTableSettings, + RefreshableTableSettings, + BackendPaginationTableSettings {} export function createStore(storageKey: string) { return createPersistedStore(storageKey, undefined, (set) => ({ ...refreshableSettings(set), + ...backendPaginationSettings(set), })); } diff --git a/app/react/edge/edge-jobs/ItemView/ResultsDatatable/types.ts b/app/react/edge/edge-jobs/ItemView/ResultsDatatable/types.ts index 890dec4c2..841cf16f5 100644 --- a/app/react/edge/edge-jobs/ItemView/ResultsDatatable/types.ts +++ b/app/react/edge/edge-jobs/ItemView/ResultsDatatable/types.ts @@ -1,10 +1,4 @@ -import { Environment } from '@/react/portainer/environments/types'; - -import { EdgeJob, JobResult } from '../../types'; - -export interface DecoratedJobResult extends JobResult { - Endpoint?: Environment; -} +import { EdgeJob } from '../../types'; interface TableMeta { table: 'edge-job-results'; diff --git a/app/react/edge/edge-jobs/queries/jobResults/useJobResults.ts b/app/react/edge/edge-jobs/queries/jobResults/useJobResults.ts index dcbe12129..9f9ecc5f9 100644 --- a/app/react/edge/edge-jobs/queries/jobResults/useJobResults.ts +++ b/app/react/edge/edge-jobs/queries/jobResults/useJobResults.ts @@ -1,35 +1,54 @@ import { useQuery } from '@tanstack/react-query'; import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { + PaginatedResults, + withPaginationHeaders, +} from '@/react/common/api/pagination.types'; +import { + BaseQueryOptions, + BaseQueryParams, + queryParamsFromQueryOptions, +} from '@/react/common/api/listQueryParams'; import { EdgeJob, JobResult } from '../../types'; +import { sortOptions } from '../../ItemView/ResultsDatatable/columns'; import { queryKeys } from './query-keys'; -import { buildUrl } from './build-url'; + +type QueryOptions = BaseQueryOptions; + +type RefetchInterval = + | number + | false + | ((data: PaginatedResults> | undefined) => number | false); export function useJobResults( id: EdgeJob['Id'], { refetchInterval, + ...query }: { - refetchInterval?: - | number - | false - | ((data: Array | undefined) => number | false); - } = {} + refetchInterval?: RefetchInterval; + } & QueryOptions = {} ) { return useQuery({ - queryKey: queryKeys.base(id), - queryFn: () => getJobResults(id), + queryKey: [...queryKeys.base(id), query], + queryFn: () => getJobResults(id, queryParamsFromQueryOptions(query)), refetchInterval, }); } -async function getJobResults(id: EdgeJob['Id']) { - try { - const { data } = await axios.get>(buildUrl({ id })); +type QueryParams = BaseQueryParams; - return data; +async function getJobResults(id: EdgeJob['Id'], params?: QueryParams) { + try { + const response = await axios.get>( + `edge_jobs/${id}/tasks`, + { params } + ); + + return withPaginationHeaders(response); } catch (err) { throw parseAxiosError(err, 'Failed fetching edge job results'); } diff --git a/app/react/edge/edge-jobs/types.ts b/app/react/edge/edge-jobs/types.ts index e98333ddf..2278204b7 100644 --- a/app/react/edge/edge-jobs/types.ts +++ b/app/react/edge/edge-jobs/types.ts @@ -1,4 +1,7 @@ -import { EnvironmentId } from '@/react/portainer/environments/types'; +import { + Environment, + EnvironmentId, +} from '@/react/portainer/environments/types'; export interface EdgeJob { Id: number; @@ -28,5 +31,6 @@ interface EndpointMeta { export interface JobResult { Id: string; EndpointId: EnvironmentId; + EndpointName: Environment['Name']; LogsStatus: LogsStatus; }