Last active
December 1, 2021 01:40
-
-
Save tunecino/366b07ad532a667d0d0b7f45b1ff0191 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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 | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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