Skip to content

Instantly share code, notes, and snippets.

@Romakita
Last active December 4, 2020 07:51
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Romakita/2dd599b6905a08ab0301414599c3858a to your computer and use it in GitHub Desktop.
Save Romakita/2dd599b6905a08ab0301414599c3858a to your computer and use it in GitHub Desktop.
Pagination example with Ts.ED
import {Controller, Get, PlatformTest, QueryParams, PlatformTest} from "@tsed/common";
import {
getSpec,
Returns,
SpecTypes
} from "@tsed/schema";
import {expect} from "chai";
import * as qs from "querystring";
import * as SuperTest from "supertest";
import {PlatformExpress} from "@tsed/platform-express";
import {Server} from "./app/Server";
import {Pagination} from "./app/models/Pagination";
import {Product} from "./app/models/Product";
import {PaginationFilter} from "./app/filters/PaginationFilter";
@Controller("/pageable")
class TestPageableCtrl {
@Get("/")
@Returns(206, Pagination).Of(Product).Title("PaginatedProduct")
@Returns(200, Pagination).Of(Product).Title("PaginatedProduct")
async get(@QueryParams() pageableOptions: Pageable, @QueryParams("all") all: boolean) {
return new Pagination<Product>({
data: [new Product({
id: "100",
title: "CANON D3000"
})],
totalCount: all ? 1 : 100, // just for test,
pageable: pageableOptions
});
}
}
describe("Pageable", () => {
let request: SuperTest.SuperTest<SuperTest.Test>;
before(
PlatformTest.bootstrap(Server, {
mount: {
"/rest": [TestPageableCtrl]
},
responseFilters: [
PaginationFilter
]
})
);
after(utils.reset);
before(() => {
request = SuperTest(PlatformTest.callback());
});
it("should generate spec", () => {
const spec = getSpec(TestPageableCtrl, {specType: SpecTypes.OPENAPI});
expect(spec).to.deep.eq({
"paths": {
"/pageable": {
"get": {
"operationId": "testPageableCtrlGet",
"parameters": [{
"in": "query",
"required": false,
"name": "page",
"schema": {"type": "integer", "description": "Page number.", "default": 0, "minimum": 0, "multipleOf": 1}
}, {
"in": "query",
"required": false,
"name": "size",
"schema": {
"type": "integer",
"description": "Number of objects per page.",
"default": 20,
"minimum": 1,
"multipleOf": 1
}
}, {
"in": "query",
"required": false,
"name": "sort",
"schema": {
"type": "array",
"description": "Sorting criteria: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported.",
"maxItems": 2,
"items": {"type": "string"}
}
}, {"in": "query", "name": "all", "required": false, "schema": {"type": "boolean"}}],
"responses": {
"200": {
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PaginatedProduct"}}},
"description": "Success"
},
"206": {
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PaginatedProduct"}}},
"description": "Partial Content"
}
},
"tags": ["TestPageableCtrl"]
}
}
},
"tags": [{"name": "TestPageableCtrl"}],
"components": {
"schemas": {
"Product": {
"type": "object",
"properties": {"id": {"type": "string"}, "title": {"type": "string"}}
},
"PaginatedProduct": {
"type": "object",
"properties": {
"page": {
"type": "integer",
"description": "Page number.",
"default": 0,
"minimum": 0,
"multipleOf": 1
},
"size": {
"type": "integer",
"description": "Number of objects per page.",
"default": 20,
"minimum": 1,
"multipleOf": 1
},
"sort": {
"type": "array",
"description": "Sorting criteria: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported.",
"maxItems": 2,
"items": {"type": "string"}
},
"data": {"type": "array", "items": {"$ref": "#/components/schemas/Product"}},
"totalCount": {"type": "integer", "minLength": 0, "multipleOf": 1}
}
}
}
}
});
});
it("should get paginated products with a status 206 Partial Content", async () => {
const options = {
page: 1,
size: 10,
sort: ["field", "asc"]
};
const {body} = await request.get("/rest/pageable?" + qs.stringify(options)).expect(206);
expect(body).to.deep.eq({
"data": [
{
"id": "100",
"title": "CANON D3000"
}
],
"page": 1,
"size": 10,
"sort": [
"asc",
"field"
],
"totalCount": 100
});
});
it("should get all products with a status 216", async () => {
const options = {
all: true
};
const {body} = await request.get("/rest/pageable?" + qs.stringify(options)).expect(200);
expect(body).to.deep.eq({
data: [ { id: '100', title: 'CANON D3000' } ],
totalCount: 1,
page: 0,
size: 20
});
});
it("should not return a bad request when sort is not given", async () => {
const options = {
page: 1,
size: 10
};
const {body} = await request.get("/rest/pageable?" + qs.stringify(options)).expect(216);
expect(body).to.deep.eq({
"data": [
{
"id": "100",
"title": "CANON D3000"
}
],
"page": 1,
"size": 10,
"totalCount": 100
});
});
it("should apply default pagination parameters", async () => {
const options = {};
const {body} = await request.get("/rest/pageable?" + qs.stringify(options)).expect(216);
expect(body).to.deep.eq({
data: [{id: "100", title: "CANON D3000"}],
totalCount: 100,
page: 0,
size: 20
});
});
it("should throw bad request when options isn\'t correct", async () => {
const options = {
page: -1
};
const {body} = await request.get("/rest/pageable?" + qs.stringify(options)).expect(400);
expect(body).to.deep.eq({
"errors": [
{
"data": -1,
"dataPath": ".page",
"keyword": "minimum",
"message": "should be >= 0",
"modelName": "Pageable",
"params": {
"comparison": ">=",
"exclusive": false,
"limit": 0
},
"schemaPath": "#/properties/page/minimum"
}
],
"message": "Bad request on parameter \"request.query\".\nPageable.page should be >= 0. Given value: -1",
"name": "AJV_VALIDATION_ERROR",
"status": 400
});
});
});
import {
CollectionOf,
Default,
Description,
Integer,
MaxItems,
Min,
MinLength,
Property,
Required,
For,
SpecTypes,
oneOf,
string,
array
} from "@tsed/schema";
import {OnDeserialize} from "@tsed/json-mapper";
import {isArray} from "@tsed/core";
class Pageable {
@Integer()
@Min(0)
@Default(0)
@Description("Page number.")
page: number = 0;
@Integer()
@Min(1)
@Default(20)
@Description("Number of objects per page.")
size: number = 20;
@For(SpecTypes.JSON, oneOf(string(), array().items(string()).maxItems(2))) // v6.11.0
@For(SpecTypes.OPENAPI, array().items(string()).maxItems(2))
@For(SpecTypes.SWAGGER, array().items(string()).maxItems(2))
@OnDeserialize((value: string | string[]) => isString(value) ? value.split(",") : value)
@Description("Sorting criteria: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported.")
sort: string | string[];
constructor(options: Partial<Pageable>) {
options.page && (this.page = options.page);
options.size && (this.size = options.size);
options.sort && (this.sort = options.sort);
}
get offset() {
return this.page ? this.page * this.limit : 0;
}
get limit() {
return this.size;
}
}
import {
CollectionOf,
Default,
Generics,
Integer,
MaxItems,
Min,
MinLength,
Property,
Required
} from "@tsed/schema";
import { Pageable } from "./Pageable";
@Generics("T")
class Pagination<T> extends Pageable {
@CollectionOf("T")
data: T[];
@Integer()
@MinLength(0)
@Default(0)
totalCount: number = 0;
constructor({data, totalCount, pageable}: Partial<Pagination<T>> & { pageable: Pageable }) {
super(pageable);
data && (this.data = data);
totalCount && (this.totalCount = totalCount);
}
}
import { ResponseFilter, ResponseFilterMethods, PlatformContext } from "@tsed/common";
@ResponseFilter("application/json")
class PaginationFilter implements ResponseFilterMethods {
transform(data: unknown, ctx: PlatformContext): any {
if (ctx.data instanceof Pagination) {// get the unserialized data
if (ctx.data.isPaginated) {
ctx.response.status(206);
}
}
return data;
}
}
import { Property } from "@tsed/schema";
class Product {
@Property()
id: string;
@Property()
title: string;
constructor({id, title}: Partial<Product> = {}) {
id && (this.id = id);
title && (this.title = title);
}
}
@arturrozputnii
Copy link

arturrozputnii commented Nov 18, 2020

thx for your help. is it expected UI on swagger page?
Screenshot 2020-11-18 at 23 42 40

and i can't type into new object. Am miss something from your example. Sorry.

i want to follow default ui from swagger ui client, here is my example
Screenshot 2020-11-18 at 23 44 22

gonna apply your logic to parse sort fields in my class.

@arturrozputnii
Copy link

arturrozputnii commented Nov 18, 2020

Seems like now i am happy, maybe if you interested, attached what i did. To fix issue with parse array, i've added simple middleware. Works like a charm. Thx

import {EndpointInfo, IMiddleware, Middleware, Req} from "@tsed/common";
import {isArray} from "@tsed/core";

@Middleware()
export class ParseSortMiddleware implements IMiddleware {
    use(@Req() req: Req, @EndpointInfo() endpoint: EndpointInfo) {
        let sort = req.query.sort;
        if (sort && !isArray<string>(sort)) {
            req.query.sort = new Array(`${sort}`)
        }
    }
}
import {Sort} from "./Sort";

export class Pageable {

    page: number = 0;

    size: number = 20;

    _sort: string[];

    constructor(options: Partial<Pageable>) {
        options.page && (this.page = options.page);
        options.size && (this.size = options.size);
        options._sort && (this._sort = options._sort);
    }

    get offset() {
        return this.page ? this.page * this.limit : 0;
    }

    get sort(): any {
        return new Sort(this._sort);
    }

    get limit() {
        return this.size;
    }
}
import {Injectable} from "@tsed/di";
import {IPipe, ParamMetadata, Req} from "@tsed/common";
import {Pageable} from "../pagination/Pageable";

@Injectable()
export class PageablePipe implements IPipe<Req, Pageable> {
    transform(req: Req, metadata: ParamMetadata): Pageable {
        const page = req.query?.page ? parseInt(req.query?.page.toString()) : 0;
        const size = req.query?.size ? parseInt(req.query?.size.toString()) : 20;
        const _sort = <string[]>req.query?.sort;

        return new Pageable({page, size, _sort});
    }
}
export class Sort {
    [key: string]: string

    constructor(options: string[]) {
        options.forEach((sort) => {
            const [key, direction] = sort.split(',')
            this[key] = direction?.toUpperCase();
        });
    }
}
    @Get("/")
    @Summary("Get list of products")
    @Returns(200, Page).Of(Product).Description("Returns list of Product objects by query and pagination")
    @Returns(500)
    @UseBefore(ParseSortMiddleware)
    async list(
        @Res() res: Res,
        @Description("Query string to search objects by name and description property.")
        @QueryParams('query') query: string = "",
        @Description("Number of objects per page.")
        @QueryParams('size') size: number = 20,
        @Description("Result page to retrieve (0..N).")
        @QueryParams('page') page: number = 0,
        @Description("Sorting criteria: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are NOT supported.")
        @QueryParams('sort', String) sort: Sort[],
        @PageableParam() pageable: Pageable,
    ): Promise<Page<Product> | null> {
        return await this.productService.findAllWithPagination(query, pageable);
    }

Screenshot 2020-11-19 at 01 52 31

@Romakita
Copy link
Author

Hello the given example need a fix for OS3 to have the same UI in swagger. Sorry. I'll fix it :) Use middleware works but isn't the elegante way. After the fix the example will work as expected.

@Romakita
Copy link
Author

Romakita commented Nov 19, 2020

With OS2 the ui is correctly generated :)
Capture d’écran 2020-11-19 à 08 05 57

@Romakita
Copy link
Author

Issue fixed

PR: tsedio/tsed#1097
Release: v6.10.1

Capture d’écran 2020-11-19 à 08 29 48

@arturrozputnii
Copy link

nice, many thx. Updating and testing

Yep, i know that was not best solution, but it is :)

@Romakita
Copy link
Author

;) Yes your solution works perfectly. But If I can give you a working solution only based on the Model, it's better ^^.

By using a model, the swagger description is automatically generated, and you can use inheritance to create many Pageable declination (change default size value, etc...). And you have only one model to describe all props for the QueryParams.

@Romakita
Copy link
Author

Romakita commented Nov 19, 2020

So thanks for your feedback, it help me to find and fix a bug ;)

@arturrozputnii
Copy link

👍

@arturrozputnii
Copy link

arturrozputnii commented Nov 22, 2020

sorry still fighting with sorting, and it doesn't work for me when i send one parameter, see what i did

@Get("/")
    @Summary("Get list of products")
    @Returns(200, Page).Of(Product).Description("Returns list of Product objects by query and pagination")
    @Returns(500)
    // @UseBefore(ParseSortMiddleware)
    async list(
        @Res() res: Res,
        @Description("Query string to search objects by name and description property.")
        @QueryParams('query') query: string = "",
        // @Description("Number of objects per page.")
        // @QueryParams('size') size: number = 20,
        // @Description("Result page to retrieve (0..N).")
        // @QueryParams('page') page: number = 0,
        // @Description("Sorting criteria: property(,asc | desc). Default sort order is ascending. Multiple sort criteria are supported.")
        // @QueryParams('sort', String) sort: Sort[],
        // @QueryParams('sort') sort: Sort[],
        // @PageableParam() pageable: Pageable,
        @QueryParams() pageable: Pageable
    ): Promise<Page<Product> | null> {
        return await this.productService.findAllWithPagination(query, pageable);
    }
import {Sort} from "./Sort";
import {CollectionOf, Description, Property} from "@tsed/schema";
import {QueryParams} from "@tsed/common";

export class Pageable {

    @Property()
    page: number = 0;

    @Property()
    size: number = 20;

    @CollectionOf(String, Array)
    @Description("Sorting criteria: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported.")
    sort: string[];

    // private _sort: any;

    constructor(options: Partial<Pageable>) {
        // options.page && (this.page = options.page);
        // options.size && (this.size = options.size);
        // options._sort && (this._sort = options._sort);
    }

    get offset() {
        return this.page ? this.page * this.limit : 0;
    }

    // @CollectionOf(String, Array)
    // @Description("Sorting criteria: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported.")
    // get sort(): any {
    //     return new Sort(this._sort);
        // return this.sort
    // }


    // set sort(value: any) {
    //     this.sort = value;
    // }

    get limit() {
        return this.size;
    }
}

and error is the same

  "name": "AJV_VALIDATION_ERROR",
  "message": "Bad request on parameter \"request.query\".\nAt Pageable.sort, value 'id,asc' should be array",
  "status": 400,
  "errors": [
    {
      "keyword": "type",
      "dataPath": ".sort",
      "schemaPath": "#/properties/sort/type",
      "params": {
        "type": "array"
      },
      "message": "should be array",
      "schema": "array",
      "parentSchema": {
        "type": "array",
        "items": {
          "type": "string"
        },
        "description": "Sorting criteria: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported."
      },
      "data": "id,asc",
      "modelName": "Pageable"
    }
  ],
  "stack": "PARAM_VALIDATION_ERROR: Bad request on parameter \"request.query\".\nAt Pageable.sort, value 'id,asc' should be array\n    at Function.from (/Users/artur/projects_ub/ubicoin/packages/backend/node_modules/@tsed/common/src/platform/errors/ParamValidationError.ts:19:21)\n    at handleError (/Users/artur/projects_ub/ubicoin/packages/backend/node_modules/@tsed/common/src/platform/services/PlatformHandler.ts:336:36)\n    at /Users/artur/projects_ub/ubicoin/packages/backend/node_modules/@tsed/common/src/platform/services/PlatformHandler.ts:343:14\n    at processTicksAndRejections (internal/process/task_queues.js:97:5)\n    at /Users/artur/projects_ub/ubicoin/packages/backend/node_modules/@tsed/common/src/platform/services/PlatformHandler.ts:341:15"
}

maybe i miss something. Just to want inform you. Still used my tricky fix for that.

@arturrozputnii
Copy link

arturrozputnii commented Nov 22, 2020

Screenshot 2020-11-22 at 23 43 58

BTW, can you please tell why description doesn't work in my Pageable model

    @Min(0)
    @Default(0)
    @Description("Result page to retrieve (0..N).")
    page: number = 0;

    @Description("Number of objects per page.")
    size: number = 20;```

@Romakita
Copy link
Author

Romakita commented Nov 23, 2020

Why have you removed the getter / setter /constructor? I really important to keep it. Without, the serialization/deserialization won't work as expected.

See the integration test here:
https://github.com/TypedProject/tsed/blob/production/packages/platform-express/test/pageable.spec.ts

The spec is correctly generated (with description) and the validation doesn't fail. I tested your scenario :) But if you change the class implementation, the result change also! (works as expected with v6.10.3)

@Romakita
Copy link
Author

Romakita commented Nov 23, 2020

I see you don't won't the sort class. Let me change the example.

Ajv complaining because you don't send an array of string, it's normal. We have to tell Ajv about the possibility to receive a string and string[].

@Romakita
Copy link
Author

v6.11.0 will introduce the jsonschema functional declaration (like Joi) and the @For decorator.

import {
  CollectionOf,
  Default,
  Description,
  Integer,
  MaxItems,
  Min,
  MinLength,
  Property,
  Required,
  For,
  SpecTypes,
  oneOf,
  string,
  array
} from "@tsed/schema";
import {OnDeserialize} from "@tsed/json-mapper";
import {isArray} from "@tsed/core";

class Pageable {
  @Integer()
  @Min(0)
  @Default(0)
  @Description("Page number.")
  page: number = 0;

  @Integer()
  @Min(1)
  @Default(20)
  @Description("Number of objects per page.")
  size: number = 20;

  @For(SpecTypes.JSON, oneOf(string(), array().items(string()).maxItems(2)))
  @For(SpecTypes.OPENAPI, array().items(string()).maxItems(2))
  @For(SpecTypes.SWAGGER, array().items(string()).maxItems(2))
  @OnDeserialize((value: string | string[]) => isString(value) ? value.split(",") : value)
  @Description("Sorting criteria: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported.")
  sort: string | string[];

  constructor(options: Partial<Pageable>) {
    options.page && (this.page = options.page);
    options.size && (this.size = options.size);
    options.sort && (this.sort = options.sort);
  }

  get offset() {
    return this.page ? this.page * this.limit : 0;
  }

  get limit() {
    return this.size;
  }
}

By using @For decorator when can instrument correctly the Ajv validation and Swagger :)

Release coming soon
Romain

@Romakita
Copy link
Author

@arturrozputnii
Copy link

cool, thx. Here is my Sort class. I've specific constructor, because _sort has string[] but in DAO good choice and easy to use obejct with key/value, and don't need to do additional logic there. My method in service here

    async findAllWithPagination(query: string, pageable: Pageable): Promise<Page<Product> | null> {
        let options: FindManyOptions = {};
        options.where = [
            {
                "name": Like("%" + query + "%")
            },
            {
                "description": Like("%" + query + "%")
            }
        ];
        const totalCount = await this.productRepo.count(options);
        options.take = pageable.limit;
        options.skip = pageable.offset;
        options.order = pageable.sort;

        const data = await this.productRepo.find(options);
        return new Page<Product>({data, totalCount, pageable});
    }
export class Sort {
    [key: string]: string

    constructor(options: string[]) {
        options.forEach((sort) => {
            const [key, direction] = sort.split(',')
            this[key] = direction?.toUpperCase();
        });
    }
}

Thx, for clarification.

@Romakita
Copy link
Author

Romakita commented Nov 23, 2020

The PR is merged and released.

@arturrozputnii
Copy link

👍

@Romakita
Copy link
Author

Ok understand, isn't easy to have good solution for that :)

@arturrozputnii
Copy link

yep, agree :)

gonna use your suggestion with @OnDeserialize, in this case i can remove my:

@Middleware()
export class ParseSortMiddleware implements IMiddleware {
    use(@Req() req: Req, @EndpointInfo() endpoint: EndpointInfo) {
        let sort = req.query.sort;
        if (sort && !isArray<string>(sort)) {
            req.query.sort = new Array(`${sort}`)
        }
    }
}

keep you posted :)

Screenshot 2020-11-23 at 22 50 56

can you help why i don't see description on UI? It's block me to use Pageable as a model, like you did in your example, and then i don't need it to declare page, size and sort in each controller.

@Romakita
Copy link
Author

Is strange, I have the description in the swagger. Your are on os2 or os3?

@arturrozputnii
Copy link

so :)

NO pipes! NO middlewares

    @Get("/")
    @Summary("Get list of products")
    @Returns(200, Page).Of(Product).Description("Returns list of Product objects by query and pagination")
    @Returns(500)
    async list(
        @Res() res: Res,
        @Description("Query string to search objects by name and description property.")
        @QueryParams('query') query: string = "",
        @QueryParams() pageable: Pageable
    ): Promise<Page<Product> | null> {
        return await this.productService.findAllWithPagination(query, pageable);
    }
import {Sort} from "./Sort";
import {array, Default, Description, For, Integer, Min, oneOf, SpecTypes, string} from "@tsed/schema";
import {OnDeserialize} from "@tsed/json-mapper";
import {isString} from "@tsed/core";

export class Pageable {

    @Integer()
    @Min(0)
    @Default(0)
    @Description("Result page to retrieve (0..N).")
    page: number = 0;

    @Integer()
    @Min(1)
    @Default(20)
    @Description("Number of objects per page.")
    size: number = 20;

    @For(SpecTypes.JSON, oneOf(string(), array().items(string()).maxItems(2)))
    @For(SpecTypes.OPENAPI, array().items(string()).maxItems(2))
    @For(SpecTypes.SWAGGER, array().items(string()).maxItems(2))
    @OnDeserialize((value: string | string[]) => isString(value) ? [value] : value)
    @Description("Sorting criteria: property(,asc|desc). Default sort order is ascending. Multiple sort criteria are supported.")
    sort: string | string[];

    constructor(options: Partial<Pageable>) {
        options.page && (this.page = options.page);
        options.size && (this.size = options.size);
        options.sort && (this.sort = options.sort);
    }

    get offset() {
        return this.page ? this.page * this.limit : 0;
    }

    get order(): any {
        return new Sort(this.sort);
    }

    get limit() {
        return this.size;
    }
}
import {CollectionOf, Generics, Property} from "@tsed/schema";
import {Pageable} from "./Pageable";

@Generics("T")
export class Page<T> extends Pageable {

    @CollectionOf("T")
    data: T[];

    @Property()
    totalCount: number = 0;

    @Property()
    totalPages: number;

    constructor({data, totalCount = 0, pageable}: Partial<Page<T>> & { pageable: Pageable }) {
        super(pageable);
        data && (this.data = data);
        totalCount && (this.totalCount = totalCount);
        this.totalPages = totalCount == 0 ? 0 : Math.ceil(totalCount / pageable.limit);
    }
}
export class Sort {
    [key: string]: string

    constructor(options: any) {
        options.forEach((sort: string) => {
            const [key, direction] = sort.split(',')
            this[key] = direction?.toUpperCase();
        });
    }
}
async findAllWithPagination(query: string, pageable: Pageable): Promise<Page<Product> | null> {
    let options: FindManyOptions = {};
    options.where = [
        {
            "name": Like("%" + query + "%")
        },
        {
            "description": Like("%" + query + "%")
        }
    ];
    const totalCount = await this.productRepo.count(options);
    options.take = pageable.limit;
    options.skip = pageable.offset;
    options.order = pageable.order;

    const data = await this.productRepo.find(options);
    return new Page<Product>({data, totalCount, pageable});
}

DONE :)

@arturrozputnii
Copy link

My swagger
Screenshot 2020-11-23 at 23 29 43

Screenshot 2020-11-23 at 23 30 41

    swagger: [
        {
            path: "/swagger",
            specVersion: "3.0.1",
            specPath: `${rootDir}/swagger.json`,
            options: {
                oauth2RedirectUrl: "",
                oauth: {
                    clientId: "",
                    clientSecret: "",
                    scopes: []
                }
            }
        }
    ],

am i miss something?

@Romakita
Copy link
Author

Ok the description doesn’t appear on the right level. I’ll fix it tommorrow ;). My bad...

@arturrozputnii
Copy link

yep, saw the same. Many thx

@arturrozputnii
Copy link

Screenshot 2020-11-24 at 10 08 08

AWESOME, thx. I am done with pagination/sorting now. I hope :)

@Romakita
Copy link
Author

Great :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment