Skip to content

Instantly share code, notes, and snippets.

@renoirb
Last active September 18, 2020 18:59
Show Gist options
  • Save renoirb/5d048d3b95f340e20fa0454d4c446b6a to your computer and use it in GitHub Desktop.
Save renoirb/5d048d3b95f340e20fa0454d4c446b6a to your computer and use it in GitHub Desktop.
/**
* This code has been written back in 2018, and has run in production in a Vue.js app.
* Notice the lack of requirements for Angular.js
*
* - https://stackblitz.com/edit/202009-ngx-translate-w-pluralization-and-gender?file=src/app/paginator.ts
* - https://202009-ngx-translate-w-pluralization-and-gender.stackblitz.io)
*
* @author Renoir Boulanger <contribs@renoirboulanger.com>
*/
/**
* Paginator function closure.
*
* A Function we receive when we ask the factory to give paginated results
* of a colleciton of items with a desired size.
*
* The returned fuction here would then allow us to ask what's inside it
* at a given page.
*/
export type IPaginatorFn<T> = (page: number) => PaginatorResultInterface<T>
/**
* The result of a single call to the pagingator function.
* The function returns a single page.
*
* Matches [Element UI's el-pagination component attributes][1]
*
* [1]: https://element.eleme.io/#/en-US/component/pagination#attributes
*
* @author Renoir Boulanger <contribs@renoirboulanger.com>
*/
export interface PaginatorResultInterface<T> {
/**
* The array of all elements in the returned page.
*/
items: T[]
/**
* Current page number (indexed at 1).
*/
page: number
/**
* Item count of each page, supports the .sync modifier
* Should be the same value as the factory second argument
*/
pageSize: number
/**
* How many pages in total.
*
* > pages = items.length / pageSize
*
*/
pages: number
/**
* The total number of elements
*/
total: number
/**
* If any errors returned. If there were no errors, will be empty
* Should there be an error, this property should exist
*/
error?: string
}
/* eslint-env jest */
import paginatorFactory from './paginator'
import { User, users } from './__fixtures__'
describe('Factory', () => {
let u: User[]
let page: number
beforeEach(() => {
/* tslint:disable-next-line:no-shadowed-variable */
u = users.map(u => ({ ...u }))
})
test('Typical use', () => {
page = 1
const max: number = 5
const paginator = paginatorFactory<User>(u, max)
const currentPage = paginator(page)
expect(currentPage).toMatchSnapshot()
expect(currentPage).toHaveProperty('page', page)
expect(currentPage.items.map(e => e.id)).toMatchObject([0, 1, 2, 3, 4])
const total = u.length
const pages = total / max
expect(currentPage).toHaveProperty('items')
expect(currentPage).toHaveProperty('pages', pages)
expect(currentPage).toHaveProperty('total', total)
const pageTwo = paginator(2)
expect(pageTwo).toMatchSnapshot()
expect(pageTwo).toHaveProperty('page', 2)
expect(pageTwo).toHaveProperty('pages', pages)
expect(pageTwo).toHaveProperty('total', total)
expect(pageTwo.items.map(e => e.id)).toMatchObject([5, 6, 7, 8, 9])
const [sixth] = pageTwo.items
expect(sixth).toMatchObject(u[5])
expect(sixth).toMatchObject({ id: 5 })
})
// should return the first page (of 3)
// page increment starts at 1
// users fixture has 15 elements
test('max=5_page=1', () => {
page = 1
const max: number = 5
const paginator = paginatorFactory<User>(u, max)
const currentPage = paginator(page)
expect(currentPage.items.length).toEqual(5)
expect(currentPage.items[0].id).toEqual(0)
expect(currentPage.items[4].id).toEqual(4)
expect(currentPage.page).toEqual(1)
expect(currentPage.pages).toEqual(3)
expect(currentPage.total).toEqual(15)
expect(currentPage.error).toBeFalsy()
})
// should return the second page (of 3)
test('max=5_page=2(middle)', () => {
page = 2
const max: number = 5
const paginator = paginatorFactory<User>(u, max)
const currentPage = paginator(page)
expect(currentPage.items.length).toEqual(5)
expect(currentPage.items[0].id).toEqual(5)
expect(currentPage.items[4].id).toEqual(9)
expect(currentPage.page).toEqual(2)
expect(currentPage.pages).toEqual(3)
expect(currentPage.total).toEqual(15)
expect(currentPage.error).toBeUndefined()
})
// should return the third page (of 3)
test('max=5_page=3(upper limit)', () => {
page = 3
const max: number = 5
const paginator = paginatorFactory<User>(u, max)
const currentPage = paginator(page)
expect(currentPage.items.length).toEqual(5)
expect(currentPage.items[0].id).toEqual(10)
expect(currentPage.items[4].id).toEqual(14)
expect(currentPage.page).toEqual(3)
expect(currentPage.pages).toEqual(3)
expect(currentPage.total).toEqual(15)
expect(currentPage.error).toBeUndefined()
})
// invalid page should return an error
test('max=5_page=4(oob)', () => {
page = 4
const max: number = 5
const paginator = paginatorFactory<User>(u, max)
const currentPage = paginator(page)
expect(currentPage.items).toMatchObject([])
expect(currentPage.error).toBe(
'The page number provided (4) is larger than the maximum number of pages (3)',
)
})
// invalid page should return an error
test('max=5_page=0(oob)', () => {
page = 0
const max: number = 5
const paginator = paginatorFactory<User>(u, max)
const currentPage = paginator(page)
expect(currentPage.items).toMatchObject([])
expect(currentPage.error).toBe(
'The page number provided (0) cannot be under 1',
)
})
// invalid page should return an error
test('max=5_page=-1(oob)', () => {
page = -1
const max: number = 5
const paginator = paginatorFactory<User>(u, max)
const currentPage = paginator(page)
expect(currentPage.error).toBe(
'The page number provided (-1) cannot be under 1',
)
})
// should default to page = 1 on lack of input
test('max=5_noPageInput', () => {
const max: number = 5
const paginator = paginatorFactory<User>(u, max)
// @ts-ignore
const currentPage = paginator()
expect(currentPage.items.length).toEqual(5)
expect(currentPage.items[0].id).toEqual(0)
expect(currentPage.items[4].id).toEqual(4)
expect(currentPage.page).toEqual(1)
expect(currentPage.pages).toEqual(3)
expect(currentPage.total).toEqual(15)
expect(currentPage.error).toBeUndefined()
})
// when max=users.length, page 1 should contain the entire items
test('max=15(maximum)_page=1', () => {
page = 1
const max: number = 15
const paginator = paginatorFactory<User>(u, max)
const currentPage = paginator(page)
expect(currentPage.items.length).toEqual(u.length)
expect(currentPage.items[0].id).toEqual(0)
expect(currentPage.items[14].id).toEqual(14)
expect(currentPage.page).toEqual(1)
expect(currentPage.pages).toEqual(1)
expect(currentPage.total).toEqual(15)
expect(currentPage.error).toBeUndefined()
})
// when max does not divide evenly into total, the last page (2) should
// contain only the remaining elements.
test('max=12(split page)_page=1', () => {
page = 2
const max: number = 12
const paginator = paginatorFactory<User>(u, max)
const currentPage = paginator(page)
expect(currentPage.items.length).toEqual(3)
expect(currentPage.items[0].id).toEqual(12)
expect(currentPage.items[2].id).toEqual(14)
expect(currentPage.page).toEqual(2)
expect(currentPage.pages).toEqual(2)
expect(currentPage.total).toEqual(15)
expect(currentPage.error).toBeUndefined()
})
// max = 1 should return 15 single-element pages
test('max=1(small page)_page=1', () => {
page = 1
const max: number = 1
const paginator = paginatorFactory<User>(u, max)
const currentPage = paginator(page)
expect(currentPage).toMatchSnapshot()
expect(currentPage.items.length).toEqual(1)
expect(currentPage.items[0].id).toEqual(0)
expect(currentPage.error).toBeUndefined()
})
// should return pages of one element each
test('max=1(small page)_page=7', () => {
page = 7
const max: number = 1
const paginator = paginatorFactory<User>(u, max)
const currentPage = paginator(page)
expect(currentPage.items.length).toEqual(1)
expect(currentPage.items[0].id).toEqual(6)
expect(currentPage.page).toEqual(7)
expect(currentPage.pages).toEqual(15)
expect(currentPage.total).toEqual(15)
expect(currentPage.error).toBeUndefined()
})
// should return pages of one element each
test('max=1(small page)_page=15', () => {
page = 15
const max: number = 1
const paginator = paginatorFactory<User>(u, max)
const currentPage = paginator(page)
expect(currentPage.items.length).toEqual(1)
expect(currentPage.items[0].id).toEqual(14)
expect(currentPage.page).toEqual(15)
expect(currentPage.pages).toEqual(15)
expect(currentPage.total).toEqual(15)
expect(currentPage.error).toBeUndefined()
})
// should return pages of two elements each
test('max=2(small page)_page=1', () => {
page = 1
const max: number = 2
const paginator = paginatorFactory<User>(u, max)
const currentPage = paginator(page)
expect(currentPage.items.length).toEqual(2)
expect(currentPage.items[0].id).toEqual(0)
expect(currentPage.items[1].id).toEqual(1)
expect(currentPage.page).toEqual(1)
expect(currentPage.pages).toEqual(8)
expect(currentPage.total).toEqual(15)
expect(currentPage.error).toBeUndefined()
})
// max out of bounds should default to total
test('max=16(oob)_page=1', () => {
page = 1
const max: number = 16
const paginator = paginatorFactory<User>(u, max)
const currentPage = paginator(page)
expect(currentPage.items.length).toEqual(u.length)
expect(currentPage.items[0].id).toEqual(0)
expect(currentPage.items[14].id).toEqual(14)
expect(currentPage.page).toEqual(1)
expect(currentPage.pages).toEqual(1)
expect(currentPage.total).toEqual(15)
expect(currentPage.error).toBeUndefined()
})
// Max below 1 should be ignored and act as if max was default=10
test('max=0(oob)_page=1', () => {
page = 1
const max: number = 0
const paginator = paginatorFactory<User>(u, max)
const currentPage = paginator(page)
expect(currentPage.page).toEqual(1)
expect(currentPage.pages).toEqual(2)
expect(currentPage.error).toBeUndefined()
})
test('max is optional', () => {
page = 1
const paginator = paginatorFactory<User>(u)
const currentPage = paginator(page)
expect(currentPage.page).toEqual(1)
expect(currentPage.pages).toEqual(2)
expect(currentPage.total).toEqual(15)
expect(currentPage.error).toBeUndefined()
})
// max when -1 should be ignored and act as if max was default=10
test('max=-1(oob)_page=1', () => {
page = 1
const max: number = -1
const paginator = paginatorFactory<User>(u, max)
const currentPage = paginator(page)
expect(currentPage.page).toEqual(1)
expect(currentPage.pages).toEqual(2)
expect(currentPage.total).toEqual(15)
expect(currentPage.error).toBeUndefined()
})
// max out of bounds should be ignored and act as if max was default=10
test('max=-10(oob)_page=1', () => {
page = 1
const max: number = -10
const paginator = paginatorFactory<User>(u, max)
const currentPage = paginator(page)
expect(currentPage.page).toEqual(1)
expect(currentPage.pages).toEqual(2)
expect(currentPage.total).toEqual(15)
expect(currentPage.error).toBeUndefined()
})
// with no input for max, should return an error
test('noMax_page=1', () => {
page = 1
const paginator = paginatorFactory<User>(u)
const currentPage = paginator(page)
expect(currentPage.page).toEqual(1)
expect(currentPage.pages).toEqual(2)
expect(currentPage.total).toEqual(15)
})
// with an empty array as input, should return an error
test('emptyArrayInput1', () => {
expect(() => {
u = []
page = 1
const max: number = 5
paginatorFactory<User>(u, max)
}).toThrowError('The provided array was empty')
})
// with an empty array as input, should return an error
test('emptyArrayInput2', () => {
expect(() => {
u = []
page = 0
const max: number = 5
paginatorFactory<User>(u, max)
}).toThrowError('The provided array was empty')
})
// with an empty array as input, should return an error
test('emptyArrayInput3', () => {
expect(() => {
u = []
page = 1
const max: number = 0
paginatorFactory<User>(u, max)
}).toThrowError('The provided array was empty')
})
// with an empty array as input, should return an error
test('emptyArrayInput4', () => {
expect(() => {
u = []
page = 10
const max: number = 5
const paginator = paginatorFactory<User>(u, max)
paginator(page)
}).toThrowError('The provided array was empty')
})
// with an empty array as input, should return an error
test('emptyArrayInput5', () => {
expect(() => {
u = []
page = -10
const max: number = -5
const paginator = paginatorFactory<User>(u, max)
paginator(page)
}).toThrowError('The provided array was empty')
})
test('smallArrayInput', () => {
u = [{ ...users[0] }]
page = 1
const max: number = 1
const paginator = paginatorFactory<User>(u, max)
const currentPage = paginator(page)
expect(currentPage.items[0].id).toEqual(0)
expect(currentPage.page).toEqual(1)
expect(currentPage.pages).toEqual(1)
expect(currentPage.total).toEqual(1)
expect(currentPage.error).toBeUndefined()
})
test('smallArrayInput2', () => {
u = [{ ...users[0] }]
page = 1
const max: number = 10
const paginator = paginatorFactory<User>(u, max)
const currentPage = paginator(page)
expect(currentPage.items[0].id).toEqual(0)
expect(currentPage.page).toEqual(1)
expect(currentPage.pages).toEqual(1)
expect(currentPage.total).toEqual(1)
expect(currentPage.error).toBeUndefined()
})
//paginator should function as expected with multiple calls
test('paginator used twice', () => {
page = 1
const max: number = 5
const paginator = paginatorFactory<User>(u, max)
const currentPage = paginator(page)
expect(currentPage).toMatchSnapshot()
expect(currentPage.items.length).toEqual(5)
expect(currentPage.items[0].id).toEqual(0)
expect(currentPage.page).toEqual(1)
expect(currentPage.pages).toEqual(3)
expect(currentPage.total).toEqual(15)
expect(currentPage.error).toBeUndefined()
const nextPage = paginator(page + 1)
expect(nextPage).toMatchSnapshot()
expect(nextPage.items).toMatchSnapshot()
expect(nextPage.items.length).toEqual(5)
expect(nextPage.items[0].id).toEqual(5)
expect(nextPage.page).toEqual(2)
expect(nextPage.pages).toEqual(3)
expect(nextPage.total).toEqual(15)
expect(currentPage.error).toBeUndefined()
})
test('samePageTwice', () => {
page = 1
const max: number = 5
const paginator = paginatorFactory<User>(u, max)
const currentPage = paginator(page)
expect(currentPage.items.length).toEqual(5)
expect(currentPage.items[0].id).toEqual(0)
expect(currentPage.items[4].id).toEqual(4)
expect(currentPage.page).toEqual(1)
expect(currentPage.pages).toEqual(3)
expect(currentPage.total).toEqual(15)
const samePage = paginator(page)
expect(samePage).toEqual(currentPage)
expect(samePage.items.length).toEqual(5)
expect(samePage.items[0].id).toEqual(0)
expect(samePage.items[4].id).toEqual(4)
expect(samePage.page).toEqual(1)
expect(samePage.pages).toEqual(3)
expect(samePage.total).toEqual(15)
})
// should return an error
test('null1', () => {
expect(() => {
page = 1
const max: number = 5
// @ts-ignore
paginatorFactory<User>(null, max)
}).toThrowError('The provided array was invalid')
})
// should return an error
test('null2', () => {
expect(() => {
page = 1
// @ts-ignore
const paginator = paginatorFactory<User>(u, null)
paginator(page)
}).toThrowError('Max pages argument MUST be an integer')
})
// should return an error
test('null3', () => {
expect(() => {
// @ts-ignore
page = null
const max: number = 5
const paginator = paginatorFactory<User>(u, max)
paginator(page)
}).toThrowError('page argument MUST be an integer')
})
// should return an error
test('floating_point_max', () => {
expect(() => {
page = 1
const max: number = 1.5
const paginator = paginatorFactory<User>(u, max)
paginator(page)
}).toThrowError('Max pages argument MUST be an integer')
})
// should return an error
test('floating_point_page', () => {
expect(() => {
page = 1.5
const max: number = 5
const paginator = paginatorFactory<User>(u, max)
paginator(page)
}).toThrowError('page argument MUST be an integer')
})
})
/* tslint:disable:prefer-conditional-expression */
import { PaginatorResultInterface } from './interfaces'
/**
* Search result paginator.
*
* https://www.typescriptlang.org/docs/handbook/generics.html
* https://mariusschulz.com/blog/typing-functions-in-typescript#function-type-literals
* https://vincent.billey.me/pure-javascript-immutable-array/
* https://dev.to/gcanti/getting-started-with-fp-ts-either-vs-validation-5eja
* https://medium.com/@dhruvrajvanshi/making-exceptions-type-safe-in-typescript-c4d200ee78e9
*
* rows: the array of elements to be paginated
* pageSize: the maximum size of each individual page (last page may be smaller)
*
* @author Renoir Boulanger <contribs@renoirboulanger.com>
*/
export default <T>(
rows: T[],
max: number = 10,
): ((page: number) => PaginatorResultInterface<T>) => {
const items: T[] = []
let pageSize: number
let pages: number = 0
let total: number = 0
let maximum: number = 0
if (!Array.isArray(rows)) {
throw new Error('The provided array was invalid')
}
if (rows.length < 1) {
throw new Error('The provided array was empty')
}
if (!Number.isInteger(max)) {
throw new Error('Max pages argument MUST be an integer')
}
if (max < 1) {
pageSize = 10
} else {
pageSize = max
}
items.push(...rows)
total = rows.length
maximum = pageSize > total ? total : pageSize
pages = Math.ceil(total / maximum)
return function paginator(page: number = 1): PaginatorResultInterface<T> {
let error: string | false = false
if (!Number.isInteger(page)) {
throw new Error('page argument MUST be an integer')
}
const result: PaginatorResultInterface<T> = {
items: [],
page,
pageSize,
pages,
total,
}
if (pageSize < 1) {
error = 'The page size provided was less than one'
}
if (page > pages) {
error = `The page number provided (${page}) is larger than the maximum number of pages (${pages})`
}
if (page < 1) {
error = `The page number provided (${page}) cannot be under 1`
}
if (error) {
result.error = error
return result
}
const startIndex = (page - 1) * maximum
const endIndex = startIndex + maximum
result.items.push(...items.slice(startIndex, endIndex))
return result
}
}

Requirements

  1. Create a wrapper/factory function that takes two arguments, returns another function ("paginator"): Objective is to tell what namespace to paginate, validate that the desired paginatable is applicable. returned function will be called at page hooks to change page and so on.
  • Take a collection of objects
  • Tell which key is the primary key (default to "id")
  • Return function on which we can ask "which page" we're on.
  • Calling returned function only gives objects for that page, or an { empty } array. Arguments (order TBD):
    • alpha: Vuex Namespace name of a object collection (e.g. request/rows)
      • MUST be an array, of shallow objects (i.e. object with properties that has strings, boolean, number, arrays, nothing else)
    • bravo: Which key to use as primary key
      • MUST be a string
      • Tell which key to use for each object ask { key }
      • Check if that { key } exists, or Throw
    • charlie: Max per page - MUST be a number Returns signature: paginator(page: number = 0): T[] => [] Notice signature: MUST return an array
  1. Create a handler ("paginator") that takes outcome , handles current page view: It's objective is to return array of only items for this page. Arguments (order TBD):
  • alpha: Current page
    • MUST be a number
    • If higher than max, return empty array
  1. VueX handler that feeds wrapper factory and is part of public interface:

Testing strategy:

  • Create an array of 100 items, with a few properties, and one of them with an increment number
  • Pass that array to factory, it returns a function
  • Find edge-cases (e.g. negative number, page too far, return empty array)
  • ... TBD

Things to avoid:

  • NO HTTP calls, just ask Vuex a collection
  • Test manipulation as internal method
  • NO Vue.js dependency, ONLY simple manipulation and plain objects

Inspiration:


Manuals:

/* eslint-env jest */
import paginatorFactory from './paginator'
class InventoryItem {
constructor(public name: string, public id: number) {}
}
const collection = () => [
new InventoryItem('Shaving Cream', 0),
new InventoryItem('Ice Cream', 1),
new InventoryItem('Carrots', 2),
new InventoryItem('Cucumber', 3),
new InventoryItem('Apples', 4),
new InventoryItem('Salt', 5),
new InventoryItem('Blackberries', 6),
]
describe('Sanity', () => {
test('alpha', () => {
const items = collection()
const pageSize = 2
const paginator = paginatorFactory<InventoryItem>(items, pageSize)
const firstPage = paginator(1)
expect(firstPage).toMatchSnapshot()
const pageNumber = firstPage.pages // 4
const lastPage = paginator(pageNumber)
expect(lastPage).toHaveProperty('page', pageNumber)
expect(lastPage).toHaveProperty('pages', 4)
expect(lastPage).toHaveProperty('pageSize', pageSize)
expect(lastPage).toHaveProperty('total', items.length /* 7 */)
expect(lastPage).toHaveProperty('items', [{ name: 'Blackberries', id: 6 }])
})
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment