-
Notifications
You must be signed in to change notification settings - Fork 1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #17148 from davelopez/add_simple_store_composable
Add simpleKeyStore composable
- Loading branch information
Showing
11 changed files
with
346 additions
and
157 deletions.
There are no files selected for viewing
This file contains 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
This file contains 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
This file contains 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
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,2 @@ | ||
export { fetcher } from "./fetcher"; | ||
export { type ApiResponse, fetcher } from "./fetcher"; | ||
export type { components, operations, paths } from "./schema"; |
This file contains 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
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,181 @@ | ||
import flushPromises from "flush-promises"; | ||
import { computed, ref } from "vue"; | ||
|
||
import { useKeyedCache } from "./keyedCache"; | ||
|
||
interface ItemData { | ||
id: string; | ||
name: string; | ||
} | ||
|
||
const fetchItem = jest.fn(); | ||
const shouldFetch = jest.fn(); | ||
|
||
describe("useKeyedCache", () => { | ||
beforeEach(() => { | ||
fetchItem.mockClear(); | ||
shouldFetch.mockClear(); | ||
}); | ||
|
||
it("should fetch the item if it is not already stored", async () => { | ||
const id = "1"; | ||
const item = { id: id, name: "Item 1" }; | ||
const fetchParams = { id: id }; | ||
const apiResponse = { data: item }; | ||
|
||
fetchItem.mockResolvedValue(apiResponse); | ||
|
||
const { storedItems, getItemById, isLoadingItem } = useKeyedCache<ItemData>(fetchItem); | ||
|
||
expect(storedItems.value).toEqual({}); | ||
expect(isLoadingItem.value(id)).toBeFalsy(); | ||
|
||
getItemById.value(id); | ||
|
||
expect(isLoadingItem.value(id)).toBeTruthy(); | ||
await flushPromises(); | ||
expect(isLoadingItem.value(id)).toBeFalsy(); | ||
expect(storedItems.value[id]).toEqual(item); | ||
expect(fetchItem).toHaveBeenCalledWith(fetchParams); | ||
}); | ||
|
||
it("should not fetch the item if it is already stored", async () => { | ||
const id = "1"; | ||
const item = { id: id, name: "Item 1" }; | ||
|
||
fetchItem.mockResolvedValue({ data: item }); | ||
|
||
const { storedItems, getItemById, isLoadingItem } = useKeyedCache<ItemData>(fetchItem); | ||
|
||
storedItems.value[id] = item; | ||
|
||
expect(isLoadingItem.value(id)).toBeFalsy(); | ||
|
||
getItemById.value(id); | ||
|
||
expect(isLoadingItem.value(id)).toBeFalsy(); | ||
expect(storedItems.value[id]).toEqual(item); | ||
expect(fetchItem).not.toHaveBeenCalled(); | ||
}); | ||
|
||
it("should fetch the item regardless of whether it is already stored if shouldFetch returns true", async () => { | ||
const id = "1"; | ||
const item = { id: id, name: "Item 1" }; | ||
const fetchParams = { id: id }; | ||
const apiResponse = { data: item }; | ||
|
||
fetchItem.mockResolvedValue(apiResponse); | ||
shouldFetch.mockReturnValue(() => true); | ||
|
||
const { storedItems, getItemById, isLoadingItem } = useKeyedCache<ItemData>(fetchItem, shouldFetch); | ||
|
||
storedItems.value[id] = item; | ||
|
||
expect(isLoadingItem.value(id)).toBeFalsy(); | ||
|
||
getItemById.value(id); | ||
|
||
expect(isLoadingItem.value(id)).toBeTruthy(); | ||
await flushPromises(); | ||
expect(isLoadingItem.value(id)).toBeFalsy(); | ||
expect(storedItems.value[id]).toEqual(item); | ||
expect(fetchItem).toHaveBeenCalledWith(fetchParams); | ||
expect(shouldFetch).toHaveBeenCalled(); | ||
}); | ||
|
||
it("should not fetch the item if it is already being fetched", async () => { | ||
const id = "1"; | ||
const item = { id: id, name: "Item 1" }; | ||
const fetchParams = { id: id }; | ||
const apiResponse = { data: item }; | ||
|
||
fetchItem.mockResolvedValue(apiResponse); | ||
|
||
const { storedItems, getItemById, isLoadingItem } = useKeyedCache<ItemData>(fetchItem); | ||
|
||
expect(isLoadingItem.value(id)).toBeFalsy(); | ||
|
||
getItemById.value(id); | ||
getItemById.value(id); | ||
|
||
expect(isLoadingItem.value(id)).toBeTruthy(); | ||
await flushPromises(); | ||
expect(isLoadingItem.value(id)).toBeFalsy(); | ||
expect(storedItems.value[id]).toEqual(item); | ||
expect(fetchItem).toHaveBeenCalledTimes(1); | ||
expect(fetchItem).toHaveBeenCalledWith(fetchParams); | ||
}); | ||
|
||
it("should not fetch the item if it is already being fetched, even if shouldFetch returns true", async () => { | ||
const id = "1"; | ||
const item = { id: id, name: "Item 1" }; | ||
const fetchParams = { id: id }; | ||
const apiResponse = { data: item }; | ||
|
||
fetchItem.mockResolvedValue(apiResponse); | ||
shouldFetch.mockReturnValue(() => true); | ||
|
||
const { storedItems, getItemById, isLoadingItem } = useKeyedCache<ItemData>(fetchItem, shouldFetch); | ||
|
||
expect(isLoadingItem.value(id)).toBeFalsy(); | ||
|
||
getItemById.value(id); | ||
getItemById.value(id); | ||
|
||
expect(isLoadingItem.value(id)).toBeTruthy(); | ||
await flushPromises(); | ||
expect(isLoadingItem.value(id)).toBeFalsy(); | ||
expect(storedItems.value[id]).toEqual(item); | ||
expect(fetchItem).toHaveBeenCalledTimes(1); | ||
expect(fetchItem).toHaveBeenCalledWith(fetchParams); | ||
expect(shouldFetch).toHaveBeenCalled(); | ||
}); | ||
|
||
it("should accept a ref for fetchItem", async () => { | ||
const id = "1"; | ||
const item = { id: id, name: "Item 1" }; | ||
const fetchParams = { id: id }; | ||
const apiResponse = { data: item }; | ||
|
||
fetchItem.mockResolvedValue(apiResponse); | ||
|
||
const fetchItemRef = ref(fetchItem); | ||
|
||
const { storedItems, getItemById, isLoadingItem } = useKeyedCache<ItemData>(fetchItemRef); | ||
|
||
expect(isLoadingItem.value(id)).toBeFalsy(); | ||
|
||
getItemById.value(id); | ||
|
||
expect(isLoadingItem.value(id)).toBeTruthy(); | ||
await flushPromises(); | ||
expect(isLoadingItem.value(id)).toBeFalsy(); | ||
expect(storedItems.value[id]).toEqual(item); | ||
expect(fetchItem).toHaveBeenCalledWith(fetchParams); | ||
}); | ||
|
||
it("should accept a computed for shouldFetch", async () => { | ||
const id = "1"; | ||
const item = { id: id, name: "Item 1" }; | ||
const fetchParams = { id: id }; | ||
const apiResponse = { data: item }; | ||
|
||
fetchItem.mockResolvedValue(apiResponse); | ||
shouldFetch.mockReturnValue(true); | ||
|
||
const shouldFetchComputed = computed(() => shouldFetch); | ||
|
||
const { storedItems, getItemById, isLoadingItem } = useKeyedCache<ItemData>(fetchItem, shouldFetchComputed); | ||
|
||
expect(isLoadingItem.value(id)).toBeFalsy(); | ||
|
||
getItemById.value(id); | ||
|
||
expect(isLoadingItem.value(id)).toBeTruthy(); | ||
await flushPromises(); | ||
expect(isLoadingItem.value(id)).toBeFalsy(); | ||
expect(storedItems.value[id]).toEqual(item); | ||
expect(fetchItem).toHaveBeenCalledWith(fetchParams); | ||
expect(shouldFetch).toHaveBeenCalled(); | ||
}); | ||
}); |
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
import { MaybeRefOrGetter, toValue } from "@vueuse/core"; | ||
import { computed, del, type Ref, ref, set, unref } from "vue"; | ||
|
||
import type { ApiResponse } from "@/api/schema"; | ||
|
||
/** | ||
* Parameters for fetching an item from the server. | ||
* | ||
* Minimally, this should include an id for indexing the item. | ||
*/ | ||
interface FetchParams { | ||
id: string; | ||
} | ||
|
||
/** | ||
* A function that fetches an item from the server. | ||
*/ | ||
type FetchHandler<T> = (params: FetchParams) => Promise<ApiResponse<T>>; | ||
|
||
/** | ||
* A function that returns true if the item should be fetched. | ||
* Provides fine-grained control over when to fetch an item. | ||
*/ | ||
type ShouldFetchHandler<T> = (item?: T) => boolean; | ||
|
||
/** | ||
* Returns true if the item is not defined. | ||
* @param item The item to check. | ||
*/ | ||
const fetchIfAbsent = <T>(item?: T) => !item; | ||
|
||
/** | ||
* A composable that provides a simple key-value cache for items fetched from the server. | ||
* | ||
* Useful for storing items that are fetched by id. | ||
* | ||
* @param fetchItemHandler Fetches an item from the server. | ||
* @param shouldFetchHandler Returns true if the item should be fetched. | ||
* Provides fine-grained control over when to fetch an item. | ||
* If not provided, by default, the item will be fetched if it is not already stored. | ||
*/ | ||
export function useKeyedCache<T>( | ||
fetchItemHandler: Ref<FetchHandler<T>> | FetchHandler<T>, | ||
shouldFetchHandler?: MaybeRefOrGetter<ShouldFetchHandler<T>> | ||
) { | ||
const storedItems = ref<{ [key: string]: T }>({}); | ||
const loadingItem = ref<{ [key: string]: boolean }>({}); | ||
|
||
const getItemById = computed(() => { | ||
return (id: string) => { | ||
const item = storedItems.value[id]; | ||
if (!loadingItem.value[id] && shouldFetch(item)) { | ||
fetchItemById({ id: id }); | ||
} | ||
return item ?? null; | ||
}; | ||
}); | ||
|
||
function shouldFetch(item?: T) { | ||
if (shouldFetchHandler == undefined) { | ||
return fetchIfAbsent(item); | ||
} | ||
return toValue(shouldFetchHandler)(item); | ||
} | ||
|
||
const isLoadingItem = computed(() => { | ||
return (id: string) => { | ||
return loadingItem.value[id] ?? false; | ||
}; | ||
}); | ||
|
||
async function fetchItemById(params: FetchParams) { | ||
const itemId = params.id; | ||
set(loadingItem.value, itemId, true); | ||
try { | ||
const fetchItem = unref(fetchItemHandler); | ||
const { data } = await fetchItem({ id: itemId }); | ||
set(storedItems.value, itemId, data); | ||
return data; | ||
} finally { | ||
del(loadingItem.value, itemId); | ||
} | ||
} | ||
|
||
return { | ||
/** | ||
* The stored items as a reactive object. | ||
*/ | ||
storedItems, | ||
/** | ||
* A computed function that returns the item with the given id. | ||
* If the item is not already stored, it will be fetched from the server. | ||
* And reactively updated when the fetch completes. | ||
*/ | ||
getItemById, | ||
/** | ||
* A computed function that returns true if the item with the given id is currently being fetched. | ||
*/ | ||
isLoadingItem, | ||
/** | ||
* Fetches the item with the given id from the server. | ||
* And reactively updates the stored item when the fetch completes. | ||
*/ | ||
fetchItemById, | ||
}; | ||
} |
Oops, something went wrong.