Skip to content

Commit 9feaec0

Browse files
authored
Merge pull request #10 from ecamp/refactor-to-classes
Refactor to classes
2 parents b8a0168 + b998b98 commit 9feaec0

12 files changed

+964
-335
lines changed

src/CanHaveItems.js

+73
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { isEntityReference } from './halHelpers.js'
2+
import LoadingStoreCollection from './LoadingStoreCollection'
3+
4+
class CanHaveItems {
5+
constructor ({ get, reload, isUnknown }, config) {
6+
this.apiActions = { get, reload, isUnknown }
7+
this.config = config
8+
}
9+
10+
/**
11+
* Defines a property getter for the items property.
12+
* The items property should always be a getter, in order to make the call to mapArrayOfEntityReferences
13+
* lazy, since that potentially fetches a large number of entities from the API.
14+
* @param items array of items, which can be mixed primitive values and entity references
15+
* @param fetchAllUri URI that allows fetching all collection items in a single network request, if known
16+
* @param property property name inside the entity fetched at fetchAllUri that contains the collection
17+
* @returns object the target object with the added getter
18+
*/
19+
addItemsGetter (items, fetchAllUri, property) {
20+
Object.defineProperty(this, 'items', { get: () => this.filterDeleting(this.mapArrayOfEntityReferences(items, fetchAllUri, property)) })
21+
Object.defineProperty(this, 'allItems', { get: () => this.mapArrayOfEntityReferences(items, fetchAllUri, property) })
22+
}
23+
24+
filterDeleting (array) {
25+
return array.filter(entry => !entry._meta.deleting)
26+
}
27+
28+
/**
29+
* Given an array, replaces any entity references in the array with the entity loaded from the Vuex store
30+
* (or from the API if necessary), and returns that as a new array. In case some of the entity references in
31+
* the array have not finished loading yet, returns a LoadingStoreCollection instead.
32+
* @param array possibly mixed array of values and references
33+
* @param fetchAllUri URI that allows fetching all array items in a single network request, if known
34+
* @param fetchAllProperty property in the entity from fetchAllUri that will contain the array
35+
* @returns array the new array with replaced items, or a LoadingStoreCollection if any of the array
36+
* elements is still loading.
37+
*/
38+
mapArrayOfEntityReferences (array, fetchAllUri, fetchAllProperty) {
39+
if (!this.containsUnknownEntityReference(array)) {
40+
return this.replaceEntityReferences(array)
41+
}
42+
43+
if (this.config.avoidNPlusOneRequests) {
44+
const completelyLoaded = this.apiActions.reload({ _meta: { reload: { uri: fetchAllUri, property: fetchAllProperty } } }, true)
45+
.then(() => this.replaceEntityReferences(array))
46+
return new LoadingStoreCollection(completelyLoaded)
47+
} else {
48+
const arrayWithReplacedReferences = this.replaceEntityReferences(array)
49+
const arrayCompletelyLoaded = Promise.all(array.map(entry => {
50+
if (isEntityReference(entry)) {
51+
return this.apiActions.get(entry.href)._meta.load
52+
}
53+
return Promise.resolve(entry)
54+
}))
55+
return new LoadingStoreCollection(arrayCompletelyLoaded, arrayWithReplacedReferences)
56+
}
57+
}
58+
59+
replaceEntityReferences (array) {
60+
return array.map(entry => {
61+
if (isEntityReference(entry)) {
62+
return this.apiActions.get(entry.href)
63+
}
64+
return entry
65+
})
66+
}
67+
68+
containsUnknownEntityReference (array) {
69+
return array.some(entry => isEntityReference(entry) && this.apiActions.isUnknown(entry.href))
70+
}
71+
}
72+
73+
export default CanHaveItems

src/EmbeddedCollection.js

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import CanHaveItems from './CanHaveItems.js'
2+
import LoadingStoreCollection from './LoadingStoreCollection'
3+
4+
/**
5+
* Imitates a full standalone collection with an items property, even if there is no separate URI (as it
6+
* is the case with embedded collections).
7+
* Reloading an embedded collection requires special information. Since the embedded collection has no own
8+
* URI, we need to reload the whole entity containing the embedded collection. Some extra info about the
9+
* containing entity must therefore be passed to this function.
10+
* @param items array of items, which can be mixed primitive values and entity references
11+
* @param reloadUri URI of the entity containing the embedded collection (for reloading)
12+
* @param reloadProperty property in the containing entity under which the embedded collection is saved
13+
* @param loadPromise a promise that will resolve when the parent entity has finished (re-)loading
14+
*/
15+
class EmbeddedCollection extends CanHaveItems {
16+
constructor (items, reloadUri, reloadProperty, { get, reload, isUnknown }, config, loadPromise = null) {
17+
super({ get, reload, isUnknown }, config)
18+
this._meta = {
19+
load: loadPromise
20+
? loadPromise.then(loadedParent => new EmbeddedCollection(loadedParent[reloadProperty], reloadUri, reloadProperty, { get, reload, isUnknown }, config))
21+
: Promise.resolve(this),
22+
reload: { uri: reloadUri, property: reloadProperty }
23+
}
24+
this.addItemsGetter(items, reloadUri, reloadProperty)
25+
}
26+
27+
$loadItems () {
28+
return new Promise((resolve) => {
29+
const items = this.items
30+
if (items instanceof LoadingStoreCollection) items._meta.load.then(result => resolve(result))
31+
else resolve(items)
32+
})
33+
}
34+
}
35+
36+
export default EmbeddedCollection

src/LoadingStoreCollection.js

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import LoadingStoreValue from './LoadingStoreValue'
2+
3+
/**
4+
* Returns a placeholder for an array that has not yet finished loading from the API. The array placeholder
5+
* will respond to functional calls (like .find(), .map(), etc.) with further LoadingStoreCollections or
6+
* LoadingStoreValues. If passed the existingContent argument, random access and .length will also work.
7+
* @param arrayLoaded Promise that resolves once the array has finished loading
8+
* @param existingContent optionally set the elements that are already known, for random access
9+
*/
10+
class LoadingStoreCollection {
11+
constructor (arrayLoaded, existingContent = []) {
12+
const singleResultFunctions = ['find']
13+
const arrayResultFunctions = ['map', 'flatMap', 'filter']
14+
this._meta = { load: arrayLoaded }
15+
singleResultFunctions.forEach(func => {
16+
existingContent[func] = (...args) => {
17+
const resultLoaded = arrayLoaded.then(array => array[func](...args))
18+
return new LoadingStoreValue(resultLoaded)
19+
}
20+
})
21+
arrayResultFunctions.forEach(func => {
22+
existingContent[func] = (...args) => {
23+
const resultLoaded = arrayLoaded.then(array => array[func](...args))
24+
return new LoadingStoreCollection(resultLoaded)
25+
}
26+
})
27+
return existingContent
28+
}
29+
}
30+
31+
export default LoadingStoreCollection

src/LoadingStoreValue.js

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import LoadingStoreCollection from './LoadingStoreCollection'
2+
3+
/**
4+
* Creates a placeholder for an entity which has not yet finished loading from the API.
5+
* Such a LoadingStoreValue can safely be used in Vue components, since it will render as an empty
6+
* string and Vue's reactivity system will replace it with the real data once that is available.
7+
*
8+
* Accessing nested functions in a LoadingStoreValue yields another LoadingStoreValue:
9+
* new LoadingStoreValue(...).author().organization() // gives another LoadingStoreValue
10+
*
11+
* Using a LoadingStoreValue or a property of a LoadingStoreValue in a view renders to empty strings:
12+
* let user = new LoadingStoreValue(...)
13+
* 'The "' + user + '" is called "' + user.name + '"' // gives 'The "" is called ""'
14+
*
15+
* @param entityLoaded a Promise that resolves to a StoreValue when the entity has finished
16+
* loading from the API
17+
* @param absoluteSelf optional fully qualified URI of the entity being loaded, if available. If passed, the
18+
* returned LoadingStoreValue will return it in calls to .self and ._meta.self
19+
*/
20+
class LoadingStoreValue {
21+
constructor (entityLoaded, absoluteSelf = null) {
22+
const handler = {
23+
get: function (target, prop, _) {
24+
if (prop === Symbol.toPrimitive) {
25+
return () => ''
26+
}
27+
if (['then', 'toJSON', Symbol.toStringTag, 'state', 'getters', '$options', '_isVue', '__file', 'render', 'constructor'].includes(prop)) {
28+
// This is necessary so that Vue's reactivity system understands to treat this LoadingStoreValue
29+
// like a normal object.
30+
return undefined
31+
}
32+
if (prop === 'loading') {
33+
return true
34+
}
35+
if (prop === 'load') {
36+
return entityLoaded
37+
}
38+
if (prop === 'self') {
39+
return absoluteSelf
40+
}
41+
if (prop === '_meta') {
42+
// When _meta is requested on a LoadingStoreValue, we keep on using the unmodified promise, because
43+
// ._meta.load is supposed to resolve to the whole object, not just the ._meta part of it
44+
return new LoadingStoreValue(entityLoaded, absoluteSelf)
45+
}
46+
if (['$reload'].includes(prop)) {
47+
// Skip reloading entities that are already loading
48+
return () => entityLoaded
49+
}
50+
if (['$loadItems', '$post', '$patch', '$del'].includes(prop)) {
51+
// It is important to call entity[prop] without first saving it into a variable, because saving to a
52+
// variable would change the value of `this` inside the function
53+
return (...args) => entityLoaded.then(entity => entity[prop](...args))
54+
}
55+
const propertyLoaded = entityLoaded.then(entity => entity[prop])
56+
if (['items', 'allItems'].includes(prop)) {
57+
return new LoadingStoreCollection(propertyLoaded)
58+
}
59+
// Normal property access: return a function that yields another LoadingStoreValue and renders as empty string
60+
const result = templateParams => new LoadingStoreValue(propertyLoaded.then(property => property(templateParams)._meta.load))
61+
result.loading = true
62+
result.toString = () => ''
63+
return result
64+
}
65+
}
66+
return new Proxy(this, handler)
67+
}
68+
}
69+
70+
export default LoadingStoreValue

src/QueryablePromise.js

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* This function allow you to modify a JS Promise by adding some status properties.
3+
* Based on: http://stackoverflow.com/questions/21485545/is-there-a-way-to-tell-if-an-es6-promise-is-fulfilled-rejected-resolved
4+
* But modified according to the specs of promises : https://promisesaplus.com/
5+
*/
6+
class QueryablePromise {
7+
constructor (promise) {
8+
// Don't modify any promise that has been already modified
9+
if (Symbol.for('isPending') in promise) return promise
10+
11+
// Set initial state
12+
let isPending = true
13+
let isRejected = false
14+
let isFulfilled = false
15+
16+
// Observe the promise, saving the fulfillment in a closure scope.
17+
const queryablePromise = promise.then(
18+
function (v) {
19+
isFulfilled = true
20+
isPending = false
21+
return v
22+
},
23+
function (e) {
24+
isRejected = true
25+
isPending = false
26+
throw e
27+
}
28+
)
29+
30+
Object.defineProperty(queryablePromise, Symbol.for('isFulfilled'), { get: function () { return isFulfilled } })
31+
Object.defineProperty(queryablePromise, Symbol.for('isPending'), { get: function () { return isPending } })
32+
Object.defineProperty(queryablePromise, Symbol.for('isRejected'), { get: function () { return isRejected } })
33+
Object.defineProperty(queryablePromise, Symbol.for('done'), { get: function () { return !isPending } }) // official terminology would be 'isSettled' or 'isResolved' (https://stackoverflow.com/questions/29268569/what-is-the-correct-terminology-for-javascript-promises)
34+
35+
return queryablePromise
36+
}
37+
38+
/**
39+
* Returns a resolved Promise and immediately mark it as 'done'
40+
*/
41+
static resolve (value) {
42+
const promise = Promise.resolve(value)
43+
44+
promise[Symbol.for('isFulfilled')] = true
45+
promise[Symbol.for('isPending')] = false
46+
promise[Symbol.for('isRejected')] = false
47+
promise[Symbol.for('done')] = true
48+
49+
return promise
50+
}
51+
}
52+
53+
export default QueryablePromise

src/StoreValue.js

+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import urltemplate from 'url-template'
2+
import { isTemplatedLink, isEntityReference, isCollection } from './halHelpers.js'
3+
import QueryablePromise from './QueryablePromise.js'
4+
import EmbeddedCollection from './EmbeddedCollection.js'
5+
import CanHaveItems from './CanHaveItems.js'
6+
7+
/**
8+
* Creates an actual StoreValue, by wrapping the given Vuex store data. The data must not be loading.
9+
* If the data has been loaded into the store before but is currently reloading, the old data will be
10+
* returned, along with a ._meta.load promise that resolves when the reload is complete.
11+
* @param data fully loaded entity data from the Vuex store
12+
*/
13+
class StoreValue extends CanHaveItems {
14+
constructor (data, { get, reload, post, patch, del, isUnknown }, StoreValueCreator, config) {
15+
super({ get, reload, isUnknown }, config)
16+
17+
this.apiActions = { get, reload, post, patch, del, isUnknown }
18+
this.config = config
19+
20+
Object.keys(data).forEach(key => {
21+
const value = data[key]
22+
if (key === 'allItems' && isCollection(data)) return
23+
if (key === 'items' && isCollection(data)) {
24+
this.addItemsGetter(data[key], data._meta.self, key)
25+
} else if (Array.isArray(value)) {
26+
this[key] = () => new EmbeddedCollection(value, data._meta.self, key, { get, reload, isUnknown }, config, data._meta.load)
27+
} else if (isEntityReference(value)) {
28+
this[key] = () => this.apiActions.get(value.href)
29+
} else if (isTemplatedLink(value)) {
30+
this[key] = templateParams => this.apiActions.get(urltemplate.parse(value.href).expand(templateParams || {}))
31+
} else {
32+
this[key] = value
33+
}
34+
})
35+
36+
// Use a trivial load promise to break endless recursion, except if we are currently reloading the data from the API
37+
const loadedPromise = data._meta.load && !data._meta.load[Symbol.for('done')]
38+
? data._meta.load.then(reloadedData => StoreValueCreator.wrap(reloadedData))
39+
: QueryablePromise.resolve(this)
40+
41+
// Use a shallow clone of _meta, since we don't want to overwrite the ._meta.load promise or self link in the Vuex store
42+
this._meta = { ...data._meta, load: loadedPromise, self: this.config.apiRoot + data._meta.self }
43+
}
44+
45+
$reload () {
46+
return this.apiActions.reload(this._meta.self)
47+
}
48+
49+
$loadItems () {
50+
return this._meta.load
51+
}
52+
53+
$post (data) {
54+
return this.apiActions.post(this._meta.self, data)
55+
}
56+
57+
$patch (data) {
58+
return this.apiActions.patch(this._meta.self, data)
59+
}
60+
61+
$del () {
62+
return this.apiActions.del(this._meta.self)
63+
}
64+
}
65+
66+
export default StoreValue

src/StoreValueCreator.js

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import StoreValue from './StoreValue.js'
2+
import LoadingStoreValue from './LoadingStoreValue.js'
3+
4+
class StoreValueCreator {
5+
constructor ({ get, reload, post, patch, del, isUnknown }, config = {}) {
6+
this.apiActions = { get, reload, post, patch, del, isUnknown }
7+
this.config = config
8+
}
9+
10+
/**
11+
* Takes data from the Vuex store and makes it more usable in frontend components. The data stored
12+
* in the Vuex store should always be JSON serializable according to
13+
* https://github.com/vuejs/vuex/issues/757#issuecomment-297668640. Therefore, we wrap the data into
14+
* a new object, and provide accessor methods for related entities. Such an accessor method fetches the
15+
* related entity from the Vuex store (or the API if necessary) when called. In case the related entity
16+
* is still being loaded from the API, a LoadingStoreValue is returned.
17+
*
18+
* Example:
19+
* // Data of an entity like it comes from the Vuex store:
20+
* let storeData = {
21+
* numeric_property: 3,
22+
* reference_to_other_entity: {
23+
* href: '/uri/of/other/entity'
24+
* },
25+
* _meta: {
26+
* self: '/self/uri'
27+
* }
28+
* }
29+
* // Apply StoreValue
30+
* let usable = storeValue(...)(storeData)
31+
* // Now we can use accessor methods
32+
* usable.reference_to_other_entity() // returns the result of this.api.get('/uri/of/other/entity')
33+
*
34+
* @param data entity data from the Vuex store
35+
* @returns object wrapped entity ready for use in a frontend component
36+
*/
37+
wrap (data) {
38+
const meta = data._meta || { load: Promise.resolve() }
39+
40+
if (meta.loading) {
41+
const entityLoaded = meta.load.then(loadedData => new StoreValue(loadedData, this.apiActions, this, this.config))
42+
return new LoadingStoreValue(entityLoaded, this.config.apiRoot + meta.self)
43+
}
44+
45+
return new StoreValue(data, this.apiActions, this, this.config)
46+
}
47+
}
48+
49+
export default StoreValueCreator

0 commit comments

Comments
 (0)