Comparing version
{ | ||
"name": "clockvine", | ||
"version": "2.0.0-alpha.8", | ||
"version": "2.0.0-alpha.10", | ||
"description": "", | ||
@@ -37,3 +37,6 @@ "author": "Kevin Hamer [kh] <kevin@imarc.com>", | ||
"vitest": "^0.24.3" | ||
}, | ||
"dependencies": { | ||
"path-to-regexp": "^6.2.1" | ||
} | ||
} |
@@ -1,7 +0,3 @@ | ||
export { default as DefaultUrlFormatter } from './DefaultUrlFormatter.js' | ||
export { default as JsonApi } from './JsonApi.js' | ||
export { default as JsonSingletonApi } from './JsonSingletonApi.js' | ||
export { default as SingletonUrlFormatter } from './SingletonUrlFormatter.js' | ||
export { default as UrlFormatter } from './UrlFormatter.js' | ||
export { default as XsrfFetch } from './XsrfFetch.js' | ||
export { default as defineApiStore } from './defineApiStore.js' | ||
export { default as useXsrfTokens } from './useXsrfTokens.js' |
import { defineStore } from 'pinia' | ||
import { reactive, toRef, computed, unref } from 'vue' | ||
import { reactive, toRef, computed, toValue } from 'vue' | ||
import JsonApi from './JsonApi' | ||
const nestedUnref = obj => { | ||
const result = Object.fromEntries(Object.entries(unref(obj)).map(([k, v]) => [k, unref(v)])) | ||
const nestedToValue = (obj) => { | ||
const result = Object.fromEntries( | ||
Object.entries(toValue(obj)).map(([k, v]) => [k, toValue(v)]) | ||
) | ||
return result | ||
@@ -15,15 +17,20 @@ } | ||
export default function defineApiStore ( | ||
const defineApiStore = function defineApiStore ( | ||
name, | ||
api, | ||
{ | ||
idField = 'id', | ||
indexDataField = 'data', | ||
showRequiresKey = true | ||
} = {} | ||
idField = defineApiStore.config.idField, | ||
indexDataField = defineApiStore.config.indexDataField, | ||
showRequiresKey = defineApiStore.config.showRequiresKey | ||
} = {}, | ||
apiActions = {} | ||
) { | ||
if (typeof api === 'string' || typeof api === 'function') { | ||
api = new JsonApi(api) | ||
if (typeof name !== 'string') { | ||
throw new Error('Store name must be a string.') | ||
} | ||
if (typeof api === 'string') { | ||
api = new JsonApi(api, defineApiStore.config.ApiOptions) | ||
} | ||
return defineStore(name, () => { | ||
@@ -46,2 +53,7 @@ /** | ||
/** | ||
* Custom actions. | ||
*/ | ||
const actions = {} | ||
// ========================================================================= | ||
@@ -74,4 +86,3 @@ // = Low Level | ||
const mergeElement = (key, element) => { | ||
const oldElement = unref(elements[key]) === undefined ? {} : unref(elements[key]) | ||
elements[key] = Object.assign(oldElement, element) | ||
elements[key] = Object.assign(elements[key] ?? {}, element) | ||
elementState[key] = VALID | ||
@@ -87,7 +98,4 @@ return toRef(elements, key) | ||
*/ | ||
const mergeElements = elements => { | ||
if (!elements.map) { | ||
console.error('elements.map not defined', elements) | ||
} | ||
return elements.map(element => mergeElement(element[idField], element)) | ||
const mergeElements = (elements) => { | ||
return elements.map((element) => mergeElement(element[idField], element)) | ||
} | ||
@@ -104,3 +112,8 @@ | ||
const setIndex = (key, index) => { | ||
index[indexDataField] = mergeElements(index[indexDataField]).map(unref) | ||
if (!index[indexDataField]) { | ||
throw new Error(`Index must have a '${indexDataField}' field`) | ||
} else if (typeof index[indexDataField].map !== 'function') { | ||
throw new Error(`Index '${indexDataField}' field must be an array`) | ||
} | ||
index[indexDataField] = mergeElements(index[indexDataField]).map(toValue) | ||
indexes[key] = index | ||
@@ -115,8 +128,8 @@ return toRef(indexes, key) | ||
/** | ||
* @param {ref|mixed} idRef | ||
* @return {ref} computed reference to elements[id] | ||
*/ | ||
const show = idRef => { | ||
* @param {ref|mixed} idRef | ||
* @return {ref} computed reference to elements[id] | ||
*/ | ||
const show = (idRef) => { | ||
return computed(() => { | ||
const id = unref(idRef) | ||
const id = toValue(idRef) | ||
if (showRequiresKey && (id === undefined || id === null)) { | ||
@@ -129,3 +142,3 @@ return | ||
elementState[id] = LOADING | ||
api.show(id).then(element => { | ||
api.get({ [idField]: id }).then((element) => { | ||
const newElement = mergeElement(id, element) | ||
@@ -144,4 +157,4 @@ elementState[id] = VALID | ||
*/ | ||
const invalidate = elementOrIdRef => { | ||
let elementOrId = unref(elementOrIdRef) | ||
const invalidate = (elementOrIdRef) => { | ||
let elementOrId = toValue(elementOrIdRef) | ||
if (typeof elementOrId === 'object' && idField in elementOrId) { | ||
@@ -152,11 +165,13 @@ elementOrId = elementOrId[idField] | ||
elementState[elementOrId] = INVALID | ||
return Promise.resolve() | ||
} | ||
/** | ||
* @param {ref|object<ref>} params | ||
* @return {ref} computed reference to elements[id] | ||
*/ | ||
* @param {ref|object<ref>} params | ||
* @return {ref} computed reference to elements[id] | ||
*/ | ||
const indexAsRef = (paramsRef = {}) => { | ||
return computed(() => { | ||
const params = nestedUnref(paramsRef) | ||
const params = nestedToValue(paramsRef) | ||
const key = api.key('index', params) | ||
@@ -167,3 +182,3 @@ | ||
indexState[key] = LOADING | ||
api.index(params).then(index => { | ||
api.index(params).then((index) => { | ||
const newIndex = setIndex(key, index) | ||
@@ -180,6 +195,8 @@ indexState[key] = VALID | ||
const invalidateIndex = (paramsRef = {}) => { | ||
const params = nestedUnref(paramsRef) | ||
const params = nestedToValue(paramsRef) | ||
const key = api.key('index', params) | ||
indexState[key] = INVALID | ||
return Promise.resolve() | ||
} | ||
@@ -191,25 +208,30 @@ | ||
} | ||
return Promise.resolve() | ||
} | ||
const index = (paramsRef = {}) => { | ||
return new Proxy({}, { | ||
get (_, prop) { | ||
return computed(() => { | ||
const params = nestedUnref(paramsRef) | ||
const key = api.key('index', params) | ||
return new Proxy( | ||
{}, | ||
{ | ||
get (_, prop) { | ||
return computed(() => { | ||
const params = nestedToValue(paramsRef) | ||
const key = api.key('index', params) | ||
if (!(key in indexState) || indexState[key] === INVALID) { | ||
indexes[key] = indexes[key] || reactive({}) | ||
indexState[key] = LOADING | ||
api.index(params).then(index => { | ||
const newIndex = setIndex(key, index) | ||
indexState[key] = VALID | ||
return newIndex | ||
}) | ||
} | ||
if (!(key in indexState) || indexState[key] === INVALID) { | ||
indexes[key] = indexes[key] || reactive({}) | ||
indexState[key] = LOADING | ||
api.index(params).then((index) => { | ||
const newIndex = setIndex(key, index) | ||
indexState[key] = VALID | ||
return newIndex | ||
}) | ||
} | ||
return indexes[key][prop] | ||
}) | ||
return indexes[key][prop] | ||
}) | ||
} | ||
} | ||
}) | ||
) | ||
} | ||
@@ -222,3 +244,3 @@ | ||
const store = async (element, params = {}) => { | ||
const newElement = await api.store(nestedUnref(element), params) | ||
const newElement = await api.post(nestedToValue(element), params) | ||
invalidateAllIndexes() | ||
@@ -229,4 +251,5 @@ return mergeElement(newElement[idField], newElement) | ||
const update = async (element, params = {}) => { | ||
const updatedElement = await api.update(nestedUnref(element), params) | ||
const id = idField in updatedElement ? updatedElement[idField] : element[idField] | ||
const updatedElement = await api.put(nestedToValue(element), params) | ||
const id = | ||
idField in updatedElement ? updatedElement[idField] : element[idField] | ||
invalidateAllIndexes() | ||
@@ -237,3 +260,3 @@ return mergeElement(id, updatedElement) | ||
const destroy = async (element, params = {}) => { | ||
await api.destroy(nestedUnref(element), params) | ||
await api.destroy(nestedToValue(element), params) | ||
invalidateAllIndexes() | ||
@@ -243,2 +266,30 @@ return deleteElement(element) | ||
const defineAction = async ( | ||
action, | ||
{ apiAction = action, invalidateIndexes = false, mergeElement = true } | ||
) => { | ||
actions[action] = async (element, params = {}) => { | ||
const updatedElement = await api[apiAction]( | ||
nestedToValue(element), | ||
params | ||
) | ||
if (invalidateIndexes) { | ||
invalidateAllIndexes() | ||
} | ||
const id = | ||
idField in updatedElement | ||
? updatedElement[idField] | ||
: element[idField] | ||
if (mergeElement && id) { | ||
return mergeElement(id, updatedElement) | ||
} else { | ||
return updatedElement | ||
} | ||
} | ||
} | ||
Object.entries(apiActions).forEach(([action, options]) => | ||
defineAction(action, options) | ||
) | ||
return { | ||
@@ -254,2 +305,3 @@ /** | ||
indexState, | ||
actions, | ||
@@ -264,5 +316,18 @@ destroy, | ||
store, | ||
update | ||
update, | ||
...actions | ||
} | ||
}) | ||
} | ||
defineApiStore.config = { | ||
idField: 'id', | ||
indexDataField: 'data', | ||
showRequiresKey: true, | ||
ApiOptions: undefined | ||
} | ||
defineApiStore.use = (plugin) => plugin(defineApiStore) | ||
export default defineApiStore |
@@ -1,57 +0,62 @@ | ||
import DefaultUrlFormatter from './DefaultUrlFormatter.js' | ||
import UrlExp from './UrlExp.js' | ||
export default function JsonApi (baseUrl, { | ||
fetch = window.fetch, | ||
Formatter = DefaultUrlFormatter, | ||
serialize = JSON.stringify | ||
const JsonApi = function JsonApi (urlExp, { | ||
fetch = JsonApi.config.fetch, | ||
headers = JsonApi.config.headers, | ||
serialize = JsonApi.config.serialize | ||
} = {}) { | ||
const createQueryUrl = (new Formatter(baseUrl)).format | ||
this.key = createQueryUrl | ||
this.index = async function (params) { | ||
const url = createQueryUrl('index', params) | ||
return fetch(url).then(r => r.json()) | ||
if (!(urlExp instanceof UrlExp)) { | ||
if (typeof urlExp === 'string') { | ||
urlExp = new UrlExp(urlExp) | ||
} else { | ||
throw new TypeError('urlExp must be a UrlExp or string') | ||
} | ||
} | ||
this.show = async function (id) { | ||
const url = createQueryUrl('show', { id }) | ||
return fetch(url).then(r => r.json()).then(r => r.data) | ||
} | ||
this.store = async function (element, params = {}) { | ||
const url = createQueryUrl('store', params, element) | ||
const options = { | ||
method: 'POST', | ||
headers: { | ||
'Content-Type': 'application/json' | ||
}, | ||
body: serialize(element) | ||
const makeAction = function ( | ||
method, | ||
{ | ||
beforeFetch = options => options, | ||
afterFetch = r => r.json() | ||
} = {} | ||
) { | ||
return async (element, params = {}) => { | ||
const url = urlExp.format(params, element) | ||
const options = { url, method, headers } | ||
if (!['get', 'head'].includes(method.toLowerCase())) { | ||
options.body = serialize(element) | ||
} | ||
beforeFetch(options) | ||
return fetch(options.url, options).then(afterFetch) | ||
} | ||
return fetch(url, options).then(r => r.json()).then(r => r.data) | ||
} | ||
this.update = async function (element, params = {}) { | ||
const url = createQueryUrl('update', params, element) | ||
const options = { | ||
method: 'PUT', | ||
headers: { | ||
'Content-Type': 'application/json' | ||
}, | ||
body: serialize(element) | ||
} | ||
return fetch(url, options).then(r => r.json()).then(r => r.data) | ||
this.defineAction = function (action, method = action, ...args) { | ||
this[action] = (element, params = {}) => makeAction(method, ...args)(element, params).then(r => r.data) | ||
} | ||
this.destroy = async function (element, params = {}) { | ||
const url = createQueryUrl('destroy', params, element) | ||
const options = { | ||
method: 'DELETE', | ||
headers: { | ||
'Content-Type': 'application/json' | ||
}, | ||
body: serialize(element) | ||
} | ||
return fetch(url, options).then(r => r.json()).then(r => r.data) | ||
} | ||
this.key = params => urlExp.format(params) | ||
this.defineAction('delete') | ||
this.destroy = this.delete | ||
this.defineAction('put') | ||
this.update = this.put | ||
this.defineAction('post') | ||
this.store = this.post | ||
this.defineAction('get') | ||
this.show = this.get | ||
this.index = this.get | ||
} | ||
JsonApi.config = { | ||
fetch, | ||
headers: { 'Content-Type': 'application/json' }, | ||
serialize: JSON.stringify | ||
} | ||
JsonApi.use = plugin => plugin(JsonApi) | ||
export default JsonApi |
@@ -1,147 +0,41 @@ | ||
import { ref, computed, isRef, watch } from 'vue' | ||
import { beforeEach, expect, test, vi } from 'vitest' | ||
import { userApiReset, mockUserApi, testUserStore, vueUpdates, ensureLoaded } from './testHelpers.js' | ||
import { beforeEach, expect, test } from 'vitest' | ||
import { ref, toValue, isRef, isReactive } from 'vue' | ||
import { setActivePinia, createPinia } from 'pinia' | ||
import { flushPromises } from '@vue/test-utils' | ||
beforeEach(userApiReset) | ||
import defineApiStore from '../src/defineApiStore.js' | ||
import mockJsonApi from './JsonApi.mock.js' | ||
test('loading an index calls api.index', () => { | ||
const indexSpy = vi.spyOn(mockUserApi, 'index') | ||
const store = testUserStore() | ||
const useMockStore = defineApiStore('testUsers', mockJsonApi) | ||
let store = null | ||
expect(indexSpy).toHaveBeenCalledTimes(0) | ||
// necessary for to trigger computing the index at all | ||
ensureLoaded(store.index().data) | ||
expect(indexSpy).toHaveBeenCalledTimes(1) | ||
beforeEach(() => { | ||
setActivePinia(createPinia()) | ||
mockJsonApi.reset() | ||
store = useMockStore() | ||
}) | ||
test('loading an index more than once only calls api.index once', () => { | ||
const indexSpy = vi.spyOn(mockUserApi, 'index') | ||
const store = testUserStore() | ||
test('can load an index', async () => { | ||
const index = store.index() | ||
ensureLoaded(store.index().data) | ||
ensureLoaded(store.index().data) | ||
toValue(index.data) | ||
await flushPromises() | ||
expect(indexSpy).toHaveBeenCalledTimes(1) | ||
expect(toValue(index.data).length).toBe(2) | ||
}) | ||
test('invalidating an index will recall api.index', async () => { | ||
const indexSpy = vi.spyOn(mockUserApi, 'index') | ||
const userStore = testUserStore() | ||
test('index is reactive', async () => { | ||
const index = store.index() | ||
const { data: users } = userStore.index() | ||
toValue(index.data) | ||
await flushPromises() | ||
watch(users, () => {}) | ||
userStore.invalidateIndex() | ||
await vueUpdates() | ||
expect(toValue(index.data).length).toBe(2) | ||
expect(indexSpy).toHaveBeenCalledTimes(2) | ||
}) | ||
store.store({ id: 3, name: 'Chuck', full_name: 'Chuck Norris' }) | ||
await flushPromises() | ||
toValue(index.data) | ||
await flushPromises() | ||
test('parameters are passed through to api.index', () => { | ||
const indexSpy = vi.spyOn(mockUserApi, 'index') | ||
const store = testUserStore() | ||
ensureLoaded(store.index({ foo: 'bar', bin: 'baz' }).data) | ||
expect(indexSpy).toHaveBeenCalledWith({ foo: 'bar', bin: 'baz' }) | ||
expect(toValue(index.data).length).toBe(3) | ||
}) | ||
test('can call with different sets of parameters', async () => { | ||
const indexSpy = vi.spyOn(mockUserApi, 'index') | ||
const store = testUserStore() | ||
ensureLoaded(store.index({ foo: 'bar', bin: 'baz' }).data) | ||
expect(indexSpy).toHaveBeenCalledWith({ foo: 'bar', bin: 'baz' }) | ||
ensureLoaded(store.index({ foo: 'alpha' }).data) | ||
await vueUpdates() | ||
expect(indexSpy).toHaveBeenCalledWith({ foo: 'alpha' }) | ||
}) | ||
test('parameter properties can be reactive', async () => { | ||
const indexSpy = vi.spyOn(mockUserApi, 'index') | ||
const store = testUserStore() | ||
const foo = ref('bar') | ||
const { users } = store.index({ foo }) | ||
ensureLoaded(users) | ||
expect(indexSpy).toHaveBeenCalledWith({ foo: 'bar' }) | ||
foo.value = 'biz' | ||
ensureLoaded(users) | ||
expect(indexSpy).toHaveBeenCalledWith({ foo: 'biz' }) | ||
}) | ||
test('Parameters itself can be reactive', async () => { | ||
const indexSpy = vi.spyOn(mockUserApi, 'index') | ||
const store = testUserStore() | ||
const foo = ref('bar') | ||
const params = computed(() => ({ foo })) | ||
const { data: users } = store.index(params) | ||
ensureLoaded(users) | ||
expect(indexSpy).toHaveBeenCalledWith({ foo: 'bar' }) | ||
foo.value = 'biz' | ||
ensureLoaded(users) | ||
expect(indexSpy).toHaveBeenCalledWith({ foo: 'biz' }) | ||
}) | ||
test('the returned value is reactive', async () => { | ||
const indexSpy = vi.spyOn(mockUserApi, 'index') | ||
const store = testUserStore() | ||
const { data: users } = store.index() | ||
expect(users.value).toBe(undefined) | ||
await vueUpdates() | ||
expect(indexSpy).toHaveBeenCalledTimes(1) | ||
expect(users.value.length).toBe(2) | ||
}) | ||
test('api.show results merge over api.index', async () => { | ||
const store = testUserStore() | ||
ensureLoaded(store.show(1)) | ||
const { data: users } = store.index() | ||
ensureLoaded(users) | ||
await vueUpdates() | ||
expect(users.value[0].shown).toBeTruthy() | ||
}) | ||
test('index returns references', async () => { | ||
const store = testUserStore() | ||
const { data } = store.index() | ||
expect(isRef(data)).toBeTruthy() | ||
ensureLoaded(data) | ||
await vueUpdates() | ||
expect(data.value.length).toBe(2) | ||
}) | ||
test('index references work properly', async () => { | ||
const store = testUserStore() | ||
const { data } = store.index() | ||
watch(data, () => {}) | ||
await vueUpdates() | ||
expect(data.value.length).toBe(2) | ||
await store.store({ id: 10, name: 'Cheese', full_name: 'Brie' }) | ||
await vueUpdates() | ||
expect(data.value.length).toBe(3) | ||
}) |
@@ -1,59 +0,70 @@ | ||
import { watch } from 'vue' | ||
import { beforeEach, expect, test, vi } from 'vitest' | ||
import { userApiReset, mockUserApi, testUserStore, vueUpdates, ensureLoaded } from './testHelpers.js' | ||
import { beforeEach, expect, test } from 'vitest' | ||
import { ref, toValue } from 'vue' | ||
import { setActivePinia, createPinia } from 'pinia' | ||
import { flushPromises } from '@vue/test-utils' | ||
beforeEach(userApiReset) | ||
import defineApiStore from '../src/defineApiStore.js' | ||
import mockJsonApi from './JsonApi.mock.js' | ||
test('ref is reactive', async () => { | ||
const showSpy = vi.spyOn(mockUserApi, 'show') | ||
const store = testUserStore() | ||
const useMockStore = defineApiStore('testUsers', mockJsonApi) | ||
let store = null | ||
beforeEach(() => { | ||
setActivePinia(createPinia()) | ||
mockJsonApi.reset() | ||
store = useMockStore() | ||
}) | ||
test('can get by ID', async () => { | ||
const person1 = store.show(1) | ||
expect(person1.value).toBe(undefined) | ||
toValue(person1) | ||
await flushPromises() | ||
await vueUpdates() | ||
expect(person1.value.name).toBe('Kevin') | ||
}) | ||
expect(showSpy).toHaveBeenCalledTimes(1) | ||
test('calls API', async () => { | ||
const person1 = store.show(1) | ||
expect(person1?.value.name).toBe('Kevin') | ||
await flushPromises() | ||
}) | ||
test('Only calls show on Api once', async () => { | ||
const show = vi.spyOn(mockUserApi, 'show') | ||
const store = testUserStore() | ||
test('get same value for multiple refs', async () => { | ||
const person1 = store.show(1) | ||
const person2 = store.show(1) | ||
ensureLoaded(store.show(1)) | ||
ensureLoaded(store.show(1)) | ||
await flushPromises() | ||
expect(show).toHaveBeenCalledTimes(1) // TODO | ||
expect(person1 === person2).toBeFalsy() | ||
expect(person1.value === person2.value).toBeTruthy() | ||
}) | ||
test('ref is mutable', async () => { | ||
const store = testUserStore() | ||
const person1 = store.show(1) | ||
const another1 = store.show(1) | ||
const person2 = store.show(1) | ||
ensureLoaded(person1) | ||
await vueUpdates() | ||
toValue(person1) | ||
toValue(person2) | ||
await flushPromises() | ||
another1.value.name = 'Chuck' | ||
expect(person1?.value.name).toBe('Chuck') | ||
person1.value.name = 'Chuck' | ||
expect(person2.value.name).toBe('Chuck') | ||
}) | ||
test('invalidating an element will recall api.show', async () => { | ||
const showSpy = vi.spyOn(mockUserApi, 'show') | ||
const store = testUserStore() | ||
const person1 = store.show(1) | ||
test('ref is reactive to ID changes', async () => { | ||
mockJsonApi.reset() | ||
const id = ref(1) | ||
const person1 = store.show(id) | ||
watch(person1, () => {}) | ||
toValue(person1) | ||
await flushPromises() | ||
await vueUpdates() | ||
expect(person1.value.name).toBe('Kevin') | ||
expect(showSpy).toHaveBeenCalledTimes(1) | ||
id.value = 2 | ||
toValue(person1) | ||
await flushPromises() | ||
store.invalidate(person1) | ||
await vueUpdates() | ||
expect(showSpy).toHaveBeenCalledTimes(2) | ||
expect(person1.value.name).toBe('Test') | ||
}) |
@@ -13,3 +13,3 @@ const mockUsersInitialState = { | ||
index: (params) => Promise.resolve({ data: Object.values(mockUsers).map(e => Object.assign({}, e)) }), | ||
show: id => Promise.resolve(id in mockUsers ? Object.assign({ shown: (new Date()) }, mockUsers[id]) : null), | ||
get: id => Promise.resolve(id in mockUsers ? mockUsers[id] : null), | ||
update: element => { | ||
@@ -16,0 +16,0 @@ mockUsers[element.id] = element |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
38611
52.94%28
64.71%742
14.86%4
-33.33%3
50%+ Added
+ Added