diff --git a/api/api.yaml b/api/api.yaml index 96284c0d3..0080afecc 100644 --- a/api/api.yaml +++ b/api/api.yaml @@ -1107,15 +1107,41 @@ paths: * `active` - Only active links. (Not expired, no issuance exceeded and not deactivated * `inactive` - Only deactivated links * `exceeded` - Expired or maximum issuance exceeded + - in: query + name: page + schema: + type: integer + format: uint + minimum: 1 + example: 1 + description: Page to fetch. First is one. If omitted, all results will be returned. + - in: query + name: max_results + schema: + type: integer + format: uint + example: 50 + default: 50 + description: Number of items to fetch on each page. Minimum is 10. Default is 50. No maximum by the moment. + - in: query + name: sort + style: form + explode: false + schema: + type: array + items: + type: string + enum: [ "createdAt", "-createdAt", "schemaType", "-schemaType", "accessibleUntil", "-accessibleUntil", "credentialIssued", "-credentialIssued", "active", "-active", "maximumIssuance", "-maximumIssuance", "status", "-status" ] + default: "-createdAt" + description: > + The minus sign (-) before createdAt means descending order. responses: '200': description: Link collection content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/Link' + $ref: '#/components/schemas/LinksPaginated' '400': $ref: '#/components/responses/400' '404': @@ -2067,6 +2093,17 @@ components: x-omitempty: false example: https://wallet.privado.id#request_uri=url + LinksPaginated: + type: object + required: [ items, meta ] + properties: + items: + type: array + items: + $ref: '#/components/schemas/Link' + meta: + $ref: '#/components/schemas/PaginatedMetadata' + CredentialSubject: type: object x-omitempty: false diff --git a/internal/api/api.gen.go b/internal/api/api.gen.go index 4e6646346..d8703bc28 100644 --- a/internal/api/api.gen.go +++ b/internal/api/api.gen.go @@ -131,6 +131,24 @@ const ( GetLinksParamsStatusInactive GetLinksParamsStatus = "inactive" ) +// Defines values for GetLinksParamsSort. +const ( + GetLinksParamsSortAccessibleUntil GetLinksParamsSort = "accessibleUntil" + GetLinksParamsSortActive GetLinksParamsSort = "active" + GetLinksParamsSortCreatedAt GetLinksParamsSort = "createdAt" + GetLinksParamsSortCredentialIssued GetLinksParamsSort = "credentialIssued" + GetLinksParamsSortMaximumIssuance GetLinksParamsSort = "maximumIssuance" + GetLinksParamsSortMinusAccessibleUntil GetLinksParamsSort = "-accessibleUntil" + GetLinksParamsSortMinusActive GetLinksParamsSort = "-active" + GetLinksParamsSortMinusCreatedAt GetLinksParamsSort = "-createdAt" + GetLinksParamsSortMinusCredentialIssued GetLinksParamsSort = "-credentialIssued" + GetLinksParamsSortMinusMaximumIssuance GetLinksParamsSort = "-maximumIssuance" + GetLinksParamsSortMinusSchemaType GetLinksParamsSort = "-schemaType" + GetLinksParamsSortMinusStatus GetLinksParamsSort = "-status" + GetLinksParamsSortSchemaType GetLinksParamsSort = "schemaType" + GetLinksParamsSortStatus GetLinksParamsSort = "status" +) + // Defines values for GetCredentialOfferParamsType. const ( GetCredentialOfferParamsTypeDeepLink GetCredentialOfferParamsType = "deepLink" @@ -146,10 +164,10 @@ const ( // Defines values for GetStateTransactionsParamsSort. const ( - MinusPublishDate GetStateTransactionsParamsSort = "-publishDate" - MinusStatus GetStateTransactionsParamsSort = "-status" - PublishDate GetStateTransactionsParamsSort = "publishDate" - Status GetStateTransactionsParamsSort = "status" + GetStateTransactionsParamsSortMinusPublishDate GetStateTransactionsParamsSort = "-publishDate" + GetStateTransactionsParamsSortMinusStatus GetStateTransactionsParamsSort = "-status" + GetStateTransactionsParamsSortPublishDate GetStateTransactionsParamsSort = "publishDate" + GetStateTransactionsParamsSortStatus GetStateTransactionsParamsSort = "status" ) // Defines values for AuthenticationParamsType. @@ -434,6 +452,12 @@ type LinkSimple struct { SchemaUrl string `json:"schemaUrl"` } +// LinksPaginated defines model for LinksPaginated. +type LinksPaginated struct { + Items []Link `json:"items"` + Meta PaginatedMetadata `json:"meta"` +} + // NetworkData defines model for NetworkData. type NetworkData struct { Name string `json:"name"` @@ -681,11 +705,21 @@ type GetLinksParams struct { // * `inactive` - Only deactivated links // * `exceeded` - Expired or maximum issuance exceeded Status *GetLinksParamsStatus `form:"status,omitempty" json:"status,omitempty"` + + // Page Page to fetch. First is one. If omitted, all results will be returned. + Page *uint `form:"page,omitempty" json:"page,omitempty"` + + // MaxResults Number of items to fetch on each page. Minimum is 10. Default is 50. No maximum by the moment. + MaxResults *uint `form:"max_results,omitempty" json:"max_results,omitempty"` + Sort *[]GetLinksParamsSort `form:"sort,omitempty" json:"sort,omitempty"` } // GetLinksParamsStatus defines parameters for GetLinks. type GetLinksParamsStatus string +// GetLinksParamsSort defines parameters for GetLinks. +type GetLinksParamsSort string + // CreateLinkQrCodeCallbackTextBody defines parameters for CreateLinkQrCodeCallback. type CreateLinkQrCodeCallbackTextBody = string @@ -1842,6 +1876,30 @@ func (siw *ServerInterfaceWrapper) GetLinks(w http.ResponseWriter, r *http.Reque return } + // ------------- Optional query parameter "page" ------------- + + err = runtime.BindQueryParameter("form", true, false, "page", r.URL.Query(), ¶ms.Page) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "page", Err: err}) + return + } + + // ------------- Optional query parameter "max_results" ------------- + + err = runtime.BindQueryParameter("form", true, false, "max_results", r.URL.Query(), ¶ms.MaxResults) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "max_results", Err: err}) + return + } + + // ------------- Optional query parameter "sort" ------------- + + err = runtime.BindQueryParameter("form", false, false, "sort", r.URL.Query(), ¶ms.Sort) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "sort", Err: err}) + return + } + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { siw.Handler.GetLinks(w, r, identifier, params) })) @@ -3646,7 +3704,7 @@ type GetLinksResponseObject interface { VisitGetLinksResponse(w http.ResponseWriter) error } -type GetLinks200JSONResponse []Link +type GetLinks200JSONResponse LinksPaginated func (response GetLinks200JSONResponse) VisitGetLinksResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") diff --git a/internal/api/links.go b/internal/api/links.go index 74d5b2ea2..af034091c 100644 --- a/internal/api/links.go +++ b/internal/api/links.go @@ -3,6 +3,7 @@ package api import ( "context" "errors" + "strings" "time" "github.com/iden3/go-iden3-core/v2/w3c" @@ -13,6 +14,7 @@ import ( "github.com/polygonid/sh-id-platform/internal/core/services" "github.com/polygonid/sh-id-platform/internal/log" "github.com/polygonid/sh-id-platform/internal/repositories" + "github.com/polygonid/sh-id-platform/internal/sqltools" ) // GetLinks - Returns a list of links based on a search criteria. @@ -23,6 +25,7 @@ func (s *Server) GetLinks(ctx context.Context, request GetLinksRequestObject) (G log.Error(ctx, "parsing issuer did", "err", err, "did", request.Identifier) return GetLinks400JSONResponse{N400JSONResponse{Message: "invalid issuer did"}}, nil } + status := ports.LinkAll if request.Params.Status != nil { if status, err = ports.LinkTypeReqFromString(string(*request.Params.Status)); err != nil { @@ -30,12 +33,79 @@ func (s *Server) GetLinks(ctx context.Context, request GetLinksRequestObject) (G return GetLinks400JSONResponse{N400JSONResponse{Message: "unknown request type. Allowed: all|active|inactive|exceed"}}, nil } } - links, err := s.linkService.GetAll(ctx, *issuerDID, status, request.Params.Query, s.cfg.ServerUrl) + + filter, err := getLinksFilter(request) if err != nil { log.Error(ctx, "getting links", "err", err, "req", request) + return GetLinks400JSONResponse{N400JSONResponse{Message: err.Error()}}, nil } + filter.Status = status - return GetLinks200JSONResponse(getLinkResponses(links)), err + links, total, err := s.linkService.GetAll(ctx, *issuerDID, *filter, s.cfg.ServerUrl) + if err != nil { + log.Error(ctx, "getting links", "err", err, "req", request) + } + + resp := GetLinks200JSONResponse{ + Items: getLinkResponses(links), + Meta: PaginatedMetadata{ + MaxResults: filter.MaxResults, + Page: filter.Page, + Total: total, + }, + } + return resp, err +} + +func getLinksFilter(req GetLinksRequestObject) (*ports.LinksFilter, error) { + const defaultMaxResults = 50 + status := ports.LinkAll + filter := &ports.LinksFilter{ + Status: status, + Query: req.Params.Query, + Page: 1, // default page + MaxResults: defaultMaxResults, // default max results + } + if req.Params.Page != nil { + filter.Page = *req.Params.Page + } + if req.Params.MaxResults != nil { + if *req.Params.MaxResults <= 0 { + filter.MaxResults = 10 + } else { + filter.MaxResults = *req.Params.MaxResults + } + } + orderBy := sqltools.OrderByFilters{} + if req.Params.Sort != nil { + for _, sortBy := range *req.Params.Sort { + var err error + field, desc := strings.CutPrefix(strings.TrimSpace(string(sortBy)), "-") + switch GetLinksParamsSort(field) { + case GetLinksParamsSortAccessibleUntil: + err = orderBy.Add(ports.LinksAccessibleUntil, desc) + case GetLinksParamsSortActive: + err = orderBy.Add(ports.LinksParamsSortLinksActive, desc) + case GetLinksParamsSortCreatedAt: + err = orderBy.Add(ports.LinksParamsSortLinksCreatedAt, desc) + case GetLinksParamsSortCredentialIssued: + err = orderBy.Add(ports.LinksParamsSortLinksCreatedAt, desc) + case GetLinksParamsSortMaximumIssuance: + err = orderBy.Add(ports.LinksParamsSortLinksMaximumIssuance, desc) + case GetLinksParamsSortSchemaType: + err = orderBy.Add(ports.LinksParamsSortLinksCreatedAt, desc) + case GetLinksParamsSortStatus: + err = orderBy.Add(ports.LinksParamsSortLinksCreatedAt, desc) + default: + return nil, errors.New("wrong sort field") + } + if err != nil { + return nil, errors.New("repeated sort by value field") + } + } + } + filter.OrderBy = orderBy + return filter, nil } // CreateLink - creates a link for issuing a credential diff --git a/internal/api/links_test.go b/internal/api/links_test.go index 415b6e9d2..180376e0b 100644 --- a/internal/api/links_test.go +++ b/internal/api/links_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strconv" "strings" "testing" "time" @@ -574,15 +575,23 @@ func TestServer_GetAllLinks(t *testing.T) { handler := getHandler(ctx, server) type expected struct { - response []Link + response GetLinksResponseObject httpCode int } + + type pagination struct { + page *int + maxResults *int + } + type testConfig struct { - name string - query *string - status *GetLinksParamsStatus - auth func() (string, string) - expected expected + name string + query *string + status *GetLinksParamsStatus + pagination *pagination + sort *string + auth func() (string, string) + expected expected } for _, tc := range []testConfig{ { @@ -600,13 +609,93 @@ func TestServer_GetAllLinks(t *testing.T) { httpCode: http.StatusBadRequest, }, }, - { name: "Happy path. All schemas", auth: authOk, expected: expected{ httpCode: http.StatusOK, - response: GetLinks200JSONResponse{linkInactive, linkExpired, linkActive}, + response: GetLinks200JSONResponse{ + Items: []Link{linkInactive, linkExpired, linkActive}, + Meta: PaginatedMetadata{ + Total: 3, + MaxResults: 50, + Page: 1, + }, + }, + }, + }, + { + name: "Happy path. All schemas with pagination - page 1", + auth: authOk, + pagination: &pagination{ + page: common.ToPointer(1), + maxResults: common.ToPointer(2), + }, + expected: expected{ + httpCode: http.StatusOK, + response: GetLinks200JSONResponse{ + Items: []Link{linkInactive, linkExpired}, + Meta: PaginatedMetadata{ + Total: 3, + MaxResults: 2, + Page: 1, + }, + }, + }, + }, + { + name: "Happy path. All schemas with pagination - page 2", + auth: authOk, + pagination: &pagination{ + page: common.ToPointer(2), + maxResults: common.ToPointer(2), + }, + expected: expected{ + httpCode: http.StatusOK, + response: GetLinks200JSONResponse{ + Items: []Link{linkActive}, + Meta: PaginatedMetadata{ + Total: 3, + MaxResults: 2, + Page: 2, + }, + }, + }, + }, + { + name: "Happy path. All schemas with pagination - page 1 - default max results", + auth: authOk, + pagination: &pagination{ + page: common.ToPointer(1), + }, + expected: expected{ + httpCode: http.StatusOK, + response: GetLinks200JSONResponse{ + Items: []Link{linkInactive, linkExpired, linkActive}, + Meta: PaginatedMetadata{ + Total: 3, + MaxResults: 50, + Page: 1, + }, + }, + }, + }, + { + name: "Happy path. All schemas with pagination - page 1 - default page", + auth: authOk, + pagination: &pagination{ + maxResults: common.ToPointer(100), + }, + expected: expected{ + httpCode: http.StatusOK, + response: GetLinks200JSONResponse{ + Items: []Link{linkInactive, linkExpired, linkActive}, + Meta: PaginatedMetadata{ + Total: 3, + MaxResults: 100, + Page: 1, + }, + }, }, }, { @@ -615,7 +704,14 @@ func TestServer_GetAllLinks(t *testing.T) { status: common.ToPointer(GetLinksParamsStatus("all")), expected: expected{ httpCode: http.StatusOK, - response: GetLinks200JSONResponse{linkInactive, linkExpired, linkActive}, + response: GetLinks200JSONResponse{ + Items: []Link{linkInactive, linkExpired, linkActive}, + Meta: PaginatedMetadata{ + Total: 3, + MaxResults: 50, + Page: 1, + }, + }, }, }, { @@ -624,7 +720,14 @@ func TestServer_GetAllLinks(t *testing.T) { status: common.ToPointer(GetLinksParamsStatus("active")), expected: expected{ httpCode: http.StatusOK, - response: GetLinks200JSONResponse{linkActive}, + response: GetLinks200JSONResponse{ + Items: []Link{linkActive}, + Meta: PaginatedMetadata{ + Total: 1, + MaxResults: 50, + Page: 1, + }, + }, }, }, { @@ -633,7 +736,14 @@ func TestServer_GetAllLinks(t *testing.T) { status: common.ToPointer(GetLinksParamsStatus("exceeded")), expected: expected{ httpCode: http.StatusOK, - response: GetLinks200JSONResponse{linkInactive, linkExpired}, + response: GetLinks200JSONResponse{ + Items: []Link{linkInactive, linkExpired}, + Meta: PaginatedMetadata{ + Total: 2, + MaxResults: 50, + Page: 1, + }, + }, }, }, { @@ -642,7 +752,14 @@ func TestServer_GetAllLinks(t *testing.T) { status: common.ToPointer(GetLinksParamsStatus("inactive")), expected: expected{ httpCode: http.StatusOK, - response: GetLinks200JSONResponse{linkInactive}, + response: GetLinks200JSONResponse{ + Items: []Link{linkInactive}, + Meta: PaginatedMetadata{ + Total: 1, + MaxResults: 50, + Page: 1, + }, + }, }, }, { @@ -652,7 +769,14 @@ func TestServer_GetAllLinks(t *testing.T) { status: common.ToPointer(GetLinksParamsStatus("exceeded")), expected: expected{ httpCode: http.StatusOK, - response: GetLinks200JSONResponse{linkInactive, linkExpired}, + response: GetLinks200JSONResponse{ + Items: []Link{linkInactive, linkExpired}, + Meta: PaginatedMetadata{ + Total: 2, + MaxResults: 50, + Page: 1, + }, + }, }, }, { @@ -662,7 +786,32 @@ func TestServer_GetAllLinks(t *testing.T) { status: common.ToPointer(GetLinksParamsStatus("exceeded")), expected: expected{ httpCode: http.StatusOK, - response: GetLinks200JSONResponse{linkInactive, linkExpired}, + response: GetLinks200JSONResponse{ + Items: []Link{linkInactive, linkExpired}, + Meta: PaginatedMetadata{ + Total: 2, + MaxResults: 50, + Page: 1, + }, + }, + }, + }, + { + name: "Exceeded with filter found, partial match and sort by createdAt desc", + auth: authOk, + query: common.ToPointer("docum"), + status: common.ToPointer(GetLinksParamsStatus("exceeded")), + sort: common.ToPointer("-createdAt"), + expected: expected{ + httpCode: http.StatusOK, + response: GetLinks200JSONResponse{ + Items: []Link{linkInactive, linkExpired}, + Meta: PaginatedMetadata{ + Total: 2, + MaxResults: 50, + Page: 1, + }, + }, }, }, { @@ -672,7 +821,14 @@ func TestServer_GetAllLinks(t *testing.T) { status: common.ToPointer(GetLinksParamsStatus("exceeded")), expected: expected{ httpCode: http.StatusOK, - response: GetLinks200JSONResponse{linkInactive, linkExpired}, + response: GetLinks200JSONResponse{ + Items: []Link{linkInactive, linkExpired}, + Meta: PaginatedMetadata{ + Total: 2, + MaxResults: 50, + Page: 1, + }, + }, }, }, } { @@ -686,6 +842,18 @@ func TestServer_GetAllLinks(t *testing.T) { endpoint.RawQuery = endpoint.RawQuery + "&query=" + *tc.query } + if tc.pagination != nil { + if tc.pagination.page != nil { + endpoint.RawQuery = endpoint.RawQuery + "&page=" + strconv.Itoa(*tc.pagination.page) + } + if tc.pagination.maxResults != nil { + endpoint.RawQuery = endpoint.RawQuery + "&max_results=" + strconv.Itoa(*tc.pagination.maxResults) + } + } + if tc.sort != nil { + endpoint.RawQuery = endpoint.RawQuery + "&sort=" + *tc.sort + } + req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil) req.SetBasicAuth(tc.auth()) require.NoError(t, err) @@ -697,25 +865,29 @@ func TestServer_GetAllLinks(t *testing.T) { case http.StatusOK: var response GetLinks200JSONResponse assert.NoError(t, json.Unmarshal(rr.Body.Bytes(), &response)) - if assert.Equal(t, len(tc.expected.response), len(response)) { - for i, resp := range response { - assert.Equal(t, tc.expected.response[i].Id, resp.Id) - assert.Equal(t, tc.expected.response[i].Status, resp.Status) - assert.Equal(t, tc.expected.response[i].IssuedClaims, resp.IssuedClaims) - assert.Equal(t, tc.expected.response[i].Active, resp.Active) - assert.Equal(t, tc.expected.response[i].MaxIssuance, resp.MaxIssuance) - assert.Equal(t, tc.expected.response[i].SchemaUrl, resp.SchemaUrl) - assert.Equal(t, tc.expected.response[i].SchemaType, resp.SchemaType) - assert.Equal(t, tc.expected.response[i].RefreshService, resp.RefreshService) - tcCred, err := json.Marshal(tc.expected.response[i].CredentialSubject) + + expectedResponse, ok := tc.expected.response.(GetLinks200JSONResponse) + require.True(t, ok) + if assert.Equal(t, len(expectedResponse.Items), len(response.Items)) { + for i, resp := range response.Items { + assert.Equal(t, expectedResponse.Items[i].Id, resp.Id) + assert.Equal(t, expectedResponse.Items[i].Status, resp.Status) + assert.Equal(t, expectedResponse.Items[i].IssuedClaims, resp.IssuedClaims) + assert.Equal(t, expectedResponse.Items[i].Active, resp.Active) + assert.Equal(t, expectedResponse.Items[i].MaxIssuance, resp.MaxIssuance) + assert.Equal(t, expectedResponse.Items[i].SchemaUrl, resp.SchemaUrl) + assert.Equal(t, expectedResponse.Items[i].SchemaType, resp.SchemaType) + assert.Equal(t, expectedResponse.Items[i].RefreshService, resp.RefreshService) + tcCred, err := json.Marshal(expectedResponse.Items[i].CredentialSubject) require.NoError(t, err) - respCred, err := json.Marshal(tc.expected.response[i].CredentialSubject) + respCred, err := json.Marshal(expectedResponse.Items[i].CredentialSubject) require.NoError(t, err) assert.Equal(t, tcCred, respCred) - assert.InDelta(t, time.Time(*tc.expected.response[i].Expiration).UnixMilli(), time.Time(*resp.Expiration).UnixMilli(), 1000) + assert.InDelta(t, time.Time(*expectedResponse.Items[i].Expiration).UnixMilli(), time.Time(*resp.Expiration).UnixMilli(), 1000) expectCredExpiration := common.ToPointer(TimeUTC(time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 0, 0, 0, 0, time.UTC))) assert.Equal(t, expectCredExpiration.String(), resp.CredentialExpiration.String()) } + assert.Equal(t, expectedResponse.Meta, response.Meta) } case http.StatusBadRequest: var response GetLinks400JSONResponse diff --git a/internal/api/state.go b/internal/api/state.go index 11e0662f0..9b08437b2 100644 --- a/internal/api/state.go +++ b/internal/api/state.go @@ -119,9 +119,9 @@ func getStateTransitionsFilter(req GetStateTransactionsRequestObject) (request * var err error field, desc := strings.CutPrefix(strings.TrimSpace(string(sortBy)), "-") switch GetStateTransactionsParamsSort(field) { - case PublishDate: + case GetStateTransactionsParamsSortPublishDate: err = orderBy.Add(ports.StateTransitionsPublishDate, desc) - case Status: + case GetStateTransactionsParamsSortStatus: err = orderBy.Add(ports.StateTransitionsStatus, desc) default: return nil, errors.New("wrong sort field") diff --git a/internal/core/ports/link_repository.go b/internal/core/ports/link_repository.go index cb0ab9cfc..30fac96a8 100644 --- a/internal/core/ports/link_repository.go +++ b/internal/core/ports/link_repository.go @@ -15,7 +15,7 @@ import ( type LinkRepository interface { Save(ctx context.Context, conn db.Querier, link *domain.Link) (*uuid.UUID, error) GetByID(ctx context.Context, issuerID w3c.DID, id uuid.UUID) (*domain.Link, error) - GetAll(ctx context.Context, issuerDID w3c.DID, status LinkStatus, query *string) ([]*domain.Link, error) + GetAll(ctx context.Context, issuerDID w3c.DID, filter LinksFilter) ([]*domain.Link, uint, error) Delete(ctx context.Context, id uuid.UUID, issuerDID w3c.DID) error AddAuthorizationRequest(ctx context.Context, linkID uuid.UUID, issuerDID w3c.DID, authorizationRequest *protocol.AuthorizationRequestMessage) error } diff --git a/internal/core/ports/link_service.go b/internal/core/ports/link_service.go index 332668a30..435415f34 100644 --- a/internal/core/ports/link_service.go +++ b/internal/core/ports/link_service.go @@ -13,6 +13,7 @@ import ( "github.com/iden3/iden3comm/v2/protocol" "github.com/polygonid/sh-id-platform/internal/core/domain" + "github.com/polygonid/sh-id-platform/internal/sqltools" ) // CreateQRCodeResponse - is the result of creating a link QRcode. @@ -27,13 +28,30 @@ type CreateQRCodeResponse struct { // LinkStatus is a Link type request. All|Active|Inactive|Exceeded type LinkStatus string +// LinksFilter - filter for links +type LinksFilter struct { + Status LinkStatus + Query *string + MaxResults uint // Max number of results to return on each call. + Page uint + OrderBy sqltools.OrderByFilters +} + const ( - LinkAll LinkStatus = "all" // LinkAll : All links - LinkActive LinkStatus = "active" // LinkActive : Active links - LinkInactive LinkStatus = "inactive" // LinkInactive : Inactive links - LinkExceeded LinkStatus = "exceeded" // LinkExceeded : Expired links or with more credentials issued than expected - AgentUrl = "%s/v2/agent" // AgentUrl : Agent URL - LinksCallbackURL = "%s/v2/identities/%s/credentials/links/callback?linkID=%s" // LinksCallbackURL : Links callback URL + LinkAll LinkStatus = "all" // LinkAll : All links + LinkActive LinkStatus = "active" // LinkActive : Active links + LinkInactive LinkStatus = "inactive" // LinkInactive : Inactive links + LinkExceeded LinkStatus = "exceeded" // LinkExceeded : Expired links or with more credentials issued than expected + AgentUrl = "%s/v2/agent" // AgentUrl : Agent URL + LinksCallbackURL = "%s/v2/identities/%s/credentials/links/callback?linkID=%s" // LinksCallbackURL : Links callback URL + LinksCreatedAt sqltools.SQLFieldName = "created_at" // LinksCreatedAt : Created at field + LinksAccessibleUntil sqltools.SQLFieldName = "links.valid_until" // LinksAccessibleUntil : Accessible until field + LinksParamsSortLinksActive sqltools.SQLFieldName = "active" // LinksParamsSortLinksActive : Links active field + LinksParamsSortLinksCreatedAt sqltools.SQLFieldName = "links.created_at" // LinksParamsSortLinksCreatedAt : Links created at field + LinksParamsSortLinksCredentialIssued sqltools.SQLFieldName = "issued_claims" // LinksParamsSortLinksCredentialIssued : Links credential issued field + LinksParamsSortLinksMaximumIssuance sqltools.SQLFieldName = "links.max_issuance" // LinksParamsSortLinksMaximumIssuance : Links maximum issuance field + LinksParamsSortLinksSchemaType sqltools.SQLFieldName = "schemas.type" // LinksParamsSortLinksSchemaType : Links schema type field + LinksParamsSortLinksStatus sqltools.SQLFieldName = "links.active" // LinksParamsSortLinksStatus : Links status field ) // LinkTypeReqFromString constructs a LinkStatus from a string @@ -64,7 +82,7 @@ type LinkService interface { Activate(ctx context.Context, issuerID w3c.DID, linkID uuid.UUID, active bool) error Delete(ctx context.Context, id uuid.UUID, did w3c.DID) error GetByID(ctx context.Context, issuerID w3c.DID, id uuid.UUID, serverURL string) (*domain.Link, error) - GetAll(ctx context.Context, issuerDID w3c.DID, status LinkStatus, query *string, serverURL string) ([]*domain.Link, error) + GetAll(ctx context.Context, issuerDID w3c.DID, filter LinksFilter, serverURL string) ([]*domain.Link, uint, error) CreateQRCode(ctx context.Context, issuerDID w3c.DID, linkID uuid.UUID, serverURL string) (*CreateQRCodeResponse, error) IssueOrFetchClaim(ctx context.Context, issuerDID w3c.DID, userDID w3c.DID, linkID uuid.UUID, hostURL string) (*protocol.CredentialsOfferMessage, error) ProcessCallBack(ctx context.Context, issuerDID w3c.DID, message string, linkID uuid.UUID, hostURL string) (*protocol.CredentialsOfferMessage, error) diff --git a/internal/core/services/link.go b/internal/core/services/link.go index 00d129e16..66fe55439 100644 --- a/internal/core/services/link.go +++ b/internal/core/services/link.go @@ -181,17 +181,17 @@ func (ls *Link) GetByID(ctx context.Context, issuerDID w3c.DID, id uuid.UUID, se return link, nil } -// GetAll returns all links from issueDID of type lType filtered by query string -func (ls *Link) GetAll(ctx context.Context, issuerDID w3c.DID, status ports.LinkStatus, query *string, serverURL string) ([]*domain.Link, error) { - links, err := ls.linkRepository.GetAll(ctx, issuerDID, status, query) +// GetAll - returns all links from issueDID of type lType filtered by query string +func (ls *Link) GetAll(ctx context.Context, issuerDID w3c.DID, filter ports.LinksFilter, serverURL string) ([]*domain.Link, uint, error) { + links, total, err := ls.linkRepository.GetAll(ctx, issuerDID, filter) if err != nil { - return links, err + return links, 0, err } for _, link := range links { ls.addLinksToLink(link, serverURL, issuerDID) } - return links, nil + return links, total, nil } func (ls *Link) addLinksToLink(link *domain.Link, serverURL string, issuerDID w3c.DID) { diff --git a/internal/repositories/link.go b/internal/repositories/link.go index 4c71fe93c..a2bfb3a77 100644 --- a/internal/repositories/link.go +++ b/internal/repositories/link.go @@ -137,39 +137,44 @@ GROUP BY links.id, schemas.id return &link, err } -func (l link) GetAll(ctx context.Context, issuerDID w3c.DID, status ports.LinkStatus, query *string) ([]*domain.Link, error) { +func (l link) GetAll(ctx context.Context, issuerDID w3c.DID, filter ports.LinksFilter) ([]*domain.Link, uint, error) { + fields := []string{ + "links.id", + "links.issuer_id", + "links.created_at", + "links.max_issuance", + "links.valid_until", + "links.schema_id", + "links.credential_expiration", + "links.credential_signature_proof", + "links.credential_mtp_proof", + "links.credential_attributes", + "links.active", + "links.refresh_service", + "links.display_method", + "links.authorization_request_message", + "count(claims.id) as issued_claims", + "schemas.id as schema_id", + "schemas.issuer_id as schema_issuer_id", + "schemas.url", + "schemas.type", + "schemas.hash", + "schemas.words", + "schemas.created_at", + } + sql := ` -SELECT links.id, - links.issuer_id, - links.created_at, - links.max_issuance, - links.valid_until, - links.schema_id, - links.credential_expiration, - links.credential_signature_proof, - links.credential_mtp_proof, - links.credential_attributes, - links.active, - links.refresh_service, - links.display_method, - links.authorization_request_message, - count(claims.id) as issued_claims, - schemas.id as schema_id, - schemas.issuer_id as schema_issuer_id, - schemas.url, - schemas.type, - schemas.hash, - schemas.words, - schemas.created_at -FROM links -LEFT JOIN schemas ON schemas.id = links.schema_id -LEFT JOIN claims ON claims.link_id = links.id AND claims.identifier = links.issuer_id -WHERE links.issuer_id = $1 -` + SELECT ##QUERYFIELDS## + FROM links + LEFT JOIN schemas ON schemas.id = links.schema_id + LEFT JOIN claims ON claims.link_id = links.id AND claims.identifier = links.issuer_id + WHERE links.issuer_id = $1 + ` + sqlArgs := make([]interface{}, 0) sqlArgs = append(sqlArgs, issuerDID.String(), time.Now()) - switch status { + switch filter.Status { case ports.LinkActive: sql += " AND links.active AND coalesce(links.valid_until > $2, true) AND coalesce(links.max_issuance>(SELECT count(claims.id) FROM claims where claims.link_id = links.id), true)" case ports.LinkInactive: @@ -180,21 +185,40 @@ WHERE links.issuer_id = $1 "OR " + "(links.max_issuance IS NOT NULL AND links.max_issuance <= (SELECT count(claims.id) FROM claims where claims.link_id = links.id))" } - if query != nil && *query != "" { - terms := tokenizeQuery(*query) + if filter.Query != nil && *filter.Query != "" { + terms := tokenizeQuery(*filter.Query) sql += " AND (" + buildPartialQueryLikes("schemas.words", "OR", 1+len(sqlArgs), len(terms)) + ")" for _, term := range terms { sqlArgs = append(sqlArgs, term) } } + // Dummy condition to include time in the query although not always used sql += " AND (true OR $1::text IS NULL OR $2::text IS NULl)" sql += " GROUP BY links.id, schemas.id" - sql += " ORDER BY links.created_at DESC" + if len(filter.OrderBy) > 0 { + sql += " ORDER BY " + filter.OrderBy.String() + } else { + sql += " ORDER BY links.created_at DESC" // default order + } + + countInnerQuery := strings.Replace(sql, "##QUERYFIELDS##", "links.id", 1) + countQuery := `SELECT count(*) FROM (` + countInnerQuery + `) as count` + + var count uint + if err := l.conn.Pgx.QueryRow(ctx, countQuery, sqlArgs...).Scan(&count); err != nil { + if strings.Contains(err.Error(), "no rows in result set") { + return nil, 0, nil + } + return nil, 0, err + } - rows, err := l.conn.Pgx.Query(ctx, sql, sqlArgs...) + sql += fmt.Sprintf(" OFFSET %d LIMIT %d;", (filter.Page-1)*filter.MaxResults, filter.MaxResults) + + query := strings.Replace(sql, "##QUERYFIELDS##", strings.Join(fields, ","), 1) + rows, err := l.conn.Pgx.Query(ctx, query, sqlArgs...) if err != nil { - return nil, err + return nil, 0, err } defer rows.Close() @@ -227,22 +251,22 @@ WHERE links.issuer_id = $1 &schema.Words, &schema.CreatedAt, ); err != nil { - return nil, err + return nil, 0, err } if err := credentialAttributes.AssignTo(&link.CredentialSubject); err != nil { - return nil, fmt.Errorf("parsing credential attributes: %w", err) + return nil, 0, fmt.Errorf("parsing credential attributes: %w", err) } link.Schema, err = toSchemaDomain(&schema) if err != nil { - return nil, fmt.Errorf("parsing link schema: %w", err) + return nil, 0, fmt.Errorf("parsing link schema: %w", err) } links = append(links, link) } - return links, nil + return links, count, nil } func (l link) Delete(ctx context.Context, id uuid.UUID, issuerDID w3c.DID) error { diff --git a/internal/repositories/link_test.go b/internal/repositories/link_test.go index 020bf7a94..ba2347722 100644 --- a/internal/repositories/link_test.go +++ b/internal/repositories/link_test.go @@ -15,6 +15,7 @@ import ( "github.com/polygonid/sh-id-platform/internal/common" "github.com/polygonid/sh-id-platform/internal/core/domain" "github.com/polygonid/sh-id-platform/internal/core/ports" + "github.com/polygonid/sh-id-platform/internal/sqltools" ) func TestSaveLink(t *testing.T) { @@ -230,105 +231,227 @@ func TestGetAll(t *testing.T) { } type expected struct { count int + total int active *string } type testConfig struct { name string - filter ports.LinkStatus query *string + filter ports.LinksFilter expected expected } for _, tc := range []testConfig{ { - name: "all", - filter: ports.LinkAll, - query: nil, + name: "all", + filter: ports.LinksFilter{ + Status: ports.LinkAll, + Query: nil, + MaxResults: 50, + Page: uint(1), + }, expected: expected{ count: 50, + total: 50, + }, + }, + { + name: "all first page max 10", + filter: ports.LinksFilter{ + Status: ports.LinkAll, + Query: nil, + MaxResults: 10, + Page: uint(1), + }, + expected: expected{ + count: 10, + total: 50, + }, + }, + { + name: "all last page max 10", + filter: ports.LinksFilter{ + Status: ports.LinkAll, + Query: nil, + MaxResults: 10, + Page: uint(5), + }, + expected: expected{ + count: 10, + total: 50, + }, + }, + { + name: "all last + 1 page max 10", + filter: ports.LinksFilter{ + Status: ports.LinkAll, + Query: nil, + MaxResults: 10, + Page: uint(6), + }, + expected: expected{ + count: 0, + total: 50, }, }, { - name: "excedeed", - filter: ports.LinkExceeded, - query: nil, + name: "excedeed", + filter: ports.LinksFilter{ + Status: ports.LinkExceeded, + Query: nil, + MaxResults: 50, + Page: uint(1), + }, expected: expected{ count: 20, // 10 expired + 10 over used active: common.ToPointer(string(ports.LinkExceeded)), + total: 20, }, }, { - name: "inactive", - filter: ports.LinkInactive, - query: nil, + name: "inactive", + filter: ports.LinksFilter{ + Status: ports.LinkInactive, + Query: nil, + MaxResults: 50, + Page: uint(1), + }, expected: expected{ count: 10, active: common.ToPointer(string(ports.LinkInactive)), + total: 10, }, }, { - name: "active", - filter: ports.LinkActive, - query: nil, + name: "active", + filter: ports.LinksFilter{ + Status: ports.LinkActive, + Query: nil, + MaxResults: 50, + Page: uint(1), + }, expected: expected{ count: 20, active: common.ToPointer(string(ports.LinkActive)), + total: 20, }, }, { - name: "active, with query that should not match", - filter: ports.LinkActive, - query: common.ToPointer("NOOOOT MATCH"), + name: "active, with query that should not match", + filter: ports.LinksFilter{ + Status: ports.LinkActive, + Query: common.ToPointer("NOOOOT MATCH"), + MaxResults: 50, + Page: uint(1), + }, expected: expected{ count: 0, + total: 0, }, }, { - name: "active, with query that should match", - filter: ports.LinkActive, - query: common.ToPointer("birthday"), + name: "active, with query that should match", + filter: ports.LinksFilter{ + Status: ports.LinkActive, + Query: common.ToPointer("birthday"), + MaxResults: 50, + Page: uint(1), + }, expected: expected{ count: 20, active: common.ToPointer(string(ports.LinkActive)), + total: 20, }, }, { - name: "active, with query that should match because of the beginning of a term", - filter: ports.LinkActive, - query: common.ToPointer("birth"), + name: "active, with query that should match because of the beginning of a term", + filter: ports.LinksFilter{ + Status: ports.LinkActive, + Query: common.ToPointer("birth"), + MaxResults: 50, + Page: uint(1), + }, expected: expected{ count: 20, active: common.ToPointer(string(ports.LinkActive)), + total: 20, }, }, { - name: "active, with query that should match because in the middle of a term", - filter: ports.LinkActive, - query: common.ToPointer("thday"), + name: "active, with query that should match because in the middle of a term", + filter: ports.LinksFilter{ + Status: ports.LinkActive, + Query: common.ToPointer("thday"), + MaxResults: 50, + Page: uint(1), + }, expected: expected{ count: 20, active: common.ToPointer(string(ports.LinkActive)), + total: 20, }, }, { - name: "inactive, with query that should match", - filter: ports.LinkInactive, - query: common.ToPointer("birthday"), + name: "inactive, with query that should match", + filter: ports.LinksFilter{ + Status: ports.LinkInactive, + Query: common.ToPointer("birthday"), + MaxResults: 50, + Page: uint(1), + }, expected: expected{ count: 10, active: common.ToPointer(string(ports.LinkInactive)), + total: 10, + }, + }, + { + name: "inactive, with query that should NOT match", + filter: ports.LinksFilter{ + Status: ports.LinkInactive, + Query: common.ToPointer("NORRR"), + MaxResults: 50, + Page: uint(1), + }, + query: common.ToPointer("NORRR"), + expected: expected{ + count: 0, + total: 0, }, }, { - name: "inactive, with query that should NOT match", - filter: ports.LinkInactive, - query: common.ToPointer("NORRR"), - expected: expected{count: 0}, + name: "all sort y creation date desc", + filter: ports.LinksFilter{ + Status: ports.LinkAll, + Query: nil, + MaxResults: 50, + Page: uint(1), + OrderBy: sqltools.OrderByFilters{{Field: "links.created_at", Desc: true}}, + }, + expected: expected{ + count: 50, + total: 50, + }, + }, + { + name: "all sort y creation date asc", + filter: ports.LinksFilter{ + Status: ports.LinkAll, + Query: nil, + MaxResults: 50, + Page: uint(1), + OrderBy: sqltools.OrderByFilters{{Field: "links.created_at", Desc: false}}, + }, + expected: expected{ + count: 50, + total: 50, + }, }, } { t.Run(tc.name, func(t *testing.T) { - all, err := linkStore.GetAll(ctx, *did, tc.filter, tc.query) + all, count, err := linkStore.GetAll(ctx, *did, tc.filter) require.NoError(t, err) require.Len(t, all, tc.expected.count) + assert.Equal(t, tc.expected.total, int(count)) for _, one := range all { if tc.expected.active != nil { assert.Equal(t, one.Status(), *tc.expected.active) @@ -401,3 +524,86 @@ func TestDeleteLink(t *testing.T) { assert.Error(t, err) assert.Equal(t, ErrLinkDoesNotExist, err) } + +func TestGetAllWithPagination(t *testing.T) { + ctx := context.Background() + didStr := "did:iden3:polygon:amoy:xAALmKpb53Qg7M81CqWmk2Ct5vwPXPYjU3bTThw4f" + schemaStore := NewSchema(*storage) + _, err := storage.Pgx.Exec(ctx, "INSERT INTO identities (identifier, keytype) VALUES ($1, $2)", didStr, "BJJ") + require.NoError(t, err) + linkStore := NewLink(*storage) + schemaID := insertSchemaForLink(ctx, didStr, schemaStore, t) + did, err := w3c.ParseDID(didStr) + require.NoError(t, err) + + tomorrow := time.Now().Add(24 * time.Hour) + nextWeek := time.Now().Add(7 * 24 * time.Hour) + + actives := make([]string, 0) + // 10 not expired links and no max issuance + for i := 0; i < 10; i++ { + linkToSave := domain.NewLink(*did, nil, &tomorrow, schemaID, &nextWeek, true, false, domain.CredentialSubject{}, nil, nil) + linkID, err := linkStore.Save(ctx, storage.Pgx, linkToSave) + require.NoError(t, err) + assert.NotNil(t, linkID) + actives = append(actives, linkID.String()) + } + + type expected struct { + count int + total int + active *string + } + type testConfig struct { + name string + filter ports.LinksFilter + expected expected + } + for _, tc := range []testConfig{ + { + name: "all order by created at desc", + filter: ports.LinksFilter{ + Status: ports.LinkAll, + Query: nil, + MaxResults: 10, + Page: uint(1), + OrderBy: sqltools.OrderByFilters{{Field: "links.created_at", Desc: true}}, + }, + expected: expected{ + active: common.ToPointer(string(ports.LinkAll)), + count: 10, + total: 10, + }, + }, + { + name: "all order by created at asc", + filter: ports.LinksFilter{ + Status: ports.LinkAll, + Query: nil, + MaxResults: 10, + Page: uint(1), + OrderBy: sqltools.OrderByFilters{{Field: "links.created_at", Desc: false}}, + }, + expected: expected{ + active: common.ToPointer(string(ports.LinkActive)), + count: 10, + total: 10, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + all, count, err := linkStore.GetAll(ctx, *did, tc.filter) + require.NoError(t, err) + require.Len(t, all, tc.expected.count) + assert.Equal(t, tc.expected.total, int(count)) + + for i, one := range all { + if tc.filter.OrderBy[0].Desc { + assert.Equal(t, actives[len(actives)-i-1], one.ID.String()) + } else { + assert.Equal(t, actives[i], one.ID.String()) + } + } + }) + } +} diff --git a/ui/src/adapters/api/credentials.ts b/ui/src/adapters/api/credentials.ts index 93f527d47..df93c054e 100644 --- a/ui/src/adapters/api/credentials.ts +++ b/ui/src/adapters/api/credentials.ts @@ -11,12 +11,7 @@ import { messageParser, serializeSorters, } from "src/adapters/api"; -import { - datetimeParser, - getListParser, - getResourceParser, - getStrictParser, -} from "src/adapters/parsers"; +import { datetimeParser, getResourceParser, getStrictParser } from "src/adapters/parsers"; import { Credential, Env, @@ -28,7 +23,7 @@ import { RefreshService, } from "src/domain"; import { API_VERSION, QUERY_SEARCH_PARAM, STATUS_SEARCH_PARAM } from "src/utils/constants"; -import { List, Resource } from "src/utils/types"; +import { Resource } from "src/utils/types"; // Credentials @@ -338,17 +333,20 @@ export async function getLink({ export async function getLinks({ env, identifier, - params: { query, status }, + params: { maxResults, page, query, sorters, status }, signal, }: { env: Env; identifier: string; params: { + maxResults?: number; + page?: number; query?: string; + sorters?: Sorter[]; status?: LinkStatus; }; signal?: AbortSignal; -}): Promise>> { +}): Promise>> { try { const response = await axios({ baseURL: env.api.url, @@ -359,18 +357,14 @@ export async function getLinks({ params: new URLSearchParams({ ...(query !== undefined ? { [QUERY_SEARCH_PARAM]: query } : {}), ...(status !== undefined ? { [STATUS_SEARCH_PARAM]: status } : {}), + ...(maxResults !== undefined ? { max_results: maxResults.toString() } : {}), + ...(page !== undefined ? { page: page.toString() } : {}), + ...(sorters !== undefined && sorters.length ? { sort: serializeSorters(sorters) } : {}), }), signal, url: `${API_VERSION}/identities/${identifier}/credentials/links`, }); - return buildSuccessResponse( - getListParser(linkParser) - .transform(({ failed, successful }) => ({ - failed, - successful: successful.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()), - })) - .parse(response.data) - ); + return buildSuccessResponse(getResourceParser(linkParser).parse(response.data)); } catch (error) { return buildErrorResponse(error); } diff --git a/ui/src/components/credentials/LinksTable.tsx b/ui/src/components/credentials/LinksTable.tsx index 301e1c336..88acd7198 100644 --- a/ui/src/components/credentials/LinksTable.tsx +++ b/ui/src/components/credentials/LinksTable.tsx @@ -17,11 +17,13 @@ import { Typography, } from "antd"; -import dayjs from "dayjs"; import { useCallback, useEffect, useState } from "react"; import { generatePath, useNavigate, useSearchParams } from "react-router-dom"; +import { Sorter, parseSorters, serializeSorters } from "src/adapters/api"; import { getLinks, linkStatusParser, updateLink } from "src/adapters/api/credentials"; +import { positiveIntegerFromStringParser } from "src/adapters/parsers"; +import { tableSorterParser } from "src/adapters/parsers/view"; import IconCreditCardPlus from "src/assets/icons/credit-card-plus.svg?react"; import IconDots from "src/assets/icons/dots-vertical.svg?react"; import IconInfoCircle from "src/assets/icons/info-circle.svg?react"; @@ -39,10 +41,16 @@ import { AsyncTask, isAsyncTaskDataAvailable, isAsyncTaskStarting } from "src/ut import { isAbortedError, makeRequestAbortable } from "src/utils/browser"; import { ACCESSIBLE_UNTIL, + DEFAULT_PAGINATION_MAX_RESULTS, + DEFAULT_PAGINATION_PAGE, + DEFAULT_PAGINATION_TOTAL, DELETE, DETAILS, LINKS, + PAGINATION_MAX_RESULTS_PARAM, + PAGINATION_PAGE_PARAM, QUERY_SEARCH_PARAM, + SORT_PARAM, STATUS, STATUS_SEARCH_PARAM, } from "src/utils/constants"; @@ -63,11 +71,29 @@ export function LinksTable() { }); const [isLinkUpdating, setLinkUpdating] = useState>({}); const [linkToDelete, setLinkToDelete] = useState(); + const [paginationTotal, setPaginationTotal] = useState(DEFAULT_PAGINATION_TOTAL); const linksList = isAsyncTaskDataAvailable(links) ? links.data : []; const statusParam = searchParams.get(STATUS_SEARCH_PARAM); const queryParam = searchParams.get(QUERY_SEARCH_PARAM); + const paginationPageParam = searchParams.get(PAGINATION_PAGE_PARAM); + const paginationMaxResultsParam = searchParams.get(PAGINATION_MAX_RESULTS_PARAM); + const sortParam = searchParams.get(SORT_PARAM); + + const sorters = parseSorters(sortParam); + const parsedStatusParam = linkStatusParser.safeParse(statusParam); + const paginationPageParsed = positiveIntegerFromStringParser.safeParse(paginationPageParam); + const paginationMaxResultsParsed = + positiveIntegerFromStringParser.safeParse(paginationMaxResultsParam); + + const paginationPage = paginationPageParsed.success + ? paginationPageParsed.data + : DEFAULT_PAGINATION_PAGE; + const paginationMaxResults = paginationMaxResultsParsed.success + ? paginationMaxResultsParsed.data + : DEFAULT_PAGINATION_MAX_RESULTS; + const showDefaultContent = links.status === "successful" && linksList.length === 0 && queryParam === null; @@ -89,7 +115,10 @@ export function LinksTable() { size="small" /> ), - sorter: ({ active: a }, { active: b }) => (a === b ? 0 : a ? 1 : -1), + sorter: { + multiple: 1, + }, + sortOrder: sorters.find(({ field }) => field === "active")?.order, title: "Active", width: md ? 100 : 60, }, @@ -102,60 +131,55 @@ export function LinksTable() { {schemaType} ), - sorter: ({ schemaType: a }, { schemaType: b }) => a.localeCompare(b), + sorter: { + multiple: 2, + }, + sortOrder: sorters.find(({ field }) => field === "schemaType")?.order, title: "Credential", }, { - dataIndex: "expiration", + dataIndex: "accessibleUntil", ellipsis: true, key: "expiration", - render: (expiration: Link["expiration"]) => ( + render: (_, { expiration }: Link) => ( {expiration ? formatDate(expiration) : "Unlimited"} ), responsive: ["sm"], - sorter: ({ expiration: a }, { expiration: b }) => { - if (a && b) { - return dayjs(a).unix() - dayjs(b).unix(); - } else if (a) { - return 1; - } else { - return -1; - } + sorter: { + multiple: 3, }, + sortOrder: sorters.find(({ field }) => field === "accessibleUntil")?.order, title: ACCESSIBLE_UNTIL, }, { - dataIndex: "issuedClaims", + dataIndex: "credentialIssued", ellipsis: true, key: "issuedClaims", - render: (issuedClaims: Link["issuedClaims"]) => { + render: (_, { issuedClaims }: Link) => { const value = issuedClaims ? issuedClaims : 0; - return {value}; }, responsive: ["md"], - sorter: ({ issuedClaims: a }, { issuedClaims: b }) => (a === b ? 0 : a ? 1 : -1), + sorter: { + multiple: 4, + }, + sortOrder: sorters.find(({ field }) => field === "credentialIssued")?.order, title: "Credentials issued", }, { - dataIndex: "maxIssuance", + dataIndex: "maximumIssuance", ellipsis: true, key: "maxIssuance", - render: (maxIssuance: Link["maxIssuance"]) => { + render: (_, { maxIssuance }: Link) => { const value = maxIssuance ? maxIssuance : "Unlimited"; return {value}; }, responsive: ["md"], - sorter: ({ maxIssuance: a }, { maxIssuance: b }) => { - if (a && b) { - return a - b; - } else if (a) { - return 1; - } else { - return -1; - } + sorter: { + multiple: 5, }, + sortOrder: sorters.find(({ field }) => field === "maximumIssuance")?.order, title: "Maximum issuance", }, { @@ -205,12 +229,40 @@ export function LinksTable() { ), - sorter: ({ status: a }, { status: b }) => a.localeCompare(b), + sorter: { + multiple: 6, + }, + sortOrder: sorters.find(({ field }) => field === "status")?.order, title: STATUS, width: 140, }, ]; + const updateUrlParams = useCallback( + ({ maxResults, page, sorters }: { maxResults?: number; page?: number; sorters?: Sorter[] }) => { + setSearchParams((previousParams) => { + const params = new URLSearchParams(previousParams); + params.set( + PAGINATION_PAGE_PARAM, + page !== undefined ? page.toString() : DEFAULT_PAGINATION_PAGE.toString() + ); + params.set( + PAGINATION_MAX_RESULTS_PARAM, + maxResults !== undefined + ? maxResults.toString() + : DEFAULT_PAGINATION_MAX_RESULTS.toString() + ); + const newSorters = sorters || parseSorters(sortParam); + newSorters.length > 0 + ? params.set(SORT_PARAM, serializeSorters(newSorters)) + : params.delete(SORT_PARAM); + + return params; + }); + }, + [setSearchParams, sortParam] + ); + const fetchLinks = useCallback( async (signal?: AbortSignal) => { setLinks((previousLinks) => @@ -223,22 +275,40 @@ export function LinksTable() { env, identifier, params: { + maxResults: paginationMaxResults, + page: paginationPage, query: queryParam || undefined, + sorters: parseSorters(sortParam), status: status, }, signal, }); if (response.success) { - setLinks({ data: response.data.successful, status: "successful" }); - notifyParseErrors(response.data.failed); + setLinks({ data: response.data.items.successful, status: "successful" }); + setPaginationTotal(response.data.meta.total); + updateUrlParams({ + maxResults: response.data.meta.max_results, + page: response.data.meta.page, + }); + + notifyParseErrors(response.data.items.failed); } else { if (!isAbortedError(response.error)) { setLinks({ error: response.error, status: "failed" }); } } }, - [env, queryParam, status, identifier] + [ + env, + queryParam, + status, + identifier, + paginationMaxResults, + paginationPage, + sortParam, + updateUrlParams, + ] ); const handleStatusChange = ({ target: { value } }: RadioChangeEvent) => { @@ -379,7 +449,22 @@ export function LinksTable() { ), }} - pagination={false} + onChange={({ current, pageSize, total }, _, sorters) => { + setPaginationTotal(total || DEFAULT_PAGINATION_TOTAL); + const parsedSorters = tableSorterParser.safeParse(sorters); + updateUrlParams({ + maxResults: pageSize, + page: current, + sorters: parsedSorters.success ? parsedSorters.data : [], + }); + }} + pagination={{ + current: paginationPage, + hideOnSinglePage: true, + pageSize: paginationMaxResults, + position: ["bottomRight"], + total: paginationTotal, + }} rowKey="id" showSorterTooltip sortDirections={["ascend", "descend"]} @@ -390,7 +475,7 @@ export function LinksTable() { - {linksList.length} + {paginationTotal} {(!showDefaultContent || status !== undefined) && (