Skip to content

Instantly share code, notes, and snippets.

@tunecino
Last active December 1, 2021 01:40
Show Gist options
  • Save tunecino/366b07ad532a667d0d0b7f45b1ff0191 to your computer and use it in GitHub Desktop.
Save tunecino/366b07ad532a667d0d0b7f45b1ff0191 to your computer and use it in GitHub Desktop.
/**
* use case example :
*
* Lets say we want to ask API for 50 products, with date = today and having 'abc' in their names,
* we only want to retrieve 'name' and 'sold' fields from db (Yii uses SQL SELECT for this),
* but also joined with their related shop (having shop defined as relation (see extraFields method))
*
* Code to run inside Vue component:
*
* import { Collection } from "~/scripts/yii.js";
*
* const products = new Collection('products');
* poroducts.select(['name', 'sold']);
* products.with('shop');
* products.where({'name': 'abc', 'date': 'today'});
* products.load(50);
*
* console.log(products.data); //<- received data stored here
* (note: while where accepts an object, select and with accepts either a string or an array of strings)
*
* Once done (after calling load() to do the xhr), pagination can be handled by calling any of the following methods:
*
* products.nextPage()
* products.prevPage()
* products.firstPage()
* products.lastPage()
* products.getPage(15)
* products.refresh() // <-- reloads current page
* All of them will update products.data.
*
* Collection can also receive a config object, the following for example allow accumulating data resuts
* each time a next page is requested into 'data' instead of overriding old results, which is useful for infinit scroll implementations:
*
* const products = new Collection('products', {accumulative: true});
*
* If for some reasons, Yii may send duplicate data, then this may help removing them at client side:
*
* const products = new Collection('products', {removeDuplicates: true});
*
* There are also few booleans to use in view/template like when you want to disable a button or show a loader:
*
* products.isLoading()
* products.isLoadingFirstPage()
* products.isFirst()
* products.isLast()
* products.existNext()
* products.existPrev()
* And finally, products.meta should hold pagination info:
*
* "meta": {
* "currentPage": 1,
* "pageCount": 39352,
* "perPage": 4,
* "totalCount": 157408
* },
**/
import axios from 'axios'
import isPlainObject from 'lodash.isplainobject'
import isArray from 'lodash.isarray'
import pick from 'lodash.pick'
import { parseLinkHeader, parseUrlParams, pregQuote } from './parsers.js'
const totalCountHeader = 'x-pagination-total-count'
const pageCountHeader = 'x-pagination-page-count'
const currentPageHeader = 'x-pagination-current-page'
const perPageHeader = 'x-pagination-per-page'
export class BaseModel {
_isLoading = false
pendingRequests = 0
_source = undefined
_axios = null
_route = null
_expand = undefined
_fields = undefined
_headers = undefined
accumulative = false
removeDuplicates = false
constructor(route, config) {
/**
* Ensure Collection have a valid route to request like 'products' for example.
*/
if (typeof route !== 'string') throw new Error('route name is missing')
this._route = route
/**
* handle configs
*/
if (config && config.accumulative) {
this.accumulative = true
}
if (config && config.removeDuplicates) {
this.removeDuplicates = true
}
this._axios = (config && config.axios) || axios
/**
* Prepare axios token to be used on cancelations.
*/
this._cancelPendingRequests()
}
_cancelPendingRequests() {
if (typeof this._source !== 'undefined') {
this._source.cancel('Operation canceled due to new request.')
}
this._source = axios.CancelToken.source()
}
isLoading() {
return this._isLoading
}
get expand() {
return this._expand
}
get fields() {
return this._fields
}
get headers() {
return this._headers
}
select(fields) {
this._fields = isArray(fields) ? fields.join() : fields
}
with(resource) {
this._expand = isArray(resource) ? resource.join() : resource
}
setHeaders(config) {
if (isPlainObject(config) === false) {
throw new Error('input should be contained inside an object')
}
this._headers = config
}
}
export class Collection extends BaseModel {
data = []
meta = []
perPage = 20
page = 1
_links = {}
_filters = {}
_isFirstPage = false
get links() {
return this._links
}
set links(obj) {
if (isPlainObject(obj) === false || typeof obj.self === 'undefined') {
throw new TypeError(
'unexpected structure for the navigation links parsed from the response headers'
)
}
this._links = obj
}
get filters() {
return this._filters
}
set filters(obj) {
if (isPlainObject(obj) === false) {
throw new TypeError('input should be contained in an object')
}
this._filters = obj
}
get currentSort() {
return this.filters.sort
}
get params() {
const params = {
'per-page': this.perPage,
page: this.page,
expand: this.expand,
fields: this.fields,
}
return { ...params, ...this.filters }
}
isLoadingFirstPage() {
return this.isLoading() && this._isFirstPage
}
isFirst() {
return this.meta.currentPage === 1
}
isLast() {
return this.meta.currentPage === this.meta.pageCount
}
existNext() {
return typeof this.links.next !== 'undefined'
}
existPrev() {
return typeof this.links.prev !== 'undefined'
}
load(perPage) {
if (perPage) this.perPage = perPage
this._isLoading = true
this._isFirstPage = true
return this.requestData()
}
nextPage() {
if (this.existNext() === false) return
return this.requestData(this.links.next)
}
prevPage() {
if (this.accumulative) {
throw new Error('method is disabled when using accumulative aproach')
}
if (this.existPrev() === false) return
return this.requestData(this.links.prev)
}
firstPage() {
if (this.accumulative) {
throw new Error('method is disabled when using accumulative aproach')
}
if (this.isFirst() === true) return
return this.requestData(this.links.first)
}
lastPage() {
if (this.accumulative) {
throw new Error('method is disabled when using accumulative aproach')
}
if (this.isLast() === true) return
return this.requestData(this.links.last)
}
refresh() {
if (this.accumulative) {
throw new Error('method is disabled when using accumulative aproach')
}
return this.requestData(this.links.self)
}
getPage(pageNumber) {
if (this.accumulative) {
throw new Error('method is disabled when using accumulative aproach')
}
if (
pageNumber === this.meta.currentPage ||
pageNumber > this.meta.totalCount
) {
return
}
this.page = pageNumber
return this.load()
}
where(params) {
if (this.accumulative && Object.keys(params).length > 0) {
// reset data if using accumulative aproach and we a alter filters
this.data = []
}
this.filters = { ...params, page: 1 }
}
andWhere(params) {
if (this.accumulative && Object.keys(params).length > 0) {
// reset data if using accumulative aproach and we a alter filters
this.data = []
}
this.filters = { ...this.filters, ...params, page: 1 }
}
sort(fields) {
this.andWhere({ ...this.filters, sort: fields })
}
requestData(url = null) {
let page = null
const config = {}
if (url === null) {
url = this._route
config.params = this.params
page = this.params.page
} else {
const p = parseUrlParams(url)
page = +p.page
}
if (page === 1) {
this._cancelPendingRequests()
}
config.cancelToken = this._source.token
this._isLoading = true
this.pendingRequests++
return this._axios
.get(url, config)
.then((response) => {
const data = this.parseResponse(response)
this.pendingRequests--
if (this.pendingRequests < 1) {
this._isLoading = false
if (this._isFirstPage) {
this._isFirstPage = false
}
}
return data
})
.catch((e) => {
this.pendingRequests--
if (this.pendingRequests < 1) {
this._isLoading = false
if (this._isFirstPage) {
this._isFirstPage = false
}
}
return Promise.reject(e)
})
}
parseResponse(response) {
this.meta = {
currentPage: +response.headers[currentPageHeader],
pageCount: +response.headers[pageCountHeader],
perPage: +response.headers[perPageHeader],
totalCount: +response.headers[totalCountHeader],
}
const serverData = response.data.map((data) => {
const resource = new Resource(this._route)
resource._setData(data, true)
return resource
})
if (this.accumulative && this.meta.currentPage > 1) {
if (this.removeDuplicates) {
const data = this.data
const clean = serverData.filter((i) => {
const c = data.filter((d) => d.id === i.id)
return c.length === 0
})
this.data = [...data, ...clean]
} else {
this.data = [...this.data, ...serverData]
}
} else {
this.data = serverData
}
const headerLink = response.headers.link
if (!headerLink) {
throw new Error(
"Enable to parse headers. Ensure 'Link' headers is exposed to browser by Yii within 'Access-Control-Expose-Headers' CORS attribute."
)
}
this.links = parseLinkHeader(headerLink)
// update local params
if (this.links.self) {
const params = parseUrlParams(this.links.self)
if (params.perPage) this.perPage = +params.perPage
if (params.page) this.page = +params.page
if (params.expand) this._expand = params.expand
if (params.fields) this._fields = params.fields
}
return serverData
}
}
export class Resource extends BaseModel {
$pk = 'id'
$errors = {}
$fromServer = false
$isLoading = false
_properties = []
get $primaryKey() {
return this[this.$pk]
}
get $isNew() {
return this.$fromServer === false || typeof this.$primaryKey === 'undefined'
}
get hasErrors() {
if (this.$errors.constructor !== Object) {
throw new Error('$errors is expected to be an object')
}
return Object.keys(this.$errors).length !== 0
}
get $serverData() {
return pick(this, this._properties)
}
_setData(obj, server = false) {
if (isPlainObject(obj) === false) {
throw new Error('properties should be contained inside an object')
}
this._properties = Object.keys(obj)
Object.assign(this, obj)
if (server) {
this.$fromServer = true
}
}
clearErrors() {
if (this.$hasErrors === true) this.$errors = {}
}
update() {
if (this.$isNew) {
throw new Error('item should be first saved')
}
this.$isLoading = true
this.clearErrors()
const config = {
cancelToken: this._source.token,
}
return this._axios
.put(this._route + `/${this.$primaryKey}`, this.$serverData, config)
.then((response) => {
this._setData(response.data, true)
this.$isLoading = false
return this
})
.catch((e) => {
this.$isLoading = false
const errors = this._handle422Errors(e)
return Promise.reject(errors)
})
}
create() {
if (!this.$isNew) {
throw new Error('item exists')
}
this.$isLoading = true
this.clearErrors()
const config = {
cancelToken: this._source.token,
}
return this._axios
.post(this._route, this, config)
.then((response) => {
this._setData(response.data, true)
this.$isLoading = false
return this
})
.catch((e) => {
this.$isLoading = false
const errors = this._handle422Errors(e)
return Promise.reject(errors)
})
}
save() {
return this.$isNew ? this.create() : this.update()
}
delete() {
if (this.$isNew) {
throw new Error('item should be first saved')
}
this.$isLoading = true
this.clearErrors()
const config = {
cancelToken: this._source.token,
}
return this._axios
.delete(this._route + `/${this.$primaryKey}`, config)
.then((response) => {
this.$isLoading = false
return response
})
.catch((e) => {
this.$isLoading = false
const errors = this._handle422Errors(e)
return Promise.reject(errors)
})
}
link(relation, id) {
if (!relation || !id) {
throw new Error(
'relation (or route) name and its id to which linking should be performed are required.'
)
}
if (this.$isNew) {
return this.create().then((item) => item.link(relation, id))
}
this.$isLoading = true
this.clearErrors()
const config = {
cancelToken: this._source.token,
}
return this._axios
.put(
`${relation}/${id}/${this._route}/${this.$primaryKey}`,
this.$serverData,
config
)
.then((response) => {
this.$isLoading = false
return response
})
.catch((e) => {
this.$isLoading = false
const errors = this._handle422Errors(e)
return Promise.reject(errors)
})
}
unlink(relation, id) {
if (!relation || !id) {
throw new Error(
'relation (or route) name and its id to which linking should be performed are required.'
)
}
if (this.$isNew) {
throw new Error('item should be first saved')
}
this.$isLoading = true
this.clearErrors()
const config = {
cancelToken: this._source.token,
}
return this._axios
.delete(`${relation}/${id}/${this._route}/${this.$primaryKey}`, config)
.then((response) => {
this.$isLoading = false
return response
})
.catch((e) => {
this.$isLoading = false
const errors = this._handle422Errors(e)
return Promise.reject(errors)
})
}
createAndLink(relation, id) {
if (!relation || !id) {
throw new Error(
'relation (or route) name and its id to which linking should be performed are required.'
)
}
if (!this.$isNew) {
throw new Error('item exists')
}
this.$isLoading = true
this.clearErrors()
const config = {
cancelToken: this._source.token,
}
return this._axios
.post(`${relation}/${id}/${this._route}`, this, config)
.then((response) => {
this._setData(response.data, true)
this.$isLoading = false
return this
})
.catch((e) => {
this.$isLoading = false
const errors = this._handle422Errors(e)
return Promise.reject(errors)
})
}
_handle422Errors(e) {
if (e && e.response && e.response.status === 422) {
e.response.data.forEach((error) => {
this.$errors[error.field] = {
message: error.message,
pattern: '(?!^' + pregQuote(this[error.field]) + '$)(^.*$)',
}
})
return this.$errors
}
return e
}
}
export function parseLinkHeader(header) {
const links = {}
const parts = header.split(',')
for (let i = 0; i < parts.length; i++) {
const section = parts[i].split(';')
if (section.length !== 2) {
throw new Error("section could not be split on ';'")
}
const url = section[0].replace(/<(.*)>/, '$1').trim()
const name = section[1].replace(/rel=(.*)/, '$1').trim()
links[name] = url
}
return links
}
export function parseUrlParams(url) {
const params = {}
const parts = url.slice(url.indexOf('?') + 1).split('&')
for (let i = 0; i < parts.length; i++) {
const query = parts[i].split('=')
params[query[0]] = query[1]
}
return params
}
export function pregQuote(str, delimiter) {
// discuss at: http://locutus.io/php/preg_quote/
// original by: booeyOH
// improved by: Ates Goral (http://magnetiq.com)
// improved by: Kevin van Zonneveld (http://kvz.io)
// improved by: Brett Zamir (http://brett-zamir.me)
// bugfixed by: Onno Marsman (https://twitter.com/onnomarsman)
// example 1: preg_quote("$40")
// returns 1: '\\$40'
// example 2: preg_quote("*RRRING* Hello?")
// returns 2: '\\*RRRING\\* Hello\\?'
// example 3: preg_quote("\\.+*?[^]$(){}=!<>|:")
// returns 3: '\\\\\\.\\+\\*\\?\\[\\^\\]\\$\\(\\)\\{\\}\\=\\!\\<\\>\\|\\:'
return (str + '').replace(
new RegExp(
'[.\\\\+*?\\[\\^\\]$(){}=!<>|:\\' + (delimiter || '') + '-]',
'g'
),
'\\$&'
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment